← Back to blog

==, eql?, equal?, ===: Ruby's Four Kinds of Equal

· Lachlan Young

Ruby gives you four ways to ask whether two objects are equal: ==, eql?, equal?, and ===. They look almost interchangeable, and most of the time == is the one you want. But each exists for a reason, and the differences explain things you’ve probably run into without quite understanding why: why case statements match the way they do, why two equal-looking objects sometimes count as different hash keys, and why 1 == 1.0 is true but 1.eql?(1.0) is not.

Here’s the whole family, in order from “loosest” to “most specific.”

==, value equality

This is the everyday one. == asks “do these represent the same value?” For most objects it’s what you reach for, and it’s the method you override when you write your own classes.

1 == 1            # => true
1 == 1.0          # => true   (same numeric value, different types)
"abc" == "abc"    # => true
:sym == :sym      # => true
[1, 2] == [1, 2]  # => true

The key thing about == is that it’s meant to be overridden. The default implementation (inherited from BasicObject) only returns true when both sides are literally the same object, but nearly every built-in class redefines it to compare values instead. When you define your own value classes, you do the same:

class Money
  attr_reader :cents, :currency

  def initialize(cents, currency)
    @cents = cents
    @currency = currency
  end

  def ==(other)
    other.is_a?(Money) &&
      cents == other.cents &&
      currency == other.currency
  end
end

Money.new(500, "USD") == Money.new(500, "USD")  # => true

Without that == override, two Money objects with identical values would be considered unequal, because the default compares object identity, not contents.

equal?, identity equality

equal? asks the strictest possible question: “are these the exact same object?” You should never override it. It compares object identity, and Ruby uses it to mean “literally the same thing in memory.”

a = "hello"
b = "hello"
c = a

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

You can confirm what it’s checking with object_id:

a.object_id == b.object_id  # => false
a.object_id == c.object_id  # => true

equal? is rarely used directly in application code. Its main value is conceptual: it’s the baseline that the other equality methods are defined against and deliberately loosen. Symbols and small integers are the interesting exception, because Ruby guarantees there’s only ever one of each:

:admin.equal?(:admin)  # => true   (symbols are interned)
1.equal?(1)            # => true   (small integers are singletons)

That’s part of why symbols make better hash keys than strings: identical symbols are genuinely the same object.

eql?, value equality without type coercion

eql? sits between == and equal?. It checks value equality like ==, but it does not do the numeric type coercion that == does. This is the method Hash uses to decide whether two keys are the same.

1 == 1.0       # => true   (== coerces across numeric types)
1.eql?(1.0)    # => false  (eql? treats Integer and Float as different)
1.eql?(1)      # => true

This distinction is exactly why a hash treats 1 and 1.0 as separate keys:

h = {}
h[1] = "integer key"
h[1.0] = "float key"
h  # => {1=>"integer key", 1.0=>"float key"}

If Hash used ==, these would collide. It uses eql? (together with hash), so they stay distinct.

This is the part that bites people writing custom classes. If you want your objects to work correctly as hash keys, overriding == is not enough. You must also override eql? and hash, and keep them consistent: two objects that are eql? must return the same hash value.

class Money
  # ... ==, attr_readers from before ...

  def eql?(other)
    self == other
  end

  def hash
    [cents, currency].hash
  end
end

prices = {}
prices[Money.new(500, "USD")] = "coffee"
prices[Money.new(500, "USD")]  # => "coffee"  (works now)

Without eql? and hash, that second lookup would return nil, because the hash would file the two equal-valued objects under different buckets. (This is one of the reasons Data.define, covered in an earlier post, is so convenient: it wires up ==, eql?, and hash for you.)

===, case equality

=== is the odd one out. Despite looking like “extra equals,” it has nothing to do with stricter equality. It’s the “case equality” or “membership” operator, and its job is to answer “does this case match this value?” Ruby calls it for you behind every when clause in a case statement.

The trick is that different classes define === to mean different, useful things:

# Class#=== asks "is this an instance of me?"
String === "hello"    # => true
Integer === 42        # => true
Integer === "hello"   # => false

# Range#=== asks "does this fall inside me?"
(1..10) === 5         # => true
(1..10) === 50        # => false

# Regexp#=== asks "does this match?"
/^a/ === "apple"      # => true

That’s what makes case statements expressive. When you write this:

case input
when Integer    then "a number"
when String     then "some text"
when (1..10)    then "small range"  # unreachable here, just illustrating
when /^\d+$/    then "digit string"
else "something else"
end

Ruby is calling Integer === input, then String === input, and so on. Each when uses the right kind of matching for its type, all because === is overridden per class. You almost never call === directly; you let case call it for you.

Putting it together

A quick reference for which is which:

The practical takeaways are short. Use == for normal comparisons. When you write a value class, override ==, and if it’ll be a hash key, override eql? and hash to match. Leave equal? alone. And remember that === in a case statement is asking “does this match,” which is why you can switch on classes, ranges, and regexes in the same construct.

When equality surprises you

Most equality bugs come from one of these mismatches: a custom class that overrides == but not hash, so it misbehaves as a hash key; or test output that compares two structures and reports them as different when they look identical, because a nested value has a different type (1 vs 1.0, a symbol vs a string).

That second case is where a failing test dumps two near-identical hashes and leaves you to find the one value whose type drifted. That’s the exact problem RubyHash solves: paste the expected and actual hashes and it highlights the precise key that differs, including type changes like nil to a string or an integer to a float, so you can see in a second what == could already tell you but the terminal couldn’t.

Four kinds of equal sounds like overkill until you realize each one answers a genuinely different question. Once the distinctions click, a surprising amount of Ruby behavior, from hash keys to case statements, stops being magic and starts being obvious.


Comparing Ruby hashes in a failing test and need to spot the difference fast? Try RubyHash, paste your two hashes for a clean, side-by-side diff.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.