Ruby Equality: The Full Picture ==, ===, eql? and equal?

To truly master equality in Ruby, you need to look at four distinct methods. Beyond == and ===, Ruby uses eql? for strict value and type checking, and equal? for strict object identity.

Here is how they stack up:

Method / OperatorPurposeStrictness LevelCore Behavior
==Value EqualityLooseReturns true if values are equivalent (e.g., 1 == 1.0 is true).
===Case EqualityContextualUsed in case/when. Checks membership, type matching, or regex matching (Class === instance).
eql?Hash Key EqualityStrictChecks if values and types match. Used by Ruby under the hood to look up keys in a Hash.
equal?Object IdentityAbsoluteChecks if both sides point to the exact same object in memory (object_id). Never override this.

1. Hash Equality (eql?)

While == will convert numbers to compare them, eql? requires them to be the exact same class.

1 == 1.0 # => true
1.eql?(1.0) # => false (Integer vs Float)
# Why it matters: Hash keys use eql?
hash = {}
hash[1] = "Integer One"
hash[1.0] = "Float One"
puts hash.keys # => [1, 1.0] (They are treated as completely separate keys!)

2. Identity Equality (equal?)

This is the ultimate test of identity. It checks if the two variables point to the same slot in your computer’s memory.

str1 = "hello"
str2 = "hello"
str1 == str2 # => true (Same value)
str1.equal?(str2) # => false (Different objects in memory)
str3 = str1
str1.equal?(str3) # => true (Both variables point to the exact same object)

How to Override Equality in Custom Classes

When you build your own objects, Ruby doesn’t automatically know what makes two instances “equal.” By default, custom objects inherit == from Object, which behaves like equal? (identity checking).

To fix this, you should override ==. When you override ==, it is best practice to also alias it to ===.

Here is a clean, modern implementation for a custom class:

class Product
attr_reader :id, :name
def initialize(id, name)
@id = id
@name = name
end
# 1. Define custom value equality
def ==(other)
# Ensure the other object is actually a Product or subclass
return false unless other.is_a?(Product)
# Consider them equal if their IDs match
self.id == other.id
end
# 2. Make case equality behave exactly the same way
alias_method :===, :==
end
prod1 = Product.new(101, "Laptop")
prod2 = Product.new(101, "MacBook Pro") # Same ID, different name string
# Testing our custom equality
puts prod1 == prod2 # => true (Because IDs match)
puts prod1 === prod2 # => true (Works seamlessly in case statements!)

Ruby Equality: In Detail

Ruby gives you four ways to compare things. Most developers use == everywhere and wonder why things break. Here’s what’s actually happening under the hood – and when to reach for each one.

The Four Operators at a Glance

MethodQuestion it asksDefault defined in
==Are you equivalent in value?BasicObject
===Do you belong to this category?Object (delegates to ==)
eql?Same value and type?Object (delegates to ==)
equal?Are you the exact same object in memory?BasicObject (never override)

== – Value Equality (The Workhorse)

This is what you use 95% of the time. == asks: are these equivalent?

1 == 1.0 # => true (Integer vs Float, Ruby coerces)
"hello" == "hello" # => true (same content, different objects)
[1, 2] == [1, 2] # => true

The key thing: == is just a method. Every class can override it. Here’s what that looks like:

class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def ==(other)
return false unless other.is_a?(Money)
amount == other.amount && currency == other.currency
end
end
Money.new(100, "USD") == Money.new(100, "USD") # => true
Money.new(100, "USD") == Money.new(100, "GBP") # => false

Watch out: If you override ==, also override hash or your objects will break in hashes and sets. More on that below.

=== – Case Equality (The Pattern Matcher)

This one is subtle. === powers case/when and asks: does the right side belong to the category defined by the left side?

# Under the hood, case/when calls ===
case status_code
when 200 # Integer#=== → 200 === 200
when 300..399 # Range#=== → range.include?(code)
when /^5\d{2}/ # Regexp#=== → regex.match?(code)
end

Each class defines what “belonging” means:

(1..10) === 5 # => true - range membership
/\d+/ === "123" # => true - regex match
String === "hello" # => true - is it an instance of String?
:ok === :ok # => true - falls back to ==

You can exploit this directly:

matchers = [Integer, /error/, (500..599)]
matchers.any? { |m| m === value } # polymorphic dispatch, zero if/else

Rule of thumb: Don’t call === explicitly in most code. It’s a framework-level tool. Let case/when use it.

eql? – Type-Strict Equality

eql? asks: same value AND same type?

1 == 1.0 # => true (== coerces)
1.eql?(1.0) # => false (different types)
1.eql?(1) # => true
"a".eql?("a") # => true

The critical contract: eql? and hash are paired. Ruby uses eql? to resolve hash key collisions. If two objects are eql?, they must return the same hash value.

# This is why 1 and 1.0 are NOT the same Hash key
h = {}
h[1] = "integer"
h[1.0] = "float"
h # => {1 => "integer", 1.0 => "float"} - two separate keys!
1.hash # => some_number
1.0.hash # => different_number (on most Ruby versions)

When to override eql?:

class Point
attr_reader :x, :y
def initialize(x, y) = @x, @y = x, y
def ==(other)
other.is_a?(Point) && x == other.x && y == other.y
end
def eql?(other)
other.is_a?(Point) && x.eql?(other.x) && y.eql?(other.y)
end
def hash
[x, y].hash # delegate to Array#hash - safe and correct
end
end
p1 = Point.new(1, 2)
p2 = Point.new(1, 2)
{p1 => "origin"}.fetch(p2) # => "origin" ✓
Set.new([p1, p2]).size # => 1 ✓

If you skip hash when overriding eql?, your objects will behave erratically in Hash and Set. Always define all three together.

equal? – Identity Equality (Object Identity)

This checks if two references point to the exact same object in memory. It’s Ruby’s object_id check in method form.

a = "hello"
b = "hello"
c = a
a == b # => true (same value)
a.equal?(b) # => false (different objects)
a.equal?(c) # => true (same object)

Never override equal?. It’s the only reliable way to check object identity. Ruby’s internals depend on it.

Practical use: checking if a method was given a sentinel value vs. a user-provided nil:

UNSET = Object.new # unique sentinel
def fetch(key, default = UNSET)
if default.equal?(UNSET)
# no default provided - raise if missing
else
# use default
end
end

The Override Contract

Here’s what you must implement together:

Override == → consider eql? and hash
Override eql? → must override hash
Override hash → must override eql?

A complete, production-ready value object:

class CurrencyAmount
attr_reader :cents, :currency
def initialize(cents, currency)
@cents = Integer(cents)
@currency = currency.to_s.upcase.freeze
freeze
end
def ==(other)
other.is_a?(CurrencyAmount) &&
cents == other.cents &&
currency == other.currency
end
alias eql? ==
def hash
[self.class, cents, currency].hash
end
def ===(other) # optional: for case/when support
self == other
end
def to_s = "#{cents} #{currency}"
def inspect = "#<CurrencyAmount #{self}>"
end

Note alias eql? == – when == and eql? have identical semantics (which they often should for value objects), alias instead of duplicating code.

Quick Decision Guide

Need to compare values? → ==
Building a case/when or pattern? → === (implicit)
Using objects as Hash keys or in Set? → eql? + hash
Checking object identity/sentinel? → equal?

The Spaceship Operator: Bonus Round

If you’re implementing ==, consider also implementing <=> and including Comparable:

class Version
include Comparable
attr_reader :major, :minor, :patch
def <=>(other)
return nil unless other.is_a?(Version)
[major, minor, patch] <=> [other.major, other.minor, other.patch]
end
end

Comparable gives you <, >, <=, >=, between?, and clamp for free. == is derived from <=> returning 0. Clean and complete.

Key Takeaways

  • == is for value equivalence – override it for domain objects
  • === is for categorization – used by case/when, rarely called directly
  • eql? and hash are inseparable – implement both or neither
  • equal? is object identity – never override it
  • The full contract for hashable value objects: ==, eql?, hash – all consistent

Ruby’s equality system looks like redundancy until you hit a Hash bug at 2am. Now you won’t.


Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a comment