The Many Faces of Equality in Ruby
Ruby’s equality model is one of its standout features, yet it’s not always intuitive. Beyond the familiar ==, there are eql?, equal?, and ===, each serving a distinct purpose. Getting implementations right is critical because equality checks underpin countless everyday operations, from comparing Employee team assignments to deduplicating managers with uniq. Predictable equality aligns with how we reason about objects; faulty implementations lead to surprising bugs where objects that should be equal are not.
While there’s no universal solution, domain-driven design offers two categories of objects—entities and value objects—each with a clear implementation pattern. Even if you don’t follow DDD, these concepts apply broadly.
Entities
Entities have an explicit identity attribute, commonly an id stored in a database. Two entities are equal when their IDs match; all other attributes are irrelevant. A change in an employee’s name, for instance, doesn’t alter their identity. In Rails, ActiveRecord models—referred to as “models” rather than entities—already implement this equality correctly by default.
Value Objects
Value objects lack explicit identity; their entire value constitutes it. A Point with x and y coordinates is equal to another if both coordinates match. Ruby’s built-in value types—integers, floats, booleans, and nil—handle equality out of the box, as do arrays thereof. Timestamps, ranges, colors, and money objects are further examples, typically composed of other value objects.
Understanding == and Its Defaults
The == operator (and its negation !=) is the standard equality check. Ruby’s built-in types and those from libraries like ActiveRecord provide sensible implementations. For custom classes, however, == defaults to reference equality—it returns true only when comparing an object with itself. This default rarely suits custom value objects or entities.
Take the Point class: without custom logic, point_one == point_two is false even for identical coordinates. The desired behavior for a value object is to compare attributes, and for an entity to compare IDs.
A proper Point implementation checks both the class and all attributes:
def ==(other)
other.instance_of?(Point) && other.x == x && other.y == y
end
This yields the expected results for == and !=. A correct implementation adheres to three properties:
- Reflexivity:
a == ais always true. - Symmetry: If
a == b, thenb == a. - Transitivity: If
a == bandb == c, thena == c.
Ruby doesn’t enforce these, so vigilance is required. One notable exception exists: NaN (per IEEE 754) is not equal to itself, making == not universally reflexive—though this is an extreme edge case.
Equality for Entities
Entity equality centers on the explicit identity, commonly an @id. The implementation is more involved than for value objects:
def ==(other)
super || (
other.instance_of?(Employee) &&
!id.nil? &&
id == other.id
)
end
This logic ensures reflexivity via super, always returns false for non-Employee types, treats unpresisted entities (where id is nil) as unequal to everything, and otherwise compares IDs.
Such identity isn’t always unambiguous. Consider a BlogPost whose title and body are completely rewritten—the id remains unchanged. Is it still the same post? This echoes the Ship of Theseus paradox, but in computing the consensus is that matching IDs imply equal entities.
Type Coercion in ==
Generally, objects of different classes aren’t equal, but there are exceptions. Integers and floats can be equal despite differing classes (float_two == integer_two is true). Another example is a Path value object that conceptually behaves like a string:
def ==(other)
other.respond_to?(:to_str) && to_s == other.to_str
end
Here, Path implements to_str, a convention indicating the type is interchangeable with String. This enables comparisons like path == "/usr/bin/ruby" to return true. Because String#== also relies on to_str, the operation remains reflexive—path == path holds true even though other.to_str triggers Path#to_s. The distinction between to_str and to_s matters: the former signals string-interchangeability, allowing Path instances to be passed to methods like IO.open that accept to_str-responding objects.
The Semantics of #equal?
Ruby's #equal? method checks for object identity: it returns true only when two variables reference the exact same instance. Two String objects with identical content are distinct instances, so #equal? returns false even though #== correctly reports them as equal.
You should never override #equal? in your own classes—it's designed to be left alone. There is, however, an important relationship you must preserve between #equal? and #==:
Property: Given objects a and b. If a.equal?(b), then a == b.
Ruby won't verify this for you. The default implementation of #== delegates to #equal?, which is why calling super at the start of an overridden #== guarantees reflexivity. Alternatively, you can explicitly check return true if equal?(other). Using super is a stylistic choice, but it keeps your implementation concise.
Hash Key Support: #eql? and #hash
Any Ruby object can serve as a Hash key, but you must implement two methods to make it work properly: #eql? and #hash.
The #eql? Method
#eql? behaves similarly to #== with one crucial difference: it does not perform type coercion. If your class's #== doesn't coerce types either, the implementations are identical. In that case, put the logic in #eql? and have #== delegate to it. This ordering is deliberate—if #eql? delegated to #==, a future change to #== could silently break the contract of #eql?.
For classes where #== does coerce types—like a Path that compares equal to a string—the two methods must be implemented independently, without delegating to each other.
A correct #eql? must uphold these properties:
- Property: Given objects
aandb. Ifa.eql?(b), thena == b. - Property: Given objects
aandb. Ifa.equal?(b), thena.eql?(b).
These aren't explicitly documented in Ruby's official docs, but all standard implementations respect them. Ruby won't check them for you.
The #hash Method
For an object to work as a Hash key, you also need to implement #hash, which returns an integer hash code satisfying:
Property: Given objects a and b. If a.eql?(b), then a.hash == b.hash.
The standard approach is to build an array of the attributes that constitute identity and delegate to Array#hash. For a value object like Point, that means hashing its coordinates. For an entity like Employee, you hash the class and the @id, since its identity is independent of mutable attributes.
It's acceptable—though not ideal—for two non-equal objects to share a hash code. Ruby resolves such collisions by calling #eql? to distinguish between candidates in the same bucket.
Why XOR Is a Bad Idea
Some developers combine attribute hash codes with XOR (^), but this increases the likelihood of collisions. More importantly, collisions degrade Hash performance and can create a denial-of-service risk if an attacker can deliberately craft colliding keys.
A slightly better refinement multiplies each attribute's hash by a unique prime before combining, but that adds arithmetic overhead and requires careful maintenance. Neither approach beats simply using Array#hash: it's clean, fast, and produces well-distributed hash codes with minimal collision risk.
Using Your Objects as Keys
Once #eql? and #hash are in place, your objects work as Hash keys and in Sets (which use a Hash internally). This applies even to classes like Path that perform type coercion in #==; the Hash lookup path relies solely on #eql? and #hash.
The Mutability Trap
All the value object examples assume immutability, and that's intentional. Consider what happens when you use a mutable Point as a hash key and then change one of its coordinates: the hash code changes, so the Hash can no longer find the point. There's no good fix for this other than making value objects immutable from the start.
Entities don't suffer this problem because their hash is based solely on explicit identity—a class and an ID—not on attributes that might change.
Case Equality
The #=== operator, called case equality, isn't an equality check at all—it's a membership test. Range#=== asks whether a range covers an element; Class#=== checks whether an object is an instance; regular expressions use it to test string matches. A case expression is just syntactic sugar for a chain of #=== calls, while Enumerable#grep leverages it to filter arrays concisely.
This mental model—treating a range as the set of its elements, a class as the set of its instances, a regexp as the infinite set of strings it matches—makes #=== intuitive. You can implement it for your own types, too. For instance, a PathPattern class could delegate to File.fnmatch to test whether a path string fits a glob pattern. Since File.fnmatch calls #to_str on its arguments, the check automatically works with any string-like object, and the whole class plugs directly into case/when expressions.
Making Objects Orderable
Equality isn't the only comparison you might need. For objects that have some natural ordering — say, a Score class that models a university grading scale — you'll also want to know which of two instances is larger. Without that ability, operations like scores.min or scores.max raise an ArgumentError because Ruby doesn't know how to compare Score objects.
The key is implementing the spaceship operator, Score#<=>. Its return value encodes the ordering:
0when the two objects are equal-1whenselfis less thanother1whenselfis greater thanothernilwhen the two objects can't be compared
In a typical implementation, you'd check whether other is of the expected type and, if not, return nil. If it is, delegate the comparison to an underlying value or attribute. For the Score example, comparing the internal numeric value gives the correct result: Score.new(6) <=> Score.new(12) evaluates to -1.
Once #<=> exists, Ruby's Enumerable methods — #min, #max, #minmax, #sort — start working on collections of your objects.
Connecting Equality and Ordering
Ruby assumes a consistency between #<=> and #==. If (a <=> b) == 0 then a == b must be true; conversely, if (a <=> b) != 0 then a != b must hold. Ruby won't enforce that for you, so verify the invariants hold across both methods when you implement them.
Getting the Comparison Operators for Free
Spaceship alone doesn't give you familiar operators like <. Attempting scores[0] < scores[1] raises an undefined method '<' error. Including the Comparable mixin fixes that:
class Score
include Comparable
def <=>(other)
return nil unless other.is_a?(Score)
value <=> other.value
end
end
With Comparable included, the class automatically gains <, <=, >, and >=, all implemented in terms of #<=>. The mixin also supplies handy methods like #between? and #clamp.
Where Things Stand
Ruby provides a family of comparison operators, each aimed at a different use case:
#==for general value equality, optionally with type coercion#equal?for instance identity — the same object in memory#eql?and#hashfor hash-key lookup semantics#===for the "is a kind of" / "is a member of" check used incasestatements#<=>for ordered comparisons, paired withComparablefor the arithmetic-style comparison operators
It's up to the implementer to keep these methods consistent with one another. For deeper reading, Ruby's object documentation covers each operator, and community articles on Ruby type conversion and triple equals are useful supplements.



