Ruby Roadmap
Ruby is a dynamic, object-oriented language designed to be pleasant to read and write. It powers Rails applications, command-line tools, build systems such as Rake and infrastructure tools like Chef and Homebrew. Companies hire Ruby developers mainly for web backends, and the language rewards people who like expressive code.
This roadmap takes you from installing Ruby to the topics that senior interviews ask about: method lookup, closures, metaprogramming, concurrency and testing. Each topic is explained in the text, with short examples and one exercise, so you can work through it without other resources.
By the end you will write idiomatic Ruby, structure programs with classes and modules, test them, package them as gems and discuss trade-offs in an interview. If your goal is web development, continue with the Ruby on Rails Roadmap afterwards.
- Level
- Beginner to advanced
- Time
- 10–14 weeks at about 8 hours a week
- Scope
- 64 topics in 9 stages
- Before you start
- Basic programming ideas such as variables, loops and functions; Comfort with a terminal and a text editor; Git and GitHub Roadmap basics for the projects
What you will be able to do
- Write idiomatic Ruby using blocks, Enumerable and expressive method chains
- Design classes and modules and explain method lookup and mixins
- Explain procs, lambdas and closures, and where they differ
- Use metaprogramming deliberately and know when to avoid it
- Manage dependencies with Bundler and publish a small gem
- Test code with Minitest or RSpec and debug with the debug gem
- Choose between threads, fibers, processes and Ractors for a workload
The roadmap at a glance
- Stage 1Getting Started with RubyBeginner · 1 week · 6 topics
- Stage 2Core Types and OperatorsBeginner · 1–2 weeks · 9 topics
- Stage 3Control Flow and MethodsBeginner · 1–2 weeks · 8 topics
- Stage 4Collections and EnumerableIntermediate · 2 weeks · 9 topics
- Stage 5Object-Oriented RubyIntermediate · 2–3 weeks · 8 topics
- Stage 6Errors, Files and TextIntermediate · 1–2 weeks · 5 topics
- Stage 7MetaprogrammingAdvanced · 2 weeks · 6 topics
- Stage 8Tooling, Testing and DebuggingIntermediate · 2 weeks · 6 topics
- Stage 9Concurrency and the WebAdvanced · 2–3 weeks · 7 topics
Stage 1 Getting Started with Ruby
Goal. Install Ruby, run scripts and the interactive shell, and read basic Ruby code with its conventions.
What Ruby Is and Everything Is an Object Core
Ruby is a dynamic, interpreted, general-purpose language created by Yukihiro Matsumoto with the aim of making programmers happy. It is used for web applications (mostly through Rails), scripts, build tools and command-line programs.
The idea that shapes everything else is that every value is an object. Numbers, strings, nil, classes and even blocks of code respond to methods.
5.class # => Integer
"hi".upcase # => "HI"
nil.to_a # => []
3.times { |i| puts i }
Integer.class # => Class
There are no primitive types and no operators that sit outside the object system. 1 + 2 is a method call, 1.+(2), written with friendlier syntax. When you meet unfamiliar code, ask which object receives the message and which class defines the method. That question solves most Ruby puzzles.
Practice. In irb, call `.class`, `.methods.size` and `.ancestors` (on classes) for five different values, and note what surprises you.
Installing Ruby and Ruby Version Managers Core
Do not use the Ruby that ships with your operating system. It is old, owned by the system and awkward to upgrade. Use a version manager so each project can pin its own version.
- rbenv shims the
rubycommand and reads a.ruby-versionfile in the project. It is small and pairs with theruby-buildplugin to compile versions. - RVM is older and heavier. It also manages gemsets and changes your shell more deeply.
- Installers such as the Windows installer, or tools like
miseandasdf, exist for platforms and teams that want one tool for many languages.
rbenv install --list-all | tail
rbenv install 3.3.0
rbenv local 3.3.0 # writes .ruby-version
ruby -v
Version managers are one kind of Ruby package manager for the interpreter itself. Gems, covered later, are the packages your code depends on. Check ruby -v before debugging anything odd; a wrong interpreter explains many early problems.
Practice. Install a recent Ruby with a version manager, set it locally for a new folder and confirm the `.ruby-version` file appears.
Running Ruby and Interactive Ruby (irb) Core
There are three ways to run Ruby. Run a file with ruby hello.rb. Run a one-liner with ruby -e 'puts 1 + 1'. Or open the interactive shell with irb and type code line by line.
Interactive Ruby (irb) evaluates each expression and prints its result, which makes it the best place to learn a method. Type puts "hi".methods.sort.first(5) or use ri String#sub in a terminal to read documentation offline. In recent versions irb has autocomplete, multiline editing and a show_doc command, and Pry is a popular replacement with deeper inspection.
Debugging with IRB works too: binding.irb placed in any file opens a shell at that line with all local variables available, so you can poke at live state.
def total(prices)
binding.irb # execution pauses here
prices.sum
end
Practice. Write a script that reads a number from `gets`, doubles it and prints it, then run it with `ruby` and test pieces in `binding.irb`.
Puts, Print and p Core
Three methods write to the terminal, and the differences matter when debugging.
putsconverts the argument withto_s, adds a newline and prints each array element on its own line.printdoes the same without the newline.pprints theinspectform and returns its argument, so it shows quotes around strings andnilas text.
puts "a" # a
print "a\n" # a
p "a" # "a"
p nil # nil
puts nil # (empty line)
x = p(5) + 1 # prints 5, x is 6
For debugging, prefer p because it shows what a value really is. A string "1" and the integer 1 look identical under puts but differ under p. pp pretty prints large nested structures across lines.
Practice. Print the same array, hash, string and nil with puts, print, p and pp, and write down the four differences.
Comments, Naming Conventions and Style Core
A # starts a comment to the end of the line. Block comments with =begin and =end exist but are rare. A magic comment on the first line, such as # frozen_string_literal: true, changes how the file behaves and is common in gems.
Naming follows strong conventions, and other Rubyists expect them:
snake_casefor methods, variables and file names.CamelCasefor classes and modules.SCREAMING_SNAKE_CASEfor constants.- A trailing
?marks a method that returns a boolean, such asempty?. - A trailing
!marks a more dangerous variant, such assort!, which changes the receiver. - A leading
@is an instance variable,@@a class variable and$a global.
Parentheses are optional for method calls but most style guides keep them when there are arguments. Two-space indentation is standard. A linter enforces these automatically, as described in the tooling stage.
Practice. Take a messy ten-line script with mixed naming styles and rename everything to match Ruby conventions.
Editors and IDEs: VS Code and RubyMine Recommended
Ruby needs less tooling than a statically typed language, but good editor support saves time. The VS Code Ruby extension (the Shopify Ruby LSP is the most common) gives navigation, completion, formatting and linting by talking to a language server that runs in your project. RubyMine from JetBrains is a full IDE with refactoring, a debugger, test runners and Rails awareness built in, at a cost for commercial use.
Whatever you choose, set up four things: format on save with the project formatter, a linter, the ability to run a single test from the editor, and a debugger with breakpoints. Terminal editors with a language server work equally well.
Practice. Configure your editor so one keystroke runs the test under the cursor and format on save fixes a deliberately badly indented file.
Stage 2 Core Types and Operators
Goal. Work confidently with numbers, strings, symbols, nil and the operators and conversions between them.
Constants and Variables Core
A variable is created by assigning to it; there is no declaration. Local variables start with a lowercase letter or underscore. Instance variables begin with @ and live inside one object. Constants begin with a capital letter.
count = 0
count += 1
MAX = 10
MAX = 11 # warning: already initialized constant
Ruby lets you reassign a constant, with a warning, but you should treat it as fixed. A constant holding a mutable object, such as an array, can still be changed internally. Call freeze to prevent that.
Variables hold references, not copies. Two names can point at the same string, and changing one is visible through the other. Use dup when you need an independent copy. Knowing this prevents a whole class of surprises with default arguments and shared hashes.
Practice. Create two variables that refer to one string, mutate it through one name and show the effect, then repeat with dup and freeze.
Integers and Floats Core
Integers have arbitrary size, so 2 ** 200 simply works. Division of two integers truncates towards negative infinity, while floats follow binary floating-point rules.
7 / 2 # => 3
7.0 / 2 # => 3.5
7.fdiv(2) # => 3.5
-7 / 2 # => -4
7 % 3 # => 1
7.divmod(2) # => [3, 1]
0.1 + 0.2 # => 0.30000000000000004
Floats cannot represent most decimals exactly. For money, store integer cents or use BigDecimal from the standard library (or Rational). Useful helpers include round(2), ceil, floor, abs, clamp, digits and Integer.sqrt. Underscores help readability: 1_000_000.
Practice. Write a method that sums a list of prices in pounds and pence without floating-point error, and test it with 0.1 and 0.2.
Strings Core
Strings are mutable sequences of bytes with an encoding, UTF-8 by default. Double quotes allow interpolation and escapes; single quotes are literal.
name = "Ada"
"Hello, #{name}!" # interpolation
name.upcase.center(11, "*")
name[0], name[-1] # => "A", "a"
"a,b,c".split(",") # => ["a", "b", "c"]
" pad ".strip
"abc".sub("b", "X") # first match only
name << " Lovelace" # mutates in place
Know the pairs: sub and gsub return new strings while sub! and gsub! change the receiver and may return nil. Heredocs (<<~TEXT) hold multi-line text and strip common indentation. With # frozen_string_literal: true string literals cannot be modified, which saves memory and prevents accidental mutation. Use +"" or String.new when you need a mutable one.
Practice. Write a method that turns "the quick brown fox" into title case and one that counts vowels, using only string methods.
Symbols Core
A symbol is an immutable, unique name written :status. The same symbol is always the same object, so comparing symbols is cheap, and they are the usual keys in hashes and the usual way to name methods and options.
:ok.object_id == :ok.object_id # => true
"ok".object_id == "ok".object_id # => false (two strings)
user = { name: "Ada", role: :admin }
user[:role] == :admin
Use symbols for fixed identifiers (states, option names, keys you write in code) and strings for text that comes from users or files. Convert with to_sym and to_s. Symbols are garbage collected in modern Ruby, so converting input to symbols is no longer a memory leak, though it is still unwise to mix them up with untrusted input. &:upcase is shorthand that turns a symbol into a block: %w[a b].map(&:upcase).
Practice. Build a state machine for an order using a hash that maps symbol states to the allowed next states.
Nil and Booleans Core
Only false and nil are falsy. Everything else is truthy, including 0, empty strings and empty arrays. This differs from many languages and is a common interview trap.
puts "yes" if 0 # prints yes
puts "yes" if "" # prints yes
nil.to_a # => []
user&.name # safe navigation, nil if user is nil
name = input || "guest" # default for nil or false
count ||= 0 # assign only if falsy
nil is the single instance of NilClass. Methods that find nothing return it, which is why NoMethodError: undefined method for nil is the most common Ruby error. Handle it with the safe navigation operator &., with fetch for hashes, or by returning an empty object instead. Question methods such as nil?, empty? and zero? return true or false. Rails adds present? and blank?.
Practice. Write a method with five inputs (nil, false, 0, empty string, empty array) and print which are truthy before running it.
Operators: Arithmetic, Comparison, Logical and Assignment Core
Ruby operators are mostly methods, so classes can define their own.
- Arithmetic operators:
+ - * / % **, withInteger#divmodfor quotient and remainder. - Comparison operators:
== != < > <= >=, the spaceship<=>that returns -1, 0 or 1, and===used bycase.equal?tests object identity,eql?tests value and type (used by hashes). - Logical operators in Ruby:
&&and||short-circuit and return one of their operands;!negates. The wordsand,orandnothave much lower precedence and are meant for control flow, so avoid them in expressions. - Assignment operators:
=, compound forms like+=,||=and&&=, and parallel assignmenta, b = b, a. - Ranges and others:
..,..., the ternarycond ? a : b, and<<for append.
a, b = 1, 2
a, b = b, a # swap
first, *rest = [1, 2, 3]
1 <=> 2 # => -1
1 == 1.0 # => true
1.eql?(1.0) # => false
Practice. Explain, with a run in irb, why `puts "x" if a = 5` is almost always a bug and what `==` versus `equal?` returns for two equal strings.
Type Casting and Conversion Core
Ruby does not convert types silently in most places. "5" + 5 raises a TypeError. You convert explicitly, in two families.
- Lenient:
to_i,to_f,to_s,to_a,to_sym. They never raise for strings;"12abc".to_iis 12 and"abc".to_iis 0. - Strict: the capitalised kernel functions
Integer("12"),Float("1.5"),String(5)andArray(nil)raiseArgumentErroron bad input.
Integer("42") # => 42
Integer("4x2") rescue "bad" # => "bad"
Integer("ff", 16) # => 255
Integer("abc", exception: false) # => nil
Array(nil) # => []
Array([1]) # => [1]
Practice. Write a parser for a form field that accepts an integer, reports a helpful error for bad input and never returns zero by accident.
Ranges Recommended
A range represents a span of values. 1..5 includes the end, 1...5 excludes it. Ranges work with anything that has <=> and succ, such as numbers, letters and dates, and they can be endless or beginless.
(1..5).to_a # => [1, 2, 3, 4, 5]
('a'..'e').include?('c') # => true
(1..).first(3) # => [1, 2, 3]
age = 30
case age
when 0...18 then "minor"
when 18.. then "adult"
end
[10, 20, 30, 40][1..2] # => [20, 30]
Practice. Use ranges to write a grade-letter method and to take every third element from an array.
Date and Time Recommended
The core Time class handles timestamps and zones, and require "date" adds Date and DateTime (the latter is discouraged). Rails adds ActiveSupport::TimeZone, Time.zone.now and helpers such as 3.days.ago.
require "date"
Time.now.utc.iso8601
Time.at(0).utc.year # => 1970
d = Date.new(2024, 2, 29)
d + 1 # => 2024-03-01
d.strftime("%d %B %Y") # => "29 February 2024"
Date.parse("2024-03-01").saturday?
Time.now - 3600 # one hour ago
Practice. Write a method returning the number of working days between two dates, and print a timestamp in two time zones.
Stage 3 Control Flow and Methods
Goal. Control program flow with conditionals and loops, and write methods with flexible parameters.
Conditional Statements: if, elsif, else Core
if is an expression, so it returns a value. Branches use elsif (not else if), and the whole thing ends with end.
grade = if score >= 90 then "A"
elsif score >= 80 then "B"
else "C"
end
puts "adult" if age >= 18 # modifier form
status = age >= 18 ? "adult" : "minor"
The modifier form is best for one short condition. Avoid nesting more than two levels; return early with guard clauses instead.
def discount(user)
return 0 unless user
return 0.2 if user.vip?
0.05
end
Remember that only nil and false are falsy, so if count is true when count is zero. Compare explicitly when you mean a number test.
Practice. Rewrite a method with three nested ifs into guard clauses and confirm the behaviour is unchanged.
Unless and Case Statements Core
unless is if not. It reads well for a single negative condition and badly with else or compound logic.
puts "no entry" unless ticket.valid?
case compares a value against each when using ===, which makes it far more flexible than a switch. A when can match a literal, a range, a class, a regular expression, a lambda or a list.
def describe(x)
case x
when Integer, Float then "number"
when /^\d+$/ then "numeric string"
when String, Symbol then "text"
when nil then "nothing"
when ->(v) { v.respond_to?(:each) } then "collection"
else "unknown"
end
end
Ruby 3 adds pattern matching with case ... in, which destructures arrays and hashes: case config in { port: Integer => port } then ... end. It is worth learning once you are comfortable with case/when.
Practice. Write a method using `case` that classifies an input as integer, float string, date string or something else.
While Loops in Ruby, Until Loop and For Loop Core
Ruby loops are while, until and for. A while loop repeats while a condition is true and until repeats until it becomes true. Both have modifier forms. The do/while loops pattern, which runs the body once before testing, is written with begin ... end while.
i = 0
while i < 3
puts i
i += 1
end
until queue.empty?
handle(queue.shift)
end
begin
line = gets
end while line.nil?
for x in list exists, but it leaks the variable into the surrounding scope and is rarely used. Prefer each, which gives each iteration its own scope. Most loops in real Ruby are iterators rather than counters, and a hand-written while with an index is a sign that a better method such as each_with_index, map or step exists.
Practice. Implement a number-guessing loop with `until`, then rewrite the same logic using `loop do ... break`.
Times Loop, Next Keyword in Loops and Break Statement in Ruby Core
The times loop (times), upto, downto and step are the counting iterators. loop do repeats forever until you leave it. Loops and enumerations overlap in Ruby, since iterators are the loops you will write most.
3.times { |i| puts i }
1.upto(3) { |i| puts i }
10.step(1, -3) { |i| puts i } # 10, 7, 4, 1
loop do
n = rand(10)
next if n.odd? # skip to the next pass
break if n == 0 # leave the loop
puts n
end
The next keyword in loops and the break statement in Ruby are the main controls, with redo as a rarer third. break exits it (and can return a value), next skips to the following iteration and redo repeats the current iteration without testing, which is almost never wanted. Inside a block, next value returns that value for the iteration, and break value becomes the result of the whole call. return inside a block returns from the enclosing method, a point worth explaining in interviews.
Practice. Write a loop that prints even numbers up to 20, skips multiples of 6 and stops at the first number above 15.
Defining Methods Core
Methods are defined with def. The last expression is the return value, so explicit return is mostly for early exits. A method defined at the top level becomes private on Object.
def greet(name)
"Hello, #{name}"
end
def adult?(age) = age >= 18 # endless method, Ruby 3
Methods return one value, but you can return an array and destructure it: min, max = bounds(list). Predicates end with ? and mutators with ! by convention. Calling a method with the wrong number of arguments raises ArgumentError.
Visibility keywords control access: public (the default), private (callable only without an explicit receiver) and protected (callable from other instances of the same class). Private methods are an implementation detail, and tests should exercise them through the public ones.
Practice. Write small methods for fizz-buzz, a palindrome check and a temperature converter, one using endless method syntax.
Method Parameters Core
Ruby has several kinds of parameter, and the order is fixed: required, optional, splat, post-required, keywords, double splat, block.
def connect(host, port = 80, *extras, timeout: 5, **opts, &blk)
end
connect("a.com")
connect("a.com", 443, timeout: 10, retries: 3)
- Optional parameters have defaults, evaluated at call time.
*argsgathers extra positional arguments into an array.- Keyword arguments (
timeout:) make call sites self-describing. A keyword without a default,name:, is required. **optsgathers remaining keywords into a hash.&blkcaptures the block as a Proc.
Practice. Write a method with a required, an optional and two keyword parameters, and call it five ways including with a splatted hash.
Scope Core
Scope decides where a name is visible. A def, class or module starts a brand new scope and cannot see local variables outside it. Blocks, however, close over the variables around them.
x = 10
def show
puts x # NameError: x is not visible
end
[1, 2].each { |i| puts x + i } # fine, blocks see x
total = 0
[1, 2].each { |n| total += n } # changes outer total
Block parameters are local to the block, and a variable first assigned inside a block disappears afterwards. Constants are looked up lexically and then through ancestors. Globals ($stdout) and class variables (@@count) are shared widely, so use them sparingly. Instance variables belong to the object that is self at that moment, which is why a method and a block inside it see the same ones.
Practice. Predict the output of a snippet mixing top-level locals, a method, a block and a lambda, then run it to check.
Recursion Recommended
A recursive method calls itself with a smaller problem and stops at a base case. Ruby does not guarantee tail-call optimisation, and the default stack allows roughly ten thousand frames, so very deep recursion raises SystemStackError.
def fact(n)
return 1 if n <= 1
n * fact(n - 1)
end
def flatten_deep(list)
list.flat_map { |x| x.is_a?(Array) ? flatten_deep(x) : [x] }
end
fib = Hash.new { |h, n| h[n] = n < 2 ? n : h[n - 1] + h[n - 2] }
fib[80]
Practice. Implement Fibonacci recursively, with memoisation and with a loop, and compare timings for n equal to 35.
Stage 4 Collections and Enumerable
Goal. Use arrays, hashes and the Enumerable toolkit fluently, and implement classic algorithms with them.
Arrays Core
An array is an ordered, growable list that can hold any mix of objects. Negative indexes count from the end.
a = [3, 1, 2]
a << 4 # append, mutates
a.push(5).unshift(0) # add at end and start
a[1..2], a.first(2), a.last
a.pop; a.shift # remove from end and start
a.insert(2, :x)
a.delete_at(0)
a.sum, a.minmax, a.sample
a + [9], a - [3], a & [1, 2], a | [7]
%w[red green blue] # string array shortcut
Know which methods mutate: sort!, map!, uniq! and compact! change the receiver, while their plain forms return a copy. Array.new(3) { [] } makes three separate arrays, but Array.new(3, []) makes three references to one, a classic bug. Destructuring works in assignments and block parameters, and dig safely reaches into nested arrays.
Practice. Remove duplicates from an array while preserving order, rotate it by k positions and find the second-largest value without sorting.
Ruby Hashes Core
A hash maps keys to values and preserves insertion order. Symbol keys use the short key: value syntax.
h = { name: "Ada", age: 36 }
h[:email] = "ada@example.com"
h.fetch(:age) # raises KeyError if missing
h.fetch(:zip, "none") # default
h.dig(:address, :city) # nil-safe nested access
h.key?(:name)
h.merge(role: :admin)
h.select { |k, v| v.is_a?(String) }
h.transform_values(&:to_s)
h.to_a, h.keys, h.values
counts = Hash.new(0)
"banana".each_char { |c| counts[c] += 1 }
Prefer fetch over [] when a missing key is a bug. Hash.new(0) supplies a default value, and a block default builds nested structures: Hash.new { |h, k| h[k] = [] }. Do not use a mutable object as the plain default argument, as every key then shares it. Keys must have consistent hash and eql?, so mutable objects make poor keys.
Practice. Count word frequencies in a paragraph, then group the words by length with `group_by` and print the three most common.
Iterating with Each Core
each is the basic iterator. It passes each element to a block and returns the receiver, so it is used for side effects such as printing or saving. Variants add context.
%w[a b c].each { |s| puts s }
%w[a b c].each_with_index { |s, i| puts "#{i}: #{s}" }
{ a: 1, b: 2 }.each { |key, value| puts "#{key}=#{value}" }
(1..10).each_slice(3).to_a # [[1,2,3],[4,5,6],[7,8,9],[10]]
[1, 2, 3].each_cons(2).to_a # [[1,2],[2,3]]
%w[a b].each_with_object({}) { |s, h| h[s] = s.upcase }
%w[a b].each.with_index(1) { |s, i| puts "#{i}. #{s}" }
When you want a transformed collection, map is better than each plus pushing into another array. Without a block most iterators return an Enumerator, which can be chained or advanced manually with next. Never modify a collection while iterating over it; build a new one or iterate over a copy.
Practice. Print a numbered list starting at one, then chunk a list of 10 items into pages of 4 and print each page.
Enumerable and Iterating with Enumerable Core
Enumerable is a module that gives dozens of methods to any class that defines each. Arrays, hashes, ranges, sets and your own collections all share them, which is why Ruby code reads so uniformly.
nums = [1, 2, 3, 4, 5, 6]
nums.map { |n| n * 2 }
nums.select(&:even?) # also: filter
nums.reject(&:even?)
nums.reduce(0) { |sum, n| sum + n } # or nums.sum
nums.partition(&:even?) # => [[2,4,6],[1,3,5]]
nums.group_by { |n| n % 3 }
nums.each_slice(2).map(&:sum)
nums.zip(nums.rotate)
nums.tally, nums.sum, nums.minmax
To make a class enumerable, include the module and define each that yields. You then get everything free. lazy turns a pipeline into a streaming one, so (1..Float::INFINITY).lazy.select(&:even?).first(3) stops early. Knowing the core ten methods well beats memorising all of them.
Practice. Build a `Playlist` class that includes Enumerable and then use three different Enumerable methods on it.
Querying Enumerable Collections: Searching and Filtering Core
Searching and filtering in Enumerable answers questions about a collection without a manual loop.
- Test:
any?,all?,none?,one?,include?andempty?. - Find:
find(ordetect) returns the first match or nil;find_index;first(n);take_while;drop_while. - Filter:
select,reject,grep(/pattern/),compact,uniq. - Count:
count,sum,min_by,max_by,tally.
users.any? { |u| u.admin? }
users.find { |u| u.email == address }
users.count(&:active?)
users.min_by(&:age)
words.grep(/^a/)
words.each_with_index.select { |w, i| i.even? }.map(&:first)
Pass a pattern to all?, any? and none? to use ===: values.all?(Integer). Stop at the first match with find instead of select.first, which scans everything. Use Set when you ask include? many times on a large collection, since set lookups run in constant time.
Practice. Given a list of orders, find the largest, the count over a threshold and whether any are unpaid, each in one line.
Sorting in Enumerable Core
sort orders items using <=>; sort_by orders them by a computed key and is usually faster and clearer. Both return a new array, and sort! mutates.
[3, 1, 2].sort # [1, 2, 3]
[3, 1, 2].sort { |a, b| b <=> a } # descending
words.sort_by { |w| [-w.length, w] } # length desc, then alphabetical
words.sort_by(&:downcase)
people.max_by(&:age)
people.min(2) { |a, b| a.age <=> b.age }
Practice. Sort a list of employees by department ascending and salary descending, with a tiebreaker on name.
Chaining Methods Core
Because each Enumerable method returns a new collection, calls chain into pipelines that read top to bottom. This is the heart of idiomatic Ruby.
orders
.select(&:paid?)
.group_by(&:customer_id)
.transform_values { |os| os.sum(&:total) }
.sort_by { |_id, total| -total }
.first(3)
Break long chains over lines with the dot at the start. Use then (or tap for side effects) to insert a custom step, and name intermediate results when a chain exceeds five or six steps. A chain that creates many large intermediate arrays can be slow; lazy or a single each_with_object pass fixes that. Remember the nil trap: a method returning nil in the middle breaks the rest, so use &. or guarantee arrays with Array().
Practice. Turn a nested loop that builds a report into a single readable chain of at most five steps.
Stacks, Queues and Sets Recommended
Arrays behave as stacks and queues without extra classes. A stack uses push and pop (last in, first out). A queue uses push and shift (first in, first out); shift is constant time in modern Ruby, so arrays are fine for queues in most programs.
stack = []
stack.push(1); stack.push(2); stack.pop # => 2
queue = []
queue.push(:a); queue.push(:b); queue.shift # => :a
require "set"
seen = Set.new([1, 2])
seen << 2 # no duplicate
seen.include?(2)
Practice. Use a stack to check that brackets in a string are balanced and a queue to run breadth-first search on a small graph.
Sorting Algorithms and Search Algorithms Recommended
Interviews ask you to write algorithms by hand even though sort and bsearch exist. Be able to write binary search, merge sort and quicksort, and to state their cost.
def binary_search(list, target)
lo, hi = 0, list.size - 1
while lo <= hi
mid = (lo + hi) / 2
case list[mid] <=> target
when 0 then return mid
when -1 then lo = mid + 1
else hi = mid - 1
end
end
nil
end
Binary search needs sorted input and takes logarithmic time. Merge sort is linearithmic and stable. Quicksort averages linearithmic time but degrades to quadratic with bad pivots. Linear search is fine for short lists. In real code, call sort_by or bsearch. The Data Structures and Algorithms Roadmap roadmap covers these in depth.
Practice. Implement merge sort on an array and verify it against the built-in sort with random input.
Stage 5 Object-Oriented Ruby
Goal. Design classes, share behaviour with inheritance and modules, and use blocks, procs and lambdas with confidence.
Defining Classes Core
A class is a blueprint, and new creates an object and calls initialize. Instance variables live inside the object; methods are shared by the class.
class Account
def initialize(owner, balance = 0)
@owner = owner
@balance = balance
end
def deposit(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
@balance += amount
self
end
def to_s = "#{@owner}: #{@balance}"
end
acct = Account.new("Ada")
acct.deposit(50).deposit(25)
Returning self allows chaining. Define to_s for readable output and inspect for debugging. Class methods are written def self.name. Classes are open, so reopening one adds methods, which is both the power of Ruby and the root of monkey patching. Ruby 3.2 adds Data.define and Struct for small value objects without writing boilerplate.
Practice. Model a bank account class with validation, a string form and a class method that creates an empty account.
Instance Variables and Attribute Accessors Core
Instance variables are private to the object. Other code can reach them only through methods. Writing these by hand is tedious, so Ruby generates attributes accessors for you.
class User
attr_reader :name # def name; @name; end
attr_writer :nickname # def nickname=(v); @nickname = v; end
attr_accessor :email # both
def initialize(name) = @name = name
end
u = User.new("Ada")
u.email = "ada@example.com"
Choose the narrowest one. Reader only is the default; a writer makes a class mutable from outside. An accessor is a method, so inside the class use self.email = x for the setter; plain email = x creates a local variable instead, a common bug. Add validation by writing the setter yourself. Reading an unset instance variable gives nil with no error, so typos silently produce nil; instance_variables lists what exists.
Practice. Create a class with a read-only id, a writable email that validates the presence of an @ sign and a derived full_name method.
Inheritance Core
A subclass inherits methods from its superclass using < and can override them. super calls the parent version. Ruby has single inheritance only, and every class ultimately descends from BasicObject.
class Animal
def initialize(name) = @name = name
def speak = "..."
def to_s = "#{self.class.name.downcase} #{@name}: #{speak}"
end
class Dog < Animal
def initialize(name, tricks = [])
super(name)
@tricks = tricks
end
def speak = "Woof"
end
Dog.superclass, Dog.ancestors.first(3)
Bare super forwards the same arguments, while super() passes none. Inheritance models an is-a relationship; if the relationship is really has-a or can-do, use composition or a module. Deep hierarchies are hard to change, so keep them to two or three levels. Duck typing means you rarely need a common parent to make objects interchangeable, only the same method names.
Practice. Build a Shape hierarchy with area and perimeter, then replace the hierarchy with duck-typed objects and compare.
Modules and Mixins Core
A module is a bag of methods and constants that cannot be instantiated. It serves two purposes: a namespace, and a mixin that adds behaviour to classes.
module Greeting
def greet = "Hello, #{name}"
end
class Person
include Greeting # instance methods
extend Searchable # class methods
prepend Auditing # sits before the class in the lookup chain
attr_reader :name
end
include adds the module's methods as instance methods, extend adds them to the object or class itself, and prepend puts the module before the class so it can wrap its methods and call super. Comparable and Enumerable are the best-known mixins: define <=> or each and receive the rest. Use module_function or extend self for utility modules, and the included hook for adding class macros. Use modules for shared behaviour and namespacing; use classes for things you instantiate.
Practice. Write a `Auditable` module that logs every call to `save` using `prepend`, and mix it into two different classes.
Method Lookup Core
When you call a method, Ruby searches the receiver's ancestors in order and runs the first match. The path is: singleton class, prepended modules, the class, included modules in reverse order of inclusion, then the superclass and so on up to BasicObject.
module A; def hi = "A"; end
module B; def hi = "B"; end
class C
include A
include B
end
C.ancestors # => [C, B, A, Object, Kernel, BasicObject]
C.new.hi # => "B"
C.instance_method(:hi).owner # => B
Practice. Build three modules and a class and predict the `ancestors` array before printing it, including a prepend case.
Singleton Classes and Class Methods Recommended
Every object can have methods of its own, stored in a hidden singleton class that sits first in the lookup chain. Class methods are exactly that: methods on the singleton class of the class object.
str = "hello"
def str.shout = upcase + "!"
str.shout # => "HELLO!"
class Config
class << self
attr_accessor :level
def reset! = @level = :info
end
end
Config.level = :debug
Practice. Add a counter at class level that tracks how many instances were created, and show subclasses do not share it unless you design that.
Blocks Core
A block is a chunk of code passed to a method, written with { } for one line or do ... end for several. The method runs it with yield.
def twice
return enum_for(:twice) unless block_given?
yield 1
yield 2
end
twice { |n| puts n * 10 }
File.open("a.txt") do |f|
f.each_line { |l| puts l }
end # file is closed automatically
Blocks power iteration, resource handling (the file is closed after the block), callbacks and domain-specific languages. Block parameters follow the same destructuring rules as assignment. In Ruby 3.4 you can write it for a single anonymous parameter: list.map { it * 2 }, and numbered parameters _1 work in earlier 3.x. A method can accept a block explicitly with &block to store or pass it on.
Practice. Write your own `my_map` and `my_select` methods that take a block, and a `with_timing` method that wraps any block and prints elapsed time.
Procs, Lambdas and Closures Core
A Proc is a block turned into an object. A lambda is a stricter proc. Both are closures: they remember the variables that existed where they were created.
counter = 0
inc = -> { counter += 1 } # lambda
inc.call; inc.(); inc[] # three ways to call
counter # => 3
add = proc { |a, b| (a || 0) + (b || 0) }
add.call(1) # => 1
mul = ->(a, b) { a * b }
mul.curry[3][4] # => 12
double = ->(x) { x * 2 }
(double >> ->(x) { x + 1 }).call(5) # => 11
Practice. Write a method that returns three counters sharing no state, and demonstrate the return difference between a proc and a lambda.
Stage 6 Errors, Files and Text
Goal. Handle failures deliberately, read and write files, match text with regular expressions and call web services.
Exception Handling with begin, rescue and ensure Core
Errors are objects that inherit from Exception. Application code rescues StandardError and its subclasses; the others (SystemExit, Interrupt, NoMemoryError) should normally pass through.
def read_config(path)
File.read(path)
rescue Errno::ENOENT => e
warn "missing: #{e.message}"
"{}"
else
# runs only when nothing was raised
ensure
puts "done" # always runs
end
A method body can carry rescue and ensure directly, without begin. retry re-runs the begin block, which suits transient network errors if you cap the attempts. raise re-raises the current error. Never write a bare rescue Exception, and avoid empty rescues that swallow failures.
Rescue the narrowest class you can name, and add context rather than hiding the cause: modern Ruby keeps the original in cause when you raise inside a rescue.
Practice. Write a method that retries a flaky operation three times with a growing delay and re-raises the last error.
Handling Exceptions: Custom Errors and Raise Core
Define your own error classes so callers can rescue precisely. Inherit from StandardError, and group related errors under one base class.
module Payments
class Error < StandardError; end
class CardDeclined < Error
attr_reader :code
def initialize(code, msg = "card declined")
@code = code
super(msg)
end
end
end
begin
raise Payments::CardDeclined.new("51")
rescue Payments::Error => e
puts "#{e.class}: #{e.message}"
end
Practice. Create an error hierarchy for a small API client with distinct classes for timeouts, not found and invalid responses, and rescue each differently.
File I/O Core
The block form of File.open closes the file for you. For small files, File.read and File.write are enough.
File.write("notes.txt", "one\ntwo\n")
File.read("notes.txt")
File.readlines("notes.txt", chomp: true)
File.foreach("big.log") { |line| puts line if line.include?("ERROR") }
File.open("out.txt", "a") { |f| f.puts "appended" }
File.exist?("out.txt")
Dir.glob("logs/*.log")
require "fileutils"
FileUtils.mkdir_p("tmp/cache")
require "json"
JSON.parse(File.read("data.json"), symbolize_names: true)
Use foreach for large files, since read loads everything into memory. Modes are r, w (truncate), a (append) and r+. Pathname and File.join build paths portably, and Tempfile and Dir.mktmpdir handle scratch files. CSV, YAML and JSON are in the standard library. Handle Errno::ENOENT and Errno::EACCES for missing or forbidden files.
Practice. Write a script that reads a log file lazily, counts lines per severity and writes a JSON summary.
Regular Expressions in Ruby Core
Regexes are literals between slashes, and strings respond to =~, match, scan, sub and gsub. Named groups give readable captures.
line = "2024-03-01 ERROR disk full"
if line =~ /(\d{4})-(\d{2})-(\d{2})/
puts $1 # "2024"
end
m = line.match(/(?<level>[A-Z]+)\s+(?<msg>.+)/)
m[:level], m[:msg]
"a1b22".scan(/\d+/) # ["1", "22"]
"Hello".gsub(/l+/) { |s| s.upcase }
"a-b_c".tr("-_", " ")
/\A\d+\z/.match?("123") # fast boolean
Use \A and \z to anchor the whole string; ^ and $ match at every line, which has caused security bugs in validation. match? is faster because it sets no globals. Flags are i, m (dot matches newline) and x (free-spacing with comments). Avoid nested quantifiers on untrusted input, since catastrophic backtracking can freeze a process; Ruby 3.2 added regex timeouts and memoisation to reduce the risk.
Practice. Write a regex with named groups that parses "name" followed by an address in angle brackets and rejects malformed ones, with tests for ten inputs.
Fetching: HTTP and JSON Recommended
The standard library includes Net::HTTP for requests and JSON for parsing, so you can call an API without extra gems.
require "net/http"
require "json"
uri = URI("https://api.example.com/users/1")
res = Net::HTTP.get_response(uri)
if res.is_a?(Net::HTTPSuccess)
user = JSON.parse(res.body, symbolize_names: true)
puts user[:name]
end
Practice. Fetch a public JSON endpoint, handle a timeout and a non-200 response separately, and print one field.
Stage 7 Metaprogramming
Goal. Use Ruby's dynamic features to write flexible libraries, and know when not to.
Metaprogramming Overview Core
Metaprogramming means code that writes or changes code at run time. Ruby makes it ordinary because classes are open objects and methods are looked up dynamically. Rails features such as has_many, validations and dynamic finders are all metaprogramming.
The main tools are send, define_method, method_missing, respond_to_missing?, instance_variable_get, class_eval, instance_eval and hooks such as inherited and included. Introspection helps too: methods, instance_methods(false), respond_to? and Module#const_get.
class Settings
%i[host port].each do |name|
define_method("#{name}?") { !!@values[name] }
end
end
The cost is readability, debuggability and speed. Generated methods are hard to grep, and errors point to framework internals. Use it to remove real repetition or build a clean DSL, prefer plain code otherwise, and document anything generated.
Practice. List five Rails or gem methods you use daily and guess which metaprogramming tool creates each.
send and public_send Core
send calls a method by name, which lets you choose the method at run time. It ignores visibility, so it can call private methods; public_send respects it.
"hello".send(:upcase) # => "HELLO"
5.send(:+, 3) # => 8
user.public_send("#{field}=", value)
obj.respond_to?(:save)
The classic use is mapping input to behaviour, such as setting attributes from a hash. Never pass untrusted user input to send without an allow list, because a caller could invoke :system or :destroy_all. Use public_send by default, and check respond_to? or a constant list first. method(:name) returns a Method object you can call later or pass as a block with &. __send__ is the alias to use if a class defines its own send.
Practice. Write a method that sets attributes on an object from a hash, but only for names on an allow list.
define_method Core
define_method creates an instance method from a block. Because a block is a closure, the method can use variables from where it was defined, which def cannot do.
class Report
FORMATS = %i[csv json text]
FORMATS.each do |fmt|
define_method("to_#{fmt}") do |options = {}|
render(fmt, **options)
end
end
end
Report.instance_methods(false) # includes :to_csv
Compare with method_missing: define_method creates real methods up front, which are faster, visible to respond_to? and show in stack traces. Use instance_variable_set and class_eval with a string only if you truly need to generate source, and be careful never to interpolate user input. Define methods inside self.included or a class macro to build declarations like validates_presence.
Practice. Create a class macro `status :open, :closed` that defines predicate methods `open?` and `closed?`.
method_missing Recommended
When a method is not found, Ruby calls method_missing on the receiver. You can override it to respond to calls dynamically, which is how proxies and old-style dynamic finders worked.
class Ghost
def method_missing(name, *args, &block)
if name.to_s.start_with?("get_")
"value of #{name.to_s.delete_prefix('get_')}"
else
super
end
end
def respond_to_missing?(name, include_private = false)
name.to_s.start_with?("get_") || super
end
end
Practice. Build a `Config` wrapper that returns values for any key in a hash as a method, with correct respond_to? behaviour.
Monkey Patching and Refinements Recommended
Because classes are open, you can reopen a core class and change it, known as monkey patching.
class String
def shout = upcase + "!"
end
"hi".shout
This works but changes behaviour for every library in the process, causes conflicts and can break after a Ruby upgrade. If you must, prefer a module with prepend, use a unique method name and keep patches in one place.
Refinements scope the change. A refinement applies only in files that opt in with using.
module Shout
refine String do
def shout = upcase + "!"
end
end
using Shout
"hi".shout
Practice. Add a refinement to Integer for currency formatting and show that it does not leak outside the file that uses it.
Ruby DSLs Recommended
A domain-specific language is a small vocabulary that reads like configuration but is real Ruby. Rake, RSpec, Rails routes and Bundler's Gemfile are DSLs. They are built from blocks, instance_eval, method chaining and good naming.
class Router
def self.draw(&block)
r = new
r.instance_eval(&block)
r.routes
end
def routes = (@routes ||= [])
def get(path, to:) = routes << [:get, path, to]
end
Router.draw do
get "/", to: "home#index"
get "/about", to: "pages#about"
end
Practice. Build a tiny task-runner DSL where `task :build do ... end` registers tasks, then run one by name.
Stage 8 Tooling, Testing and Debugging
Goal. Manage dependencies, test with Minitest and RSpec, keep style consistent and debug with the right tool.
RubyGems and Package Managers Core
A gem is a packaged Ruby library with a name, a version and dependencies. RubyGems is the package manager and ships with Ruby; gem install, gem list and gem build work with the public registry. Versions follow semantic versioning, and the pessimistic operator ~> 2.1 allows 2.x but not 3.0.
gem install rake
gem list --local
gem build mylib.gemspec
A gem has a .gemspec, a lib/ directory with a file named after the gem, and often an executable in exe/. Most programs do not call gem install directly. They declare dependencies in a Gemfile and let Bundler resolve them. Treat every dependency as code you must trust: check maintenance, download counts and licence, and run bundle audit to look for known vulnerabilities.
Practice. Create a tiny gem with `bundle gem`, add one method, build it and install it locally.
Bundler Core
Bundler resolves and locks the set of gems a project uses. The Gemfile lists wanted gems and constraints, Gemfile.lock records the exact versions found, and both are committed so every machine runs identical code.
source "https://rubygems.org"
ruby "3.3.0"
gem "rails", "~> 7.1"
gem "pg"
group :development, :test do
gem "rspec-rails"
gem "debug"
end
Commands to know: bundle install, bundle update gemname (update one gem, not everything), bundle exec (run a command with the locked versions), bundle outdated and bundle add. Groups keep test tools out of production, installed with bundle config set without development test. Do not edit the lockfile by hand. If you see "could not find gem", compare ruby -v and your lockfile platforms first.
Practice. Add two gems to a fresh project, run a script with and without `bundle exec`, then update only one gem.
Minitest and Test::Unit Core
Minitest ships with Ruby and is the default in Rails. Tests are classes whose methods start with test_, or it blocks in the spec style. Test::Unit is the older xUnit library with a similar shape, available as a bundled gem; you will meet it in legacy projects.
require "minitest/autorun"
class CartTest < Minitest::Test
def setup
@cart = Cart.new
end
def test_total_is_zero_when_empty
assert_equal 0, @cart.total
end
def test_rejects_negative_quantity
assert_raises(ArgumentError) { @cart.add("tea", -1) }
end
end
Core assertions are assert, assert_equal expected, actual, assert_nil, assert_raises, assert_in_delta and refute. Each test runs in a random order against a fresh instance, so tests must not depend on each other. Minitest also includes mocks and stubs with Minitest::Mock and stub. Write one behaviour per test and name it as a sentence.
Practice. Write five Minitest tests for a shopping cart, including one error case, and run them in random order several times.
RSpec Core
RSpec is the most widely used test framework in the Ruby community. Its syntax describes behaviour in nested groups.
RSpec.describe Cart do
subject(:cart) { described_class.new }
describe "#total" do
it "is zero when empty" do
expect(cart.total).to eq(0)
end
context "with items" do
before { cart.add("tea", 2, price: 3) }
it "sums line totals" do
expect(cart.total).to eq(6)
end
end
end
end
Key features: let (lazy, memoised values), before hooks, subject, many matchers (eq, include, raise_error, change, match), shared examples, and doubles for isolating collaborators (instance_double checks that a method really exists). Run with bundle exec rspec and a path and line number to run one example. Keep examples short, avoid deep nesting and over-mocking, and prefer real objects where they are cheap.
Practice. Rewrite the Minitest cart tests in RSpec using `let` and one `context`, and add a test using `instance_double`.
RuboCop and Standard Recommended
RuboCop is the style checker and formatter for Ruby. It reads .rubocop.yml, reports offences by "cop" and can fix many with -a (safe) or -A (also unsafe). Standard is a wrapper with a fixed, non-configurable ruleset, which ends style debates and needs no config file.
bundle exec rubocop
bundle exec rubocop -a app/models
bundle exec standardrb --fix
Add it to CI and to an editor on-save hook, and adopt it early because fixing a large codebase later is noisy. Use rubocop:disable comments sparingly, with a reason. rubocop-rails, rubocop-rspec and rubocop-performance add framework-specific rules. Linting catches style and some real bugs, but it is not a replacement for tests. For documentation, RDoc (bundled with Ruby) and YARD generate pages from comments, and YARD tags such as @param and @return record types.
Practice. Run RuboCop on an old script, fix everything it auto-corrects and manually fix the remaining three offences.
Debugging: debug, Byebug and Pry Core
Start with the cheap tools: p, pp and reading the backtrace from the bottom up for your own code. Move to an interactive debugger when printing is slow.
def calc(a, b)
binding.irb # or: debugger, binding.pry
a / b
end
The debug gem is bundled with Ruby 3.1 and later, and debugger opens it with next, step, continue, break and info commands. Byebug is the older equivalent for earlier versions. Pry replaces irb with ls, cd into objects, show-source and edit, and pry-byebug adds stepping. In VS Code and RubyMine, a visual debugger uses the same debug gem.
Habits that help: reproduce with the smallest input, bisect with a failing test, and remove every debugger call before committing. A lint rule or a CI grep prevents the common embarrassment.
Practice. Debug a method with a deliberate off-by-one error using stepping and a breakpoint, without any puts statements.
Stage 9 Concurrency and the Web
Goal. Choose the right concurrency model, understand Rack, and know how Sinatra and Rails sit on top of it.
Concurrency and Parallelism Core
Concurrency means several tasks make progress in overlapping time; parallelism means they literally run at the same moment on different cores. The reference Ruby interpreter has a global VM lock (GVL), so only one thread runs Ruby code at a time, but threads release the lock while waiting for input or output.
The practical rule: threads help with I/O-bound work such as HTTP calls, database queries and file reads. For CPU-bound work you need processes (fork, Puma workers, Sidekiq processes) or Ractors. JRuby and TruffleRuby have no GVL and run threads in parallel.
Choose by workload:
- Many slow network calls: threads or a fiber scheduler.
- Heavy computation: several processes.
- Background work from a web app: a job queue.
Whichever you pick, shared mutable state is the hazard, and the safest design passes messages and avoids sharing.
Practice. Fetch ten URLs sequentially and with threads, time both and explain why the CPU-heavy variant does not speed up.
Threads and Mutex Core
A thread is created with Thread.new and waited on with join. Each thread has its own stack but shares objects with the others.
results = []
lock = Mutex.new
threads = 5.times.map do |i|
Thread.new do
value = i * i
lock.synchronize { results << value }
end
end
threads.each(&:join)
A Mutex makes a critical section run in one thread at a time with synchronize. Without it, compound updates like count += 1 can interleave and lose updates, even with the GVL, because the operation is several steps. Queue is a thread-safe channel for producer and consumer designs, and ConditionVariable lets threads wait for a signal. Exceptions in a thread are raised only when you join it unless report_on_exception prints them. Prefer thread pools over unbounded thread creation, and keep locks short to avoid deadlock.
Practice. Build a worker pool of four threads that pulls jobs from a Queue and writes results into a mutex-protected hash.
Fibers Optional
A fiber is a lightweight unit of work that you pause and resume yourself, so switching is explicit and cheap. A fiber yields control with Fiber.yield and is resumed with resume.
gen = Fiber.new do
a, b = 0, 1
loop { Fiber.yield a; a, b = b, a + b }
end
5.times.map { gen.resume } # => [0, 1, 1, 2, 3]
Practice. Write a generator with Fiber that yields the first n primes and compare it with an Enumerator version.
Ractors Optional
Ractors, introduced in Ruby 3.0, are actors with isolated object spaces that can run in parallel on separate cores. They do not share mutable objects. They communicate through messages, using send and receive or take, and objects are copied or moved between them.
r = Ractor.new { Ractor.receive * 2 }
r.send(21)
r.take # => 42
Practice. Split a sum over a large range across four Ractors and compare the speed with a plain loop.
Concurrent Ruby Recommended
The concurrent-ruby gem is a toolbox that Rails and Sidekiq use internally. It provides tested building blocks so you do not write your own locking.
require "concurrent"
pool = Concurrent::FixedThreadPool.new(4)
futures = urls.map { |u| Concurrent::Promises.future(pool) { fetch(u) } }
Concurrent::Promises.zip(*futures).value!
counter = Concurrent::AtomicFixnum.new(0)
counter.increment
cache = Concurrent::Map.new
cache.compute_if_absent(:key) { expensive }
Practice. Rewrite your threaded URL fetcher with a fixed thread pool and promises, and cap concurrency at four.
Rack Core
Rack is the interface between Ruby web servers and web applications, and Rails, Sinatra and Hanami are all built on it. A Rack app is any object with call(env) that returns three things: a status code, a hash of headers and a body that responds to each.
# config.ru
class Hello
def call(env)
[200, { "content-type" => "text/plain" }, ["Hello from Rack"]]
end
end
run Hello.new
rackup serves it using a server such as Puma. Middleware are Rack apps that wrap another app, to handle logging, sessions, compression or authentication, and use adds them to the stack. Rails shows its stack with bin/rails middleware. Understanding Rack explains where a request is parsed, why middleware order matters and how to write a small plug-in without touching controllers.
Practice. Write a middleware that adds an X-Request-Id header to every response and mount it in front of the Hello app.
Sinatra Recommended
Sinatra is a small web framework that maps routes to blocks. It is useful for tiny services, prototypes and learning how HTTP maps to code before meeting Rails.
require "sinatra"
get "/hello/:name" do
"Hello, #{params[:name]}"
end
post "/notes" do
content_type :json
{ saved: params[:text] }.to_json
end
Practice. Build a tiny JSON API in Sinatra with two routes and test it with `Rack::Test`.
Projects to build
Build these in order. Each one should end up in a public repository with a short README that explains your decisions.
- Command-line expense trackerBeginner
Build a script that adds, lists and totals expenses stored in a JSON file. Support filtering by category and month from command-line arguments.
Hashes and arrays · File I/O and JSON · Date and Time · Methods
- Text adventure engineBeginner
Create a small game where rooms, items and commands are modelled as classes. Load the map from a YAML file and add a save command.
Classes · Inheritance and modules · Case statements · YAML
- Log analyser gemIntermediate
Write a gem that parses web server logs lazily and reports slow requests, error rates and top paths. Provide an executable and a full test suite.
Regular expressions · Enumerable and lazy · Bundler and gemspec · RSpec
- Mini test frameworkIntermediate
Implement a tiny RSpec-like library with `describe`, `it` and `expect(...).to eq(...)`. Print a readable report and exit non-zero on failure.
Blocks and instance_eval · DSL design · Exceptions · Metaprogramming
- Concurrent link checkerAdvanced
Crawl a site with a thread pool, check every link and write a report. Limit concurrency, respect timeouts and handle errors without losing results.
Threads and Mutex · Queue · Net::HTTP · Error handling
- Rack-based micro frameworkAdvanced
Build a router and controller layer on top of Rack with middleware for logging and sessions. Serve it with Puma and cover it with request specs.
Rack · Metaprogramming · Middleware · Testing
Interview questions
Try to answer each question out loud before you open the answer.
What does "everything is an object" mean in Ruby?Junior
Every value, including integers, nil, classes and blocks once converted to a Proc, is an instance of a class and responds to messages. There are no primitives, so 5.times and nil.to_a both work. Operators such as + are method calls. This uniformity is why Ruby can add methods to any class, why Class is itself an object and why debugging usually comes down to asking which object received which message.
Which values are falsy in Ruby?Junior
Only false and nil. Everything else is truthy, including 0, "", [] and {}. This matters in conditions such as if count, which is true for zero. When I mean "has items" I write list.empty? or count.positive? explicitly. The ||= operator also treats both nil and false as unset, which can overwrite a deliberate false.
What is the difference between a symbol and a string?Junior
A string is a mutable sequence of characters, and each literal creates a new object. A symbol is an immutable name, and the same symbol is always the same object. I use symbols as hash keys, option names and states, and strings for text that comes from users or files. Symbol comparison is faster, and since Ruby 2.2 symbols are garbage collected, so dynamic ones no longer leak memory.
Explain the difference between include, extend and prepend.Mid
include adds a module's methods as instance methods, placed after the class in the ancestors chain. extend adds them to a single object, which for a class means class methods. prepend puts the module before the class, so its methods run first and can call super to wrap the class's own. I check the result with ancestors. Prepend suits decorators such as logging or auditing.
How does Ruby look up a method?Mid
It starts with the receiver's singleton class, then walks ancestors: prepended modules, the class, included modules (last included first), then the superclass and so on to BasicObject. The first match runs and super continues from the next entry. If none matches, Ruby calls method_missing, which by default raises NoMethodError. Module#instance_method(:x).owner shows where a method came from.
What is the difference between a proc and a lambda?Mid
Both are closures. A lambda checks its arguments strictly and raises ArgumentError on a mismatch; a proc ignores extras and fills missing ones with nil. return in a lambda returns from the lambda, while in a proc it returns from the enclosing method, and can raise LocalJumpError if that method has finished. Lambdas behave like anonymous methods, and procs like blocks as objects.
What is the difference between map, each and select?Junior
each runs a block for its side effects and returns the receiver. map returns a new array of the block's results. select returns a new array of elements for which the block is truthy. If I find myself creating an empty array before each and pushing into it, map, select or each_with_object is the better choice.
How do you handle errors in Ruby?Mid
I rescue specific StandardError subclasses with begin/rescue/ensure, or in a method body directly. I never rescue Exception and I avoid empty rescue blocks. I define a base error class for each library so callers can rescue one class, retry only transient failures with a cap and re-raise when I cannot recover. ensure releases resources, though block forms such as File.open do do that automatically.
What does `attr_accessor` do?Junior
It defines a reader and a writer method for an instance variable at class definition time, using metaprogramming. attr_reader defines only the reader and attr_writer only the writer. Inside the class, assigning through the writer needs self.name = value, since plain name = value creates a local variable. I choose the narrowest option, because a writer makes the object mutable from outside.
When would you use method_missing, and what are the risks?Senior
It suits proxies, wrappers and builders where names are not known in advance. The risks are speed, hidden bugs when typos reach it and confusing stack traces. I always call super for unhandled names and implement respond_to_missing? so introspection works. If the names are known up front, define_method is better because it creates real methods that are faster and easier to debug.
What is the GVL and how does it affect threads?Senior
The global VM lock in CRuby lets only one thread execute Ruby code at a time, though threads release it during blocking I/O. So threads give a speed-up for I/O-bound work like HTTP or database calls, but not for CPU-bound code. For parallel computation I use several processes, or Ractors where possible. JRuby and TruffleRuby have no such lock. Shared mutable state still needs a Mutex because operations like count += 1 are not atomic.
What is the difference between a Mutex and a Queue?Senior
A Mutex protects shared data by allowing only one thread inside a critical section at a time. A Queue is a thread-safe channel for handing items from producers to consumers, and its pop blocks while empty. I prefer designs that pass messages through a queue and share nothing, since locks are easy to misuse and can deadlock. Where shared state is needed, I keep critical sections short.
How would you make a class work with Enumerable?Mid
Include Enumerable and define each so that it yields each element, returning an enumerator when no block is given. That gives map, select, reduce, sort_by, min_by, group_by, first and many more. For ordering methods like sort or min, elements must implement <=>. If the class needs comparison itself, include Comparable and define <=>.
What is the frozen string literal comment and why use it?Mid
The comment # frozen_string_literal: true makes string literals in that file immutable. It cuts allocations because identical literals can be shared, and it catches accidental mutation with a FrozenError. When I need a mutable string I use +"", String.new or dup. Many gems and RuboCop's default configuration use it, and future Ruby versions are moving in that direction.
How do keyword arguments differ from positional ones, and what changed in Ruby 3?Mid
Keyword arguments are passed by name and make call sites readable, with required ones declared as name:. Before Ruby 3 a trailing hash could silently become keywords. Since 3.0 they are separated, so I pass **options to forward a hash as keywords. This removed a confusing source of bugs and warnings. I use keywords for any method with more than two parameters or boolean flags.
How would you test a class that calls an external HTTP API?Senior
I isolate the network behind a small client class and test the rest against a fake or a double. With RSpec I use instance_double for the client, and with WebMock or VCR I stub HTTP responses for the client's own tests. I cover success, a non-200 status, a timeout and malformed JSON. One thin test against a sandbox in CI checks the real contract, but ordinary tests never touch the network.
What is monkey patching and how would you do it safely?Senior
Reopening an existing class, often a core one, to add or change methods. It is risky because it affects the whole process, collides with other libraries and can break in upgrades. If it is necessary I prefer refinements, which apply only where using is called, or a prepended module with clear naming. I keep patches small, in one file and covered by tests, and never override a method that other gems may rely on.
Common mistakes
- Mutating a collection while iterating over it; build a new collection with `map` or `select`, or iterate over a copy.
- Using `Array.new(3, [])` and getting three references to one array; pass a block, `Array.new(3) { [] }`.
- Rescuing `Exception` or leaving `rescue` empty; rescue specific `StandardError` subclasses and log or re-raise.
- Forgetting `self.` when calling a setter inside a class, which silently creates a local variable.
- Using floats for money; store integer cents or use `BigDecimal`.
- Calling `send` with user input; use `public_send` and an allow list of names.
- Using `^` and `$` to validate a whole string; use `\A` and `\z` so a newline cannot slip through.
- Reaching for metaprogramming before plain code; add it only after you can name the repetition it removes.
- Relying on `puts` debugging for everything; learn `p`, `binding.irb` and the `debug` gem.
Ready-for-interview checklist
- I can explain why every value in Ruby is an object and give three examples.
- I can write a class with accessors, validation, inheritance and a mixed-in module.
- I can predict the order of `ancestors` when include, prepend and extend are mixed.
- I can use map, select, reduce, group_by and sort_by to turn a nested loop into a short pipeline.
- I can explain closures and the differences between blocks, procs and lambdas.
- I can write custom exceptions and handle errors without swallowing them.
- I can use `define_method` and explain when `method_missing` is the worse choice.
- I can set up a project with Bundler, pin a Ruby version and run the tests in CI.
- I can write unit tests in RSpec or Minitest with setup, doubles and error cases.
- I can explain what the GVL means for threads, and choose between threads, processes and Ractors.
- I can describe the shape of a Rack application and where middleware fits.
- I can debug with a breakpoint, read a backtrace and reduce a failure to a minimal case.
Related roadmaps
- Ruby on Rails RoadmapMVC, Active Record, Hotwire, jobs and testing.
- SQL RoadmapQueries, joins, window functions, indexes and transactions.
- Git and GitHub RoadmapCommits, branches, rebasing, pull requests, Actions and recovery.
- Python RoadmapSyntax to packaging: data model, typing, async, testing.
- API Design RoadmapREST, gRPC, GraphQL, versioning, auth, pagination and rate limits.
FAQ
How long does it take to learn Ruby?
Most beginners write useful scripts within three to four weeks. Reaching interview level, including OOP, Enumerable, testing, metaprogramming and concurrency, takes about three months at eight hours a week. Writing small programs every week matters more than reading.
Is Ruby still worth learning?
Yes, for web backends, tooling and scripting. Rails remains in wide production use and Ruby 3 brought better performance through YJIT, parallel Ractors and type tooling. It is less common for data science or mobile, so choose it if your goal is backend web work or you like its design.
Should I learn Ruby before Rails?
Learn enough Ruby first, around the first six stages here. Rails relies heavily on blocks, modules and metaprogramming, and learners who skip the language find it a pile of magic. You can start a small Rails project in parallel once you are comfortable with classes and Enumerable.
Which Ruby version should I install?
Use the latest stable release for new projects and install it with a version manager. For an existing application, use whatever the project pins in .ruby-version and the Gemfile, and plan upgrades because old versions stop receiving security fixes.
What is the best way to practise Ruby for interviews?
Solve array, hash and string problems using Enumerable methods, write each solution twice (loop and pipeline) and explain the complexity aloud. Then build the projects above and practise on coding practice. Be ready to explain method lookup and closures with a whiteboard example.