Ruby Interview Questions and Answers
Last updated:
Check out 45 of the most common Ruby interview questions, then take an AI-powered practice interview
Q1Which values are falsy in Ruby, and how does that trip up developers coming from JavaScript or Python?
BasicObject Model
Answer
Only two objects are falsy in Ruby: nil and false. Everything else is truthy, including 0, the empty string, the empty array, and the empty hash. Developers arriving from JavaScript or Python get burned by this constantly, because in those languages 0 and "" are falsy.
A line like `if params[:count]` in Ruby is true even when count is 0, and `if name` is true even when name is an empty string, so validation code that looks correct silently passes garbage through. The idiomatic fixes are explicit: use `value.zero?`, `str.empty?`, or Rails' `blank?` and `present?` when you are in a Rails codebase (they are ActiveSupport, not core Ruby, and interviewers do check that you know the difference). nil itself is a singleton instance of NilClass, and it responds to a small set of methods: `to_s` returns "", `to_a` returns [], `to_i` returns 0, and `inspect` returns "nil". Calling anything else on it raises NoMethodError with the classic message "undefined method 'foo' for nil".
Ruby 3.4 improved that message so it no longer prints the useless "for nil:NilClass" suffix. The safe navigation operator `&.` short-circuits to nil instead of raising, which is useful but easy to overuse: chaining `a&.b&.c&.d` usually means you are hiding a modelling problem rather than handling one.
count = 0
puts "truthy" if count # => truthy (surprises JS/Python devs)
puts "zero" if count.zero? # correct intent
name = ""
puts "present" if name # => present
puts "blank" if name.empty? # correct intent
# nil is an object with a tiny API
nil.to_s.inspect # => "\"\""
nil.to_a # => []
nil.respond_to?(:upcase) # => false
# Safe navigation vs the ternary
user = nil
user&.email # => nil, no exception
user ? user.email : nil # equivalent, more verboseKey Points
- Only nil and false are falsy; 0, "" and [] are all truthy
- Use zero?, empty?, nil? instead of relying on truthiness
- blank?/present? come from ActiveSupport, not core Ruby
- &. returns nil instead of raising NoMethodError, but hides design smells when chained
Q2Symbols versus strings: when does a symbol actually save memory, and when is `to_sym` dangerous?
BasicCore Types
Answer
A symbol is an immutable, interned identifier. Every occurrence of :status in your process points to the same object, so `:status.object_id == :status.object_id` is always true, while "status" allocates a brand new String object each time it is evaluated unless the file is frozen-string-literal enabled. That interning is why symbols are the default choice for hash keys, method names passed to `send`, and any fixed vocabulary of identifiers: you avoid millions of duplicate String allocations across a request.
The historical danger is symbol table growth. Before Ruby 2.2, symbols were never garbage collected, so calling `params[:key].to_sym` on user-controlled input was a denial-of-service vector: an attacker sends a million distinct strings and your process grows without bound. Since 2.2, dynamically created symbols are mortal and can be collected, but symbols created from literals in your source remain pinned for the process lifetime. The practical rule still stands: never call `to_sym` on untrusted input, and if you must map user input to a symbol, whitelist it against a fixed array first.
In interviews the follow-up is usually about equality and conversion cost. Symbols compare by identity, which makes symbol-keyed hash lookups slightly faster than string-keyed ones, but the gap is small in modern Ruby because String hashing is fast and frozen literals are deduplicated. The bigger correctness trap is mixing key types: `h[:a]` and `h["a"]` are different entries in a plain Hash. HashWithIndifferentAccess in Rails papers over that, plain Ruby does not.
:status.equal?(:status) # => true, same object
"status".equal?("status") # => false without frozen literals
# Different keys in a plain Hash
h = { a: 1 }
h[:a] # => 1
h["a"] # => nil
# Dangerous: unbounded symbol creation from user input
# params[:role].to_sym
# Safe: whitelist first
ALLOWED = %i[admin editor viewer].freeze
role = ALLOWED.find { |r| r.to_s == params_role } || :viewer
# Deduplicating strings without symbols
key = -"status" # String#-@ returns the frozen, deduplicated instance
key.frozen? # => trueKey Points
- Symbols are interned and immutable; string literals allocate unless frozen
- Since Ruby 2.2 dynamic symbols are GC-able, literal symbols are not
- Never call to_sym on untrusted input; whitelist against a fixed list
- String#-@ gives you a frozen deduplicated string, a good middle ground
Q3What does the `# frozen_string_literal: true` magic comment do, and what changed in Ruby 3.4?
BasicStrings
Answer
The magic comment must be the first line of the file (after the shebang, if any) and makes every string literal in that file frozen and deduplicated. Two benefits follow: identical literals share one object, cutting allocations in hot loops, and accidental mutation of a shared string raises FrozenError instead of corrupting data somewhere else. RuboCop's Style/FrozenStringLiteralComment cop enforces it, and virtually every gem and Rails app written in the last several years has it at the top of every file.
Ruby 3.4 introduced an intermediate state usually called chilled strings. If a file has no magic comment at all and you run with the deprecation warning enabled, mutating a literal still works but emits a warning telling you it will be frozen in a future version. This is the migration path toward literals being frozen by default. The important nuance for interviews: the comment applies per file, not per project, and it does not freeze interpolated strings. `"user-#{id}"` produces a new mutable String even in a frozen-literal file, because interpolation builds the object at runtime.
The practical gotcha is code that builds strings by mutation. `buffer = ""` followed by `buffer << row` raises FrozenError once you add the comment. The fix is either `buffer = +""` (String#+@ returns an unfrozen copy) or `String.new`. Note also that `<<` mutates in place while `+=` allocates a new object every iteration, so string building in a loop should always use `<<` or `String#*`, and for very large output you should build into an Array and call `join` once.
# frozen_string_literal: true
A = "hello"
B = "hello"
A.equal?(B) # => true, same frozen object
A.frozen? # => true
# A << " world" # raises FrozenError: can't modify frozen String
buffer = +"" # unary plus gives a mutable copy
3.times { |i| buffer << "row#{i}\n" }
# Interpolation is never frozen by the magic comment
id = 42
"user-#{id}".frozen? # => false
# Building strings: << mutates, += reallocates
out = +""
1000.times { |i| out << i.to_s } # 1 buffer
# out = ""; 1000.times { |i| out += i.to_s } # 1000 allocationsQ4What is the difference between a Proc and a lambda in Ruby?
BasicBlocks and Procs
Answer
Both are Proc objects, but `lambda?` returns true for only one of them, and that flag changes two behaviours that matter.
First, argument checking. A lambda enforces arity like a method: pass the wrong number of arguments and it raises ArgumentError. A non-lambda proc is forgiving, missing arguments become nil and extra arguments are discarded, and it also auto-splats a single Array argument into multiple parameters. That auto-splat is exactly why `[[1, 2], [3, 4]].each { |a, b| }` works: blocks are proc-flavoured.
Second, and more important in production, is what `return` means. Inside a lambda, `return` returns from the lambda itself and execution continues in the caller. Inside a proc, `return` returns from the enclosing method, so a proc created in one method and called from another raises LocalJumpError because the defining method has already finished. This bites people who store callbacks in a hash or class-level registry: define them with `->()` or `lambda`, never with `Proc.new` or `proc`.
A third detail interviewers like: `next` inside either one returns from that block, and `break` inside a proc invoked outside its defining context raises LocalJumpError. Also worth knowing is the curry method, which works on both and is handy for building partial applications, and that `Method#to_proc` gives you a lambda so `method(:puts).to_proc` behaves with strict arity.
add_l = ->(a, b) { a + b }
add_p = proc { |a, b| a.to_i + b.to_i }
add_l.lambda? # => true
# add_l.call(1) # ArgumentError: wrong number of arguments (given 1, expected 2)
add_p.call(1) # => 1, b is nil
add_p.call(1, 2, 3) # => 3, extra arg dropped
# Procs auto-splat arrays, lambdas do not
add_p.call([1, 2]) # => 3
# add_l.call([1, 2]) # ArgumentError
def with_lambda
l = -> { return :from_lambda }
l.call
:from_method
end
def with_proc
p = Proc.new { return :from_proc }
p.call
:never_reached
end
with_lambda # => :from_method
with_proc # => :from_procKey Points
- Lambdas check arity strictly; procs coerce and auto-splat arrays
- return in a lambda exits the lambda; in a proc it exits the enclosing method
- Procs called after their defining method returned raise LocalJumpError
- Blocks behave like procs, which is why |a, b| destructures array elements
Q5How does `yield` work, and how do you convert between blocks, procs and symbols?
BasicBlocks and Procs
Answer
Every Ruby method can receive one anonymous block without declaring it. `yield` invokes that block, `block_given?` tells you whether one was passed, and calling `yield` with no block raises LocalJumpError. This implicit block is the cheapest form because Ruby does not allocate a Proc object for it.
When you write `def each(&block)`, Ruby does allocate a Proc so you can store it, pass it on, or inspect its arity. That allocation is measurable in hot loops, which is why core Enumerable methods and performance-sensitive gems prefer `yield` over `&block`. Going the other direction, prefixing an argument with `&` at the call site converts a Proc (or anything responding to `to_proc`) into a block: `list.each(&printer)`.
Symbol#to_proc is the reason `%w[a b].map(&:upcase)` works: `:upcase.to_proc` returns a proc that calls the named method on its argument. `Method#to_proc` does the same for a bound method object, so `[1, 2].each(&method(:puts))` is valid. In recent Ruby you can also forward an anonymous block with `def wrapper(&) = inner(&)`, and forward everything with `...`.
Ruby 3.4 added `it` as an implicit name for the first block parameter, so `map { it * 2 }` is now valid and is a slightly cleaner alternative to `_1`. Numbered parameters `_1`, `_2` remain available. Interviewers commonly ask you to write a method that yields each element and returns an Enumerator when no block is given, which is the standard shape for any custom `each`.
def each_twice(list)
return to_enum(:each_twice, list) unless block_given?
list.each do |item|
yield item
yield item
end
end
each_twice([1, 2]) { |x| print x } # => 1122
each_twice([1, 2]).to_a # => [1, 1, 2, 2]
# &block materialises a Proc you can store or forward
def retrying(times, &block)
attempts = 0
begin
block.call
rescue StandardError
retry if (attempts += 1) < times
raise
end
end
# Symbol#to_proc and Method#to_proc
%w[a b].map(&:upcase) # => ["A", "B"]
[1, 2].each(&method(:puts))
# Ruby 3.4 implicit parameter
[1, 2, 3].map { it * 2 } # => [2, 4, 6]
[1, 2, 3].map { _1 * 2 } # => [2, 4, 6]Key Points
- yield uses the implicit block with no Proc allocation; &block allocates one
- Return to_enum(:method_name) when no block is given, matching core Ruby
- &:symbol works through Symbol#to_proc, &method(:name) through Method#to_proc
- Ruby 3.4 added `it`; `_1`/`_2` numbered params still work
Q6Explain public, private and protected in Ruby, including the `self.attribute =` gotcha.
BasicObject Model
Answer
Ruby's visibility rules are about the receiver, not about the class hierarchy the way Java's are. A public method can be called with any receiver. A private method cannot be called with an explicit receiver, with one deliberate exception. A protected method can be called with an explicit receiver as long as the caller is an instance of the same class or a subclass, which is what makes comparison methods like `<=>` between two objects of the same type possible without exposing state publicly.
The exception people forget: since Ruby 2.7 you may call a private method with `self` as the explicit receiver in all cases, and even before that, private setters were always callable as `self.name = value`. Without `self.`, Ruby parses `name = value` as a local variable assignment, so the setter never runs and the assignment silently does nothing to the object. That is one of the most common real bugs in Ruby code and a favourite interview trap.
Also note that private applies to instance methods defined after it in the class body, but `private :method_name` and `private def foo; end` both work and are clearer in review. Private does not apply to methods defined with `def self.foo`, for those you need `private_class_method :foo` or a `class << self` block with private inside it. Finally, visibility is not a security boundary: `send` bypasses it entirely, while `public_send` respects it. If you are dispatching a method name that came from user input, always use `public_send`.
class Account
def initialize(balance)
self.balance = balance # calls the private setter
# balance = balance # BUG: creates a local variable, no-op
end
def richer_than?(other)
balance > other.balance # works: balance reader is protected
end
protected
def balance = @balance # endless method, Ruby 3.0+
private
def balance=(value)
@balance = value
end
def self.secret_factory = new(0)
private_class_method :secret_factory
end
a = Account.new(100)
a.richer_than?(Account.new(50)) # => true
# a.balance # NoMethodError: protected method
a.send(:balance) # => 100, send ignores visibilityQ7What does `attr_accessor` actually generate, and when should you write the methods by hand?
BasicObject Model
Answer
`attr_reader :name` defines a method that returns @name. `attr_writer :name` defines `name=(value)` that sets @name. `attr_accessor :name` defines both. They are private class methods on Module implemented in C, so the generated readers are faster than an equivalent hand-written `def name; @name; end` in the interpreter, and YJIT optimises them further. Since Ruby 3.0 these methods return an array of the symbols they defined, which is what makes `private attr_reader :secret` work as a single expression.
You write the method by hand when there is behaviour: lazy initialisation (memoisation), coercion, validation, or a computed value. The classic memoisation idiom `@list ||= expensive` is fine when the result can never be nil or false; if it can, `||=` recomputes every call and you need `defined?(@list) ? @list : (@list = expensive)` instead. That nil-memoisation bug shows up in real code review rounds.
Two design points interviewers probe. First, attr_accessor on every field is a smell: exposing writers turns your object into a mutable bag and makes invariants impossible to enforce. Prefer attr_reader plus explicit intention-revealing methods that mutate. Second, referencing @name directly inside a class bypasses any logic in the reader, and typos in instance variable names do not raise, they just evaluate to nil (Ruby warns about undefined instance variables only under `-W:deprecated`-style verbose modes for some cases). Using the reader consistently avoids a whole class of silent nil bugs.
class Report
attr_reader :rows
private attr_writer :rows # returns [:rows=] in Ruby 3.0+
def initialize(rows) = self.rows = rows
# Hand-written because it memoises
def summary
@summary ||= rows.sum { |r| r[:amount] }
end
# Correct memoisation when the value may be nil or false
def cached_lookup
return @cached_lookup if defined?(@cached_lookup)
@cached_lookup = slow_lookup # may legitimately be nil
end
private
def slow_lookup = nil
end
r = Report.new([{ amount: 10 }, { amount: 5 }])
r.summary # => 15
# r.rows = [] # NoMethodError: private method 'rows='Key Points
- attr_* are C-implemented Module methods, faster than hand-written readers
- They return an array of symbols since Ruby 3.0, enabling `private attr_reader :x`
- ||= memoisation is wrong when the value can be nil or false; use defined?
- Default to attr_reader; blanket attr_accessor destroys invariants
Q8Explain the difference between `==`, `eql?`, `equal?` and `===`, and what a valid Hash key requires.
BasicCore Types
Answer
`equal?` is identity: true only when both references point to the same object. You should never override it. `==` is value equality and is the one you define for your domain objects; the default implementation on Object falls back to identity. `eql?` is value equality with no type conversion, and it is the method Hash uses together with `hash` to decide whether two keys are the same. The canonical demonstration is numeric: `1 == 1.0` is true, but `1.eql?(1.0)` is false, and therefore `{ 1 => :int }[1.0]` returns nil.
`===` is case equality, the method `case/when` calls on the when clause with the subject as argument. It is deliberately polymorphic: Module#=== does an is_a? check, Regexp#=== does a match, Range#=== does cover?, and Proc#=== calls the proc. That is why `case value when Integer then ... when /abc/ then ... when 1..10 then ... end` all work through one mechanism. Defining `===` on your own class lets it be used as a case matcher, which is a neat pattern for validators and routers.
The Hash key contract is the part interviewers actually care about: if you override `eql?` you must override `hash` so that objects equal under `eql?` return the same hash value, otherwise lookups fail unpredictably. The other half of the contract is mutation. If you use a mutable object as a key and then change it, its hash changes and the entry becomes unreachable until you call `Hash#rehash`. Strings are special-cased: Hash dups and freezes String keys automatically, which is why this trap usually bites with Arrays and custom objects.
1 == 1.0 # => true
1.eql?(1.0) # => false, no type conversion
1.equal?(1) # => true, same Integer object
{ 1 => :int }[1.0] # => nil
Integer === 5 # => true
/ab/ === "cabbage" # => true
(1..10) === 7 # => true
class Money
attr_reader :cents, :currency
def initialize(cents, currency) = (@cents, @currency = cents, currency)
def ==(other)
other.is_a?(Money) && cents == other.cents && currency == other.currency
end
alias eql? ==
def hash = [cents, currency].hash # must match eql?
end
h = { Money.new(100, :inr) => "paid" }
h[Money.new(100, :inr)] # => "paid"
# Mutable key trap
key = [1, 2]
h2 = { key => :v }
key << 3
h2[[1, 2, 3]] # => nil until h2.rehashQ9What changed with keyword arguments in Ruby 3, and how do `**opts` and `...` forwarding behave now?
BasicMethods
Answer
Ruby 3.0 completed the separation of positional and keyword arguments. In Ruby 2.x a trailing Hash was automatically converted into keyword arguments and vice versa, which produced years of ambiguous behaviour. Since 3.0 they are distinct: a method that declares keyword parameters will not accept a Hash positionally, and a method that takes a positional Hash will not silently absorb keywords. To pass a hash as keywords you must splat it with `**`, and to pass keywords as a hash you build the hash explicitly.
The practical consequences show up in delegation code. `def wrapper(*args); inner(*args); end` loses keywords entirely, so the modern forms are `def wrapper(*args, **kwargs, &blk); inner(*args, **kwargs, &blk); end` or, more simply, `def wrapper(...) = inner(...)` which forwards positionals, keywords and the block in one token. Ruby 3.2 added anonymous forwarding for the individual pieces too: `def wrapper(*, **, &) = inner(*, **, &)`.
Other details worth naming: keyword arguments can be required (`def f(a:)`) or defaulted (`def f(a: 1)`); `**nil` explicitly declares that a method accepts no keywords, which is useful to prevent a hash being absorbed; and Ruby 3.1 added shorthand hash literals, so `{ x:, y: }` picks up local variables x and y. When a method takes both a defaulted positional and keywords, argument parsing errors get cryptic, so keep signatures small. If you need more than three or four parameters, take a keyword-argument options object or a Data value instead.
def charge(amount, currency: "INR", idempotency_key: nil)
[amount, currency, idempotency_key]
end
opts = { currency: "USD" }
# charge(100, opts) # ArgumentError in Ruby 3.x
charge(100, **opts) # => [100, "USD", nil]
# Full forwarding, Ruby 3.0+
def logged(...) = charge(...)
logged(500, currency: "INR")
# Anonymous piecewise forwarding, Ruby 3.2+
def traced(*, **, &) = charge(*, **, &)
# Refuse keywords entirely
def strict(hash, **nil) = hash
strict({ a: 1 }) # => {a: 1}
# strict(a: 1) # ArgumentError: no keywords accepted
# Hash shorthand, Ruby 3.1+
x = 1
y = 2
{ x:, y: } # => {x: 1, y: 2}Key Points
- Ruby 3 fully separates positional Hash from keyword arguments
- Use **hash to pass a hash as keywords; no implicit conversion remains
- def m(...) forwards positionals, keywords and block; *, **, & work anonymously in 3.2+
- **nil declares a method accepts no keyword arguments at all
Q10What are the gotchas with `Hash.new` default values, and when should you use `fetch` or `dig`?
BasicCore Types
Answer
`Hash.new(0)` sets a default object returned for missing keys, which makes counters read nicely: `counts[word] += 1` works because the missing key evaluates to 0. The trap is that the default is a single shared object, not a fresh one per key. `Hash.new([])` returns the same Array for every missing key, so `h[:a] << 1` mutates that shared default without ever storing a key, and `h` stays empty while the default array grows. The block form `Hash.new { |hash, key| hash[key] = [] }` creates and assigns a new Array per key, which is what you almost always want.
Also remember that a default only affects reads of missing keys. `h.fetch(:missing)` ignores the default and raises KeyError, which is exactly why fetch is the right tool for required configuration: it fails loudly at the point of the mistake instead of propagating nil into code five frames away. `fetch` also takes a second argument or a block for a computed fallback, and the block form is lazy so an expensive default is not built unless needed.
`dig` walks nested structures and returns nil the moment any level is missing, so `payload.dig(:data, :user, :email)` replaces a chain of `&.[]` calls. It raises TypeError if an intermediate value is a non-diggable scalar, which is a useful signal that your data shape is wrong. For counting, Ruby 2.7 added `tally`, and Ruby 3.1 added `Hash#except`, both of which replace hand-rolled default-hash code in many cases.
counts = Hash.new(0)
"a b a".split.each { |w| counts[w] += 1 }
counts # => {"a" => 2, "b" => 1}
# Shared-default trap
bad = Hash.new([])
bad[:x] << 1
bad # => {} , key was never created
bad[:y] # => [1] , the shared default grew
# Correct per-key default
good = Hash.new { |h, k| h[k] = [] }
good[:x] << 1
good # => {x: [1]}
# fetch ignores defaults and fails loudly
config = { host: "db.internal" }
config.fetch(:host) # => "db.internal"
# config.fetch(:port) # KeyError: key not found: :port
config.fetch(:port) { 5432 } # lazy fallback
# dig for nested payloads
payload = { data: { user: { email: "a@b.com" } } }
payload.dig(:data, :user, :email) # => "a@b.com"
payload.dig(:data, :account, :id) # => nil
%w[a b a].tally # => {"a" => 2, "b" => 1}Key Points
- Hash.new(obj) shares one default object across all missing keys
- Hash.new { |h, k| h[k] = [] } creates and stores a fresh value per key
- fetch bypasses defaults and raises KeyError, ideal for required config
- dig returns nil on any missing level, raises TypeError on a non-diggable scalar
Q11Walk through Ruby's exception hierarchy: what does a bare `rescue` catch, and when do you use `retry` and `ensure`?
BasicExceptions
Answer
Everything raisable descends from Exception. Directly under it sit the ones you must not swallow: NoMemoryError, SystemExit (raised by `exit`), SignalException (Ctrl-C becomes Interrupt), ScriptError and its child LoadError, and SystemStackError. Under StandardError sit the ordinary runtime errors: ArgumentError, TypeError, NameError and its child NoMethodError, ZeroDivisionError, IOError, RuntimeError (what a bare `raise "message"` produces), and KeyError and IndexError under it.
A bare `rescue => e` catches StandardError and its descendants only. That is deliberate and correct: `rescue Exception` also catches Interrupt and SystemExit, so a worker written that way cannot be stopped with Ctrl-C and swallows the signal Kubernetes sends during a rolling deploy. If an interviewer asks for the single most common Ruby error-handling mistake, `rescue Exception` is a strong answer, closely followed by rescuing without re-raising and losing the original backtrace.
`ensure` runs whether or not an exception was raised, including when the method returns early, and is where you close files, release connections, or unlock a Mutex. Never put an explicit `return` inside ensure: it swallows the in-flight exception silently. `retry` restarts the begin block from the top and is the idiomatic way to build a bounded retry with backoff, but it needs an explicit counter or it loops forever. `raise` with no arguments inside a rescue re-raises the current exception with its original backtrace intact, and `raise MyError, "msg"` accepts a `cause:` keyword so you can chain. Custom errors should inherit from StandardError, never from Exception.
class PaymentGatewayError < StandardError; end
def charge(amount, attempts: 3)
tries = 0
begin
tries += 1
gateway_call(amount)
rescue Timeout::Error, Errno::ECONNRESET => e
if tries < attempts
sleep(2**tries * 0.1) # exponential backoff
retry
end
raise PaymentGatewayError, "gateway failed after #{tries} tries", cause: e
ensure
metrics.increment("charge.attempts", tries)
end
end
# Bad: unstoppable worker
# begin; work; rescue Exception; retry; end
# Re-raise preserving the original backtrace
begin
risky
rescue StandardError => e
logger.error(e.full_message)
raise
endKey Points
- Bare rescue catches StandardError only, which is what you want
- rescue Exception traps Interrupt and SystemExit and breaks graceful shutdown
- ensure always runs; a return inside ensure silently swallows the exception
- Bare raise inside rescue re-raises with the original backtrace; use cause: to chain
Q12What is the difference between `require`, `require_relative`, `load` and `autoload`?
BasicLoading
Answer
`require` resolves its argument against `$LOAD_PATH` (also spelled `$:`), loads the file once, and records the absolute path in `$LOADED_FEATURES` so a second call returns false and does nothing. Bundler and RubyGems put gem lib directories on the load path, which is how `require "json"` finds the right file. `require` with a relative-looking string like "lib/foo" is resolved against the load path, not against the current file, which is a frequent source of LoadError when a script is run from a different working directory.
`require_relative` resolves against the directory of the file calling it, which makes it the right choice inside a gem or application for your own files, and it does not depend on the process working directory at all. `load` re-reads and re-executes the file every time it is called, ignores the loaded-features cache, and takes an optional second argument that wraps the file in an anonymous module. It is for reloading in a console or a task runner, not for normal application code.
`autoload` registers a constant name against a path and loads the file the first time that constant is referenced, deferring the cost. Core Ruby's autoload has thread-safety caveats and Rails uses Zeitwerk rather than plain autoload, so in a Rails context the honest answer is that Zeitwerk resolves constants from file paths using an inflector, and misnaming a file (user_api.rb defining UserAPI without an inflection rule) produces a NameError at boot. Being able to explain the Zeitwerk naming contract is a common Rails-adjacent Ruby interview question.
# From lib/my_gem.rb inside a gem
require "json" # searched on $LOAD_PATH
require_relative "my_gem/client" # relative to THIS file
require "json" # => false the second time, already loaded
$LOADED_FEATURES.grep(/json/).first
# load re-executes every call, useful in a console
# load "./config/seeds.rb"
# Deferred constant resolution
autoload :CSV, "csv"
CSV # csv.rb is required at this moment
# Add a directory to the load path from a script
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))Key Points
- require searches $LOAD_PATH and loads once; require_relative is relative to the calling file
- load re-executes on every call and ignores $LOADED_FEATURES
- autoload defers loading until the constant is first referenced
- Rails uses Zeitwerk, where file names must match constant names via the inflector
Q13Which Enumerable methods do you reach for most, and when does `each_with_object` beat `reduce`?
BasicEnumerable
Answer
Enumerable is a module that any class can include as long as it defines `each`; everything else (map, select, reduce, sort_by, group_by, min_by, sum, each_slice, each_cons, zip, partition, flat_map) is built on that one method. Knowing the less obvious members is what separates fluent Ruby from Java-written-in-Ruby.
The ones that come up constantly in review: `filter_map` (Ruby 2.7) replaces `map { }.compact` and `select { }.map { }` in one pass; `tally` counts occurrences; `group_by` plus `transform_values` builds grouped aggregates; `each_slice(1000)` batches; `each_cons(2)` gives sliding pairs for diffing time series; `sum` is faster and more accurate than `reduce(:+)` for Floats because it uses Kahan summation; and `partition` splits into two arrays in one traversal.
`reduce` versus `each_with_object` is the classic question. `reduce` passes the accumulator through the return value of each block iteration, so if you forget to return the accumulator you get a confusing nil. `each_with_object` yields the accumulator as the second argument and returns it automatically at the end, so mutation-based accumulation is safer and reads better. Rule of thumb: use reduce when the accumulator is immutable (a number, a frozen value), use each_with_object when you are building a mutable Hash or Array. Also note that `sort_by` is a Schwartzian transform and is faster than `sort` with a block when the key computation is expensive, because it computes each key once.
orders = [
{ id: 1, city: "Pune", amount: 900, status: :paid },
{ id: 2, city: "Pune", amount: 100, status: :failed },
{ id: 3, city: "Kochi", amount: 400, status: :paid },
]
# one pass instead of select + map
orders.filter_map { |o| o[:id] if o[:status] == :paid } # => [1, 3]
# grouped aggregate
orders.group_by { |o| o[:city] }
.transform_values { |os| os.sum { |o| o[:amount] } }
# => {"Pune" => 1000, "Kochi" => 400}
# each_with_object returns the accumulator for you
orders.each_with_object(Hash.new(0)) { |o, acc| acc[o[:status]] += 1 }
# => {paid: 2, failed: 1}
# reduce needs the block to return the accumulator
orders.reduce(0) { |sum, o| sum + o[:amount] } # => 1400
# orders.reduce({}) { |h, o| h[o[:id]] = o } # BUG: returns o, not h
orders.each_slice(2).to_a.size # => 2 batches
[1, 4, 9].each_cons(2).to_a # => [[1, 4], [4, 9]]Key Points
- Include Enumerable and define each to get 50+ methods for free
- filter_map, tally, partition, each_cons and each_slice replace common hand-rolled loops
- each_with_object returns the accumulator automatically; reduce depends on the block's return value
- sort_by computes each sort key once, unlike sort with a comparison block
Q14What is the difference between `dup`, `clone` and `freeze`, and why is a copy still not safe?
BasicCore Types
Answer
`dup` and `clone` both produce a shallow copy. The differences are that `clone` copies the frozen state and the singleton class of the original, while `dup` produces an unfrozen object without singleton methods. `clone(freeze: false)` lets you copy singleton methods while unfreezing. In practice you use dup for "give me a mutable working copy" and clone when you specifically need to preserve the object's full identity including any methods defined on it directly.
Shallow is the word that matters. Copying an Array copies the container but not its elements, so `b = a.dup; b[0] << "x"` mutates the same String that a[0] points to. The same applies to nested Hashes: dup gives you a new top-level Hash whose values are the identical objects. For a true deep copy Ruby has no built-in, and the common workarounds are `Marshal.load(Marshal.dump(obj))` (works only for marshalable objects, is slow, and silently breaks on Procs, IO objects and singleton methods) or explicit recursive copying.
`freeze` prevents further mutation of that object only, again not of what it references. `["a"].freeze` still allows `arr[0] << "b"`. `Ractor.make_shareable(obj)` performs a deep freeze and is the correct tool when you truly need an immutable object graph. Freezing constants is good practice because Ruby only warns when you reassign a constant, it does not stop you mutating the object the constant points to, so `CONFIG = { retries: 3 }` without freeze is globally mutable state that any code in the process can change.
a = ["hello"]
b = a.dup
b << "world"
a # => ["hello"], container is separate
b[0] << "!"
a # => ["hello!"], element is shared
s = "x".freeze
s.dup.frozen? # => false
s.clone.frozen? # => true
s.clone(freeze: false).frozen? # => false
# freeze is shallow
CONFIG = { hosts: ["a"] }.freeze
# CONFIG[:x] = 1 # FrozenError
CONFIG[:hosts] << "b" # allowed, inner array is not frozen
# Deep freeze
SAFE = Ractor.make_shareable({ hosts: ["a"] })
# SAFE[:hosts] << "b" # FrozenError
# Deep copy workaround, with caveats
deep = Marshal.load(Marshal.dump({ a: { b: [1] } }))Key Points
- dup drops frozen state and singleton methods; clone keeps both
- Both are shallow: nested objects are shared between original and copy
- freeze protects one object, not the objects it references
- Ractor.make_shareable performs a genuine deep freeze
Q15Explain Ruby's method parameter types and how to inspect a method's signature at runtime.
BasicMethods
Answer
Ruby supports required positionals, optional positionals with defaults, a splat for the rest, post-required positionals after the splat, required keywords, optional keywords, a double splat for the rest of the keywords, and a block parameter. The order is fixed: required, optional, splat, post-required, keywords, double splat, block. You can also declare anonymous splats (`*`, `**`, `&`) when you only intend to forward them.
At runtime, `method(:name)` returns a Method object. `arity` returns the number of required arguments, with a negative value meaning "at least n, encoded as -(n+1)", so a method with one required and one optional argument reports -2. `parameters` is far more useful: it returns an array of [type, name] pairs where type is one of :req, :opt, :rest, :keyreq, :key, :keyrest, :block. Serialization libraries, dependency injection helpers and RSpec's verifying doubles all use `parameters` to check call compatibility, which is exactly why `instance_double` can catch a wrong-arity stub while a plain `double` cannot.
`Method#source_location` returns the file and line where a method was defined, which is the single most useful debugging call in a large Rails codebase full of gems and metaprogramming: when you cannot find where `#deliver_later` comes from, `User.instance_method(:deliver_later).source_location` tells you. `owner` tells you which module in the ancestor chain defined it, and `super_method` walks up the chain. Those four calls together let you reverse-engineer almost any unfamiliar Ruby codebase without grepping.
def search(query, limit = 10, *filters, region:, locale: "en", **extra, &blk)
[query, limit, filters, region, locale, extra]
end
m = method(:search)
m.arity # => -2 (one required positional, rest optional)
m.parameters
# => [[:req, :query], [:opt, :limit], [:rest, :filters],
# [:keyreq, :region], [:key, :locale], [:keyrest, :extra], [:block, :blk]]
# Where does this method actually come from?
String.instance_method(:blank?)&.source_location # nil in plain Ruby
m.owner # => Object
m.source_location
# Walking the chain
class Base; def call = :base; end
class Child < Base; def call = :child; end
Child.instance_method(:call).super_method.owner # => BaseQ16What causes "invalid byte sequence in UTF-8" in Ruby, and how do you fix encoding problems correctly?
BasicStrings
Answer
Every Ruby String carries an encoding tag alongside its bytes. Source files default to UTF-8, but data arriving from a file, a socket, a legacy database column or an uploaded CSV may carry different bytes while still being tagged UTF-8. When you then run a regex or call `split` or `upcase` on it, Ruby raises ArgumentError: invalid byte sequence in UTF-8. A related error is Encoding::CompatibilityError when you concatenate two strings with incompatible encodings, and Encoding::UndefinedConversionError when a character genuinely has no representation in the target encoding.
The critical distinction is `force_encoding` versus `encode`. `force_encoding` relabels the bytes without changing them, which is correct only when you know the bytes were already in that encoding and the tag was wrong (common with `Net::HTTP` bodies tagged ASCII-8BIT, meaning raw binary). `encode` actually transcodes the bytes from one encoding to another, and it takes `invalid: :replace` and `undef: :replace` options plus a `replace:` string. For cleaning up junk in place, `String#scrub` replaces invalid byte sequences with a replacement character and is usually the shortest correct fix.
In Indian product work this shows up constantly with names and addresses in Devanagari or Tamil, and with CSVs exported from Excel in Windows-1252 that contain smart quotes. The production pattern is: decide the encoding at the boundary, transcode once with `encode("UTF-8", invalid: :replace, undef: :replace)`, and keep everything inside your application in UTF-8. Also remember that `String#length` counts characters while `bytesize` counts bytes, so database column limits (which are usually byte limits in MySQL) need bytesize, not length.
raw = "caf\xE9" # Latin-1 byte, mislabelled
raw.encoding # => #<Encoding:UTF-8>
raw.valid_encoding? # => false
# raw =~ /caf/ # ArgumentError: invalid byte sequence in UTF-8
# Relabel only when you know the true encoding
fixed = raw.dup.force_encoding("ISO-8859-1").encode("UTF-8")
fixed # => "café"
# Or drop the bad bytes
raw.scrub("?") # => "caf?"
raw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
# Binary from HTTP responses is ASCII-8BIT
body = "\xE0\xA4\xA8".dup.force_encoding(Encoding::ASCII_8BIT)
body.force_encoding(Encoding::UTF_8).valid_encoding? # => true
"नमस्ते".length # => 6 characters
"नमस्ते".bytesize # => 18 bytesKey Points
- force_encoding relabels bytes; encode actually transcodes them
- String#scrub is the shortest fix for invalid byte sequences
- ASCII-8BIT means binary, common on Net::HTTP bodies and file reads in binary mode
- length counts characters, bytesize counts bytes; database limits are usually bytes
Q17Explain Bundler: what is the difference between the Gemfile, the gemspec and Gemfile.lock, and when do you need `bundle exec`?
BasicTooling
Answer
The Gemfile declares the dependencies of an application, including groups such as :development and :test, and can point at git repos, paths, or specific versions. The gemspec declares the dependencies and metadata of a library you are publishing, splitting them into runtime dependencies (`add_dependency`) and development dependencies (`add_development_dependency`). A gem's Gemfile is usually one line, `gemspec`, which tells Bundler to read requirements from the .gemspec file.
Gemfile.lock records the exact resolved version of every gem including transitive ones, plus the platforms the resolution covers. Commit it for applications so every developer and every deploy installs identical versions. Do not commit it for published gems, because the gem must work across the range of versions its gemspec allows, and pinning would only test one point in that range. The platform lines matter in 2026: a Mac developer on arm64-darwin and a Linux container on x86_64-linux need both platforms present, which is what `bundle lock --add-platform x86_64-linux` fixes when CI complains that the lockfile does not include the current platform.
`bundle exec` runs a command with the load path and RubyGems activation restricted to exactly the versions in the lockfile. Without it, an executable resolves to whatever version of the gem is newest on the system, which is how you end up with a local rubocop reporting different offences than CI. Binstubs generated by `bundle binstubs rubocop` embed the same behaviour without typing bundle exec. Version managers (rbenv, asdf, mise) sit one level below this and select which Ruby you are running at all, which is a separate concern from which gems are activated.
# Gemfile (application)
source "https://rubygems.org"
ruby "3.4.1"
gem "sinatra", "~> 4.0"
gem "pg", "~> 1.5"
group :development, :test do
gem "rspec", "~> 3.13"
gem "rubocop", require: false
end
# Shell
# bundle install --jobs 4
# bundle add sidekiq --version "~> 7.3"
# bundle update --conservative pg # bump pg, keep its deps pinned
# bundle lock --add-platform x86_64-linux
# bundle exec rspec spec/models
# bundle binstubs rubocop
# bundle outdated --strict
# my_gem.gemspec (library)
# spec.add_dependency "faraday", ">= 2.0", "< 3"
# spec.add_development_dependency "minitest", "~> 5.0"Key Points
- Gemfile is for applications, gemspec for published libraries
- Commit Gemfile.lock for apps, never for gems
- bundle exec pins executables to lockfile versions; without it you run whatever is newest
- bundle lock --add-platform fixes lockfile platform mismatches between Mac dev and Linux CI
Q18How do you handle nil safely in Ruby without littering the code with nil checks?
BasicIdioms
Answer
Ruby gives you several tools and each has a correct place. `&.` calls a method only if the receiver is not nil, so `user&.profile&.city` avoids NoMethodError. It is precise: `a&.b` is nil-safe on a, not on the result of b, and `x&.zero?` returns nil rather than false, which then behaves as falsy in a conditional but is not equal to false, a subtle difference that breaks `== false` comparisons.
`fetch` on a Hash or Array raises when the key is missing instead of returning nil, which converts a silent nil-propagation bug into a loud error at the right place. `dig` is the opposite: it walks deep structures and returns nil on the first miss. `Array()` wraps a value into an array, turning nil into [] and leaving arrays alone, which is a clean way to accept "one or many" arguments. `to_s`, `to_i` and `to_a` on nil return sensible empty values, but `to_str` does not exist on nil, and that distinction is the whole point: `to_s` is a loose conversion any object can offer, `to_str` is a strong claim that the object really is a string, which is why string concatenation raises TypeError on nil instead of silently producing "abc".
Beyond the operators, the real answer interviewers want is design. Return an empty collection instead of nil from query methods, use a null object for optional collaborators, give value objects sensible defaults in the constructor, and validate at the boundary so nil never enters the core. A codebase full of `&.` usually means nil is being allowed to travel too far from where it originated.
user = nil
user&.email # => nil
prefs = { notifications: { email: nil } }
prefs.dig(:notifications, :email) # => nil
# prefs.fetch(:theme) # KeyError, loud and local
prefs.fetch(:theme, "light") # => "light"
Array(nil) # => []
Array("a") # => ["a"]
Array([1, 2]) # => [1, 2]
# to_s is loose, to_str is a strong claim
"count: " + nil.to_s # => "count: "
# "count: " + nil # TypeError: no implicit conversion of nil into String
# Null object beats scattered nil checks
class NullNotifier
def deliver(*) = nil
end
notifier = configured_notifier || NullNotifier.new
notifier.deliver("hi") # no branch needed at the call siteKey Points
- &. is nil-safe per call, and returns nil rather than false for predicate methods
- fetch fails loudly, dig fails quietly; pick based on whether the value is required
- Array() normalises nil and scalars into arrays
- Prefer empty collections, defaults and null objects over spreading &. everywhere
Q19Describe Ruby's method lookup path, and the difference between `include`, `prepend` and `extend`.
IntermediateObject Model
Answer
When you call a method, Ruby walks the receiver's singleton class first, then the receiver's class, then any modules prepended to that class (last prepended wins), then the class itself, then modules included in it (last included wins), then the superclass, and so on up to BasicObject. If nothing matches, Ruby calls `method_missing`, which by default raises NoMethodError. `SomeClass.ancestors` prints this list in order and is the fastest way to settle an argument about which definition wins.
`include Mod` inserts Mod into the ancestor chain just above the class, so the class's own methods take precedence over the module's. `prepend Mod` inserts it below the class, so the module wins and can call `super` to reach the original implementation. That makes prepend the clean way to wrap existing behaviour: instrumentation, caching, and deprecation shims all use it instead of the old alias_method_chain pattern, which was fragile because it renamed methods and broke when applied twice. `extend Mod` adds the module's methods to a single object's singleton class, so `obj.extend(Mod)` gives methods to that object only, and `extend Mod` inside a class body adds class methods.
Interviewers push on two follow-ups. First, `Module#included` and `Module#prepended` hooks, plus the ActiveSupport::Concern pattern where `included do ... end` runs class-level configuration. Second, what happens with diamond-shaped module inclusion: Ruby inserts each module only once, at the position of its first inclusion, so re-including a module later does not move it in the chain. Being able to read an `ancestors` output out loud and predict which `super` runs is the actual skill being tested.
module Loud
def speak = super.upcase
end
module Quiet
def speak = "quiet"
end
class Speaker
include Quiet
prepend Loud
def speak = "hello"
end
Speaker.ancestors
# => [Loud, Speaker, Quiet, Object, Kernel, BasicObject]
Speaker.new.speak # => "HELLO" (Loud runs first, super hits Speaker#speak)
# extend adds methods to one object only
module Debuggable
def debug_id = "#{self.class}:#{object_id}"
end
a = Speaker.new
a.extend(Debuggable)
a.debug_id # works
# Speaker.new.debug_id # NoMethodError
# extend inside a class body defines class methods
class Speaker
extend Debuggable
end
Speaker.debug_id # works on the class objectKey Points
- Lookup order: singleton class, prepended modules, class, included modules, superclass
- include sits above the class, prepend sits below it and can wrap with super
- extend adds methods to a single object's singleton class
- prepend replaced alias_method_chain as the safe way to wrap existing methods
Q20How does `method_missing` work, and why must you always define `respond_to_missing?` with it?
IntermediateMetaprogramming
Answer
`method_missing(name, *args, &block)` is called when method lookup fails on every ancestor. Overriding it lets you build dynamic APIs: ActiveRecord's old dynamic finders, OpenStruct's attribute access, SOAP and HTTP client wrappers, and configuration DSLs all use it. The mandatory discipline is to handle only the patterns you actually own and call `super` for everything else, otherwise a typo in an unrelated method produces a confusing custom error instead of NoMethodError, and debugging becomes miserable.
`respond_to_missing?(name, include_private)` is the other half of the contract. `respond_to?`, `method(:name)`, `Object#public_send` guard clauses, duck-typing checks in third-party libraries, and RSpec's verifying doubles all consult respond_to?. If you implement method_missing without respond_to_missing?, your object answers the call but claims it cannot, and code that checks before calling will take the wrong branch. Defining respond_to_missing? also makes `method(:dynamic_thing)` return a working Method object for free.
The performance argument matters at senior level. Every method_missing dispatch is a full failed lookup up the ancestor chain plus a Ruby-level method call, and it defeats the inline caches that YJIT relies on. The standard fix is define-on-first-use: inside method_missing, call `define_singleton_method` or `self.class.define_method` to create the real method, then invoke it, so only the first call pays the cost. Also remember that `method_missing` does not intercept operators dispatched on other objects, and that BasicObject-based proxies (used by delegation libraries) rely on undefining almost every method so that method_missing actually fires.
class Settings
PREFIXES = %w[db_ redis_].freeze
def initialize(store) = @store = store
def method_missing(name, *args, &blk)
key = name.to_s
return super unless PREFIXES.any? { |p| key.start_with?(p) }
# define it once, then dispatch normally forever after
self.class.define_method(name) { @store.fetch(key, nil) }
send(name, *args, &blk)
end
def respond_to_missing?(name, include_private = false)
PREFIXES.any? { |p| name.to_s.start_with?(p) } || super
end
end
s = Settings.new("db_host" => "10.0.0.4")
s.db_host # => "10.0.0.4"
s.respond_to?(:db_host) # => true
s.method(:db_host) # => #<Method: ...>
# s.typo_here # NoMethodError, thanks to superQ21When would you use `define_method` instead of `def`, and what are the trade-offs?
IntermediateMetaprogramming
Answer
`define_method` takes a name computed at runtime and a block that becomes the method body. Because it takes a block, the method body closes over the surrounding local scope, which `def` does not: a `def` creates a new scope that cannot see enclosing locals. That closure property is exactly why you use define_method when generating methods from a list, from a schema, or from configuration.
The trade-offs are real. A define_method body is a block, so `return` returns from the method as expected (blocks converted to methods get method semantics), but you cannot use `yield` inside it, you must declare `&blk` explicitly. Stack traces and `source_location` point at the line where define_method was called, not at anything meaningful about the generated method, which makes debugging generated code harder. And because the body is a closure, it retains references to everything in the enclosing scope, which can hold objects alive longer than you expect, a subtle memory retention source in gems that generate methods from large configuration hashes.
The performance question comes up often. Once defined, a define_method method dispatches at essentially the same speed as a def method, so this is not a hot-path concern; the cost is at definition time only. Compare that with method_missing, which pays on every call. That gives the standard hierarchy for dynamic APIs: define_method at load time is best, define-on-first-use inside method_missing is second, permanent method_missing dispatch is last. Also note that define_method is private on Module before Ruby 2.5, and public from 2.5 onward, so `SomeClass.define_method(:x) { }` works directly in modern Ruby.
class Report
METRICS = { revenue: :sum, orders: :count, aov: :average }.freeze
METRICS.each do |name, aggregate|
# the block closes over `aggregate`, a `def` could not
define_method("#{name}_for") do |period, &formatter|
value = compute(aggregate, period)
formatter ? formatter.call(value) : value
end
define_method("#{name}_available?") { !compute(aggregate, :all).nil? }
end
private
def compute(aggregate, period) = { aggregate: aggregate, period: period }
end
r = Report.new
r.revenue_for(:last_30_days) # => {aggregate: :sum, period: :last_30_days}
r.orders_available? # => true
Report.instance_methods(false).sort.first(3)
# => [:aov_available?, :aov_for, :orders_available?]Key Points
- define_method closes over enclosing locals; def opens a fresh scope
- No implicit yield inside define_method; take &blk explicitly
- Dispatch speed matches def after definition; only definition time costs extra
- Backtraces point at the define_method call site, which hurts debugging
Q22What is a singleton class, and what are the different ways to define a class method?
IntermediateObject Model
Answer
Every Ruby object can have its own hidden class holding methods that belong to that object alone. That is the singleton class, reachable with `obj.singleton_class` or the `class << obj` syntax. Since classes are themselves objects (instances of Class), a "class method" is simply an instance method defined on the class object's singleton class. That single sentence explains most of Ruby's apparently strange class-level behaviour.
There are four common ways to define one: `def self.foo` inside the class body, `class << self; def foo; end; end`, `define_singleton_method(:foo) { }`, and `extend SomeModule` where the module's instance methods become class methods. They differ in ergonomics. The `class << self` form is the only one where `private` works naturally for class methods, and it groups related class methods visibly. `def self.foo` is the most common and the easiest to grep. Using `extend` on a module is how you share class methods across classes, and it is what ActiveSupport::Concern's `class_methods do` block ultimately does.
The follow-up interviewers ask is about inheritance: class methods are inherited, because the singleton class of a subclass inherits from the singleton class of the superclass. So `Child.create` finds `Parent.create`, and `self` inside that method is Child, not Parent, which is what makes the `self.new` pattern in a superclass factory method work correctly for subclasses. The `inherited(subclass)` hook fires when a subclass is created and is how registries and plugin systems auto-discover subclasses, though holding subclasses in a class-level array is a classic memory leak in code-reloading environments.
class Job
def self.enqueue(*args) = new(*args).perform_later # form 1
class << self # form 2
def registry = @registry ||= []
def inherited(sub)
super
registry << sub
end
private
def internal_helper = :hidden
end
end
module Retryable
def with_retries(n) = @retries = n # form 4
end
class EmailJob < Job
extend Retryable
with_retries 3
define_singleton_method(:queue_name) { "mailers" } # form 3
end
Job.registry # => [EmailJob]
EmailJob.queue_name # => "mailers"
EmailJob.singleton_class.ancestors.first(2)
# => [#<Class:EmailJob>, Retryable]
# Job.internal_helper # NoMethodError, private class methodKey Points
- A class method is an instance method on the class object's singleton class
- class << self is the only form where `private` applies cleanly to class methods
- Class methods are inherited because singleton classes form a parallel hierarchy
- The inherited hook powers plugin registries but leaks under code reloading
Q23What are refinements, and when are they better than monkey patching?
IntermediateMetaprogramming
Answer
A refinement is a scoped monkey patch. You define it with `Module#refine` inside a module, and activate it with `using` at file scope or inside a module or class body. Outside that lexical scope, the original behaviour is untouched. This solves the core problem with global monkey patching: two gems both adding `String#truncate` with different semantics, and whichever loads last silently wins for the entire process.
The rules are strict and you should be able to state them. Activation is lexical, so a refinement is active from the `using` call to the end of that file or scope, and it does not apply to code in other files that your refined code calls into. That means a refinement is invisible to `send`, to `method_missing`, to `respond_to?`, and to dynamically dispatched calls that resolve outside the lexical scope. Refinements also cannot be activated inside a method body or via eval in older versions, and they interact badly with tooling: RuboCop, Sorbet and YJIT's inline caches all have to work harder, and refined method calls are somewhat slower than plain ones.
The honest practical position for 2026 is that refinements are correct but rarely used. Most teams prefer a plain module with explicit functions, a decorator object, or `Module#prepend` inside their own namespace, because those are visible in `ancestors` and behave predictably under dynamic dispatch. Refinements make sense when you genuinely need to change a core class for readability in one bounded area, for example a DSL file or a spec support file. If an interviewer asks why refinements never took off, the lexical-scope-only limitation plus poor interaction with dynamic dispatch is the answer.
module IndianNumbering
refine Integer do
def to_lakhs = (self / 100_000.0).round(2)
def to_inr
digits = to_s.reverse
head = digits[0, 3]
tail = digits[3..].to_s.scan(/\d{1,2}/).join(",")
"₹#{(tail.empty? ? head : "#{head},#{tail}").reverse}"
end
end
end
using IndianNumbering
1_250_000.to_lakhs # => 12.5
# Outside the refined scope this raises NoMethodError
def via_send(n) = n.send(:to_lakhs)
# via_send(1_250_000) # NoMethodError: refinements are invisible to send
# The usual alternative: an explicit module function
module Money
module_function
def lakhs(paise_or_rupees) = (paise_or_rupees / 100_000.0).round(2)
end
Money.lakhs(1_250_000) # => 12.5Key Points
- refine + using scopes a patch to one lexical file or module scope
- Refinements are invisible to send, respond_to?, and method_missing dispatch
- They avoid the gem-versus-gem core class conflict that global patching causes
- Most teams still prefer explicit modules or prepend inside their own namespace
Q24Compare Struct, Data, OpenStruct and a plain Hash for value objects.
IntermediateCore Types
Answer
`Struct.new(:a, :b)` generates a class with accessors, positional or keyword construction (`keyword_init: true`, and since Ruby 3.2 a Struct accepts both forms by default), value equality, `to_a`, `to_h`, and Enumerable behaviour. Struct members are mutable, which is sometimes what you want and often not.
`Data.define(:a, :b)`, added in Ruby 3.2, is the immutable counterpart and is the modern default for value objects. Instances are frozen after construction, there are readers but no writers, construction accepts positional or keyword arguments, and `with(a: 2)` returns a copy with one field changed. Equality is by value, `deconstruct` and `deconstruct_keys` are defined so Data works directly in pattern matching, and there is no Enumerable or index access, which is deliberate: a Data object is a record, not a collection.
`OpenStruct` is the one to argue against. It builds methods dynamically per instance through the singleton class, which invalidates method caches, allocates a singleton class per object, and is roughly an order of magnitude slower to read from than a Struct or Hash. It also masks typos, since any attribute returns nil rather than raising. Its legitimate uses are throwaway test fixtures and quick JSON exploration in a console, not production models.
A plain Hash is best when the keys are genuinely dynamic (parsed JSON, user-supplied filters). The moment the shape is fixed and you find yourself typing the same string keys everywhere, promote it to a Data. In interviews, being able to say "Data since 3.2, frozen, with `with`, pattern-matchable" signals that you have kept up with the language.
Point = Data.define(:x, :y) do
def distance = Math.sqrt(x**2 + y**2)
end
p1 = Point.new(x: 3, y: 4)
p1.distance # => 5.0
p1.frozen? # => true
# p1.x = 9 # NoMethodError, no writers
p2 = p1.with(y: 0) # => #<data Point x=3, y=0>
p1 == Point.new(3, 4) # => true, value equality
case p2
in { x: Integer => x, y: 0 } then "on the axis at #{x}"
end
# Struct is mutable and Enumerable
Row = Struct.new(:id, :amount)
r = Row.new(1, 500)
r.amount = 600
r.to_a # => [1, 600]
# OpenStruct: convenient, slow, typo-friendly
require "ostruct"
o = OpenStruct.new(name: "x")
o.nmae # => nil, no error raisedKey Points
- Data.define (Ruby 3.2+) is the immutable value object: frozen, with(), pattern-matchable
- Struct is mutable, Enumerable, and indexable
- OpenStruct allocates a singleton class per instance and hides typos as nil
- Keep plain Hashes for genuinely dynamic keys only
Q25Explain Enumerator and lazy evaluation. How do you handle an infinite or very large sequence?
IntermediateEnumerable
Answer
An Enumerator is an object that knows how to produce a sequence on demand. Calling an iterator method with no block returns one (`[1,2,3].each` returns an Enumerator), and `Enumerator.new { |y| ... }` lets you build one from arbitrary logic by pushing values into the yielder. Enumerators support external iteration through `next`, `peek` and `rewind`, which is implemented with a Fiber under the hood, so pulling one value at a time from an infinite generator costs a fiber context switch, not an evaluation of the whole sequence.
`Enumerator::Lazy`, returned by `.lazy`, changes evaluation order. Normally `range.map { }.select { }.first(5)` materialises the entire intermediate array at each step, so on a large range it allocates millions of objects or never terminates on an infinite one. With `.lazy`, map and select are recorded and applied element by element only until the terminal operation (`first`, `take` with `force`, `to_a`, `each`) is satisfied. That turns "find the first five records matching this expensive predicate out of ten million" into a bounded amount of work.
The production use cases are paginated API traversal, streaming a large file line by line, and building composable pipelines over database batches. The gotchas: `lazy` adds per-element overhead so it is slower than eager evaluation on small collections, forgetting the terminal `force` or `first` leaves you holding an unevaluated Lazy object, and `Enumerator#next` keeps a Fiber alive until the enumerator is garbage collected, so do not create thousands of them in a loop. `Enumerator::Product` and `Enumerator.produce` (Ruby 2.7) round out the toolkit; `Enumerator.produce` is the cleanest way to express "repeat this transformation until a condition holds".
# Infinite sequence, only 5 values computed
naturals = Enumerator.produce(1) { |n| n + 1 }
naturals.lazy.select { |n| n % 7 == 0 }.map { |n| n * n }.first(5)
# => [49, 196, 441, 784, 1225]
# Paginated API traversal as a lazy stream
def all_candidates(client)
Enumerator.new do |y|
page = 1
loop do
batch = client.fetch(page: page)
break if batch.empty?
batch.each { |c| y << c }
page += 1
end
end
end
# all_candidates(client).lazy.select { |c| c[:city] == "Pune" }.first(20)
# Streaming a large file without loading it
# File.foreach("events.log").lazy
# .map { |line| JSON.parse(line) }
# .select { |e| e["level"] == "error" }
# .first(10)
# External iteration is fiber-backed
e = [1, 2, 3].each
e.next # => 1
e.peek # => 2
e.next # => 2Key Points
- Enumerator.new + yielder wraps any producer as a standard enumerable
- lazy defers map/select until a terminal call like first, take or force
- Enumerator.produce expresses unfold-style infinite sequences cleanly
- External iteration with next holds a Fiber alive until collected
Q26How does pattern matching with `case/in` work, and how do you make your own classes matchable?
IntermediatePattern Matching
Answer
Pattern matching arrived in Ruby 2.7 and stabilised in 3.0. `case value; in pattern; end` matches structure, not just equality. Array patterns `in [Integer => id, *rest]` destructure sequences, hash patterns `in { status: "paid", amount: }` destructure by key and bind the value to a local, guards `in Integer => n if n > 100` add conditions, alternatives `in :paid | :settled` accept either, and the pin operator `^existing` compares against an existing variable instead of binding a new one. Hash patterns are non-exhaustive by default (extra keys are fine); use `in { status:, **nil }` to require an exact key set.
If nothing matches and there is no `else`, Ruby raises NoMatchingPatternError, which is a feature: it turns an unhandled case into a loud failure rather than a silent nil. There is also the one-line form `value => { id:, name: }` for rightward destructuring assignment, and `value in pattern` which returns true or false and is handy inside conditionals.
To make your own class matchable you implement `deconstruct` (returns an Array, used by array patterns) and `deconstruct_keys(keys)` (returns a Hash, used by hash patterns; keys is the list of keys the pattern asked for, or nil, so you can avoid computing expensive fields). Struct and Data implement both already, which is a strong reason to model API responses as Data objects. The real-world payoff is parsing webhook payloads and API responses: instead of a ladder of `if payload["type"] == ...` checks, one case/in block both branches and extracts the fields, and an unexpected shape raises instead of producing nil deep in the handler.
def handle(event)
case event
in { type: "payment.captured", payload: { payment: { entity: { id:, amount: } } } }
"captured #{id} for #{amount / 100.0}"
in { type: "payment.failed", payload: { payment: { entity: { error_code: } } } }
"failed: #{error_code}"
in { type: String => t } if t.start_with?("refund.")
"refund event #{t}"
else
"ignored"
end
end
handle(type: "payment.captured",
payload: { payment: { entity: { id: "pay_1", amount: 50_000 } } })
# => "captured pay_1 for 500.0"
class Interval
attr_reader :from, :to
def initialize(from, to) = (@from, @to = from, to)
def deconstruct = [from, to]
def deconstruct_keys(keys) = { from: from, to: to }
end
case Interval.new(1, 9)
in [1, Integer => upper] then "starts at 1, ends #{upper}"
end
# One-line destructuring, raises on mismatch
{ id: 7, city: "Kochi" } => { id:, city: }
id # => 7Q27What is the difference between `instance_eval`, `class_eval` and `instance_exec`?
IntermediateMetaprogramming
Answer
All three run a block in a changed context, and they differ in what `self` becomes and where `def` lands.
`instance_eval` on an object sets `self` to that object and sets the default definee to the object's singleton class. So inside `obj.instance_eval { def foo; end }` the method foo is defined on obj alone. You get access to the object's instance variables, which is what makes it useful for configuration DSLs and for test setup that pokes at internals. When called on a class, `MyClass.instance_eval { def bar; end }` defines a class method, because the class object's singleton class is the definee.
`class_eval` (alias `module_eval`) can only be called on a Module or Class. It sets `self` to the class and the default definee to the class itself, so `MyClass.class_eval { def baz; end }` defines an ordinary instance method. That is the pair people mix up: instance_eval on a class gives class methods, class_eval on a class gives instance methods.
`instance_exec` is instance_eval that accepts arguments, passing them to the block. That matters because a block passed to instance_eval closes over its original scope for locals but loses `self`, so you cannot reach the caller's methods; instance_exec lets you pass what you need explicitly. Both class_eval and instance_eval also accept a string form, which supports `__FILE__` and `__LINE__` arguments for sane backtraces, but the string form is an injection risk if any part comes from user input and should be avoided. The DSL pattern in modern gems is to yield a builder object rather than instance_eval a block, because instance_eval hides which methods are available and breaks editor autocompletion.
class Config
def initialize = @values = {}
def set(k, v) = @values[k] = v
def to_h = @values
end
# instance_eval: self becomes the object, DSL style
c = Config.new
c.instance_eval do
set(:timeout, 5)
set(:retries, 3)
end
c.to_h # => {timeout: 5, retries: 3}
# instance_eval on a CLASS defines a class method
Config.instance_eval { def default = new }
Config.default.class # => Config
# class_eval on a class defines an INSTANCE method
Config.class_eval { def empty? = to_h.empty? }
Config.new.empty? # => true
# instance_exec passes arguments in
limit = 10
c.instance_exec(limit) { |l| set(:limit, l) }
c.to_h[:limit] # => 10
# Safer modern DSL: yield a builder instead
def configure = yield(Config.new)Key Points
- instance_eval: self = object, definee = singleton class
- class_eval: self = class, definee = the class (defines instance methods)
- instance_eval on a class defines class methods; class_eval defines instance methods
- instance_exec is instance_eval with arguments; avoid the string forms entirely
Q28Why does adding threads to a CPU-bound Ruby program not make it faster, and what does the GVL actually protect?
IntermediateConcurrency
Answer
CRuby runs real OS threads, but a Global VM Lock allows only one of them to execute Ruby bytecode at a time inside a process. Threads therefore buy you concurrency, not parallelism. The VM releases the GVL around blocking operations: socket and file I/O, `sleep`, `Process.wait`, DNS resolution, and any C extension that wraps its blocking call in `rb_thread_call_without_gvl`. That is why a Sidekiq worker with twenty threads doing HTTP calls and Postgres queries scales beautifully, while four threads parsing JSON in pure Ruby finish in roughly the same wall time as one thread doing all four.
The second half of the question is what the GVL does not protect. It keeps the interpreter's own internals consistent; it says nothing about your data. A thread can be preempted between the read and the write of `counter += 1` or `cache[key] ||= expensive`, so both lose updates under contention. Ruby also preempts a running thread roughly every 100 milliseconds, so a tight Ruby loop does not starve the others outright, but it never yields throughput.
For genuine parallelism you fork processes (Puma cluster mode, the parallel gem, Resque), use Ractors, or push the work into a native library that releases the GVL, which is why image processing through libvips and number crunching through Numo actually scale. JRuby and TruffleRuby have no GVL at all, and that is the standard follow-up. Ruby 3.2 exposed an internal thread event API so tools like gvltools can report how long threads spent waiting for the lock, which is the honest way to prove a latency problem is GVL contention rather than a slow database.
require "benchmark"
def cpu_work = 3_000_000.times.reduce(:+)
# Four threads of pure Ruby: about the same total time as running them serially
Benchmark.realtime { 4.times.map { Thread.new { cpu_work } }.each(&:join) }
# Four threads that sleep: about 1 second, because sleep releases the GVL
Benchmark.realtime { 4.times.map { Thread.new { sleep 1 } }.each(&:join) }
# The GVL does not make read-modify-write atomic
counter = 0
10.times.map { Thread.new { 100_000.times { counter += 1 } } }.each(&:join)
counter # frequently less than 1_000_000
# Real parallelism needs separate processes
pids = 4.times.map { fork { cpu_work } }
pids.each { |pid| Process.wait(pid) }
# An unjoined thread swallows its result; report_on_exception still logs it
t = Thread.new { raise "boom" }
t.join rescue puts $!.message # => boomKey Points
- One thread executes Ruby bytecode at a time; the GVL is released around blocking I/O
- Threads help I/O-bound work only; CPU-bound work needs processes, Ractors or native code
- The GVL guarantees VM integrity, not atomicity of your own read-modify-write code
- JRuby and TruffleRuby have no GVL; gvltools measures lock wait time on CRuby
Q29How do you make shared state thread-safe in Ruby, and what does `Thread::Queue` give you that a Mutex around an Array does not?
IntermediateConcurrency
Answer
`Mutex#synchronize` is the primitive. Two things about it catch people out. First, Ruby's Mutex is not reentrant: locking the same mutex twice in the same thread raises ThreadError with "deadlock; recursive locking", which happens the moment a synchronized method calls another synchronized method on the same object. `Monitor` (from the monitor library) is the reentrant version and is what you want for that shape. Second, a mutex only helps if every access goes through it, so a lazily memoised class-level cache written as `@cache ||= build` is still racy even when reads are synchronized elsewhere.
`Thread::Queue` is a C-implemented thread-safe FIFO with blocking semantics. `pop` blocks until an element is available instead of busy-looping, `close` makes every waiting `pop` return nil so workers drain and exit cleanly, and `Thread::SizedQueue` blocks the producer when the queue is full, giving you backpressure for free. A Mutex around an Array gives mutual exclusion but no blocking wait and no shutdown protocol, so you end up hand-rolling a ConditionVariable and usually getting the wakeup logic wrong.
Two more details interviewers like. `Thread.current[:key]` is fibre-local despite the name, so under a fibre-based server or inside an Enumerator it is not the storage you think it is; `Thread#thread_variable_set` is genuinely thread-local, and `Fiber[]` (Ruby 3.2) is the inheritable request-scoped option. And exceptions inside a thread kill only that thread and re-raise at `join`; since Ruby 2.5 `Thread.report_on_exception` defaults to true so at least you see them in logs. For richer structures, concurrent-ruby gives Concurrent::Map, Concurrent::AtomicFixnum and thread pools rather than rolling your own.
require "monitor"
m = Mutex.new
begin
m.synchronize { m.synchronize { :never } }
rescue ThreadError => e
e.message # => "deadlock; recursive locking"
end
mon = Monitor.new
mon.synchronize { mon.synchronize { :fine } } # reentrant
# Blocking, closable worker pool
queue = Thread::Queue.new
workers = 4.times.map do
Thread.new do
while (job = queue.pop) # blocks, returns nil once closed and drained
handle(job)
end
end
end
jobs.each { |j| queue << j }
queue.close
workers.each(&:join)
# Backpressure for a fast producer
bounded = Thread::SizedQueue.new(100)
# Thread.current[] is FIBRE local, not thread local
Thread.current[:request_id] = "a" # fibre-local
Thread.current.thread_variable_set(:request_id, "a") # thread-local
Fiber[:request_id] = "a" # inherited by child fibresQ30What is a Fiber, and what did the Fiber Scheduler in Ruby 3.0 change about writing concurrent I/O?
IntermediateConcurrency
Answer
A Fiber is a coroutine: a block of code with its own stack that you suspend with `Fiber.yield` and resume with `Fiber#resume`. Scheduling is cooperative and happens entirely in userspace, so there is no kernel context switch and a fiber costs a few kilobytes rather than the megabytes of stack a thread reserves. Enumerator's external iteration (`e.next`) is implemented with a fiber, which is why holding thousands of half-consumed enumerators is a memory problem.
Ruby 3.0 added `Fiber::Scheduler`, a hook interface, plus `Fiber.schedule` and `Fiber.set_scheduler`. When a scheduler is installed on the current thread, operations that would block (IO waits, `sleep`, Mutex waits, DNS lookups, `Process.wait`) call into the scheduler instead, which suspends the fiber and runs another one. The practical result is that ordinary blocking-looking code becomes non-blocking without callbacks or async/await keywords. The async gem implements a production scheduler and Falcon is the server built on it, which is how a single Ruby process serves tens of thousands of idle connections.
The caveats are what senior interviews probe. CPU-bound code in a fiber blocks every other fiber on that thread because nothing yields. C extensions that block without scheduler awareness do the same, and some database drivers still fall in that category. Connection pools are the classic production trap: ActiveRecord's pool is sized for threads, and running hundreds of fibers against a pool of five checkouts just moves the queueing. `Fiber.blocking { }` opts a section out of the scheduler when you need the old behaviour, and `Fiber[]` storage (Ruby 3.2) is the correct place for request-scoped context because it is inherited by child fibers, unlike `Thread.current[]`.
f = Fiber.new do
puts "step 1"
Fiber.yield :paused
puts "step 2"
:done
end
f.resume # prints "step 1", returns :paused
f.resume # prints "step 2", returns :done
f.alive? # => false
# With a scheduler installed, blocking calls yield instead of blocking
require "async"
require "net/http"
Async do
tasks = 50.times.map do |i|
Async { Net::HTTP.get(URI("https://api.example.com/items/#{i}")) }
end
tasks.map(&:wait) # 50 concurrent requests, one thread, no callbacks
end
# Opt a section back out of the scheduler
Fiber.blocking { legacy_driver_call }
# Request-scoped storage that child fibers inherit, Ruby 3.2+
Fiber[:tenant_id] = 42
Fiber.schedule { Fiber[:tenant_id] } # => 42Key Points
- Fibers are cooperative coroutines with their own stack, cheap compared to threads
- Fiber::Scheduler makes blocking IO, sleep and Mutex waits yield automatically
- CPU work or a scheduler-unaware C extension inside a fiber blocks the whole thread
- Size database connection pools for fibers, not threads, or you just move the queue
Q31Explain Ruby's garbage collector: what do generational and incremental marking mean, and which `GC.stat` numbers do you actually watch?
IntermediateMemory
Answer
CRuby uses a mark-and-sweep collector with two major refinements. Generational GC (RGenGC, since 2.1) promotes objects that survive a few collections into an old generation; a minor GC then marks only young objects plus old objects that have been written to, tracked through a write barrier and a remembered set. Because most objects die young, minor GCs are cheap and frequent while major GCs are rare and expensive. Incremental marking (2.2) slices the major mark phase so a single pause does not stall a request. Compaction arrived as `GC.compact` in 2.7 and `GC.auto_compact = true` in 3.0, moving objects to defragment the heap, which also restores copy-on-write sharing that gets destroyed after fork.
The numbers worth watching in `GC.stat`: `minor_gc_count` and `major_gc_count` (a climbing major count is what hurts latency), `heap_live_slots` versus `heap_free_slots`, `total_allocated_objects` as a per-request allocation counter, and `malloc_increase_bytes` with `oldmalloc_increase_bytes`. If major GCs are climbing while live slots stay flat, the trigger is malloc pressure from large strings and buffers rather than object count, and the fix is different. Ruby 3.2 added `GC.stat_heap` for per-size-pool numbers.
Tuning is done through environment variables read at boot, not at runtime: `RUBY_GC_HEAP_GROWTH_FACTOR`, `RUBY_GC_HEAP_FREE_SLOTS`, `RUBY_GC_MALLOC_LIMIT` and `RUBY_GC_OLDMALLOC_LIMIT`. Raising the malloc limits is the usual first move for a worker that GCs constantly under large payloads, at the cost of higher RSS. Ruby 3.4 shipped an experimental modular GC interface so an alternative collector can be plugged in, which is worth naming but not worth claiming production experience with.
GC.stat.slice(:minor_gc_count, :major_gc_count,
:heap_live_slots, :heap_free_slots,
:malloc_increase_bytes, :total_allocated_objects)
# Cheapest allocation metric there is: count objects created by a block
before = GC.stat(:total_allocated_objects)
1_000.times { { id: 1, city: "Pune" }.to_json }
GC.stat(:total_allocated_objects) - before
# Deterministic measurement: settle the heap first
GC.start(full_mark: true, immediate_sweep: true)
GC.compact # auto_compact = true does this for you
GC.stat_heap.keys # per size pool, Ruby 3.2+
ObjectSpace.count_objects # rough breakdown by internal type
# Boot-time tuning, set in the environment, never at runtime:
# RUBY_GC_HEAP_GROWTH_FACTOR=1.1
# RUBY_GC_MALLOC_LIMIT=64000000
# RUBY_GC_OLDMALLOC_LIMIT=64000000
# RUBY_YJIT_ENABLE=1Key Points
- Generational GC skips old objects in minor collections using a write barrier
- Incremental marking splits the major mark phase to cut pause time
- Watch major_gc_count and malloc_increase_bytes, not just heap_live_slots
- GC tuning is environment variables applied at boot, not runtime API calls
Q32In RSpec, when does `let` bite you, and how do `instance_double` and `allow` differ from a plain `double`?
IntermediateTesting
Answer
`let` defines a lazily memoised helper: the block runs the first time it is referenced in an example and is cached for that example only. `let!` wraps it in a `before` hook so it always runs. Both are misused in predictable ways. A `let` with side effects, typically a factory call that writes to the database, never runs in an example that does not reference it, so the test passes for a reason you did not intend and fails later when someone adds a reference. The mirror problem is `let!` on everything, which builds every fixture for every example and is one of the two biggest causes of a slow suite (the other being factories that create whole associated object graphs when `build_stubbed` would do).
`subject` is a `let` with a special name that one-liner matchers use implicitly, so `it { is_expected.to be_valid }` works. Always name it (`subject(:service)`), because a bare `subject` referenced ten lines away is unreadable.
On doubles: `double("Gateway", charge: true)` will happily stub a method that does not exist on the real class, so the spec keeps passing after somebody renames it. `instance_double(RazorpayGateway, charge: true)` is a verifying double: RSpec checks the method exists on the class and that the arity and keyword arguments match, and raises if they do not. Turn on `verify_partial_doubles` and use `instance_double`/`class_double` everywhere. `allow` sets a stub, while `expect(x).to receive` sets a message expectation that fails if the call never happens; leaning on message expectations produces tests that assert implementation and break on every refactor. `have_received` after the fact usually reads better.
RSpec.describe PayoutService do
let(:gateway) { instance_double(RazorpayGateway) }
let(:merchant) { build_stubbed(:merchant, kyc_verified: true) }
subject(:service) { described_class.new(gateway: gateway) }
before { allow(gateway).to receive(:transfer).and_return(id: "trf_1") }
it "sends the amount in paise" do
service.call(merchant, amount_rupees: 250)
expect(gateway).to have_received(:transfer)
.with(hash_including(amount: 25_000, currency: "INR"))
end
it "refuses an unverified merchant" do
allow(merchant).to receive(:kyc_verified?).and_return(false)
expect { service.call(merchant, amount_rupees: 250) }
.to raise_error(PayoutService::NotVerified)
end
end
# spec_helper.rb
RSpec.configure do |config|
config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true }
config.order = :random
Kernel.srand(config.seed)
endKey Points
- let is lazy and per-example; let! runs unconditionally in a before hook
- A let with side effects that is never referenced makes a test pass for the wrong reason
- instance_double verifies method existence and arity; plain double verifies nothing
- allow stubs, expect(...).to receive asserts; prefer have_received for readability
Q33How does Minitest differ from RSpec in practice, and how do you keep a large Ruby suite under ten minutes?
IntermediateTesting
Answer
Minitest ships with Ruby, is small enough to read in an afternoon, and its tests are plain classes inheriting `Minitest::Test` with `assert_equal`, `refute`, `assert_raises` and friends. It also offers a spec DSL through minitest/spec if you prefer `describe`/`it`. RSpec is a much larger DSL: `let`, `subject`, shared examples, custom matchers, verifying doubles, tagging and filtering. Rails generates Minitest by default, but most product teams in India that advertise Ruby roles run RSpec, so you should be able to read both and not evangelise either in an interview.
Speed is where the real answer is. Run `rspec --profile 10` or Minitest with a reporter to find the slowest examples first, because suite time is almost always concentrated in a handful of specs. Then: parallelise (`parallelize(workers: :number_of_processors)` in Rails forks a process and a database per worker; `Minitest.parallelize_me!` uses threads), replace `create` with `build_stubbed` wherever persistence is irrelevant, use test-prof to find factories that quietly build ten associated records, drop bcrypt cost to the minimum in the test environment, and split across CI nodes by recorded timing rather than filename.
Reliability matters as much as speed. Keep `config.order = :random` with the seed printed, and when a random order fails use `rspec --bisect` to shrink it to the minimal reproducing pair. Freeze time with `travel_to` instead of comparing against `Time.now`. Never `sleep` in a system spec; Capybara's matchers already wait. And do not reach into private methods with `send` from a spec: interviewers read that as a design problem, not a testing one.
require "minitest/autorun"
class SlugTest < Minitest::Test
def setup = @slug = Slug.new("Senior Ruby Developer")
def test_downcases_and_hyphenates
assert_equal "senior-ruby-developer", @slug.to_s
end
def test_rejects_blank_input
assert_raises(ArgumentError) { Slug.new("") }
end
end
# Same runner, spec-flavoured syntax
describe Slug do
it "is idempotent" do
_(Slug.new(Slug.new("A B").to_s).to_s).must_equal "a-b"
end
end
# Shell levers that actually move suite time
# bin/rails test:prepare && bin/rails test # parallel by default in Rails
# bundle exec rspec --profile 10 # slowest 10 examples
# bundle exec rspec --bisect --seed 51423 # shrink an order dependency
# FPROF=1 bundle exec rspec # test-prof factory profiler
# bundle exec rspec --tag ~slowQ34How do you make a class sortable and enumerable, and what is the contract for `<=>`?
IntermediateCore Types
Answer
Include `Comparable` and define `<=>`, which must return -1, 0 or 1, and importantly must return nil when the two objects are not comparable at all. Comparable then derives `<`, `<=`, `==`, `>`, `>=`, `between?` and `clamp` from that single method. Returning nil rather than raising is the part people get wrong: `sort` reacts to a nil comparison with "comparison of Candidate with String failed" (ArgumentError), which is a clear, localised error, whereas raising your own exception from inside `<=>` produces a backtrace deep in the sort implementation. The contract also demands consistency: antisymmetric and transitive, or `sort` gives you a nondeterministic order rather than an error. Note that Comparable defines `==` in terms of `<=>`, so if the object is also a Hash key you must keep `eql?` and `hash` consistent with that definition.
For multi-key ordering, the idiom is to compare arrays, because `Array#<=>` compares element by element and stops at the first difference. Negating a numeric field inverts that key only, which is how you express "highest score first, then shortest notice period" in one line.
For enumerability, include `Enumerable` and define `each` that yields and returns `to_enum(:each)` when no block is given. You inherit map, select, reduce, lazy, min_by, group_by and the rest. Pass a size block to `to_enum` if the count is known cheaply, otherwise `count` walks the entire collection. The real payoff appears when the collection is backed by an API or database: implement `each` in terms of batched fetches and `lazy.first(20)` stops after the first page instead of pulling everything.
class Candidate
include Comparable
attr_reader :name, :score, :notice_days
def initialize(name, score, notice_days)
@name, @score, @notice_days = name, score, notice_days
end
# highest score first, then shortest notice period
def <=>(other)
return nil unless other.is_a?(Candidate)
[-score, notice_days] <=> [-other.score, other.notice_days]
end
end
class Shortlist
include Enumerable
def initialize(rows) = @rows = rows
def each(&block)
return to_enum(:each) { @rows.size } unless block
@rows.each(&block)
end
end
list = Shortlist.new([Candidate.new("Asha", 80, 30),
Candidate.new("Bala", 80, 15)])
list.min.name # => "Bala"
list.sort.map(&:name) # => ["Bala", "Asha"]
list.max_by(&:score).name # => "Asha"
# list.sort_by { _1 <=> "x" } # ArgumentError: comparison failedKey Points
- <=> returns -1/0/1, and nil for incomparable operands so sort raises a clear ArgumentError
- Comparable derives ==, so keep eql? and hash consistent if the object is a Hash key
- Compare arrays for multi-key ordering; negate a numeric key to reverse just that key
- Enumerable needs only each; return to_enum(:each) when no block is given
Q35How do `Time`, `Date` and `ActiveSupport::TimeWithZone` differ, and how do you avoid IST bugs in a Ruby service?
IntermediateStandard Library
Answer
Core `Time` wraps an epoch value plus an offset, and `Time.now` uses whatever the process TZ environment variable says, which is why the same code behaves differently on a developer laptop set to Asia/Kolkata and a container running UTC. `Date` has no time component at all. `DateTime` is a legacy subclass of Date that the standard library itself now steers you away from; use Time. Ruby's Time accepts a fixed offset with `Time.now(in: "+05:30")`, which handles the offset but knows nothing about named zones or daylight saving. ActiveSupport adds `Time.zone`, an `ActiveSupport::TimeZone` configured by `config.time_zone`, and `Time.zone.now` returns a TimeWithZone that carries the real zone and converts correctly.
The rule that removes most bugs: store and compute in UTC, convert only at the presentation boundary, and never call `Time.now`, `Date.today` or `Time.parse` in application code. Use `Time.current`, `Date.current` and `Time.zone.parse` instead. RuboCop's Rails/TimeZone cop exists precisely to enforce that, and reviewers at Ruby shops do flag it.
The India-specific traps are worth naming out loud. IST is UTC+05:30, a half-hour offset, so any code that assumes whole-hour offsets or divides an offset by 3600 as an integer silently truncates. A report "for yesterday in IST" over UTC-stamped rows has its boundary at 18:30 UTC the previous day, and getting that wrong is the single most common analytics bug in Indian dashboards. India has no daylight saving, which hides DST bugs until the product opens a second market. Finally, never measure elapsed time with wall clock: NTP can move it backwards. Use `Process.clock_gettime(Process::CLOCK_MONOTONIC)`.
require "time"
Time.now.zone # depends on the process TZ variable
Time.now.utc.iso8601 # always safe to log
Time.now(in: "+05:30") # fixed offset, no named-zone or DST awareness
# An IST day does not start at a UTC midnight
ist_midnight = Time.new(2026, 8, 11, 0, 0, 0, "+05:30")
ist_midnight.utc.to_s # => "2026-08-10 18:30:00 UTC"
# Rails: configure once, then never touch Time.now again
# config.time_zone = "Asia/Kolkata"
# Time.current # TimeWithZone in the app zone
# Time.zone.parse("2026-08-11 09:00")
# Date.current.all_day # UTC range for an IST calendar day
# Parsing untrusted input
Time.iso8601("2026-08-11T09:00:00+05:30") # strict, raises on garbage
# Time.parse("tomorrow") # lenient, surprises you later
# Durations come from the monotonic clock, never from Time.now
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
run_report
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - startedKey Points
- Time.now follows the process TZ; Time.current follows the configured application zone
- Store UTC, convert at the edges, and let Rails/TimeZone cop enforce it
- IST is a half-hour offset, so an IST day boundary is 18:30 UTC the previous day
- Measure durations with Process::CLOCK_MONOTONIC, not wall-clock Time
Q36What are RBS and Sorbet, and what does adding types to a Ruby codebase actually buy in 2026?
IntermediateTooling
Answer
RBS is the official signature language that shipped alongside Ruby 3.0. Signatures live in separate `.rbs` files under `sig/`, core and standard library signatures ship with the interpreter, and `rbs collection install` pulls community signatures for gems from gem_rbs_collection. RBS itself only describes types; Steep is the checker most teams pair with it, and TypeProf can infer a rough first draft of signatures from existing code. Nothing about runtime behaviour changes.
Sorbet is Stripe's checker and takes the opposite approach: signatures are written inline as `sig { params(...).returns(...) }` directly above the method, each file carries a `# typed:` sigil controlling strictness, `srb tc` runs the static check, and sorbet-runtime validates the same signatures at runtime so violations from untyped callers still fail loudly. That runtime check costs a little per call and can be tuned or disabled through `T::Configuration`. Sorbet has the more mature editor tooling and RBI files for most popular gems; RBS is the standard the language itself maintains.
The honest answer about value is what interviewers want. Types pay for themselves in large, long-lived codebases with many contributors and at library boundaries, where they catch nil and arity mistakes that tests miss and make refactors mechanical. They cost real effort exactly where Ruby is most Ruby: `define_method`, `method_missing`, ActiveRecord attributes generated from the schema, and DSLs, all of which the checker cannot see, so you hand-write signatures or fall back to untyped. Most Ruby teams in India today run neither, relying on RuboCop plus a strong test suite, and typing the core domain objects only is the pragmatic middle position worth arguing for.
# sig/payout.rbs (RBS, checked with: bundle exec steep check)
# class Payout
# attr_reader amount_paise: Integer
# def initialize: (amount_paise: Integer, ?currency: String) -> void
# def to_rupees: () -> Float
# def self.from_order: (Order) -> Payout?
# end
# Sorbet, inline style
# typed: true
require "sorbet-runtime"
class Payout
extend T::Sig
sig { params(amount_paise: Integer, currency: String).void }
def initialize(amount_paise:, currency: "INR")
@amount_paise = amount_paise
@currency = currency
end
sig { returns(Float) }
def to_rupees = @amount_paise / 100.0
sig { params(raw: T.nilable(String)).returns(Integer) }
def self.paise(raw) = (T.must(raw).to_f * 100).round
end
# Payout.new(amount_paise: "500")
# => TypeError from sorbet-runtime, even though the caller is untypedKey Points
- RBS is the official signature language in sig/*.rbs, checked by Steep
- Sorbet uses inline sig blocks, # typed: sigils, srb tc, and validates at runtime too
- Metaprogramming and ActiveRecord attributes are where typing costs the most effort
- Typing the core domain only is the realistic position for most Ruby teams
Q37Where do Ractors actually work in 2026, and what makes an object shareable?
AdvancedConcurrency
Answer
A Ractor is an isolated execution context with its own GVL, so Ruby code in two Ractors genuinely runs on two cores inside one process. The price is a strict isolation model, and understanding that model is the whole question. A Ractor may only reference shareable objects: deeply frozen objects, Integers, Symbols and other immediates, Class and Module objects, and objects it created itself. `Ractor.shareable?` tests an object and `Ractor.make_shareable` deep-freezes an object graph. Anything else must cross the boundary by copy (`Ractor#send` performs a deep copy) or by move (`send(obj, move: true)`, after which the sender's reference is invalidated and touching it raises).
The blocker in real applications is global mutable state. Referencing an unfrozen constant, a class-level instance variable, or a gem's mutable configuration object from inside a Ractor raises `Ractor::IsolationError`. That single rule is why essentially no Rails application runs in Ractors: ActiveSupport, ActiveRecord and most gems keep mutable caches, registries and configuration at class level. The interpreter still prints a warning that Ractor is experimental, and the API has changed across releases, so anything you build should be pinned to the Ruby version you actually deploy.
Where they do work today is self-contained computation over shareable input: checksums, parsing, numeric transforms, image maths in a script or a small service with no gem-held global state. The comparison an interviewer wants is against forking. Processes are proven, work with the entire ecosystem, and cost a full heap of RSS each; Ractors share the VM and are cheaper in memory but exclude most of the ecosystem. In 2026 the correct answer for production parallelism in CRuby is still processes, with Ractors named as the direction of travel.
# Each Ractor has its own GVL, so this really uses multiple cores
LIMITS = Ractor.make_shareable([2_000_000, 3_000_000, 4_000_000, 5_000_000])
workers = LIMITS.map { |n| Ractor.new(n) { |limit| (1..limit).reduce(:+) } }
workers.map(&:take) # #value on newer releases; check your version
Ractor.shareable?("mutable") # => false
Ractor.shareable?(-"frozen") # => true
Ractor.shareable?(:symbol) # => true
CONFIG = { retries: 3 } # a plain, unfrozen constant
# Ractor.new { CONFIG[:retries] }.take
# => Ractor::IsolationError: can not access non-shareable objects
SAFE = Ractor.make_shareable({ retries: 3 })
Ractor.new { SAFE[:retries] }.take # => 3
# Copy versus move across the boundary
sizer = Ractor.new { Ractor.receive.bytesize }
buffer = "x" * 1_000_000
sizer.send(buffer, move: true)
sizer.take # => 1000000
# buffer.bytesize # raises: the object was moved out of this RactorKey Points
- Each Ractor owns a GVL, giving true parallelism inside one process
- Only deeply frozen objects, immediates and class objects are shareable
- Any mutable global or class-level state raises Ractor::IsolationError
- Rails and most gems are not Ractor-safe; forked processes remain the production answer
Q38How do you decide whether to enable YJIT, and what do you measure before and after?
AdvancedPerformance
Answer
YJIT is a just-in-time compiler written in Rust using lazy basic block versioning. It arrived experimentally in Ruby 3.1, became genuinely production usable in 3.2, and improved again in 3.3 and 3.4. Enable it with `--yjit`, `RUBYOPT="--yjit"` or `RUBY_YJIT_ENABLE=1`, and confirm at runtime with `RubyVM::YJIT.enabled?`. It compiles methods once they pass a call-count threshold and specialises the generated code on the types it actually observed, so it pays off most where Ruby executes a lot of small method calls: request dispatch, serializers, view rendering, ActiveRecord attribute access.
It pays off least where Ruby bytecode is not the bottleneck. An endpoint that spends 80 percent of its time waiting on Postgres, a job dominated by network calls, or work that is really C code inside String and Regexp will move barely at all. So the decision starts with a profile, not with a blog post benchmark.
The cost is memory. Generated code lives in an executable region sized by `--yjit-exec-mem-size`, and YJIT keeps per-method metadata besides. Under Puma cluster mode you pay that per worker, so on a small container the sensible move is to lower the exec memory size or reduce worker count rather than disable YJIT outright. The other practical trap is a Ruby build compiled without YJIT support, which happens with older distribution packages, and shows up as `RubyVM::YJIT.enabled?` returning false while your flag looks correct.
Measure with `RubyVM::YJIT.runtime_stats` for `ratio_in_yjit` and compiled method counts, benchmark-ips with a real warmup, and then production p50 and p95 latency plus RSS per worker. ZJIT is the newer method-based compiler with a proper intermediate representation being developed alongside YJIT; treat it as experimental and benchmark it rather than assuming it replaces YJIT.
# Enable at boot, not from inside the app
# RUBYOPT="--yjit" bundle exec puma -C config/puma.rb
# RUBY_YJIT_ENABLE=1 bundle exec sidekiq
# ruby --yjit --yjit-exec-mem-size=64 --yjit-call-threshold=30 app.rb
RubyVM::YJIT.enabled? # => false if the build lacks YJIT support
RubyVM::YJIT.runtime_stats&.slice(:ratio_in_yjit, :compiled_iseq_count)
# Benchmark with warmup, otherwise you are measuring the interpreter
require "benchmark/ips"
Benchmark.ips do |x|
x.warmup = 5 # YJIT needs to see the method get hot
x.time = 10
x.report("serialize") { CandidateSerializer.new(record).as_json }
x.compare!
end
# RSS per worker is the number that decides it on a small container
rss_mb = File.read("/proc/#{Process.pid}/status")[/VmRSS:\s+(\d+)/, 1].to_i / 1024
# Sanity check the ratio: below roughly 0.9 means most time is outside YJIT
RubyVM::YJIT.runtime_stats&.fetch(:ratio_in_yjit, nil)Key Points
- YJIT helps method-call-heavy Ruby, not workloads dominated by IO or C code
- Enable with --yjit or RUBY_YJIT_ENABLE=1; verify with RubyVM::YJIT.enabled?
- Generated code costs memory per process, which multiplies under Puma cluster mode
- Judge it on production p95 and RSS, with ratio_in_yjit as the diagnostic
Q39What are object shapes, and how should they change the way you write an initializer?
AdvancedPerformance
Answer
Before Ruby 3.2 an object's instance variables were kept in a per-object table looked up by variable ID, so every ivar read involved a hash-like lookup. Ruby 3.2 introduced object shapes: each object points at a shape, a node in a global tree that records exactly which instance variables the object has and in what order. Assigning a new ivar transitions the object to a child shape. Because a shape fixes the layout, a call site can cache "shape X means @total lives at offset 2" and later reads become a shape-ID comparison plus a direct memory read. This is what makes ivar access fast under YJIT and why the two features shipped together.
The practical consequence, and the reason this is an interview question rather than trivia, is that assignment order and conditional assignment matter. Two instances of the same class that set the same variables in different orders land on different shapes, so a method that sees both becomes polymorphic and loses its inline cache. `@coupon = c if c` produces one shape when the coupon is present and another when it is not. A memoised `@total ||=` that first runs later in the object's life creates yet another shape transition after the object is already old.
The rule that follows is simple: assign every instance variable in `initialize`, always in the same order, even when the value is nil. Beyond that, avoid `instance_variable_set` with names computed at runtime and avoid OpenStruct in hot paths, because both explode the shape tree; when an object accumulates too many variables Ruby falls back to a slower complex shape and the optimisation is lost for it. Shape counters are visible through `RubyVM.stat`, and this is also the mechanism-level reason interviewers give for preferring `Data` over dynamically built objects.
class Order
# Every ivar assigned, same order, every time: one shape for every instance
def initialize(id:, coupon: nil)
@id = id
@coupon = coupon
@total = nil # declare it even though it is unknown here
end
def total
@total ||= compute_total
end
end
class ShapeChurn
def initialize(id:, coupon: nil)
@id = id
@coupon = coupon if coupon # two different shapes for the same class
end
def total
@total ||= compute_total # a third shape, created after the fact
end
end
Order.new(id: 1).instance_variables # => [:@id, :@coupon, :@total]
ShapeChurn.new(id: 1).instance_variables # => [:@id]
RubyVM.stat.keys.grep(/shape/) # shape counters, Ruby 3.2+
# Explodes the shape tree; never do this per request
# 5_000.times { |i| obj.instance_variable_set("@field_#{i}", i) }Key Points
- A shape records which ivars an object has and in what order, enabling offset-based reads
- Conditional or late ivar assignment creates extra shapes and breaks inline caches
- Initialise every instance variable in initialize, in a fixed order, even as nil
- Dynamic instance_variable_set and OpenStruct push objects into slow complex shapes
Q40A Sidekiq worker's RSS climbs from 300MB to 2GB over a day. How do you tell a leak from bloat, and what do you actually change?
AdvancedMemory
Answer
First separate the two failure modes, because the fixes have nothing in common. A leak means live Ruby objects keep accumulating: a class-level array that jobs append to, a memoised cache hanging off a constant, an ActiveSupport::Notifications subscriber registered per job instead of once, `Thread.current` state never cleared between jobs, or a closure captured by a define_method that pins a large object graph. Bloat means the object count is stable but the process never returns memory to the operating system, usually glibc malloc arena fragmentation caused by many threads allocating differently sized buffers.
The discriminator is cheap: sample `GC.stat[:heap_live_slots]` alongside RSS every few minutes. Live slots climbing in step with RSS is a leak. Live slots flat while RSS climbs is bloat, or a leak inside a C extension, which no Ruby-level tool will show you.
For a leak, `memory_profiler` around one unit of work gives you retained bytes grouped by gem and by file, and retained is the column that matters; allocated only tells you about churn. When that is not enough, `ObjectSpace.trace_object_allocations_start` plus two `ObjectSpace.dump_all` snapshots taken hours apart, diffed by allocation site and generation, finds the exact line: old-generation objects that are still alive and growing in count are the suspects.
For bloat, `MALLOC_ARENA_MAX=2` limits arena count, and linking jemalloc through LD_PRELOAD usually flattens the curve much more effectively. `GC.auto_compact = true` reduces fragmentation of the Ruby heap itself. Then reduce peak allocation: stream large files with `File.foreach` or `CSV.foreach` instead of `read`, batch with `find_each`, and avoid building giant intermediate arrays where a lazy enumerator works. Finally, say out loud that an RSS-based worker restart is a mitigation that buys time, not a fix, because interviewers are listening for that distinction.
require "objspace"
# 1. Leak or bloat? Sample both numbers together over hours.
def memory_sample
{
rss_mb: File.read("/proc/#{Process.pid}/status")[/VmRSS:\s+(\d+)/, 1].to_i / 1024,
live_slots: GC.stat(:heap_live_slots),
allocated: GC.stat(:total_allocated_objects),
}
end
# 2. Retained bytes for one unit of work
require "memory_profiler"
report = MemoryProfiler.report { PayoutJob.new.perform(order_id) }
report.pretty_print(to_file: "/tmp/payout.txt", scale_bytes: true)
# read the "retained" tables, not "allocated"
# 3. Two heap dumps, diffed by allocation site and generation
ObjectSpace.trace_object_allocations_start
GC.start
File.open("/tmp/heap-1.json", "w") { |f| ObjectSpace.dump_all(output: f) }
# ... let the worker run a few thousand jobs ...
GC.start
File.open("/tmp/heap-2.json", "w") { |f| ObjectSpace.dump_all(output: f) }
# 4. Bloat, not leak: allocator level
# MALLOC_ARENA_MAX=2
# LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
GC.auto_compact = trueKey Points
- heap_live_slots rising with RSS means a leak; flat live slots with rising RSS means bloat
- memory_profiler's retained column, not allocated, points at the leak
- Diff two ObjectSpace.dump_all snapshots by allocation site to find the exact line
- MALLOC_ARENA_MAX and jemalloc address fragmentation; RSS-based restarts only buy time
Q41How do you size Puma workers and threads for a Rails API on a 4 vCPU container, and what does `preload_app!` actually change?
AdvancedProduction
Answer
Puma has two independent dials. Workers are forked processes, each with its own GVL, and they give you parallelism. Threads inside a worker give concurrency across I/O waits only, because of the GVL. A reasonable starting point on 4 vCPU is workers equal to the core count with a little headroom for the OS and any sidecar, and 3 to 5 threads per worker. Pushing threads to 16 does not help once Ruby execution is the constraint, and it actively hurts through database connection pressure and longer GVL queueing, which shows up as a worse p99 even when average latency looks fine.
The arithmetic that gets forgotten: database connections equal workers times threads, per container. Four workers with five threads is twenty connections from one pod, and the ActiveRecord pool must be at least the thread count per process or you get "could not obtain a database connection within 5.000 seconds" under load. Multiply that by replica count and you find the real reason Postgres runs out of connections, which is why pgbouncer exists.
`preload_app!` boots the entire application in the master process and forks workers afterwards, so the loaded code and constants are shared copy-on-write and each additional worker starts with a small private RSS instead of a full heap. That sharing degrades as workers write to old pages, which is why running `GC.compact` in `before_fork` helps and why generational GC's write barrier matters here. Critically, anything holding a socket must be re-established after fork: ActiveRecord connections, Redis clients, and any HTTP keep-alive pool. Two processes sharing one inherited file descriptor produces some of the most confusing bugs in Ruby operations. Use `before_fork` to disconnect and `on_worker_boot` to reconnect.
# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY", 4))
max_threads = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads max_threads, max_threads # min == max avoids thread churn
preload_app! # boot once in the master, fork after: copy-on-write
before_fork do
ActiveRecord::Base.connection_pool.disconnect!
GC.compact # defragment so forked pages stay shared longer
end
on_worker_boot do
# Every socket inherited across fork must be re-established here
ActiveRecord::Base.establish_connection
Redis.current = Redis.new(url: ENV.fetch("REDIS_URL"))
end
lowlevel_error_handler { |e| Sentry.capture_exception(e); [500, {}, ["error"]] }
# config/database.yml
# pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
# Connections from one container = workers * threads = 4 * 5 = 20
# WEB_CONCURRENCY=4 RAILS_MAX_THREADS=5 bundle exec puma -C config/puma.rbQ42An endpoint got slower after a release but the SQL is unchanged. Which Ruby profilers do you reach for, and how do you read the output?
AdvancedPerformance
Answer
Start by deciding whether you are chasing wall time or CPU time, because the tools answer different questions. StackProf is a sampling profiler with three modes that matter: `:cpu` counts CPU time and therefore hides time spent waiting on the database or an HTTP call, `:wall` includes that waiting, and `:object` samples allocations rather than time. The allocation mode is the one that finds "this action allocates four hundred thousand strings per request", which is the usual explanation for a regression where the queries did not change. Read the dump with `stackprof --text` and understand the two columns: total counts every sample where a frame appeared anywhere on the stack, self counts only samples where it was on top. A frame with huge total and tiny self is a caller, not the problem.
Vernier is the newer sampling profiler for Ruby 3.2 and above, and it is the better choice inside a threaded Puma or Sidekiq process because it understands threads, fibers, GC and GVL waits. StackProf will tell you a method is slow; Vernier will tell you the thread spent three hundred milliseconds waiting for the GVL while another thread parsed JSON, which is a completely different fix. Its output opens in the Firefox Profiler UI as a timeline.
For comparing two implementations, use benchmark-ips rather than `Benchmark.bm`: it runs each variant for a fixed time, reports iterations per second with a standard deviation, and `compare!` states whether the difference is real. The mistakes that invalidate microbenchmarks are always the same: no warmup (fatal once YJIT is on, since you measure the interpreter), benchmarking a constant expression, and using toy data when the production data is a hundred times larger. When you only need a quick signal, a `GC.stat(:total_allocated_objects)` delta around the code is faster than any profiler.
require "stackprof"
StackProf.run(mode: :wall, interval: 1000, raw: true, out: "/tmp/wall.dump") do
100.times { CandidatesController.new.index_payload(filters) }
end
# stackprof /tmp/wall.dump --text --limit 20
# stackprof /tmp/wall.dump --method 'CandidateSerializer#as_json'
# Allocation sampling: the mode that explains "same SQL, slower endpoint"
StackProf.run(mode: :object, out: "/tmp/alloc.dump") { build_payload }
# Vernier understands threads, fibers, GC and GVL waits
require "vernier"
Vernier.profile(out: "/tmp/profile.json") { PayoutJob.new.perform(1) }
# open /tmp/profile.json in profiler.firefox.com
require "benchmark/ips"
Benchmark.ips do |x|
x.warmup = 3
x.time = 8
x.report("filter_map") { rows.filter_map { |r| r[:id] if r[:paid] } }
x.report("select+map") { rows.select { |r| r[:paid] }.map { |r| r[:id] } }
x.compare!
end
# Cheapest signal of all
before = GC.stat(:total_allocated_objects)
build_payload
GC.stat(:total_allocated_objects) - beforeKey Points
- StackProf :wall includes IO waits, :cpu excludes them, :object samples allocations
- In a StackProf report, self time identifies the culprit, total time identifies the caller
- Vernier is thread, fiber and GVL aware, which StackProf is not
- benchmark-ips with real warmup is the only honest way to compare two implementations under YJIT
Q43Why does `bundle install` compile C for some gems, and when would you write a native extension in C, FFI or Rust?
AdvancedExtensions
Answer
A gem whose gemspec declares `extensions` ships C source plus an `extconf.rb`. RubyGems runs that script, mkmf probes for the headers and libraries it needs, generates a Makefile, and compiles a shared object linked against the Ruby headers. That is why installing pg, mysql2, nokogiri, bcrypt or grpc needs a compiler plus development headers such as libpq-dev, and why slim Docker images fail with a missing `mkmf.log`, an absent `ruby/config.h`, or the classic message telling you to install development tools. Precompiled platform-specific gems sidestep the compile entirely, which is exactly why your `Gemfile.lock` needs the right platform lines and why `bundle lock --add-platform x86_64-linux` fixes an install that works on an Apple Silicon laptop and breaks in CI.
There are three routes when you genuinely need native code. A CRuby C extension gives the most speed and full VM access, including releasing the GVL around blocking work with `rb_thread_call_without_gvl` so other threads keep running. The costs are severe: it only works on CRuby, mistakes segfault the process instead of raising, and every VALUE you hold must be reachable by the GC or it gets collected underneath you. FFI binds an already-installed shared library at runtime with no compilation, works on JRuby, and is the right tool for wrapping a C library you do not own, at the price of per-call marshalling overhead. Rust through magnus and rb-sys is the modern middle path: memory safety, cargo for dependencies, and it is the same direction the interpreter itself took with YJIT.
The judgement interviewers are testing is whether you reach for native code first. Usually the right move is to find the library that already did it (libvips instead of hand-rolled image code, oj or the C JSON parser instead of a custom one) rather than to write your own.
# ext/fast_hamming/extconf.rb
require "mkmf"
create_makefile("fast_hamming/fast_hamming")
# my_gem.gemspec
# spec.extensions = ["ext/fast_hamming/extconf.rb"]
# spec.add_development_dependency "rake-compiler"
# rake compile
# FFI: bind an existing shared library, no compilation at install time
require "ffi"
module Blake3
extend FFI::Library
ffi_lib "blake3"
attach_function :blake3_hasher_init, [:pointer], :void
end
# Rust with magnus, built through rb_sys and cargo (src/lib.rs):
# #[magnus::init]
# fn init(ruby: &Ruby) -> Result<(), Error> {
# ruby.define_global_function("hamming", function!(hamming, 2));
# Ok(())
# }
# Why installs break on slim images and how to unbreak them
# apt-get install -y build-essential libpq-dev
# gem install pg -- --with-pg-config=/usr/bin/pg_config
# bundle lock --add-platform x86_64-linux aarch64-linux
# bundle config set force_ruby_platform falseKey Points
- extconf.rb plus mkmf is what compiles a gem at install time; precompiled platform gems skip it
- bundle lock --add-platform fixes the Apple Silicon laptop versus Linux CI mismatch
- C extensions are fastest and can release the GVL, but segfault instead of raising
- FFI needs no compiler and works on JRuby; Rust via magnus is the modern safe option
Q44Which Ruby features turn user input into remote code execution, and how do you review a codebase for them?
AdvancedSecurity
Answer
Ordered by how often they actually appear in Ruby codebases. String `eval`, `instance_eval` and `class_eval` with an interpolated string, plus `system`, backticks and `%x` built by interpolation, are the obvious sinks; for shell calls the fix is the multi-argument form of `system` or `Open3.capture3`, which executes the binary directly without a shell so metacharacters are inert. `send` ignores method visibility, so any dispatch of a name that came from params must use `public_send` and, more importantly, must check the name against a fixed whitelist first. A params-driven sort or filter endpoint built on `send` is the most common way this ships.
Deserialization is next. `Marshal.load` on bytes an attacker can influence is straightforwardly remote code execution, because Marshal reconstructs arbitrary objects and triggers their hooks; that includes cookies, cache entries and queue payloads. YAML had the same class of problem historically, and Psych 4, bundled from Ruby 3.1, made `YAML.load` safe by default: the dangerous behaviour now requires `YAML.unsafe_load` or `aliases: true`, so those two strings are what you grep for. `JSON.parse` with `create_additions: true` has an equivalent object-instantiation issue.
Rails-adjacent sinks: `constantize` and `safe_constantize` on user input reach any loaded class, `render params[:template]` allows local file inclusion, and building a Regexp from user input invites catastrophic backtracking, which Ruby 3.2 finally gave you a global mitigation for through `Regexp.timeout`.
The review process is what you should describe, not just the list. Brakeman in CI for Rails, bundler-audit or Dependabot against the gem advisory database, a grep list of the sinks above in every pull request template, and a rule that any method name, class name or file path derived from user input must be mapped through an explicit constant hash rather than interpolated.
require "open3"
# Shell: never interpolate into a single command string
# system("convert #{params[:file]} out.png")
stdout, stderr, status = Open3.capture3("convert", params[:file], "out.png")
# Dispatch: whitelist first, then public_send
SORTABLE = %w[created_at score name].freeze
column = SORTABLE.include?(params[:sort]) ? params[:sort] : "created_at"
# Deserialization
# Marshal.load(cookies[:state]) # remote code execution, no caveats
YAML.safe_load(payload, permitted_classes: [Date, Symbol], aliases: false)
# YAML.unsafe_load(payload) # grep for this name in review
# Constants: map, do not constantize
# klass = params[:type].constantize
TYPES = { "payout" => Payout, "refund" => Refund }.freeze
klass = TYPES.fetch(params[:type]) { raise ArgumentError, "unknown type" }
# Catastrophic backtracking has a global backstop since Ruby 3.2
Regexp.timeout = 1.0
# /(a+)+\z/.match?("a" * 40 + "!") # raises Regexp::TimeoutError
# In CI
# bundle exec brakeman -q -w2
# bundle exec bundle-audit check --updateQ45A production Ruby process is pinned at 100% CPU, or stuck doing nothing. How do you diagnose it without restarting?
AdvancedProduction
Answer
Classify it from the host first: `top` tells you whether the process is burning CPU or idle, and whether it sits in D state waiting on the kernel. Then get stacks, because everything after that is guesswork without them.
The cheapest option is a signal handler you shipped before the incident. Trap USR2 (or TTIN, which Puma and Sidekiq already use for their own purposes) and dump `Thread.list` with each thread's status and backtrace to the log. If nothing was installed, rbspy attaches to a running PID from outside the process, needs no gem, no code change and no restart, and gives you either a single `rbspy dump` snapshot or a flamegraph from `rbspy record`. That is the answer interviewers expect in 2026; rbtrace is the older alternative and requires the gem to already be loaded.
For a stuck process, the recurring causes are: an HTTP client with no timeouts, which is by a distance the most common production hang in Ruby services because `Net::HTTP` will happily wait forever if you never set `open_timeout` and `read_timeout`; a connection pool where every thread is waiting on checkout, visible in `ActiveRecord::Base.connection_pool.stat`; a Mutex deadlock, where Ruby only prints "No live threads left. Deadlock?" if literally everything is blocked; and a `Queue#pop` on a queue that was never closed.
For 100% CPU, thirty seconds of `rbspy record` names the hot frame directly, and the usual culprits are catastrophic regexp backtracking, an accidental quadratic loop over a list that grew, and a retry loop without backoff hammering a failing dependency.
Prevention beats diagnosis: explicit timeouts on every network client, `Regexp.timeout` as a global backstop, and avoid `Timeout.timeout` for anything but a last resort, since it raises at an arbitrary instruction and can leave objects half-updated.
# Ship this before you need it
Signal.trap("USR2") do
Thread.new do
Thread.list.each do |t|
warn "thread=#{t.object_id} status=#{t.status.inspect}"
warn Array(t.backtrace).join("\n")
end
end
end
# kill -USR2 <pid>
# No handler installed? Attach from outside, no restart
# rbspy dump --pid 4321
# rbspy record --pid 4321 --duration 30 --format flamegraph
# The hang you will actually meet: a client with no timeouts
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 2
http.read_timeout = 5
http.write_timeout = 5
# Prove pool exhaustion instead of guessing at it
ActiveRecord::Base.connection_pool.stat
# => {size: 5, connections: 5, busy: 5, dead: 0, idle: 0, waiting: 12}
Regexp.timeout = 1.0 # global backstop for pathological patternsKey Points
- rbspy attaches to a live PID with no code change and no restart
- A pre-installed USR2 handler dumping Thread.list backtraces is the cheapest insurance
- Missing Net::HTTP open_timeout and read_timeout is the most common Ruby production hang
- connection_pool.stat turns "the app is slow" into a measured waiting count
Frequently Asked Questions
How much does a Ruby developer earn in India in 2026?
Broadly ₹6-20 LPA, with a wide spread that depends more on the employer than on years of experience. Freshers and engineers with under two years typically land ₹5-10 LPA at services firms and smaller product companies. Three to six years of solid Ruby and Rails work usually sits in the ₹12-25 LPA band. Product companies that run Ruby at scale, including Freshworks, BrowserStack, Zendesk, and the India teams of Stripe, GitHub and Shopify, pay well above that for senior engineers, and consultancies such as BigBinary and Josh Software pay competitively for people who can own a codebase end to end. The premium goes to engineers who can talk about performance, memory and background job reliability rather than only about Rails conventions.
How long does it take to prepare for a Ruby interview?
If you already write Ruby daily, two to three weeks of focused revision is usually enough: the object model and method lookup, blocks versus procs versus lambdas, keyword arguments after the Ruby 3 split, Enumerable fluency, the GVL and what threads actually buy you, and one honest production story about memory or a hung process. Coming from another language, budget six to eight weeks and spend most of it writing code rather than reading, because Ruby interviews probe idiom heavily and a Java-shaped solution in Ruby syntax is obvious to a reviewer. Build one small gem and one small service, add RSpec specs and RuboCop, profile something with StackProf, and you will have concrete answers for most of the intermediate and advanced questions on this page.
What is the difference between fresher and experienced Ruby interviews?
Fresher rounds concentrate on language mechanics and reasoning: truthiness, symbols versus strings, blocks and yield, Enumerable methods, exception handling, and a small coding problem where the interviewer watches whether you write idiomatic Ruby or a translated for-loop. Getting `each_with_object` and `filter_map` right at that level already stands out. Experienced rounds move almost entirely to systems questions: why a Sidekiq worker leaked memory, why adding threads did not help, how you sized Puma workers against database connections, how you found a slow endpoint that had no slow queries, and how you handled a bad deploy. Above roughly five years, expect design discussion and code review of someone else's Ruby rather than puzzles, and expect to justify trade-offs rather than recite definitions.
Is Ruby still worth learning in 2026?
Yes, with clear eyes about the market. Ruby is not the default choice for new greenfield startups the way it was a decade ago, so the number of openings is smaller than for JavaScript, Java or Python. What that produces is a thinner but higher quality market: the companies still hiring Ruby have large, revenue-generating codebases that must be maintained and extended, they pay accordingly, and there are far fewer candidates competing for each role. The language itself has improved substantially, with YJIT, object shapes, pattern matching, Data and Ractors all landing in the 3.x line. If you enjoy the language and are willing to learn the operational side, Ruby remains a strong career bet, particularly in Chennai, Bengaluru, Pune and Mumbai where the Ruby employers are concentrated.
Do I need to know Rails to get a Ruby job?
In practice, almost always yes, because the overwhelming majority of Ruby roles in India are Rails roles. The useful nuance is that the interview itself is often pure Ruby: object model, blocks, memory, concurrency and testing, with Rails coming up as context rather than as the subject. Candidates who only know Rails conventions and cannot explain what `ancestors` prints, why `Time.now` differs from `Time.current`, or what the GVL does tend to fail the technical round even with Rails experience on the resume. The right preparation is to learn Ruby properly first and treat Rails as an application of it. Sinatra or a plain Rack service is a good way to prove you understand what Rails is doing for you.
How does Ruby compare with Python or Go for a backend career in India?
Python has by far the most openings, driven by data, machine learning and scripting rather than by web backends, and its web frameworks are less dominant in their own ecosystem than Rails is in Ruby's. Go is the common choice for infrastructure, high-concurrency services and platform teams, and it pays well, but the work is often lower level and less product-facing. Ruby occupies a specific niche: rapid product development on large, long-lived business applications, where developer throughput matters more than raw request-per-second numbers. Salary bands for senior engineers overlap heavily across all three, so the decision is better made on the kind of work you want. Many Indian engineers end up running Ruby for the product surface and Go for the throughput-critical services in the same company.
Introduction
Ruby in 2026 is a mature, fast language that people still underestimate. The 3.x line changed the performance story completely: YJIT went from an experiment in 3.1 to production default-grade in 3.2 and later, Prism became the standard parser in 3.4, object shapes cut instance-variable access cost, and the GC learned compaction. Meanwhile the language kept adding ergonomics that show up in interviews: pattern matching with case/in, the Data value class, endless methods, the implicit block parameter it, and Ractors for real parallelism. Most Ruby jobs are still Rails jobs, but the interview loop is usually pure Ruby: object model, blocks, memory, and concurrency.
Indian Ruby hiring is concentrated but well paid. Freshworks in Chennai, BrowserStack in Mumbai, Stripe and GitHub in Bengaluru, Zendesk in Pune, and product consultancies like BigBinary and Josh Software all run serious Ruby in production, and their interviewers do not ask trivia. They ask why a background job leaked memory, why a Sidekiq worker with twenty threads did not go twenty times faster, what happens when two threads mutate the same Hash, how you would prove that a slow endpoint is slow because of allocations rather than SQL, and how frozen string literals interact with the changes shipped in Ruby 3.4.
This guide covers 45 Ruby interview questions asked in 2026, ordered basic to advanced. The basic set locks down the object model, blocks and procs, keyword arguments after the Ruby 3 split, exceptions, and Bundler. The intermediate set moves into method lookup, metaprogramming, Enumerator laziness, pattern matching, the GVL, fibers, garbage collection, and testing with RSpec and Minitest. The advanced set covers what senior offers actually turn on: Ractors, YJIT and ZJIT, object shapes, heap-dump triage, native extensions, Puma process tuning, and dynamic dispatch as a security surface. Code examples run on Ruby 3.2 and above.
Ready to practice Ruby interviews?
Don't just read, practice these Ruby questions live with an AI interviewer that asks follow-ups and scores your answers.