sort and sort_by

Published by Programming Line in Ruby. Last updated .

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 — tasks itself 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_by whenever 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 sort with 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_by block ([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 include Comparable rather than writing one-off comparison blocks everywhere; you get sort, min, max, and the comparison operators for free.
  • Remember that only nil and false are falsy in Ruby — a block that returns 0, "", 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], use sort_by to 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 :pages in descending order and print just the titles, one per line. The first line printed should be Advanced Ruby.
  • Define a Struct called Player with :name and :score, include Comparable in it, and implement <=> so that comparing two players compares their scores. Build an array of a few players and confirm that calling plain sort orders them from lowest to highest score.

Summary

  • sort orders elements using <=> (or a block that returns -1, 0, or 1), comparing elements directly.
  • sort_by computes 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! and sort_by! mutate the receiver in place and are subject to shared-reference bugs.
  • Use an array like [primary, secondary] as a sort_by key for reliable multi-level sorting, since Ruby’s sort is not guaranteed stable.
  • Mixing incomparable values (like nil) into a sort raises ArgumentError; unary minus only reverses numeric keys, not strings or other objects.
  • Including Comparable and defining <=> on a custom class gives you sort, min, max, and comparison operators for free.