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 / Operator | Purpose | Strictness Level | Core Behavior |
|---|---|---|---|
== | Value Equality | Loose | Returns true if values are equivalent (e.g., 1 == 1.0 is true). |
=== | Case Equality | Contextual | Used in case/when. Checks membership, type matching, or regex matching (Class === instance). |
eql? | Hash Key Equality | Strict | Checks if values and types match. Used by Ruby under the hood to look up keys in a Hash. |
equal? | Object Identity | Absolute | Checks 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 # => true1.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 = str1str1.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 :===, :==endprod1 = Product.new(101, "Laptop")prod2 = Product.new(101, "MacBook Pro") # Same ID, different name string# Testing our custom equalityputs 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
| Method | Question it asks | Default 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 endendMoney.new(100, "USD") == Money.new(100, "USD") # => trueMoney.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_codewhen 200 # Integer#=== → 200 === 200when 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 matchString === "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 keyh = {}h[1] = "integer"h[1.0] = "float"h # => {1 => "integer", 1.0 => "float"} - two separate keys!1.hash # => some_number1.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 endendp1 = 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 = aa == 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 sentineldef fetch(key, default = UNSET) if default.equal?(UNSET) # no default provided - raise if missing else # use default endend
The Override Contract
Here’s what you must implement together:
Override == → consider eql? and hashOverride eql? → must override hashOverride 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? + hashChecking 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] endend
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 bycase/when, rarely called directlyeql?andhashare inseparable – implement both or neitherequal?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.