sort and sort_by
Sorting is one of the most common things you do with a collection of data in Ruby, and Ruby gives you two closely related tools for it: sort and sort_by. Both come from the Enumerable module, so they work on any object that implements each and mixes in Enumerable — arrays, hashes, ranges, sets, and your own custom collections. They look similar but work differently under the hood, and picking the wrong one (or misunderstanding how comparisons work) is a very common source of subtle bugs and slow code once your sorting logic gets more complex than “put the numbers in order.”
Overview: How Sorting Works in Ruby
By default, sort orders elements using the spaceship operator, <=>. For any two objects a and b, a <=> b returns -1 if a should come before b, 0 if they are equal for ordering purposes, 1 if a should come after b, and nil if the two objects simply cannot be compared. Ruby’s built-in numeric, string, symbol, and array classes all define <=>, which is why [3, 1, 2].sort or ["b", "a"].sort just work without any extra code.
Because almost everything in Ruby is an object, this mechanism extends to your own classes too. If you mix in the Comparable module and define a single <=> method, your objects automatically gain sort, min, max, between?, clamp, and the comparison operators (<, >, ==, and so on) — all derived from that one method via Ruby’s method lookup along the ancestor chain (your class, then Comparable, then its superclass, and so on).
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
readings = [Temperature.new(72), Temperature.new(58), Temperature.new(90)]
puts readings.sort.map(&:to_s).join(", ")
Output:
58°, 72°, 90°
sort also accepts a block that takes two elements and must return -1, 0, or 1 (or nil, which raises an error). This lets you override the default ordering, for example to sort descending.
sort_by, on the other hand, doesn’t compare elements directly at all. Instead, its block computes a sort key for each element, and Ruby sorts the elements by comparing those keys with <=>. Internally this is the “Schwartzian transform” (decorate–sort–undecorate) pattern: Ruby computes the key for every element exactly once, pairs each element with its key, sorts by the keys, and then discards the keys, returning just the elements in the new order. This matters for performance: with sort { |a, b| ... }, the block runs on every comparison during the sort (roughly O(n log n) calls), but with sort_by, the key-computing block runs exactly once per element (O(n) calls). If computing the key is expensive — parsing a string, calling a method that hits a database, doing math — sort_by is usually both faster and easier to read.
Both sort and sort_by return a new array and leave the receiver untouched. Their bang counterparts, sort! and sort_by! (defined on Array), mutate the receiver in place and return it. One more thing worth knowing up front: Ruby’s sort is not guaranteed to be stable — if two elements compare as equal, their relative order after sorting is not guaranteed to match their original order.
Syntax
array.sort # new array, default <=> ordering
array.sort { |a, b| ... } # new array, custom comparator (-1, 0, 1)
array.sort! # mutates array in place
enumerable.sort_by { |elem| key } # new array, ordered by the block's return value
array.sort_by! { |elem| key } # mutates array in place
| Form | Returns | When to use |
|---|---|---|
sort |
New array | Elements already support <=> (numbers, strings, Comparable objects) |
sort { |a,b| ... } |
New array | You need custom comparison logic that isn’t a simple transformed value |
sort! |
Same array, mutated | You intentionally want to reorder the existing array object |
sort_by { |x| key } |
New array | You can compute a value to sort by (length, an attribute, a computed score) |
sort_by! |
Same array, mutated | Same as sort_by, but in place |
Examples
Example 1: Basic ascending and descending sort
numbers = [42, 7, 19, 3, 88, 15]
ascending = numbers.sort
descending = numbers.sort { |a, b| b <=> a }
puts "Original: #{numbers}"
puts "Ascending: #{ascending}"
puts "Descending: #{descending}"
Output:
Original: [42, 7, 19, 3, 88, 15]
Ascending: [3, 7, 15, 19, 42, 88]
Descending: [88, 42, 19, 15, 7, 3]
numbers.sort uses the default <=> for integers to produce ascending order without any block. For descending order, the block flips the comparison by swapping the arguments: b <=> a instead of a <=> b. Notice that numbers itself is untouched afterward — both calls returned new arrays.
Example 2: sort_by with a computed key
words = ["banana", "kiwi", "fig", "watermelon"]
by_length = words.sort_by { |word| word.length }
by_length_desc = words.sort_by { |word| -word.length }
puts by_length.inspect
puts by_length_desc.inspect
Output:
["fig", "kiwi", "banana", "watermelon"]
["watermelon", "banana", "kiwi", "fig"]
Here the block doesn’t compare two words directly — it produces a number (the word’s length) for each word, and Ruby sorts by those numbers. Negating the length (-word.length) is a simple, idiomatic way to reverse a numeric sort key without having to reverse the whole array afterward. This only works because the key is a number; we’ll see why that trick doesn’t generalize to every type in Common Mistakes below.
Example 3: Multi-key sorting with an array key
tasks = [
{ title: "Fix login bug", priority: 2 },
{ title: "Write docs", priority: 3 },
{ title: "Deploy hotfix", priority: 1 },
{ title: "Update tests", priority: 1 }
]
sorted_tasks = tasks.sort_by { |task| [task[:priority], task[:title]] }
sorted_tasks.each do |task|
puts "[#{task[:priority]}] #{task[:title]}"
end
Output:
[1] Deploy hotfix
[1] Update tests
[2] Fix login bug
[3] Write docs
This is the idiomatic way to sort by more than one criterion in Ruby: return an array as the key. Arrays compare element by element, so tasks are ordered primarily by priority; when two tasks share the same priority (both are 1 here), Ruby moves on to the second element of the key array, title, to break the tie alphabetically. This is more reliable than chaining separate sort_by calls, since Ruby’s sort isn’t guaranteed stable.
How It Works Step by Step
Walking through Example 3’s sort_by call:
- Ruby calls the block once for every element in
tasks, producing a key array like[2, "Fix login bug"]for each task. - Each task is paired internally with its computed key (this is the “decorate” step of the Schwartzian transform).
- The pairs are sorted by comparing keys with
<=>. Since the keys are themselves arrays,Array#<=>compares the first elements (priority) first; only when those are equal does it compare the second elements (title). - The original tasks are extracted back out in their new order (the “undecorate” step), and that new array is returned —
tasksitself is never modified.
Plain sort with a block works differently: there’s no decoration step. Ruby repeatedly picks pairs of elements and calls your block directly on them as it sorts, so for an array of n elements, the block can run on the order of n log n times rather than just n times.
Common Mistakes
Mistake 1: Sorting a collection that contains incomparable values
nil doesn’t define a meaningful <=> against numbers, so mixing it into a numeric array blows up at sort time:
irb> [3, nil, 1, nil, 2].sort
(irb):1:in `sort': comparison of Integer with nil failed (ArgumentError)
The fix is to remove or handle the incomparable values before sorting, for example with compact:
values = [3, nil, 1, nil, 2]
sorted_values = values.compact.sort
puts sorted_values.inspect
Output:
[1, 2, 3]
Mistake 2: Assuming unary minus reverses any sort_by key
Negating a numeric key (-word.length) reverses a numeric sort, but applying the same trick to a string doesn’t do what people expect. Ruby strings define unary minus (-@) to mean “return a frozen, deduplicated copy of this string” — not “reverse my sort order.” The code runs without error, which makes the bug easy to miss:
names = ["Grace", "Ada", "Marie", "Alan"]
wrong_desc = names.sort_by { |name| -name }
puts wrong_desc.inspect
Output:
["Ada", "Alan", "Grace", "Marie"]
That’s alphabetical ascending order — the exact opposite of what the name wrong_desc promises, because -name just returns an equivalent frozen string, not a reversed comparison key. To sort strings (or any non-numeric key) in descending order, sort ascending first and then reverse the resulting array, or flip the comparison in a block:
names = ["Grace", "Ada", "Marie", "Alan"]
correct_desc = names.sort_by { |name| name }.reverse
puts correct_desc.inspect
Output:
["Marie", "Grace", "Alan", "Ada"]
Mistake 3: Forgetting that sort! mutates a shared reference
Arrays are objects, and a variable holding an array just holds a reference to it. If two variables point at the same array, sort! on one affects what you see through the other too — it’s the same object in memory:
original = [3, 1, 2]
current_scores = original
sorted = original.sort!
puts "original: #{original}"
puts "current_scores: #{current_scores}"
puts "same object? #{original.equal?(current_scores)}"
Output:
original: [1, 2, 3]
current_scores: [1, 2, 3]
same object? true
If current_scores was meant to keep the pre-sort order for later comparison, this silently breaks that assumption. Use dup to make an independent copy before mutating, or simply prefer the non-bang sort, which never touches the original:
original = [3, 1, 2]
current_scores = original.dup
sorted = original.sort!
puts "original: #{original}"
puts "current_scores: #{current_scores}"
Output:
original: [1, 2, 3]
current_scores: [3, 1, 2]
Best Practices
- Reach for
sort_bywhenever the ordering depends on a computed or extracted value (a length, an attribute, a parsed number) — it’s clearer and avoids recomputing the key on every comparison. - Reach for
sortwith a block only when the comparison genuinely can’t be reduced to a single key, such as comparing multiple independent rules with custom tie-breaking logic. - For multi-level sorting, return an array from the
sort_byblock ([primary_key, secondary_key]) instead of chaining multiple sorts, since Ruby’s sort is not guaranteed stable. - Only use
sort!/sort_by!when you specifically want to mutate the existing array and are sure no other variable relies on its original order — otherwise prefer the non-mutating versions. - Give custom classes a
<=>method and includeComparablerather than writing one-off comparison blocks everywhere; you getsort,min,max, and the comparison operators for free. - Remember that only
nilandfalseare falsy in Ruby — a block that returns0,"", or[]as a sort key is still a valid, truthy key, so don’t confuse “falsy-looking” keys with actually broken ones.
Practice Exercises
- Given
offsets = [-8, 3, -1, 10, -6, 2], usesort_byto order the values by their absolute value, smallest first. Expected output:[-1, 2, 3, -6, -8, 10]. - Given an array of hashes
books = [{title: "Ruby Basics", pages: 120}, {title: "Advanced Ruby", pages: 340}, {title: "Ruby for Kids", pages: 80}], sort by:pagesin descending order and print just the titles, one per line. The first line printed should beAdvanced Ruby. - Define a
StructcalledPlayerwith:nameand:score, includeComparablein it, and implement<=>so that comparing two players compares their scores. Build an array of a few players and confirm that calling plainsortorders them from lowest to highest score.
Summary
sortorders elements using<=>(or a block that returns-1,0, or1), comparing elements directly.sort_bycomputes a key for each element once (the Schwartzian transform) and sorts by that key — faster and clearer when the key involves any computation.- Both return new arrays;
sort!andsort_by!mutate the receiver in place and are subject to shared-reference bugs. - Use an array like
[primary, secondary]as asort_bykey for reliable multi-level sorting, since Ruby’s sort is not guaranteed stable. - Mixing incomparable values (like
nil) into a sort raisesArgumentError; unary minus only reverses numeric keys, not strings or other objects. - Including
Comparableand defining<=>on a custom class gives yousort,min,max, and comparison operators for free.
