skip to content

In Ruby, how does Enumerable#sort with a <=> block differ from sort_by, and which one is faster?

level: middleimportance: must knowfreq 62%

answer

  1. two elements per call vs one
  2. block runs per comparison vs per element
  3. cached keys: the Schwartzian transform
  4. cheap keys: plain sort wins
  5. nil from the block raises ArgumentError

basics

~20 s

sort's block compares two elements and runs once per comparison; sort_by's block maps each element to a key once, then Ruby sorts the cached keys. sort_by wins when keys are expensive; blockless sort wins when elements are their own keys.

solid answer

~50 s

`sort { |a, b| ... }` is a **comparator**: Ruby calls it with two elements for every comparison the sort makes, roughly n log n times, and it must return a negative integer, zero or a positive integer (usually via `<=>`). `sort_by { |x| ... }` is a **key function**: Ruby calls it once per element, stores `[key, element]` pairs and sorts the pairs by key, the Schwartzian transform. So `sort_by` wins when computing the key is expensive (a method call, a `downcase`, a file stat), because each key is computed n times instead of about 2·n log n times. For keys that are the elements themselves, plain `sort` without a block is faster: it compares Integers and Strings in C with no block calls and no tuple array. A comparator that returns `nil` raises `ArgumentError` (`comparison of ... failed`).

code

ruby · 14 lines
ruby
Hotel = Data.define(:name, :rating, :price)
hotels = [
  Hotel.new("Ash", 4.5, 120),
  Hotel.new("Birch", 4.8, 200),
  Hotel.new("Cedar", 4.5, 90)
]

calls = 0
hotels.sort { |a, b| calls += 2; a.price <=> b.price }
puts calls # key computed on both sides of each comparison

calls = 0
hotels.sort_by { |h| calls += 1; h.price }
puts calls # => 3, once per element

go deeper

for a junior

Recall the two block shapes: sort takes two elements and returns the result of <=>, sort_by takes one element and returns a key.

for a middle

Explain the Schwartzian transform behind sort_by, count the block calls on each side, and say why plain sort beats sort_by when elements are their own keys.

for a senior

Pick the variant by key cost on hot paths, spot comparators that recompute keys or return booleans, and know that incomparable keys raise ArgumentError at runtime.

for a principal

Frame the choice as a readability default (sort_by) with measured exceptions, and insist on benchmarks rather than folklore before rewriting sorts for speed.

## Two ways to tell Ruby what "sorted" means `Enumerable#sort` and `Enumerable#sort_by` both return a **new Array** with the elements in ascending order. They differ in what their block is asked to do. | | `sort { \|a, b\| ... }` | `sort_by { \|x\| ... }` | |---|---|---| | Block receives | two elements | one element | | Block returns | negative, zero or positive number | a **key** object | | Block calls for n elements | once per comparison, roughly n log n | exactly n | | Extra memory | none beyond the result | a temporary array of key/element pairs | | Without a block | compares elements with their own `<=>` | returns an `Enumerator` | The block of `sort` is a **comparator**. Ruby's contract, from the rdoc in `enum.c` and `array.c`, is that it returns a negative integer when `a` should come first, zero when they are equivalent, and a positive integer when `b` should come first. That is exactly what the **spaceship operator** `<=>` returns, which is why nearly every `sort` block is written `a.something <=> b.something`. The block of `sort_by` is a **key function**: it maps each element to a value that Ruby then compares with `<=>`. ## What sort_by does internally The rdoc for `sort_by` spells it out: it builds an array of tuples holding each element and its mapped value, sorts that array by the mapped value, and then pulls the elements back out. Perl programmers call this the **Schwartzian transform** (decorate, sort, undecorate). The consequences: 1. The block runs **once per element**, so an expensive key (`name.downcase`, `File.mtime(path)`, a parsed date) is computed n times. 2. A comparator doing the same work inside `sort` computes it on **both sides of every comparison**, so about 2 · n log n times. 3. The decorated array costs an allocation and extra memory proportional to n. In CRuby 4.0, `sort_by` also has a fast path: when every key is an Integer that fits in a machine word or a Float, `enum.c` sorts the pairs with its own introsort and compares keys in C without calling `<=>`. ## Which is faster There is no single winner; it depends on how expensive the key is. - **Sorting the elements themselves** (`numbers.sort`, `names.sort`): use `sort` **without a block**. For Integers, Strings and Floats, `Array#sort` compares directly in C (as long as `<=>` has not been redefined) and never calls a block. The rdoc benchmark for `sort_by` shows `a.sort` roughly ten times faster than `a.sort_by { |a| a }` on 100,000 random Integers. - **Sorting by a derived key** (`hotels.sort_by { |h| h.rating }`): use `sort_by`. It is clearer and calls the key method n times. - **Sorting by an expensive key**: `sort_by` is the clear winner, because its advantage grows with the cost of the key. - **Custom comparison logic** that is not "compare two keys" (for example, mixing ascending and descending string keys): use `sort` with a block. A `sort` block that calls methods on both sides is the slowest option of all for a derived key, because it pays both the block call and the key computation on every comparison. ## Errors a comparator can raise The return value of a `sort` block goes through CRuby's `rb_cmpint`, which accepts Integers (and treats their sign as the answer) but raises on `nil`: ```ruby [3, 1, 2].sort { |a, b| nil } # raises ArgumentError: comparison of Integer with ... failed [3, 1, "2"].sort # raises ArgumentError too: Integer <=> String returns nil ``` The same happens in `sort_by` when two keys cannot be compared, because `<=>` between them returns `nil`. Returning `true` or `false` from a comparator is a common mistake carried over from languages whose sort takes a "less than" predicate; Ruby expects a number. ## Hotel ranking example ```ruby Hotel = Data.define(:name, :rating, :price) hotels = [Hotel.new("Ash", 4.5, 120), Hotel.new("Birch", 4.8, 200)] hotels.sort { |a, b| a.price <=> b.price } # comparator: 2 keys per call hotels.sort_by { |h| h.price } # key function: 1 key per element ``` Both return the same order here. The `sort_by` version is what most Ruby style guides and reviewers expect for a single-key sort. ## What to say in an interview - `sort`'s block compares two elements; `sort_by`'s block extracts one key. - `sort_by` computes each key once, at the cost of a temporary array. - Plain `sort` with no block is fastest when the elements are their own keys. - Neither method is guaranteed to keep equal elements in their original order.

  • When would you still reach for sort with a block rather than sort_by?
    When the ordering is not "compare one key": for example, a descending String key mixed with an ascending one, where negation is impossible, written as `(b.name <=> a.name).nonzero? || a.price <=> b.price`. Also when an object's comparison logic is already written as a comparator you want to reuse.
  • What does sort_by return when called without a block?
    An `Enumerator`. That is what makes `hotels.sort_by.with_index { |h, i| [h.rating, i] }` work: the enumerator passes each element and its index to the block, and the block's return value becomes the sort key.
  • Why can sort_by be slower than sort for an array of Integers?
    Because it still calls the block once per element and allocates a temporary array of key/element pairs, while `Array#sort` without a block compares Integers directly in C with no Ruby calls and no extra array.

saying these in an interview costs you the question

  • sort_by calls its block once per comparison, just like sort
  • sort_by is always faster than sort, even for plain Integers
  • A sort block may return true or false like a less-than predicate
  • A sort block returning nil is treated as equal
  • sort with a block sorts the receiver in place