← Back to blog

Ruby's Comparable Module in 5 Minutes

· Lachlan Young

Ruby’s Comparable module is one of those things that feels too good to be true. You include it in your class, define a single method (<=>), and suddenly your objects support <, >, <=, >=, between?, and clamp. They also work with sort, min, max, and every Enumerable method that relies on ordering.

One method. Six operators. Full sorting support. Let’s see how it works.

The spaceship operator

Everything hinges on <=>, sometimes called the spaceship operator. It compares two objects and returns:

Ruby’s built-in types already implement it:

1 <=> 2       # => -1
"b" <=> "a"   # => 1
3 <=> 3       # => 0
1 <=> "hello" # => nil

When you include Comparable and define <=>, the module generates all the comparison methods from that single definition. You’re telling Ruby “here’s how to order my objects,” and Ruby handles the rest.

A real example: Version numbers

Say you’re working with software version numbers and need to compare them properly. String comparison won’t work because "9.0" sorts after "10.0" lexicographically.

class Version
  include Comparable

  attr_reader :major, :minor, :patch

  def initialize(version_string)
    parts = version_string.split(".").map(&:to_i)
    @major = parts[0] || 0
    @minor = parts[1] || 0
    @patch = parts[2] || 0
  end

  def <=>(other)
    return nil unless other.is_a?(Version)

    [major, minor, patch] <=> [other.major, other.minor, other.patch]
  end

  def to_s
    "#{major}.#{minor}.#{patch}"
  end
end

That array comparison on line 14 is doing the heavy lifting. Ruby compares arrays element by element, which is exactly what semantic versioning needs: major first, then minor, then patch.

Now look at what you get for free:

v1 = Version.new("1.2.3")
v2 = Version.new("1.3.0")
v3 = Version.new("2.0.0")

v1 < v2            # => true
v2 > v1            # => true
v1 >= v1           # => true
v2.between?(v1, v3) # => true
v3.clamp(v1, v2)   # => #<Version 1.3.0>

[v3, v1, v2].sort  # => [v1, v2, v3]
[v3, v1, v2].min   # => v1
[v3, v1, v2].max   # => v3

You wrote one method and your Version objects behave like first-class citizens in Ruby’s type system.

Another example: Money

Comparable is perfect for value objects. Here’s a simple money type:

class Money
  include Comparable

  attr_reader :amount, :currency

  def initialize(amount, currency = "USD")
    @amount = amount
    @currency = currency
  end

  def <=>(other)
    return nil unless other.is_a?(Money)
    return nil unless currency == other.currency

    amount <=> other.amount
  end

  def to_s
    "#{currency} #{'%.2f' % amount}"
  end
end

Notice the second nil return: if the currencies don’t match, comparison is meaningless. Returning nil tells Ruby these objects can’t be ordered relative to each other.

a = Money.new(10, "USD")
b = Money.new(20, "USD")
c = Money.new(15, "EUR")

a < b    # => true
a < c    # => nil (different currencies, not comparable)
b.clamp(a, Money.new(25, "USD"))  # => #<Money USD 20.00>

prices = [Money.new(50, "USD"), Money.new(10, "USD"), Money.new(30, "USD")]
prices.sort  # => [USD 10.00, USD 30.00, USD 50.00]
prices.min   # => USD 10.00

How sort uses <=>

When you call sort on an array, Ruby calls <=> between pairs of elements to determine ordering. That’s it. There’s no separate sorting interface. If your class defines <=>, it’s sortable.

versions = ["2.1.0", "1.0.3", "1.2.0", "3.0.0"].map { |v| Version.new(v) }
versions.sort.map(&:to_s)
# => ["1.0.3", "1.2.0", "2.1.0", "3.0.0"]

sort_by also works, and it’s often faster for complex comparisons because it only calls the block once per element:

versions.sort_by { |v| [v.major, v.minor, v.patch] }

The clamp method

clamp is underused but incredibly handy. It restricts a value to a given range:

v = Version.new("0.5.0")
min = Version.new("1.0.0")
max = Version.new("3.0.0")

v.clamp(min, max)  # => #<Version 1.0.0>  (clamped up to minimum)

For numbers, you’ve probably seen [0, value, 100].sort[1] as a manual clamp. With Comparable, you just call value.clamp(0, 100). Cleaner, clearer, built-in.

Since Ruby 2.7, clamp also accepts a Range:

score = 150
score.clamp(0..100)  # => 100

When to reach for Comparable

Any time your custom objects have a natural ordering, include Comparable. Some common cases:

The pattern is always the same: include the module, define <=>, and you’re done. Ruby handles the rest.

One thing to watch

If you include Comparable, make sure your <=> is consistent with ==. By default, Comparable defines == using <=>, so two objects where a <=> b returns 0 will be considered equal. If that’s not what you want (maybe you need identity-based equality), you’ll need to override == explicitly.

# Comparable's == uses <=>
v1 = Version.new("1.0.0")
v2 = Version.new("1.0.0")
v1 == v2  # => true, because <=> returns 0

For value objects, this is usually the behavior you want. Two version 1.0.0 objects should be equal. But it’s worth knowing the mechanism.

The takeaway

Comparable is Ruby at its best: a tiny module with a minimal contract that gives you a lot of behavior for free. One method, and your objects participate fully in Ruby’s comparison and sorting ecosystem. If you’re building any kind of value object, it should probably be in your include list.


Comparing complex objects in your test output? Try RubyHash to get a clean, highlighted diff of any two Ruby hashes.

Enjoyed this post?

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