Skip to main content
⚡ Calmops

Equality and Comparison in Ruby: ==, eql?, equal?, <=>, and ===

Introduction

Ruby has five distinct equality/comparison operators, each with a specific semantic meaning. Using the wrong one is a common source of bugs. This guide explains each one clearly with examples.

== : Value Equality

== tests whether two objects have the same value. It’s the most commonly used equality check and can be overridden in any class.

# Numbers
1 == 1      # => true
1 == 1.0    # => true   (Integer and Float with same value)
1 == "1"    # => false  (different types)

# Strings
"hello" == "hello"  # => true
"hello" == "Hello"  # => false  (case-sensitive)

# Arrays
[1, 2, 3] == [1, 2, 3]  # => true
[1, 2, 3] == [1, 2]     # => false

# Hashes
{a: 1} == {a: 1}  # => true
{a: 1} == {a: 2}  # => false

# nil
nil == nil    # => true
nil == false  # => false

Custom == in Your Classes

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x, @y = x, y
  end

  def ==(other)
    other.is_a?(Point) && x == other.x && y == other.y
  end
end

p1 = Point.new(1, 2)
p2 = Point.new(1, 2)
p3 = Point.new(3, 4)

p1 == p2  # => true   (same values)
p1 == p3  # => false
p1 == [1, 2]  # => false (not a Point)

eql? : Strict Value Equality

eql? is stricter than == — it requires both value and type to match. It’s used by Hash to compare keys.

1.eql?(1)     # => true
1.eql?(1.0)   # => false  (Integer ≠ Float, even with same value)
1.eql?("1")   # => false

"hello".eql?("hello")  # => true
"hello".eql?("Hello")  # => false

:foo.eql?(:foo)  # => true

Why eql? Matters for Hash Keys

Hash uses eql? (and hash) to determine key equality:

h = {}
h[1]   = "integer one"
h[1.0] = "float one"

puts h.length  # => 2  (1 and 1.0 are different keys!)
puts h[1]      # => "integer one"
puts h[1.0]    # => "float one"

# But:
1 == 1.0    # => true
1.eql?(1.0) # => false  (different hash keys)

Implementing eql? in Custom Classes

When you override eql?, you must also override hash to maintain the contract:

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x, @y = x, y
  end

  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  # must be consistent with eql?
  end
end

p1 = Point.new(1, 2)
p2 = Point.new(1, 2)

# Now Points work correctly as Hash keys
h = { p1 => "first point" }
puts h[p2]  # => "first point"  (p1.eql?(p2) is true)

equal? : Object Identity

equal? tests whether two variables refer to the exact same object in memory (same object_id). It should never be overridden.

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

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

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

# Symbols and small integers are always the same object
:foo.equal?(:foo)  # => true
1.equal?(1)        # => true

<=> : The Spaceship Operator (Comparison)

<=> returns -1, 0, or 1 (or nil if not comparable). It’s the foundation of sorting and the Comparable module.

1 <=> 2   # => -1  (less than)
2 <=> 2   # => 0   (equal)
3 <=> 2   # => 1   (greater than)
1 <=> "a" # => nil (not comparable)

"apple" <=> "banana"  # => -1
"banana" <=> "apple"  # => 1
"apple" <=> "apple"   # => 0

Using Comparable

Include Comparable and define <=> to get <, <=, >, >=, between?, and clamp for free:

class Temperature
  include Comparable

  attr_reader :degrees

  def initialize(degrees)
    @degrees = degrees
  end

  def <=>(other)
    degrees <=> other.degrees
  end

  def to_s
    "#{degrees}°"
  end
end

temps = [Temperature.new(100), Temperature.new(0), Temperature.new(37)]

puts temps.sort.map(&:to_s).inspect
# => ["0°", "37°", "100°"]

puts temps.min  # => 0°
puts temps.max  # => 100°

body_temp = Temperature.new(37)
puts body_temp.between?(Temperature.new(36), Temperature.new(38))  # => true

=== : Case Equality (Triple Equals)

=== is used by case/when statements. Its behavior varies by class:

# Integer: same as ==
1 === 1      # => true
1 === 1.0    # => true

# Range: membership test
(1..10) === 5   # => true
(1..10) === 15  # => false

# Regexp: pattern match
/hello/ === "hello world"  # => true
/hello/ === "goodbye"      # => false

# Class/Module: is_a? check
String === "hello"   # => true
Integer === 42       # => true
Integer === "42"     # => false

# Proc/Lambda: call the proc
even = ->(n) { n.even? }
even === 4   # => true
even === 3   # => false

How case/when Uses ===

value = 42

case value
when 1..10      # (1..10) === 42  => false
  puts "small"
when 11..100    # (11..100) === 42 => true
  puts "medium"
when Integer    # Integer === 42  => true (not reached)
  puts "an integer"
end
# => "medium"

# Pattern matching with Regexp
http_status = "404 Not Found"

case http_status
when /^2\d\d/
  puts "Success"
when /^4\d\d/
  puts "Client error"  # => this matches
when /^5\d\d/
  puts "Server error"
end

Custom === for DSLs

class AgeRange
  def initialize(min, max)
    @min, @max = min, max
  end

  def ===(age)
    age >= @min && age <= @max
  end

  def to_s
    "#{@min}-#{@max}"
  end
end

child  = AgeRange.new(0, 12)
teen   = AgeRange.new(13, 17)
adult  = AgeRange.new(18, 64)
senior = AgeRange.new(65, 120)

age = 25

case age
when child  then puts "Child"
when teen   then puts "Teen"
when adult  then puts "Adult"   # => matches
when senior then puts "Senior"
end

=~ : Pattern Match

=~ tests a string against a regular expression and returns the position of the match (or nil):

"hello world" =~ /world/  # => 6  (position of match)
"hello world" =~ /xyz/    # => nil

# Sets $~ (MatchData), $1, $2, etc.
if "2026-03-29" =~ /(\d{4})-(\d{2})-(\d{2})/
  puts $1  # => "2026"
  puts $2  # => "03"
  puts $3  # => "29"
end

Quick Reference

Operator Tests Override? Used by
== Value equality Yes General comparison
eql? Strict value + type Yes (with hash) Hash keys
equal? Object identity Never Identity check
<=> Ordering Yes Sorting, Comparable
=== Case equality Yes case/when
=~ Regex match Yes Pattern matching

Resources

Comments