The 60 Ruby questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Ruby is a dynamic, interpreted, object-oriented programming language created by Yukihiro Matsumoto (Matz) and first released in 1995, built around the idea that code should be pleasant to read and write. Everything is an object, even integers and nil, and the language leans on blocks, mixins, and open classes to stay expressive. It's dynamically and strongly typed, which is why '1' + 1 raises instead of coercing silently. Ruby powers Ruby on Rails, and most Ruby hiring runs through Rails work, so interviews probe the object model, blocks and iterators, and metaprogramming rather than memorized trivia. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the official documentation at docs.ruby-lang.org is the canonical reference; increasingly the first Ruby round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Ruby Programming Language - Full Course
Video: Ruby Programming Language - Full Course (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Ruby certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Ruby is a dynamic, interpreted, object-oriented language created by Yukihiro Matsumoto with a focus on programmer happiness and readable code. It's dynamically and strongly typed and treats everything as an object.
What makes it distinctive is expressiveness: blocks, mixins via modules, open classes, and metaprogramming let you write code that reads close to plain English. Ruby on Rails is the reason most teams pick it, so its ecosystem centers on web development.
Key point: A one-line definition plus two concrete traits (everything is an object, blocks) beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Ruby in 100 Seconds (Fireship, YouTube)
Every value in Ruby, including integers, nil, true, and even classes themselves, is an object with methods. That's why you can write 5.times, "hi".upcase, or nil.to_s without special cases.
This uniformity is why Ruby has no separate primitive types like Java's int. It also explains method chaining and why numbers respond to methods you might expect to be operators.
3.times { |i| puts i } # 0, 1, 2
-7.abs # 7
nil.to_a # []
42.class # IntegerKey point: Say it plainly, then give one example that surprises people (nil.to_a). That shows you have actually internalized it, not memorized the slogan.
A string is a mutable sequence of characters, and each string literal creates a new object. A symbol (:name) is an immutable identifier stored once in memory, so the same symbol always refers to the same object.
Use symbols for fixed names like hash keys and method references, where reuse and identity matter. Use strings for text you build, read, or change.
"hello".object_id == "hello".object_id # false, two objects
:hello.object_id == :hello.object_id # true, one object
user = { name: "Asha", role: "engineer" } # symbol keys| String | Symbol | |
|---|---|---|
| Mutability | Mutable | Immutable |
| Identity | New object each literal | Same object reused |
| Typical use | Text you build or change | Hash keys, method names |
| Syntax | "text" | :name |
Key point: The follow-up is 'why use symbols as hash keys?'. Have the reuse and identity answer ready.
Watch a deeper explanation
Video: Ruby Basics: Symbols vs Strings (GoRails, YouTube)
nil is Ruby's representation of nothing, a single object of class NilClass. Methods with no explicit return often return nil.
Only false and nil are falsy. Everything else is truthy, including 0, an empty string, and an empty array. That surprises developers from C, Python, or JavaScript, where 0 or empty collections are falsy.
puts "yes" if 0 # prints yes, 0 is truthy
puts "yes" if "" # prints yes, empty string is truthy
puts "no" if nil # prints nothing
name = nil
name.nil? # trueKey point: The trap is assuming 0 or an empty string is falsy. Naming that difference from other languages is exactly what this question screens for.
Ruby signals variable scope with the first character: a lowercase name or underscore is local, @name is an instance variable, @@name is a class variable, $name is global, and a capitalized name is a constant.
This naming convention is the scope rule; there's no separate declaration. Reading an uninitialized instance variable returns nil rather than raising, which is a common source of typo bugs.
count = 1 # local
@user = "Asha" # instance
@@total = 0 # class
$config = {} # global
MAX_RETRIES = 3 # constantInside double-quoted strings, #{expression} evaluates the expression and inserts its to_s result. Single-quoted strings don't interpolate; they treat #{...} literally.
Interpolation reads better than concatenation and handles any expression, not just variables. Reach for double quotes when you interpolate and single quotes for plain literals.
name = "Asha"
puts "Hello, #{name}! You have #{2 + 3} tasks."
# Hello, Asha! You have 5 tasks.
puts 'Hello, #{name}' # Hello, #{name} (no interpolation)An array is an ordered, integer-indexed collection that can hold mixed types and grows dynamically. A hash is a collection of key-value pairs where keys can be any object, most often symbols.
Both come with a deep set of built-in methods. Since Ruby 1.9 hashes keep insertion order, and the symbol-key shorthand ({ name: "Asha" }) is the idiom you'll see everywhere in Rails.
nums = [3, 1, 2]
nums.sort # [1, 2, 3]
nums << 4 # [3, 1, 2, 4]
user = { name: "Asha", age: 31 }
user[:name] # "Asha"
user.fetch(:role, "n/a") # "n/a", no errorIterators are methods that yield each element to a block, so you loop by passing behavior rather than managing an index. each is the base; map, select, reject, and reduce build on the same idea.
This block-based style is the idiomatic Ruby way to loop. You rarely write a for loop or a manual counter because the collection knows how to walk itself.
[1, 2, 3].each { |n| puts n }
doubled = [1, 2, 3].map { |n| n * 2 } # [2, 4, 6]
evens = (1..10).select { |n| n.even? } # [2, 4, 6, 8, 10]
total = [1, 2, 3].reduce(0) { |sum, n| sum + n } # 6Key point: Reaching for map and select instead of each-plus-append is a strong fluency signal. Interviewers notice when someone writes Ruby like it's another language.
A block is a chunk of code you attach to a method call, written with { } for one line or do...end for many. The method runs your block with yield, optionally passing values into the block's parameters.
Blocks are how Ruby does callbacks and iteration. They aren't objects on their own; a method receives at most one block, and you call it with yield or capture it with an &block parameter.
def repeat(n)
n.times { |i| yield i }
end
repeat(3) { |i| puts "run #{i}" }
# run 0
# run 1
# run 2Key point: Explaining yield out loud, and that a method takes only one block, is the bar. The follow-up is usually procs and lambdas.
def name(args) ... end defines a method. Parentheses on arguments are optional but conventional. The last evaluated expression is returned automatically, so an explicit return is only needed to exit early.
This implicit return is idiomatic Ruby; adding return on the final line indicates noise to experienced reviewers. Methods can take default arguments and keyword arguments too.
def greet(name, greeting: "Hello")
"#{greeting}, #{name}"
end
greet("Asha") # "Hello, Asha"
greet("Ben", greeting: "Hi") # "Hi, Ben"class Name ... end defines a class, and initialize is the constructor that runs when you call Name.new. Instance variables (@name) hold per-object state and are private to the object.
You expose state with accessor methods rather than direct variable access. attr_accessor, attr_reader, and attr_writer generate those methods so you don't write boilerplate getters and setters.
class User
attr_reader :name
def initialize(name)
@name = name
end
def greet
"Hi, I am #{@name}"
end
end
User.new("Asha").greet # "Hi, I am Asha"They generate accessor methods for instance variables so you skip writing them by hand. attr_reader makes a getter, attr_writer makes a setter, and attr_accessor makes both.
Behind the scenes attr_accessor :name defines name and name= that read and write @name. Choosing reader-only is how you make read-only attributes.
class Account
attr_reader :id # read-only
attr_accessor :balance # read and write
def initialize(id)
@id = id
@balance = 0
end
endputs writes its argument and adds a newline, calling to_s. print writes without a trailing newline. p writes the inspect form (showing quotes and structure) and returns the object, which makes it handy for debugging.
The practical rule: puts for user-facing output, p when you want to see exactly what an object is, including whether something is a string or a symbol.
puts "hi" # hi (returns nil)
print "hi" # hi (no newline)
p "hi" # "hi" (returns "hi")
p :hi # :hi (shows it is a symbol)Ruby has if, elsif, else, and end, plus unless as the negation of if. Both work as modifiers at the end of a line, which reads naturally: return unless valid.
Because if is an expression, it returns a value you can assign. case/when handles multi-branch checks and uses === under the hood, so it matches ranges, classes, and regexes, not just equality.
status = if score >= 90 then "A" else "B" end
puts "denied" unless authorized
grade = case score
when 90..100 then "A"
when 70...90 then "B"
else "C"
endA range represents an interval between two values. (1..5) includes 5 (inclusive), and (1...5) excludes it (exclusive). Ranges work with numbers, characters, and anything comparable.
They're common in iteration, case statements, and slicing. Ranges are lazy for iteration and cheap to hold, so (1..1_000_000) doesn't build a giant array.
(1..5).to_a # [1, 2, 3, 4, 5]
(1...5).to_a # [1, 2, 3, 4]
("a".."e").to_a # ["a", "b", "c", "d", "e"]
(1..10).step(2).to_a # [1, 3, 5, 7, 9]The everyday set: upcase, downcase, capitalize, strip (trim whitespace), split, gsub (global substitution), include?, and length. Ruby strings carry a deep library, so most text work is one method call.
gsub with a regex is the workhorse for find-and-replace, and split turns text into an array. Knowing these by reflex shows you write Ruby idiomatically rather than looping character by character.
" Hello World ".strip # "Hello World"
"a,b,c".split(",") # ["a", "b", "c"]
"hello".gsub("l", "L") # "heLLo"
"Ruby".include?("ub") # trueYes. Most operators are method calls in disguise: a + b is a.+(b), and array[0] is array.[](0). That's why you can define + or [] on your own classes to make them behave like built-ins.
This is part of everything-is-an-object. Operator overloading in Ruby is just defining a method with the operator's name, so custom types can support arithmetic, comparison, and indexing.
class Vector
def initialize(x, y) = (@x, @y = x, y)
def +(other)
Vector.new(@x + other.x, @y + other.y)
end
attr_reader :x, :y
endrequire loads a file once per program run and tracks what it has already loaded, so repeated requires are cheap and safe. load runs the file every time it's called and doesn't track prior loads.
You'll almost always use require (or require_relative for paths relative to the current file). load is for the rare case where you deliberately want to re-execute a file, such as reloading changed config.
For candidates with working experience: language mechanics, the object model, and the questions that separate users from understanders.
A block is code passed to a method, not an object on its own; a method takes one and runs it with yield. A proc and a lambda are both objects that wrap a block, so you can store them, pass several, and call them later.
Procs and lambdas differ in two ways. Lambdas check argument count and raise on a mismatch; procs ignore extras and fill missing ones with nil. A return inside a lambda exits only the lambda; a return inside a proc exits the method that created it.
square = ->(x) { x * x } # lambda
square.call(4) # 16
add = proc { |a, b| (a || 0) + (b || 0) }
add.call(2) # 2, proc tolerates missing arg
# square.call(4, 5) # ArgumentError, lambda checks arity| Block | Proc | Lambda | |
|---|---|---|---|
| Is an object | No | Yes | Yes |
| Argument count check | n/a | Lenient | Strict |
| return behavior | n/a | Exits enclosing method | Exits the lambda only |
| Created with | { } or do..end | proc { } / Proc.new | -> { } / lambda { } |
Key point: The arity and return differences are the whole point of this question. Have a one-line example of each ready.
Watch a deeper explanation
Video: Blocks, Procs, and Lambdas - Ruby Programming (Andrew Courter, YouTube)
yield calls the block passed to the current method, optionally handing it arguments. block_given? returns true when a block was passed, so you can make the block optional and avoid a LocalJumpError.
This pair is how you write flexible iteration methods. Capture the block explicitly with an &block parameter when you need to store it, pass it on, or convert it to a proc.
def each_pair(hash)
return hash.to_enum(:each_pair) unless block_given?
hash.each { |k, v| yield k, v }
end
each_pair(a: 1, b: 2) { |k, v| puts "#{k}=#{v}" }A module is a container for methods and constants that can't be instantiated. Its two jobs are namespacing (grouping related code) and mixins (sharing behavior across unrelated classes).
include mixes a module's instance methods into a class, extend adds them as class methods, and prepend inserts the module ahead of the class in the lookup chain. This is how Ruby shares code without multiple inheritance.
module Greetable
def greet
"Hi, I am #{name}"
end
end
class User
include Greetable
attr_reader :name
def initialize(name) = @name = name
end
User.new("Asha").greet # "Hi, I am Asha"Key point: Naming include, extend, and prepend and what each does is the bar. The classic follow-up is how modules replace multiple inheritance.
include adds a module's methods as instance methods of the class. extend adds them as methods on whatever object it's called on, so at class level it creates class methods. prepend inserts the module before the class itself in the ancestor chain, so its methods override the class's own.
The difference is where the module lands in method lookup. Check any class's ancestors array to see the exact order Ruby walks when resolving a method.
module Logging
def save
puts "logging..."
super
end
end
class Record
prepend Logging
def save = puts "saving"
end
Record.new.save # logging... then saving
Record.ancestors # [Logging, Record, ...]Duck typing means Ruby cares whether an object responds to the method you call, not what class it is. If it walks like a duck and quacks like a duck, it's a duck. Code works with any object that has the needed methods.
This is why explicit type checks are rare and interfaces are informal. You depend on behavior (does it respond to each?) rather than on class membership, which keeps code flexible and testable.
def print_all(collection)
collection.each { |item| puts item }
end
print_all([1, 2, 3]) # works
print_all(1..3) # works, Range responds to each
print_all({ a: 1 }) # works, Hash responds to eachself is the current object, and what it points to depends on context. Inside an instance method it's the instance; inside a class body or a class method it's the class itself.
You use self to call the object's own methods explicitly, to define class methods (def self.method), and to disambiguate a setter call from creating a local variable (self.name = x, since name = x would just make a local).
class Counter
def self.build # class method, self is Counter
new
end
attr_accessor :count
def reset
self.count = 0 # setter, not a local variable
end
endKey point: The setter gotcha (self.count = 0 versus count = 0) is a favorite follow-up. Being able to explain why the self is required shows real understanding.
public methods can be called by anyone (the default). private methods can only be called without an explicit receiver, so they're internal to the object. protected methods can be called with an explicit receiver, but only from within the same class or its subclasses.
protected is the one people forget: it exists so an object can compare against another instance of the same class (comparing balances) while still hiding that method from outside code.
class Account
def >(other)
balance > other.balance # allowed: protected + same class
end
protected
def balance = @balance
endThe spaceship operator <=> returns -1, 0, or 1 (or nil if the two aren't comparable). Sorting and comparison methods use it under the hood.
Define <=> on your class and include Comparable, and you get <, <=, ==, >=, >, and between? for free. That's a lot of behavior from one method, and it's the idiomatic way to make custom objects sortable.
class Version
include Comparable
attr_reader :num
def initialize(num) = @num = num
def <=>(other) = num <=> other.num
end
Version.new(2) > Version.new(1) # true
[Version.new(3), Version.new(1)].min.num # 1Enumerable gives a class dozens of collection methods (map, select, reduce, sort, group_by, min, max) from a single each method. Array, Hash, Range, and Set all include it.
Define each and include Enumerable, and your own class becomes a first-class collection. This is duck typing at the library level: any object that knows how to iterate gets the full toolbox.
class Playlist
include Enumerable
def initialize(tracks) = @tracks = tracks
def each(&block) = @tracks.each(&block)
end
p = Playlist.new(["a", "b", "c"])
p.map(&:upcase) # ["A", "B", "C"]
p.select { |t| t > "a" } # ["b", "c"]Key point: Explaining that Enumerable is built entirely on each is the insight the question needs. It ties duck typing, mixins, and blocks together.
The & converts a symbol into a block. map(&:upcase) is shorthand for map { |x| x.upcase }: Ruby calls to_proc on the symbol, producing a proc that calls that method on each element.
It reads cleanly for one-method transforms. The same & prefix on a proc or lambda argument passes it as the method's block, so &block in a signature captures the block into a variable.
["a", "b"].map(&:upcase) # ["A", "B"]
[1, -2, 3].select(&:positive?) # [1, 3]
# equivalent long form
["a", "b"].map { |s| s.upcase }fetch (raises or returns a default for missing keys), dig (safe nested access), merge, each_with_object, transform_values, and group_by cover most real work. Hash.new(0) or Hash.new { |h, k| h[k] = [] } gives default values so you skip key checks.
The default-value pattern is the Ruby answer to Python's defaultdict, and it collapses counting or grouping loops to a few lines.
counts = Hash.new(0)
"mississippi".each_char { |c| counts[c] += 1 }
counts # {"m"=>1, "i"=>4, "s"=>4, "p"=>2}
config = { db: { host: "local" } }
config.dig(:db, :host) # "local", nil if missingbegin/rescue/ensure is the structure: rescue catches an exception, ensure always runs for cleanup, and else runs when nothing was raised. Inside a method or block you can skip begin and rescue directly. raise throws one; retry re-runs the begin block.
Rescue specific classes, not a bare rescue, which by default catches StandardError but is easy to misuse. Your own exceptions should subclass StandardError so they're caught by ordinary rescue clauses.
def parse(input)
Integer(input)
rescue ArgumentError => e
puts "bad input: #{e.message}"
0
ensure
puts "done"
endKey point: Rescuing StandardError versus Exception is a common trap: rescuing Exception swallows Ctrl-C and system exits. Naming that shows care.
freeze makes an object immutable; further attempts to modify it raise a FrozenError. It's how you protect constants and shared data from accidental mutation.
The magic comment # frozen_string_literal: true at the top of a file freezes all string literals in it, which avoids allocating a new string object for repeated identical literals and prevents accidental in-place edits. Many style guides require it.
# frozen_string_literal: true
GREETING = "hello".freeze
GREETING << "!" # FrozenError
label = "ok"
label.frozen? # true with the magic commentcase/when compares each when value against the target using ===, the case-equality operator, not ==. Different classes define === differently: a Class checks instance membership, a Range checks inclusion, and a Regexp checks a match.
That's why case handles ranges, types, and patterns in one clean structure. Understanding === explains why case is far more flexible than a chain of == checks.
def describe(x)
case x
when Integer then "a number"
when 1..10 then "small range"
when /^a/ then "starts with a"
else "something else"
end
end
Integer === 5 # true, class membership checkKeyword arguments name each parameter at the call site (def send(to:, subject:)), which makes calls self-documenting and order-independent. Required keywords raise if omitted; give a default to make one optional.
Since Ruby 3.0, keyword arguments are separated from positional hash arguments, so a trailing hash is no longer silently treated as keywords. Use ** to accept or forward arbitrary keyword pairs.
def create_user(name:, role: "member", **extra)
{ name: name, role: role, **extra }
end
create_user(name: "Asha", team: "platform")
# {name: "Asha", role: "member", team: "platform"}Struct generates a small class with accessors, equality, and a constructor from a list of attribute names, good for lightweight mutable value objects. Data (Ruby 3.2+) does the same for immutable value objects, which is now the preferred choice when you don't need to change fields.
Both save boilerplate compared with writing a full class. Reach for Data when immutability communicates intent and Struct when you genuinely need mutability.
Point = Data.define(:x, :y)
p1 = Point.new(x: 1, y: 2)
p1.x # 1
p1 == Point.new(x: 1, y: 2) # true
# p1.x = 9 # NoMethodError, immutableA singleton method is defined on a single object rather than its whole class, so only that object has it. Class methods are really singleton methods on the class object, which is why def self.foo works.
Each object has a hidden singleton class (its metaclass) that holds these methods. You can open it with class << self to define several class-level methods together.
config = Object.new
def config.reload
"reloaded"
end
config.reload # "reloaded", only this object has it
class Service
class << self
def instance = @instance ||= new
end
endA gem is a packaged Ruby library distributed through RubyGems. You declare dependencies in a Gemfile, and Bundler resolves compatible versions and writes them to Gemfile.lock so every machine installs the exact same set.
bundle install reads the Gemfile, and bundle exec runs commands with the locked versions. The lock file is what makes builds reproducible across development, CI, and production.
# Gemfile
source "https://rubygems.org"
gem "rails", "~> 7.1"
gem "rspec", group: :test
# then: bundle install
# run with locked versions: bundle exec rspecRSpec is the popular choice: describe and context group examples, it defines a single behavior, and expect(...).to matchers assert outcomes. Minitest ships with Ruby and uses plain assert-style or spec-style syntax with less setup.
A strong answer tests behavior over implementation, uses let for lazy setup, and stubs external boundaries (network, time) rather than hitting them for real.
RSpec.describe Calculator do
it "adds two numbers" do
expect(Calculator.new.add(2, 3)).to eq(5)
end
context "with zero" do
it "returns the other number" do
expect(Calculator.new.add(0, 4)).to eq(4)
end
end
end@result ||= expensive_call computes the value on first access and caches it in an instance variable, so later calls reuse it. It expands to @result || (@result = expensive_call), so it only runs the right side when @result is nil or false.
The bite is caching a legitimately falsy result: if expensive_call returns nil or false, ||= re-runs it every time because the cache indicates unset. When false or nil is a valid answer, check defined?(@result) instead, or use a sentinel.
def config
@config ||= load_config # cached after first call
end
# breaks if load_config can return false or nil:
def flag
return @flag if defined?(@flag)
@flag = compute_flag
endWhat @result ||= value evaluates step by step
This is why ||= re-runs the work whenever the cached value is nil or false. Use defined? when a falsy result is valid.
Key point: The follow-up is 'what if the value can be false?'. The defined? answer is what separates a memorized idiom from understanding it.
advanced rounds probe internals, metaprogramming, and production scars. Expect every answer here to draw a follow-up.
When you call a method that doesn't exist, Ruby walks the ancestor chain and, finding nothing, calls method_missing with the method name and arguments. Overriding it lets an object respond to methods dynamically, which is how Rails dynamic finders and proxy objects work.
Always pair it with respond_to_missing? so respond_to? and introspection stay honest, and call super for names you don't handle so real NoMethodErrors still surface. Overusing method_missing hurts readability and performance, so reach for define_method when the method set is known.
class Config
def initialize(data) = @data = data
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
endKey point: The follow-up is always 'why respond_to_missing?'. Answering it, and the performance cost, is what matters.
Watch a deeper explanation
Video: Ruby Tutorial | Metaprogramming in Ruby - Method Missing (Techmaker Studio, YouTube)
define_method creates a method at runtime from a name and a block, closing over surrounding variables. It's the right tool when you know the set of methods to generate, such as building similar accessors or wrapping an API's endpoints.
Prefer it over method_missing whenever the names are known ahead of time: define_method builds real methods that respond_to? sees, are faster to call, and show up in introspection, without the interception overhead on every unknown call.
class Report
%i[pdf csv json].each do |format|
define_method("to_#{format}") do
"rendering #{format}"
end
end
end
Report.new.to_csv # "rendering csv"Ruby searches the object's ancestor chain in order: the singleton class, then prepended modules, the class itself, included modules (last included wins), then up through superclasses and their modules to BasicObject. The first match runs. ancestors shows the exact order.
super continues the search from the current point in that chain, which is how mixins cooperate. If nothing matches, method_missing is called, and only if that also declines does NoMethodError raise.
module A; def hi = "A"; end
module B; def hi = "B"; end
class C
include A
include B # included later, wins
end
C.new.hi # "B"
C.ancestors # [C, B, A, Object, Kernel, BasicObject]The default runtime (CRuby/MRI) has a Global VM Lock that lets only one thread run Ruby code at a time, so threads help with I/O-bound work (the lock releases during blocking I/O) but not CPU-bound parallelism. For real CPU parallelism you fork processes.
For a fuller picture: Ractors (Ruby 3+) offer actor-style parallelism with isolated object graphs, fibers give cooperative concurrency for high-I/O work, and JRuby and TruffleRuby have no GIL, so threads there run truly in parallel.
| Workload | Right tool (CRuby) | Why |
|---|---|---|
| I/O-bound (HTTP, DB) | Threads or fibers | Lock releases during I/O waits |
| CPU-bound (compute) | Processes or Ractors | Bypass the single VM lock |
| High-concurrency I/O | Fibers (async gem) | Cheap cooperative switching |
Key point: The trap is saying threads are useless. Showing you know they help for I/O, and naming Ractors and non-GIL runtimes, is the senior-level answer.
Every object has a class, and classes are themselves objects, instances of Class, which descends from Module, then Object, then BasicObject at the root. Each object also has a hidden singleton class where its per-object (singleton) methods live, including class methods on class objects.
This uniform model is why classes can have methods (they're objects), why def self.foo defines a singleton method on the class, and why you can reopen any class to add methods. It's the foundation everything else in Ruby stands on.
String.class # Class
Class.superclass # Module
Integer.ancestors.last # BasicObject
5.singleton_class # the hidden per-object classRuby classes are open: you can reopen any class, including core ones like String, and add or redefine methods. Rails uses this heavily (String#blank?). Monkey patching is adding to a class you don't own.
The risks are real: name collisions between libraries, surprising behavior for anyone reading the code, and hard-to-trace bugs when two patches fight. Safer alternatives are refinements (scoped patches) and adding methods through a module you include instead.
class String
def shout
upcase + "!"
end
end
"hello".shout # "HELLO!"
# safer, scoped alternative: refinements with `using`A refinement is a scoped monkey patch: you define changes inside a module with refine, then activate them with using only in a specific file or scope. Outside that scope, the core class is untouched.
They beat global patching when you want to add a method to a core class but don't want to affect the whole program or risk clashing with other libraries. The cost is lexical scoping rules that can confuse teammates, so use them deliberately.
module Shouting
refine String do
def shout = upcase + "!"
end
end
using Shouting
"hi".shout # "HI!" only where `using` is activeinstance_eval runs a block with self set to a given object, so you can access its internals or define singleton methods on it. class_eval (also module_eval) runs a block in a class's context, letting you define instance methods dynamically. instance_exec is like instance_eval but also passes arguments into the block.
These power DSLs: a configuration block that looks declarative is usually instance_eval changing what self means so bare method calls target your object. Use them sparingly, because they bend the normal reading of code.
class Config
def initialize(&block)
instance_eval(&block) # self is this Config
end
def set(key, value) = (@data ||= {})[key] = value
end
Config.new { set(:env, "prod") }Objects live on a managed heap, and Ruby's garbage collector reclaims unreachable ones. Modern CRuby uses a generational, incremental mark-and-sweep collector with compaction, which reduces pauses and fragmentation compared with the older stop-the-world approach.
You rarely tune it, but the model explains real issues: retained references (module-level caches, long-lived arrays) keep objects alive, and object allocation churn drives GC pressure. GC.stat and tools like derailed_benchmarks or memory_profiler help diagnose bloat.
Yes, practically. Unbounded caches, constants and class variables that only grow, global registries, and C-extension leaks all inflate a long-running process even with garbage collection, because the references keep objects reachable.
Diagnose by watching RSS over time, then use memory_profiler or ObjectSpace to see which classes are growing, and heap dumps diffed across snapshots to find the retaining path. The fix is usually a bounded cache or clearing state between requests; in Rails, tools like jemalloc and periodic worker restarts are common mitigations.
Measure allocations first with memory_profiler or ObjectSpace on a realistic workload, because intuition is usually wrong. Common wins: freeze string literals (or the magic comment), reuse objects instead of rebuilding them in loops, prefer symbols over strings for keys, and avoid intermediate arrays by chaining lazy enumerators.
Enumerator::Lazy lets you compose map and select over a large or infinite source without materializing each intermediate array, which cuts both memory and GC pressure on big pipelines.
# eager: builds two full arrays
(1..Float::INFINITY).select(&:even?).first(3) # hangs
# lazy: pulls only what is needed
(1..Float::INFINITY).lazy.select(&:even?).first(3) # [2, 4, 6]Blocks, procs, and lambdas are closures: they capture the surrounding local variables by reference, so they see and can change those variables even after the enclosing method returns. This is what makes generator-style and callback code work.
A Binding object packages up that execution context (variables, self, method access), which is why binding.irb or binding.pry can drop you into a live REPL with the exact local state at that line, a genuinely useful debugging tool.
def counter
count = 0
increment = -> { count += 1 }
increment.call # 1
increment.call # 2
count # 2, the closure mutated it
endA class method is a singleton method on the class object, stored in the class's singleton class (its eigenclass). def self.build and def ClassName.build both define into that singleton class.
class << self opens the singleton class directly, so anything defined inside becomes a class-level method. It's the idiomatic way to group several class methods and to make one private with private inside that block, which the def self.foo form can't do cleanly.
class Api
class << self
def get(path) = request(:get, path)
private
def request(verb, path) = "#{verb} #{path}"
end
end
Api.get("/users") # "get /users"Rails is a Model-View-Controller framework built on convention over configuration: models wrap database tables through Active Record, controllers handle requests, and views render responses. Following the conventions means writing far less wiring code.
Ruby's design is what makes Rails feel declarative. Blocks and instance_eval build its DSLs (routes, migrations), method_missing and define_method power dynamic finders and attribute methods, and open classes let Active Support add methods like 2.days.ago to core types.
Key point: Connecting a Rails feature back to the Ruby mechanism behind it (dynamic finders to method_missing, routes DSL to blocks) is exactly the depth senior interviewers are checking for.
Watch a deeper explanation
Video: Learn Ruby on Rails - Full Course (freeCodeCamp.org, YouTube)
An N+1 happens when you load a collection with one query, then trigger one extra query per record to load an association: 1 query for N posts, then N more for each post's author. It quietly turns a fast page into hundreds of queries.
The fix is eager loading: includes (or preload/eager_load) tells Active Record to fetch the associations up front in a couple of queries. Tools like the bullet gem flag N+1s in development before they reach production.
# N+1: one query for posts, then one per author
Post.all.each { |p| puts p.author.name }
# fixed: eager load authors in advance
Post.includes(:author).each { |p| puts p.author.name }Key point: This is a Rails favorite that's really about understanding lazy loading. Naming the fix and a detection tool (bullet) shows production experience.
Prefer immutable data and avoid shared mutable state, because the GIL protects single bytecode operations but not multi-step read-modify-write sequences. When you must share, guard it with a Mutex, or hand work between threads through a thread-safe Queue.
Class variables and module-level state are the usual landmines in a threaded server. In Rails, the request cycle isolates most state, but memoization on shared objects and global caches need explicit synchronization or a concurrent data structure from the concurrent-ruby gem.
require "thread"
counter = 0
mutex = Mutex.new
threads = 10.times.map do
Thread.new { 1000.times { mutex.synchronize { counter += 1 } } }
end
threads.each(&:join)
counter # 10000, correct because of the mutexSince Ruby 3.0, case/in destructures and matches against the shape of data: arrays, hashes, and objects, binding variables as it goes. It's deconstruction plus conditional matching in one, useful for parsing structured data like JSON or API responses.
It matches by structure and can pin existing values, add guards, and bind captures. This is a different tool from case/when: when tests with ===, while in matches and destructures shape.
config = { role: "admin", perms: ["read", "write"] }
case config
in { role: "admin", perms: [*, "write"] }
puts "admin who can write"
in { role: String => r }
puts "role is #{r}"
endtap yields the object to a block and returns the same object, so you can run side effects (logging, mutation, debugging) in the middle of a method chain without breaking it. then (also called yield_self) yields the object and returns the block's result, so you can wrap a value in a transformation step.
tap is for peeking or configuring without interrupting a chain; then is for piping a value through a transform when a method chain would read awkwardly. Both keep code flowing left to right.
user = User.new.tap do |u|
u.name = "Asha"
u.role = "admin"
end # returns the user
result = 5.then { |n| n * 2 }.then { |n| n + 1 } # 11Hash and Set find keys by first comparing hash values, then confirming with eql?. So the rule is: two objects that are eql? must return the same hash, and an object's hash must not change while it's a key. Define both together or lookups silently fail.
By default eql? and hash compare object identity, so two distinct objects with the same fields won't match as keys. For a value object, override eql? to compare fields and derive hash from the same fields, usually by hashing an array of them.
class Coord
attr_reader :x, :y
def initialize(x, y) = (@x, @y = x, y)
def eql?(other)
other.is_a?(Coord) && x == other.x && y == other.y
end
def hash = [x, y].hash
end
seen = { Coord.new(1, 2) => "a" }
seen[Coord.new(1, 2)] # "a", matches by valueKey point: The trap is defining == or eql? but forgetting hash. Naming that the two must agree, and why, is exactly the senior-level signal.
Observe first: logs and APM traces around the slow path, then a sampling profiler like stackprof or rbspy on the live process to see whether time goes to CPU, GC, a lock, or waiting on I/O. Correlate with recent deploys and gem upgrades.
For Rails specifically, check the usual suspects: N+1 queries, missing database indexes, slow external calls without timeouts, and memory bloat forcing worker restarts. Then fix at the right layer and confirm with a measurement. The methodology, observe, hypothesize, verify, fix, confirm, is what matters.
Key point: The methodology is; tools and Rails specifics are supporting evidence.
Ruby wins when developer happiness and readable, convention-driven web work matter most, which is why Rails shops reach for it. It trades some raw execution speed for expressive syntax and one of the tightest full-stack web frameworks around. Where it loses is CPU-bound parallelism (the GIL in the default runtime) and cold-start-sensitive serverless work, where Node.js or a compiled language fits better. Knowing these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Language | Typing | Best at | Watch out for |
|---|---|---|---|
| Ruby | Dynamic | Rails web apps, scripting, DSLs | CPU parallelism (GIL), raw speed |
| Python | Dynamic | Data, ML, scripting, automation | GIL, smaller web-convention ecosystem |
| PHP | Dynamic | Shared-hosting web, WordPress | Older APIs, inconsistent standard library |
| Node.js | Dynamic | High-concurrency I/O, real-time apps | Callback complexity, CPU-bound work |
Prepare in layers, and practice out loud. Most Ruby rounds move from concept questions to live coding to a design or debugging discussion, often with a Rails flavor, so rehearse each stage rather than only reading answers.
The typical Ruby interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Ruby questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works
Q19. How do comments and naming conventions work in Ruby?
Single-line comments #, and multi-line comments sit between =begin and =end at the start of a line comes first. Conventions carry meaning: snake_case for methods and variables, CamelCase for classes and modules, SCREAMING_SNAKE_CASE for constants.
Method names ending in ? return a boolean (empty?), and names ending in ! signal a mutating or riskier version (sort!). Teams enforce style with RuboCop rather than by memory, which is worth mentioning.
Key point: the ? and ! conventions is useful because matters.