Ruby Coding Interview Gotchas: The Footguns Nobody Warns You About

Array.new(n, [])shares one array across all rows; use a blockArray.new(n) { [] }to get independent rows- Only
nilandfalseare falsy in Ruby;0,"", and[]all evaluate as true - Symbol keys and string keys are not interchangeable;
:name != "name"and JSON always returns string keys - Bang methods like
gsub!anduniq!returnnilwhen nothing changed; never assign their result Array#shiftis O(n); track a front-index pointer for BFS queues on large graphs- Ruby's sort is not guaranteed stable; decorate with index if order of equal elements matters
..includes the end,...excludes it; most array index loops want three dots
Ruby reads like pseudocode. That's exactly why it tricks you. Write Array.new(3, []) and you get three pointers to one list. Check if 0 and the branch runs. Call sort! and get back nil. None of these produce an error. They silently produce wrong answers at runtime, which in an interview means a confident wrong answer with no stack trace to blame. This guide covers each trap with a working example and a fix. For the broader overview, start with Ruby for Coding Interviews.
The Aliasing Trap: Why Array.new Lies to You
grid = Array.new(3, []) grid[0] << 1 p grid # [[1], [1], [1]] -- you only touched index 0
Every row is the same object. Array.new(n, default) fills the array with n references to one shared default value. Works fine for immutable types like integers or symbols. Breaks silently for anything mutable. The bug won't show up on your first test case. It shows up when you're twenty lines deep, the output is totally wrong, and row 2 is inexplicably contaminated.
The fix is a block:
grid = Array.new(3) { [] } grid[0] << 1 p grid # [[1], [], []] -- correct
The block runs once per element, so each row gets its own fresh array. This is the canonical Ruby idiom for 2D arrays and adjacency lists.
Hash.new has the same trap:
# Wrong: all keys share the same array groups = Hash.new([]) groups[:a] << "foo" p groups[:b] # ["foo"] -- contaminated # Right: block creates a new array per key groups = Hash.new { |h, k| h[k] = [] } groups[:a] << "foo" p groups[:b] # []
Hash.new(0) is fine for frequency counting because integers are immutable. Hash.new([]) is almost never what you want.
Only nil and false Are Falsy
If you learned Python or JavaScript first, this will catch you. It matters more than it sounds in practice. A lot more.
In Ruby, 0, "", and [] are all truthy. Only nil and the literal false are falsy. Go ahead and sit with that for a second.
if 0 puts "runs" # this runs end if "" puts "runs" # this too end
This changes how you guard against empty collections:
return if arr.nil? # only blocks nil return if arr.empty? # only blocks [] return if arr.nil? || arr.empty? # blocks both
The corollary: methods that "fail" return nil, not false or 0. So Array#index, Hash#[], and String#match all return nil on a miss. That is consistent once you know it, but it means if hash[key] silently fails when the stored value is false or 0.
seen = { 0 => false } if seen[0] # evaluates to false -- wrong guard puts "found" end # Correct: if seen.key?(0) puts "found" end
This one bites particularly hard on graph problems where you track visited nodes and want to store extra state. The key exists, the value is falsy, and your traversal happily revisits the node.

When seen[node] is false but you used if seen[node] to check.
Symbol Keys and String Keys Are Not Interchangeable
This trips people up in interview code more than almost anything else. It's also the most embarrassing kind of bug because everything looks completely correct. You will stare at it for a solid minute wondering if you've lost your mind.
h = { "name" => "Alice" } h[:name] # => nil h["name"] # => "Alice"
Hash lookup uses == for comparison, and :name != "name". The symbol :name and the string "name" are different objects. Ruby's two hash literal syntaxes produce different key types:
{ "a" => 1 } # string key { a: 1 } # symbol key -- equivalent to { :a => 1 }
This becomes painful when you parse JSON, which always returns string keys:
require 'json' data = JSON.parse('{"name": "Alice"}') data[:name] # => nil data["name"] # => "Alice"
Pick one key type per hash and stay consistent. In interview code, use symbols for hashes you define yourself. Anything that comes from external input arrives as strings.
Integer Division Floors Toward Negative Infinity
Ruby uses floored division, not truncation. For positive numbers this doesn't matter. For negative numbers, you get a result one lower than you'd expect from Java or C++. In an interview, that's one off-by-one error you didn't see coming.
7 / 2 # => 3 (same everywhere) -7 / 2 # => -4 (Ruby: floor(-3.5) = -4) # Java/C++: truncate(-3.5) = -3
Ruby's % always has the sign of the divisor, not the dividend. This differs from Java, C, and Go.
-7 % 3 # => 2 (positive like divisor 3) -7 % -3 # => -1 (negative like divisor -3)
In Java: -7 % 3 == -1. In Ruby: 2. When you need C-style truncated remainder, use remainder:
-7.remainder(3) # => -1
The upside: circular arithmetic works without a guard. (index - 1) % n wraps correctly in Ruby even when index is 0. In Java you would need (index - 1 + n) % n. In Ruby you do not. That is a genuine win, but you have to know the rules to trust it.
Bang Methods Can Return nil
Ruby's ! convention marks methods that modify in place. Many of them return nil when nothing changed. That's the trap. Silent. No error. Just nil where you expected a string.
Assigning the result of a bang method is almost always a bug.
arr = [1, 2, 2, 3] result = arr.uniq! # returns arr if duplicates found, nil if already unique str = "hello" result = str.gsub!(/x/, "y") # returns nil -- no match
The dangerous pattern:
def clean(str) str.downcase! # returns nil if already lowercase str.strip! # returns nil if no leading/trailing whitespace end # Returns nil when input was already clean
Use the non-bang versions when you need the return value:
def clean(str) str.downcase.strip # always returns the processed string end
Use bang methods when you want in-place mutation and the return value doesn't matter. That's a narrower use case than it sounds.

Using method! to "clean up" your code and then chaining the nil return.
No Built-in Heap, and shift Is O(n)
Ruby has no stdlib priority queue. If you need one, you're implementing a binary heap from scratch, using sort+pop for small inputs, or restructuring the problem. Welcome to the language. For a refresher on how heaps work, see The Heap Data Structure.
The quick hack for small inputs:
arr = [3, 1, 4, 1, 5] arr.sort!.reverse! # O(n log n) top = arr.pop # O(1) pop from end
This breaks the moment you interleave pushes and pops, which is exactly what Dijkstra and any priority-based traversal requires. For those problems you need a real heap or a restructured approach.
Array#shift is O(n). It removes the first element by shifting everything left. Draining a BFS queue with shift on a large graph turns O(V+E) into O(V²). It passes on a 10-node test case. It times out on a 10,000-node test case. The interviewer writes "correctness issues" in the feedback.
# O(n) per dequeue -- wrong for large graphs queue = [start] while !queue.empty? node = queue.shift # ... end
Track a front index instead:
queue = [start] front = 0 while front < queue.size node = queue[front] front += 1 # ... end
Sort Is Not Guaranteed Stable
Ruby's documentation is explicit: "The result is not guaranteed to be stable." When two elements compare as equal, their relative order in the output is undefined. Not probably stable. Undefined.
people = [{name: "Alice", age: 30}, {name: "Bob", age: 30}] people.sort_by { |p| p[:age] } # May return Alice before Bob, or Bob before Alice
If you need stable sort, decorate with the original index:
people .each_with_index .sort_by { |person, i| [person[:age], i] } .map(&:first)
This is the Schwartzian transform adapted for stability. MRI's implementation tends to produce stable results for small arrays in practice, but the spec does not guarantee it. The Merge Sort vs Quicksort breakdown explains why the choice of underlying algorithm determines this guarantee.
Two Dots or Three?
Ruby has two range operators and they are not interchangeable:
(1..5).to_a # [1, 2, 3, 4, 5] -- inclusive end (1...5).to_a # [1, 2, 3, 4] -- exclusive end
When iterating over array indices, you almost always want three dots:
(0...arr.length).each { |i| ... }
Using .. instead of ... accesses arr[arr.length], which silently returns nil. On small inputs with non-nil elements this passes tests. On edge cases where the last element happens to be nil, the bug is completely invisible until a test case with the right shape shows up. In an interview, that shape shows up right after you say "I think this covers all cases."
Ruby Interview Quick Reference
| Behavior | Ruby | Java/Python |
|---|---|---|
-7 / 2 | -4 (floor) | -3 (Java) / -4 (Python //) |
-7 % 3 | 2 (sign of divisor) | -1 (Java) / 2 (Python) |
0 in condition | truthy | falsy (Python/JS) |
"" in condition | truthy | falsy (Python/JS) |
[] in condition | truthy | falsy (Python) |
| Sort stability | not guaranteed | stable (Java/Python) |
| Integer overflow | none (arbitrary precision) | silent wrap / exception |
| Built-in heap | no | yes (Java, Python) |
Array#shift | O(n) | O(1) (Java Deque) |
| Symbol vs string key | different | N/A |
Ruby has Float::INFINITY for "no max int" in shortest-path problems. Integer has arbitrary precision: 2**100 works without overflow.
Catch These Before the Interviewer Does
Voice-based mock interviews surface this class of error better than LeetCode does. When you're typing, you spot Array.new(3, []) on read-back. When you're narrating code out loud, you have to say "and now I'm creating an array of empty arrays" and catch the aliasing yourself. SpaceComplexity runs AI-powered voice mock interviews with rubric-based feedback on exactly this kind of reasoning: whether you're catching your own traps before the interviewer does.
Key Takeaways
Array.new(n, [])shares one object. UseArray.new(n) { [] }.Hash.new([])shares one object. UseHash.new { |h,k| h[k] = [] }.- Only
nilandfalseare falsy.0,"", and[]all evaluate as true. - Symbol keys and string keys are different.
:name != "name". JSON returns string keys. -7 / 2 == -4in Ruby (floor, not truncate).-7 % 3 == 2(sign of divisor).- Bang methods like
gsub!anduniq!returnnilwhen nothing changed. Never assign their result. Array#shiftis O(n). Track a front-index for BFS instead.- Sort is not guaranteed stable. Decorate with index if order of equals matters.
..is inclusive,...is exclusive. Off-by-one errors hide here.