Ruby Interview Questions · 2026

Ruby Interview Questions (2026): Most Asked, With Answers

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.

50Questions
Duck TypingCore Concept
GVL + FibersConcurrency Model
Ruby 3.4Current Stable

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

15

In 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.

ruby
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"
end

A 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.

ruby
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.

ruby
["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.

ruby
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 instantiated

All 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.

ruby
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 both

Reach 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.

ruby
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 private

The 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.

ruby
begin
 raise ArgumentError, "bad input"
rescue => e  # implicitly rescues StandardError and subclasses only
 puts e.message
end

Writing 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.

ruby
def read_file(path)
 file = File.open(path)
 file.read
ensure
 file&.close # runs even if File.open succeeds but read raises
end

Enumerable 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.

ruby
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.

ruby
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 values

Ruby 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.

ruby
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}"
end

Medium questions

25

freeze 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.

ruby
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.

ruby
def render(item)
 item.to_s # works for a String, an Integer, a custom object, anything with to_s
end

In 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.

ruby
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 nil

yield 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.

ruby
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 error

Prefixing 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.

ruby
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 recognize

The 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.

ruby
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 Report

include 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.

ruby
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.

ruby
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 defined

Skip 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.

ruby
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 class

define_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.

ruby
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
end

That'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.

ruby
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)) # true

It'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.

ruby
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) # 0

Legitimate 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.

ruby
attempts = 0
begin
 attempts += 1
 response = fetch_from_flaky_api
rescue Net::TimeoutError
 if attempts < 3
  sleep(attempts)
  retry
 else
  raise
 end
end

Calling 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.

ruby
begin
 risky_operation
rescue => e
 logger.error(e.message)
 raise # re-raises the exact same exception, same backtrace
end

reduce 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.

ruby
[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.

ruby
(1..Float::INFINITY).lazy
.select { |n| n.even? }
.map { |n| n * n }
.first(3)
# [4, 16, 36], never tries to build an infinite array

Without .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.

ruby
users.select { |u| u.admin? } # array of every admin, could be empty
users.find { |u| u.admin? }  # the first admin found, or nil

The 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.

ruby
$cache = {}

def memoize(key, value)
 $cache[key] = value # never evicted, grows forever, technically "reachable" forever
end

The 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.

ruby
original = { user: { name: "Ada" } }
copy = original.dup

copy[:user][:name] = "Grace"
original[:user][:name] # "Grace" too, the nested hash was shared, not duped

Ruby 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.

ruby
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 wait

What 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.

ruby
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.

ruby
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 now

The 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.

ruby
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)
end

Using 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

10

Ruby 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.

ruby
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 class

A 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.

ruby
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 call

This 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.

ruby
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 calls

Make 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.

ruby
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 String

In 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.

ruby
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 scope

Refinements 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 <=>.

ruby
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 <=> defined

object_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.

ruby
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 interned

This 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.

ruby
ractor = Ractor.new do
 Ractor.receive
end

ractor.send("hello")
# strings and most mutable objects get copied when sent, not shared by reference

Ractors 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.

ruby
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 atomic

The 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.

Leave a Reply

Your email address will not be published. Required fields are marked *