A backend candidate at a mid-size logistics company got asked to explain why calling .freeze on an array doesn't stop you from mutating the objects inside it. He'd shipped Rails code for six years, knew his way around ActiveRecord callbacks, and still froze on the spot, because he'd never had to think about the difference between a frozen reference and a frozen object graph. That gap between "I use Ruby every day" and "I understand what Ruby is actually doing" is exactly what most Ruby interview loops are built to find. Ruby isn't the language most teams reach for on a brand-new greenfield service in 2026, but Rails still runs a striking share of production web apps at companies like Shopify, GitHub, and Basecamp, and Stack Overflow's 2025 developer survey shows Ruby holding a small but stable slice of professional developer usage even as newer languages get more attention (Stack Overflow Developer Survey, 2025). Teams hiring for it tend to skip the trivia and go straight for whether you understand the object model underneath.
Here's the pattern worth naming: a lot of Ruby prep material spends its time on syntax sugar, string interpolation, the ternary operator, symbols versus strings, because that's easy to demo in a five-minute blog post. The questions that actually separate a candidate who's shipped Ruby for years from one who's read a tutorial are about the object model itself. Why does method_missing exist and when does it bite you. What actually happens when Ruby looks up a method through a module included via include versus prepend. Why can two threads run Ruby code but not truly run it at the same time on CPython-adjacent MRI. Those questions don't show up in a "10 Ruby interview questions" listicle nearly as often as they show up in real interviews.
This page covers Ruby interview questions across nine areas: core language fundamentals (objects, nil, symbols, truthiness), blocks/procs/lambdas, the object model and mixins, metaprogramming, exception handling, Enumerable and collections, memory management and garbage collection, concurrency (threads, the GVL, Fibers, Ractors), and a few modern-Ruby features worth knowing going into 2026. Code examples run on Ruby 3.x throughout.
Core fundamentals: objects, nil, symbols, and truthiness
Every Ruby loop starts somewhere near here, even for a candidate who's been writing Rails controllers for a decade. A shaky answer to one of these sets a bad tone early.
Easy questions
15In Ruby, integers, strings, nil, true, false, and even classes themselves are objects with methods you can call on them. 3.times { puts "hi" } works because 3 is an instance of Integer, not a primitive. There's no unboxed primitive type hiding underneath the way there is in Java with int versus Integer.
The honest caveat: a handful of things aren't objects in the strict sense, blocks on their own (before you turn one into a Proc), and keywords like if and def, which are part of the language's grammar, not method calls. Everything you can assign to a variable and call a method on, though, is a real object with a real class.
In a boolean context, only nil and false are falsy in Ruby. Everything else is truthy, including 0, an empty string, and an empty array, which trips up people coming from Python or JavaScript where those are falsy.
if 0
puts "this runs" # 0 is truthy in Ruby
end
if ""
puts "this runs too" # empty string is truthy
end
if nil
puts "never runs"
endA symbol like :name is an immutable, interned identifier, Ruby only keeps one copy of a given symbol in memory no matter how many times it appears in your code, and comparing two symbols is a fast object identity check rather than a character-by-character comparison. A string is mutable and each literal is a separate object unless you freeze it.
The rule of thumb: use symbols for things that function as identifiers or fixed labels, hash keys, method names passed around, enum-like values. Use strings for anything that's actual text content, something a user typed, something you're going to concatenate or transform. Rails' convention of hash keys like { status: "active" } is exactly this split in practice, symbol key, string value.
A block is a chunk of code attached to a method call, written with do...end or curly braces, that isn't a full object on its own until you capture it. Every Ruby method can accept an implicit block whether or not its signature mentions one, and the method decides whether to invoke it with yield.
def repeat(n)
n.times { |i| yield i }
end
repeat(3) { |i| puts "iteration #{i}" }Symbol#to_proc converts a symbol into a Proc that calls the method of that name on whatever argument it receives, and the & prefix converts that Proc into a block at the call site. &:upcase is shorthand for { |item| item.upcase }, nothing more mysterious than that.
["hello", "world"].map(&:upcase)
# equivalent to:
["hello", "world"].map { |s| s.upcase }It only works for methods that take zero arguments, since Symbol#to_proc's generated block only forwards the single receiver, not any additional arguments.
A class can be instantiated and can inherit from exactly one other class. A module can't be instantiated on its own and doesn't support inheritance the same way, but a class can mix a module in with include, extend, or prepend. Modules exist for namespacing and for sharing behavior across otherwise unrelated classes.
module Trackable
def track_event(name)
puts "tracked: #{name}"
end
end
class Order
include Trackable
end
Order.new.track_event("checkout") # works, mixed in from the module
Trackable.new # NoMethodError, modules can't be instantiatedAll three are shorthand that generate simple instance variable accessors so you don't have to write them by hand. attr_reader generates only a getter, attr_writer generates only a setter, and attr_accessor generates both.
class User
attr_reader :id
attr_accessor :name
def initialize(id, name)
@id = id
@name = name
end
end
user = User.new(1, "Ada")
user.id = 2 # NoMethodError, attr_reader gives no setter
user.name = "Grace" # fine, attr_accessor gives bothReach for attr_reader on anything that shouldn't be reassigned from outside the object, an id set once at creation, a value derived from other state, and save attr_accessor for genuinely mutable public state.
send calls a method by name dynamically and ignores visibility entirely, it'll happily call a private or protected method. public_send does the same dynamic dispatch but respects visibility and raises NoMethodError if the target method isn't public.
class Account
def balance
@balance ||= 0
end
private
def reset_balance!
@balance = 0
end
end
account = Account.new
account.send(:reset_balance!) # works, send bypasses private
account.public_send(:reset_balance!) # NoMethodError, respects privateThe real risk is calling send with a method name that comes from unvalidated user input, a param, a query string, effectively letting an attacker invoke any method on that object including ones explicitly marked private for a reason. That's an actual, exploitable pattern, not a theoretical one.
Exception is the root of the entire hierarchy, and StandardError is one branch of it that covers the errors an application is expected to actually handle, like ArgumentError, TypeError, and ZeroDivisionError. Things like SystemExit, NoMemoryError, and SignalException sit outside StandardError specifically because a bare rescue should not accidentally swallow a Ctrl-C or an out-of-memory condition.
begin
raise ArgumentError, "bad input"
rescue => e # implicitly rescues StandardError and subclasses only
puts e.message
endWriting rescue Exception to "catch everything" is a common beginner mistake and a real interview red flag, because it also swallows the signals a process needs to shut down cleanly.
ensure runs no matter what, whether the block succeeded, raised an exception that got rescued, raised one that didn't, or even hit an explicit return partway through. Code placed after a begin/rescue block only runs if nothing above it raised an unhandled exception or returned early.
def read_file(path)
file = File.open(path)
file.read
ensure
file&.close # runs even if File.open succeeds but read raises
endEnumerable is a mixin that implements dozens of methods, map, select, reject, reduce, sort_by, group_by, in terms of a single required method: each. Include Enumerable in your class, define each to yield the elements one at a time, and every one of those methods becomes available automatically, built on top of that one primitive.
class Playlist
include Enumerable
def initialize(songs)
@songs = songs
end
def each
@songs.each { |song| yield song }
end
end
playlist = Playlist.new(["a", "b", "c"])
playlist.map(&:upcase) # ["A", "B", "C"], for free from Enumerable
playlist.select { |s| s == "a" } # ["a"]each iterates and returns the original collection unchanged, used purely for side effects. map iterates and returns a new array built from whatever the block returns on each call, used to transform data. The common bug: writing each when you meant to build a new array, then trying to use the return value as if it were the transformed collection, when it's actually just the original untouched input.
result = [1, 2, 3].each { |n| n * 2 }
result # [1, 2, 3], NOT [2, 4, 6], each returns the original array
result = [1, 2, 3].map { |n| n * 2 }
result # [2, 4, 6], map actually returns the transformed valuesRuby uses a generational, mostly mark-and-sweep garbage collector. It walks every reachable object starting from a set of roots (local variables, the stack, global variables, constants), marks each one it finds as still in use, then sweeps through memory and reclaims anything that wasn't marked. A collection cycle typically triggers when Ruby's internal object allocator runs low on free slots and needs to reclaim space before allocating more.
The GVL is a lock in MRI (the reference Ruby implementation most people run) that ensures only one thread executes Ruby code at any given instant, even on a multi-core machine. It doesn't prevent you from creating multiple threads, and it doesn't prevent real parallelism during I/O waits or inside C extensions that explicitly release it, but it does prevent two threads from running pure Ruby bytecode on two CPU cores at the exact same moment.
case/in, stabilized in Ruby 3.0, matches a value against a structural shape rather than just a series of equality or === checks, and it can destructure matched values into local variables in the same step. case/when only checks each branch's condition; it can't reach into a hash or array and bind pieces of it to variables the way case/in can.
response = { status: "success", data: { id: 1, name: "Ada" } }
case response
in { status: "success", data: { id:, name: } }
puts "#{name} has id #{id}"
in { status: "error", message: }
puts "failed: #{message}"
endMedium questions
25freeze makes the object you called it on immutable, it stops you from adding or removing elements from that specific array, but it does nothing to the objects referenced inside it. Freezing the array freezes the container, not its contents.
names = ["ada", "grace"].freeze
names << "margaret" # raises FrozenError, can't modify the array itself
names[0].upcase! # works fine, mutates the string inside the array
names[0] # => "ADA"To actually get a deeply immutable structure you have to freeze every object in the graph yourself, or reach for a library like Ractor's shareable objects, which do enforce deep freezing for anything passed between Ractors.
Duck typing means Ruby cares about whether an object responds to the methods you're about to call on it, not about its declared class or any interface it formally implements. "If it walks like a duck and quacks like a duck" is the whole idea: you don't check obj.is_a?(Duck), you just call obj.quack and let it work for anything that defines quack.
def render(item)
item.to_s # works for a String, an Integer, a custom object, anything with to_s
endIn practice this means Ruby method signatures rarely type-check their arguments up front. Instead you write code that calls the methods it needs and trusts duck typing, or you explicitly check with respond_to? when you genuinely need to branch on capability rather than let a NoMethodError surface.
Both are objects that wrap a block of code you can call later, but they differ in two concrete ways. A lambda checks the number of arguments strictly and raises ArgumentError if you call it with the wrong count, a proc just fills missing arguments with nil and silently drops extras. And a return inside a lambda returns from the lambda itself, while return inside a proc returns from the enclosing method, which can raise a LocalJumpError if that method has already returned.
strict = lambda { |a, b| a + b }
strict.call(1) # ArgumentError, wrong number of arguments
loose = proc { |a, b| [a, b] }
loose.call(1) # [1, nil], no error, b is just nilyield invokes whatever block was passed to the current method, without you needing to name it as a parameter. Calling yield when no block was given raises LocalJumpError, so the standard defensive pattern is checking block_given? first.
def process
if block_given?
yield "data"
else
"no block, default behavior"
end
end
process { |d| puts d } # prints "data"
process # returns the default string, no errorPrefixing the last parameter in a method definition with & captures the implicit block passed to that method and converts it into a Proc object you can store, pass to another method, or call explicitly with .call. The same & works in reverse at a call site, prefixing a Proc or Symbol with & converts it back into a block for that call.
def run_twice(&block)
block.call
block.call
end
run_twice { puts "hi" } # prints "hi" twice
add_one = ->(n) { n + 1 }
[1, 2, 3].map(&add_one) # [2, 3, 4], proc converted back into a block
[3, 1, 2].sort(&:<=>) # symbol-to-proc trick, rarely needed but good to recognizeThe ancestor chain is the ordered list Ruby walks when resolving a method call, starting at the object's singleton class, then its class, then any modules mixed into that class, then the superclass and its modules, all the way up to Object, Kernel, and BasicObject. Call ancestors on any class to see the exact order Ruby will search.
module Loggable; end
module Cacheable; end
class Report
include Loggable
include Cacheable
end
Report.ancestors
# [Report, Cacheable, Loggable, Object, Kernel, BasicObject]
# note the reverse order: the last include ends up closest to Reportinclude inserts the module into the ancestor chain right above the class itself, so the class's own methods still win if there's a name collision. extend adds the module's methods as singleton methods on the specific object it's called on, on a class itself that means they become class methods rather than instance methods. prepend inserts the module before the class in the ancestor chain, so the module's methods run first and can call super to reach the class's original implementation.
module Auditing
def save
puts "auditing before save"
super
end
end
class Record
prepend Auditing
def save
puts "actually saving"
end
end
Record.new.save
# "auditing before save"
# "actually saving"prepend is the one candidates rarely reach for on their own even after years of Ruby, because include covers the common case. It's the right tool specifically when you need to wrap or intercept an existing method rather than just add new ones.
method_missing is a hook Ruby calls automatically whenever a method lookup fails to find a match anywhere in the ancestor chain, letting you intercept the call and handle it dynamically instead of raising NoMethodError. It's the mechanism behind a lot of Rails magic, dynamic attribute readers on ActiveRecord models, for instance.
class DynamicConfig
def initialize(data)
@data = data
end
def method_missing(name, *args)
@data.key?(name) ? @data[name] : super
end
def respond_to_missing?(name, include_private = false)
@data.key?(name) || super
end
end
config = DynamicConfig.new(timeout: 30)
config.timeout # => 30
config.respond_to?(:timeout) # true, only because respond_to_missing? was definedSkip respond_to_missing? and every introspection call, respond_to?, method(:name), a lot of serialization and duck-typing code that checks capability before calling, silently reports false even though the call would actually succeed. That mismatch causes real bugs in code that trusts respond_to? to make a decision.
An instance method lives on the class itself and is shared by every instance. A class method actually lives on the class's singleton class, a hidden, per-object class that Ruby creates lazily to hold methods unique to one specific object, in this case the class object itself. def self.method_name inside a class body is exactly equivalent to opening that singleton class directly.
class Widget
def self.create_default
new
end
class << self
def create_special
new
end
end
end
# both self.method_name and class << self do the same thing:
# define a method on Widget's singleton classdefine_method defines a method at runtime, taking the name as a symbol or string and a block as the body, which means the name and the body can both be generated programmatically instead of typed out by hand. A plain def is fixed at parse time.
class Product
%w[name price description].each do |attr|
define_method(attr) { instance_variable_get("@#{attr}") }
define_method("#{attr}=") { |val| instance_variable_set("@#{attr}", val) }
end
endThat's a hand-rolled version of what attr_accessor already does, deliberately, to make the mechanics visible. The real use case shows up in gems that generate methods from a schema or config at load time, an ORM defining a reader for every database column without anyone hand-writing thirty accessor methods.
Comparable is a built-in module that gives you <, <=, ==, >, >=, between?, and clamp for free, as long as your class defines a single method: <=> (the spaceship operator), returning -1, 0, or 1 depending on whether self is less than, equal to, or greater than the other object.
class Money
include Comparable
attr_reader :cents
def initialize(cents)
@cents = cents
end
def <=>(other)
cents <=> other.cents
end
end
Money.new(500) > Money.new(300) # true
Money.new(100).between?(Money.new(50), Money.new(200)) # trueIt's a solid example of Ruby's general design philosophy: implement one small, well-defined method, and get a whole family of related behavior derived from it automatically.
They read and write an object's instance variables by name, given as a string or symbol, entirely bypassing whatever accessor methods, or lack of them, the class defines. That means you can inspect or mutate internal state even on an object whose class never declared attr_reader or attr_accessor for that variable at all.
class Account
def initialize(balance)
@balance = balance
end
end
account = Account.new(500)
account.instance_variable_get(:@balance) # 500, no attr_reader needed
account.instance_variable_set(:@balance, 0)
account.instance_variable_get(:@balance) # 0Legitimate uses are mostly in test helpers (stubbing internal state without adding a public setter just for a spec) and serialization libraries that need to walk every instance variable generically. Reaching for it in ordinary application code is usually a sign the class should just expose the state through a real method instead.
retry jumps execution back to the start of the begin block, re-running everything from the top, which makes it useful for handling transient failures like a flaky network call. Without a guard it can loop forever, so real code almost always pairs it with a counter.
attempts = 0
begin
attempts += 1
response = fetch_from_flaky_api
rescue Net::TimeoutError
if attempts < 3
sleep(attempts)
retry
else
raise
end
endCalling raise with no arguments inside a rescue block re-raises the exception currently being handled, preserving its original class, message, and backtrace, which is the right move when you want to log or run cleanup and then let the failure propagate unchanged. Calling raise SomeError, "new message" creates and raises an entirely new exception instead, discarding the original backtrace unless you explicitly reattach it.
begin
risky_operation
rescue => e
logger.error(e.message)
raise # re-raises the exact same exception, same backtrace
endreduce walks a collection carrying an accumulator forward through each iteration, starting from an optional initial value, and returns whatever the accumulator ends up as after the last element. It's most familiar as a sum, but the accumulator can be any object, including a hash you're building up entry by entry.
[1, 2, 3, 4].reduce(0) { |sum, n| sum + n } # 10
users = [{ id: 1, name: "Ada" }, { id: 2, name: "Grace" }]
users.reduce({}) { |acc, u| acc.merge(u[:id] => u[:name]) }
# { 1 => "Ada", 2 => "Grace" }A normal chain of map and select on an array processes every element at each step before moving to the next method in the chain, building a full intermediate array along the way. A lazy enumerator processes one element at a time through the entire chain before moving to the next element, which matters enormously on infinite or very large sequences where building an intermediate array isn't possible or is wasteful.
(1..Float::INFINITY).lazy
.select { |n| n.even? }
.map { |n| n * n }
.first(3)
# [4, 16, 36], never tries to build an infinite arrayWithout .lazy, calling select on (1..Float::INFINITY) directly hangs forever, because select tries to evaluate the entire range before returning anything.
select (aliased filter) returns every element for which the block is truthy, as an array, possibly empty. detect (aliased find) returns the first element for which the block is truthy, or nil if none matches, stopping iteration as soon as it finds a hit rather than walking the whole collection.
users.select { |u| u.admin? } # array of every admin, could be empty
users.find { |u| u.admin? } # the first admin found, or nilThe mix-up shows up when someone reaches for select and then immediately calls .first on the result, which works but walks the entire collection even after finding a match early on. find does the same job and stops as soon as it's satisfied.
Generational GC is built on the observation that most objects die young, a temporary string built inside a loop, an intermediate array from a map call, while a much smaller set of objects (loaded classes, long-lived caches, application config) survive for the whole process lifetime. Ruby's GC splits objects into a young generation and an old generation, and runs cheap, frequent minor collections that only scan the young generation, only occasionally running a full major collection that scans everything.
The performance win is that scanning only recently-allocated objects is far faster than re-scanning the entire heap on every collection. An object that survives enough minor collections gets promoted to the old generation and stops being rescanned every time, which is why a long-running Rails process with a stable set of loaded classes and caches doesn't pay the cost of re-marking those same objects on every GC pass.
A GC only reclaims objects that are actually unreachable, nothing still holds a live reference to them. A "leak" in a garbage-collected language almost always means something is unintentionally holding a reference alive far longer than intended, a growing global cache with no eviction, a closure captured in a long-lived object that keeps referencing data it should have let go of, an ever-growing array appended to but never trimmed.
$cache = {}
def memoize(key, value)
$cache[key] = value # never evicted, grows forever, technically "reachable" forever
endThe GC is doing exactly what it's supposed to here. The bug is that the application logic keeps something reachable that should have been let go of, which is why leak-hunting in Ruby usually means finding an unbounded collection or an unexpectedly long-lived reference, not blaming the collector itself.
dup creates a new top-level object but copies references to whatever that object contains, so a duped array has its own identity but the objects inside it are still shared with the original. A deep copy recursively duplicates everything nested inside, so nothing is shared at any level.
original = { user: { name: "Ada" } }
copy = original.dup
copy[:user][:name] = "Grace"
original[:user][:name] # "Grace" too, the nested hash was shared, not dupedRuby doesn't ship a built-in generic deep copy method. The common workarounds are a manual recursive dup, or the blunt-but-reliable Marshal.load(Marshal.dump(obj)) round trip, which works for most plain data but fails on objects holding things that can't be marshaled, an open file handle, a Proc, a database connection.
The GVL is released around blocking I/O, waiting on a database query, an HTTP call to a third-party API, reading a file, so while one thread is blocked waiting on the network, the GVL frees up and another thread gets to run. Most real-world web request handling spends far more wall-clock time waiting on I/O than actually executing Ruby, which is exactly the case where threads deliver a real concurrency win despite the GVL.
threads = 5.times.map do
Thread.new { HTTParty.get("https://slow-api.example.com") }
end
threads.each(&:join)
# all 5 requests overlap in wall-clock time, GVL released during each network waitWhat the GVL does block is CPU-bound work, five threads each doing heavy in-memory computation won't run any faster than one thread doing the same work serially, because there's never a point where the GVL gets released for one of them to hand off to another.
A Fiber is a lightweight, cooperative unit of execution that you explicitly control with resume and yield, rather than the OS scheduler preemptively deciding when to switch between them the way it does with threads. Only one fiber runs at a time and it keeps running until it explicitly yields control back, which means you never have to worry about another fiber interrupting mid-operation the way a thread can.
fiber = Fiber.new do
puts "first"
Fiber.yield
puts "second"
end
fiber.resume # prints "first"
fiber.resume # prints "second"Fibers are the low-level primitive underneath Ruby's Enumerator (each Enumerator with external iteration uses one internally) and underneath async I/O libraries like the Fiber Scheduler introduced in Ruby 3.0, which lets a gem like the `async` gem interleave thousands of concurrent I/O operations on a single thread.
A Mutex guarantees that only one thread at a time can execute the code inside a synchronize block, so the read-add-write sequence behind count += 1 becomes effectively atomic, no other thread can interleave in the middle of it.
count = 0
mutex = Mutex.new
threads = 10.times.map do
Thread.new { 1000.times { mutex.synchronize { count += 1 } } }
end
threads.each(&:join)
count # always exactly 10000 nowThe risk is overusing it, wrapping too much code in a single Mutex serializes work that didn't actually need to be serialized, erasing whatever concurrency benefit threads were supposed to provide in the first place, and a Mutex held across a blocking I/O call is a common way to accidentally create a bottleneck far worse than not using threads at all.
YJIT is a just-in-time compiler built into MRI, developed by Shopify, that compiles frequently-executed Ruby bytecode down to native machine code at runtime instead of interpreting it every time. It ships inside the standard Ruby binary starting with 3.1 and is enabled with the --yjit flag or the RUBY_YJIT_ENABLE environment variable rather than being on by default.
Shopify's own engineering team has published production numbers showing meaningful throughput and response-time improvements running YJIT on their Rails monolith (Shopify Engineering, "Ruby's YJIT Is Production-Ready"), and Rails-focused hosts and gems have picked it up widely since. The honest caveat for an interview answer: the win is workload-dependent, apps that are already I/O-bound (waiting on the database or external APIs most of the time) see less benefit than CPU-heavy workloads, since YJIT only speeds up the Ruby execution itself, not the waiting.
Subclass StandardError (or a shared app-level base error) for each category of failure your app can produce, so callers can rescue at whatever granularity actually matters to them, a specific error, a whole family of related errors, or everything your app raises without touching unrelated third-party errors.
class AppError < StandardError; end
class PaymentError < AppError; end
class InsufficientFundsError < PaymentError; end
begin
raise InsufficientFundsError, "balance too low"
rescue PaymentError => e
# catches InsufficientFundsError and any other PaymentError subclass
notify_billing_team(e)
endUsing bare StandardError everywhere forces every caller to either rescue too broadly (catching errors they didn't mean to handle) or not rescue at all. A real hierarchy is what makes selective rescuing possible in the first place.
Hard questions
10Ruby has three different equality methods and they answer three different questions. equal? checks object identity, are these literally the same object in memory, and it's almost never overridden. == checks value equality and is meant to be overridden by classes that want a custom notion of "the same," which is why two different String objects with the same characters return true. eql? sits between the two, used mainly by Hash to decide whether two keys collide, and it's stricter than == in cases like 1 == 1.0 being true but 1.eql?(1.0) being false because the types differ.
a = "hello"
b = "hello"
a == b # true, same characters
a.equal?(b) # false, different objects in memory
a.eql?(b) # true, same value and same classA closure is a block, proc, or lambda that captures the local variable bindings from the scope it was defined in, not just their values at that moment but the actual bindings, so later mutations to those variables are visible inside the closure too. Ruby procs are closures because they hold a reference to the surrounding scope's binding, which keeps that binding alive in memory even after the method that created it has already returned and its stack frame is gone.
def make_counter
count = 0
increment = -> { count += 1 }
current = -> { count }
[increment, current]
end
inc, cur = make_counter
inc.call
inc.call
cur.call # => 2, the binding for `count` outlived the method callThis is the same mechanism behind memoization helpers and a lot of DSL-building code in gems like RSpec, a block captures the surrounding context and Ruby keeps that context alive as long as something still references the block.
A private method can't be called with an explicit receiver at all, not even self.method_name in most cases (setter methods are the one exception), it can only be called bare from inside the same object. A protected method can be called with an explicit receiver, but only from within another instance of the same class or a subclass, which is exactly the case private can't handle: comparing two instances of the same class against each other's internal state.
class Money
def initialize(cents)
@cents = cents
end
def >(other)
cents > other.cents # needs to call other.cents with an explicit receiver
end
protected
def cents
@cents
end
end
Money.new(500) > Money.new(300) # true, protected allows this
Money.new(500).cents # NoMethodError, protected blocks external callsMake cents private instead of protected here and the comparison method itself breaks, because private forbids the explicit-receiver call other.cents even from inside the same class. That's the exact scenario protected exists for.
instance_eval changes self to the receiver for the duration of the block, letting you call private methods on that object and access its instance variables directly from outside. class_eval (also called module_eval) does the same thing but changes self to the class or module itself, which is how you'd add methods to a class you don't control the source of, reopening a gem's class to patch or extend it.
class Widget
def initialize
@secret = "hidden"
end
end
widget = Widget.new
widget.instance_eval { @secret } # "hidden", direct access from outside
String.class_eval do
def shout
upcase + "!"
end
end
"hello".shout # "HELLO!", monkey-patched onto every StringIn application code you'd mostly avoid both. They show up legitimately in test frameworks (RSpec's describe block runs via instance_eval-like mechanics) and in DSL gems, but reaching for them in ordinary business logic is usually a sign there was a cleaner object-oriented way to solve the problem.
Refinements let you modify a class's behavior in a way that's scoped to wherever you explicitly activate it with using, instead of changing that class globally for every file that happens to load afterward. A plain monkey patch on String, for instance, changes it everywhere in the process, in every gem, in every file, forever, for the life of that process.
module StringShout
refine String do
def shout
upcase + "!"
end
end
end
"hello".shout # NoMethodError, refinement isn't active here
using StringShout
"hello".shout # "HELLO!", only works after `using` is called in this scopeRefinements exist specifically to make monkey patching safer in a shared codebase, but they've never fully replaced plain monkey patching in practice, partly because refinement scoping rules have real edge cases around method_missing and Kernel#send that surprise people, and partly because the ecosystem's biggest monkey-patchers (ActiveSupport included) shipped long before refinements existed and never migrated.
sort needs a way to compare any two elements to decide their relative order, and by default it relies on each object's <=> method. Plain custom objects don't define one (Object's default only returns 0 for identity, nil otherwise), so calling bare sort on an array of them raises ArgumentError, comparison of X with X failed, the moment it tries to compare two elements that return nil from <=>.
class Task
attr_reader :priority
def initialize(priority)
@priority = priority
end
end
tasks = [Task.new(3), Task.new(1)]
tasks.sort # ArgumentError, comparison failed
tasks.sort { |a, b| a.priority <=> b.priority } # works, block supplies the comparison
class Task
include Comparable
def <=>(other)
priority <=> other.priority
end
end
tasks.sort # now works, sort finds <=> definedobject_id returns a unique identifier for an object for its lifetime, and it's the mechanism equal? uses internally. For most objects it's a reliable way to check identity. But small integers, symbols, true, false, and nil in MRI are what's called immediate values, they don't get allocated as separate heap objects at all, so every occurrence of the integer 5 anywhere in your program shares the exact same object_id by definition, not by coincidence.
5.object_id == 5.object_id # true, always, same immediate value
"hi".object_id == "hi".object_id # false, two separate String allocations
:sym.object_id == :sym.object_id # true, symbols are internedThis isn't fragile so much as it's a category error to worry about: comparing object_id for two integers is really just comparing their values, which is what == already does more clearly. The fragility shows up when someone assumes object_id behavior generalizes the same way across strings, symbols, and integers, when the underlying representation of each is genuinely different.
The RSS graph tells you memory is growing but nothing about why. Real diagnosis starts with ObjectSpace.count_objects to see which object types are accumulating, followed by taking heap dumps with ObjectSpace.dump_all at two points in time and diffing them with a tool like heapy or the derailed_benchmarks gem to see exactly which allocation sites are responsible for the growth between the two snapshots.
In a Rails app specifically, the usual suspects are a middleware or initializer holding an unbounded in-memory cache, a background job library accumulating retry state, or a gem that memoizes per-request data into a class-level (not instance-level) variable that never gets cleared between requests. The fix is almost never "call GC.start more often," forcing a full collection doesn't reclaim objects that are still reachable, it only reclaims what's genuinely unreferenced, so the growth keeps coming back until the actual retained reference is found and removed.
Ractors, introduced in Ruby 3.0, provide actual parallel execution of Ruby code across multiple CPU cores, each Ractor gets its own GVL, which is the thing threads fundamentally can't offer since they all share one GVL. The tradeoff is isolation: Ractors can't share mutable objects freely the way threads can. Passing an object between Ractors either deep-copies it, moves ownership of it entirely (the sender loses access), or requires the object to be explicitly marked shareable and frozen.
ractor = Ractor.new do
Ractor.receive
end
ractor.send("hello")
# strings and most mutable objects get copied when sent, not shared by referenceRactors are still marked experimental in the Ruby core docs as of the latest 3.x releases, and most gems and even parts of Ruby's own standard library aren't fully Ractor-safe yet, so adoption in production Rails apps has been much slower than Ruby core's original roadmap suggested.
The GVL prevents two threads from executing Ruby bytecode simultaneously, but it doesn't make a multi-step operation atomic. The scheduler can switch threads between any two bytecode instructions, so an operation that looks like one line of Ruby, @count += 1, actually compiles to a read, an add, and a write as three separate steps, and a thread switch between the read and the write is exactly how two threads both read the same starting value and one increment gets lost.
count = 0
threads = 10.times.map do
Thread.new { 1000.times { count += 1 } }
end
threads.each(&:join)
count # often less than 10000, because += isn't atomicThe fix is the same as in any threaded language: use a Mutex around the critical section, or reach for one of Ruby's actually-atomic primitives like Concurrent::AtomicFixnum from the concurrent-ruby gem, rather than assuming the GVL makes every individual line of Ruby thread-safe by default. It doesn't.
How to prepare for a Ruby interview in 2026
Skip re-reading a syntax cheat sheet. Build one small thing that forces you to touch the object model directly: a tiny DSL using method_missing and respond_to_missing? together, a class that mixes in Comparable and Enumerable and gets a dozen methods for free, a threaded worker pool that deliberately hits a race condition and then gets fixed with a Mutex. Watching those bugs happen and disappear teaches the object model and the GVL far faster than reading about either one.
Across backend and full-stack mock interviews run through LastRoundAI, the include versus prepend question and the "why doesn't sort work on my custom class" question trip up more candidates than method_missing does, even though method_missing gets more attention in prep guides. Our read is that method_missing feels like the "advanced" Ruby topic people study on purpose, while the ancestor chain and Comparable feel like things you'll just pick up on the job. We don't have a precise number on that gap. It comes up often enough in review to flag here, not often enough that we'd stake a specific percentage on it.
Get the reps in before the real thing
Explaining the difference between include and prepend on paper is not the same as defending it out loud once an interviewer changes one detail and asks what breaks. LastRoundAI's mock interview mode runs live coding rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $29/mo (or $19/mo on the 3-month plan) if a handful of sessions isn't enough runway.
Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough backend and Rails roles that actually test Ruby instead of treating it as a legacy skill. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.
How this list was built
Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.
What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.
If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.
Frequently asked questions
Is Ruby still worth learning in 2026?
For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.
Should I memorise Ruby syntax for the interview?
Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.
What is the most common mistake in Ruby interviews?
Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.
How long does it take to prepare for a Ruby interview?
If you already work with Ruby day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.
What Ruby topics come up most often?
Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.
