Ruby's Comparable Module in 5 Minutes
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:
-1if the receiver is less than the argument0if they’re equal1if the receiver is greaternilif they’re not comparable
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:
- Version numbers (as shown above)
- Money/currency values (same currency only)
- Priority or rank objects (tasks, queue items)
- Date-like wrappers (fiscal quarters, sprint numbers)
- Scores or ratings
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.