begin, rescue, ensure
Ruby programs don’t have to crash the moment something goes wrong. The begin, rescue, and ensure keywords let you run code that might fail, catch specific problems you anticipate, and guarantee that cleanup code — closing a file, releasing a lock, logging an attempt — runs no matter what happens. Understanding exactly when each clause fires, and how exceptions travel through Ruby’s class hierarchy, is what separates code that merely works from code that fails safely.
Overview: How Exception Handling Works in Ruby
In Ruby, an exception is not a special language construct — it is an ordinary object, an instance of a class that ultimately descends from Exception. When something goes wrong (dividing by zero, calling a method that doesn’t exist, converting an invalid string to a number), Ruby builds one of these exception objects and raises it. Raising immediately halts normal, top-to-bottom execution and starts unwinding the call stack, looking for a place that knows how to handle that particular kind of error. If it reaches the top of the stack with nothing to catch it, Ruby prints a backtrace and the program terminates.
A begin ... end block marks a region of code you want to supervise. Immediately after the protected code, one or more rescue clauses list the exception classes you know how to recover from. Ruby compares the raised exception against each rescue clause in order, using === (true for the exact class or any subclass), and runs the body of the first clause that matches. An optional else clause runs only when the begin body completed without raising anything — and, importantly, an exception raised inside else is not caught by the rescue clauses above it, because those only watch the begin body. An optional ensure clause always runs last, whether an exception was raised, rescued, left unhandled, or the block exited early via return or break — it is Ruby’s guarantee that cleanup code executes.
You rarely need the literal begin keyword: a def, class, or module body already acts as an implicit begin block, so you can attach rescue and ensure directly to a method definition, as several examples below do.
The exception class hierarchy
Every exception class inherits, directly or indirectly, from Exception. A bare rescue (no class named) only catches StandardError and its subclasses — this is deliberate. Serious, low-level conditions like NoMemoryError, SystemExit, or the Interrupt raised when a user presses Ctrl+C descend directly from Exception but not from StandardError, precisely so that ordinary rescue code does not accidentally swallow them.
| Class | Typical cause |
|---|---|
ZeroDivisionError |
Integer division by zero |
NoMethodError |
Calling a method that doesn’t exist on the receiver (a subclass of NameError) |
TypeError |
An object of the wrong type is passed, e.g. Integer(nil) |
ArgumentError |
Wrong number or format of arguments, e.g. Integer("abc") |
IndexError / KeyError |
Invalid array index / missing hash key via fetch |
RuntimeError |
The default class used by a bare raise "message" |
IOError |
Problems reading or writing streams and files |
Syntax
begin
# code that might raise an exception
rescue SpecificError => e
# handle SpecificError specifically
rescue AnotherError => e
# handle AnotherError specifically
else
# runs only if NO exception was raised
ensure
# always runs, whether an exception happened or not
end
begin— opens the protected block; often omitted becausedef/class/modulealready supply an implicit one.rescue ExceptionClass => e— catches that class (and any subclass) and binds the exception object toe; you can stack several, and Ruby tries them top to bottom, using the first match.rescue => e(no class) — shorthand forrescue StandardError => e.else— optional; runs only when the protected code raised nothing at all.ensure— optional; always runs, even after areturn, abreak, or an exception that is re-raised.end— closes the block.
Inside any rescue clause, calling raise with no arguments re-raises the exception currently being handled (useful for logging before letting it propagate), and calling retry jumps back to the very top of the begin block and runs it again — local variables keep whatever values they already held, which makes it easy to build a bounded retry loop.
Examples
Example 1: Catching a specific exception and guaranteeing cleanup
def divide(a, b)
begin
result = a / b
puts "Result: #{result}"
rescue ZeroDivisionError => e
puts "Error: #{e.message}"
ensure
puts "Division attempt finished."
end
end
divide(10, 2)
divide(10, 0)
Output:
Result: 5
Division attempt finished.
Error: divided by 0
Division attempt finished.
The first call divides cleanly, so the rescue is skipped and only ensure runs afterward. The second call divides by zero, which raises ZeroDivisionError; the rescue clause matches and prints the error’s message, and then ensure still runs — notice it fires exactly once per call, whether or not an exception occurred.
Example 2: Multiple rescue clauses on an implicit begin block
def parse_number(str)
Integer(str)
rescue ArgumentError => e
puts "Invalid number: #{str.inspect} (#{e.message})"
nil
rescue TypeError => e
puts "Wrong type: #{e.message}"
nil
end
puts parse_number("42").inspect
puts parse_number("abc").inspect
puts parse_number(nil).inspect
Output:
42
Invalid number: "abc" (invalid value for Integer(): "abc")
nil
Wrong type: can't convert nil into Integer
nil
This method has no literal begin at all — the def itself is the protected block. Integer("42") succeeds and the method implicitly returns 42. Integer("abc") raises ArgumentError, matching the first rescue. Integer(nil) raises TypeError instead (nil can’t be converted at all), so Ruby skips the first rescue clause — whose class doesn’t match — and falls through to the second one. Each rescue branch returns nil as the method’s value, which is why the outer puts ... .inspect prints nil after the warning.
Example 3: A custom exception, with ensure surviving an early return
class InsufficientFundsError < StandardError
def initialize(msg = "Not enough funds in the account")
super
end
end
def withdraw(balance, amount)
begin
raise InsufficientFundsError if amount > balance
balance - amount
rescue InsufficientFundsError => e
puts "Transaction failed: #{e.message}"
return balance
ensure
puts "Withdrawal attempt logged."
end
end
puts withdraw(100, 30)
puts withdraw(100, 150)
Output:
Withdrawal attempt logged.
70
Transaction failed: Not enough funds in the account
Withdrawal attempt logged.
100
InsufficientFundsError is a custom exception class that inherits from StandardError (never from bare Exception — see Best Practices) and supplies a default message. The first withdrawal succeeds, so ensure runs right after the begin body finishes. The second withdrawal raises the custom error; the rescue clause prints a message and then calls return balance to bail out of the method early — but ensure still runs before that return actually hands control back to the caller. This is the core guarantee of ensure: it runs even when you leave the block via return, break, or an unrescued exception.
How It Works Step by Step
When Ruby executes a begin (or an implicit method-level one), it follows this order:
- Run the statements in the begin body, top to bottom.
- If a statement raises, execution jumps immediately to the rescue clauses — the rest of the begin body is skipped entirely.
- Ruby checks each
rescueclause in the order written, using===against the raised exception’s class; the first match wins and its body runs. - If no
rescueclause matches, the exception keeps propagating up the call stack (past this begin block) looking for a match elsewhere. - If nothing raised at all, an
elseclause (if present) runs after the begin body. - Regardless of any of the above, the
ensureclause runs last, right before control actually leaves the block. - If a rescue clause calls
retry, Ruby discards the ensure-pending exit and jumps back to step 1, re-running the begin body from the top with local variables intact.
def fetch_with_retry
attempts_left = 3
begin
attempts_left -= 1
raise "Temporary network glitch" if attempts_left > 0
"Data fetched successfully"
rescue RuntimeError => e
puts "Attempt failed (#{e.message}), #{attempts_left} attempts left."
retry if attempts_left > 0
"Giving up after retries"
end
end
puts fetch_with_retry
Output:
Attempt failed (Temporary network glitch), 2 attempts left.
Attempt failed (Temporary network glitch), 1 attempts left.
Data fetched successfully
attempts_left is declared once, outside the begin block, so retry doesn’t reset it — each pass decrements it further. The first two passes raise on purpose to simulate a flaky operation; by the third pass attempts_left is 0, the raise condition is false, and the begin body’s last expression, "Data fetched successfully", becomes the method’s return value without ever reaching the rescue clause again. Had all three attempts failed, the rescue body’s own last line, "Giving up after retries", would have been returned instead. Always cap retries like this — an unconditional retry can loop forever.
Common Mistakes
Mistake 1: Rescuing bare Exception instead of the default
It’s tempting to write rescue Exception => e to “catch everything,” but Exception is the root of the entire hierarchy, including things that should almost never be silently caught, like SystemExit and Interrupt (Ctrl+C). Catching those means your program can’t be cleanly stopped and can hide serious VM-level problems.
def risky_operation
raise "Something went wrong"
rescue Exception => e
puts "Rescued: #{e.class}: #{e.message}"
end
risky_operation
Output:
Rescued: RuntimeError: Something went wrong
For an ordinary RuntimeError this looks harmless, but the same handler would also swallow a user’s Ctrl+C. Use a bare rescue (or rescue StandardError) instead, which produces identical behavior for expected application errors while letting system-level signals pass through untouched:
def risky_operation
raise "Something went wrong"
rescue => e
puts "Rescued: #{e.class}: #{e.message}"
end
risky_operation
Output:
Rescued: RuntimeError: Something went wrong
Mistake 2: Using the inline rescue modifier to hide bugs
The one-line expression rescue fallback form catches any StandardError, which means it will just as happily swallow a typo that raises NoMethodError as it will a genuinely missing value — you lose the ability to tell them apart.
def find_user_age(users, name)
users[name].age rescue nil
end
users = { "Ada" => Struct.new(:age).new(36) }
puts find_user_age(users, "Ada")
puts find_user_age(users, "Grace").inspect
Output:
36
nil
This looks fine here, but the same rescue nil would just as silently hide a real bug, such as calling .ages instead of .age by mistake — you’d get nil back with no clue why. Check explicitly for the condition you actually expect instead:
def find_user_age(users, name)
user = users[name]
return nil unless user
user.age
end
users = { "Ada" => Struct.new(:age).new(36) }
puts find_user_age(users, "Ada")
puts find_user_age(users, "Grace").inspect
Output:
36
nil
The output is identical for these inputs, but now a genuine typo like user.ages would raise NoMethodError loudly instead of being swallowed.
Best Practices
- Rescue the most specific exception class you can, not bare
Exception— let unexpected, system-level conditions propagate. - Keep the
beginbody as small as possible; only wrap the lines that can actually raise, so you don’t accidentally rescue an error from unrelated code. - Define custom errors as subclasses of
StandardError, neverException, so they’re caught by ordinary barerescueclauses. - Avoid the one-line
rescuemodifier in production code; it silently catches everyStandardError, including ones you didn’t anticipate. - Don’t use exceptions for ordinary control flow (like “value not found”); reserve them for truly exceptional situations, and use return values or
nilchecks otherwise. - Always bound a
retryloop with a counter or condition — an unconditionalretrycan spin forever. - Use
ensurefor side effects only (closing files, releasing locks); its own return value is discarded unless it explicitly overrides one withreturn, which is rarely a good idea. - Log or re-raise an exception you catch but can’t fully handle — don’t let a rescue clause quietly discard information you’ll need later.
Practice Exercises
- Write a method
safe_sqrt(n)that callsMath.sqrt(n)and rescuesMath::DomainError(raised for negative input), returningnilin that case instead of crashing. Test it with16and-4; you should get4.0andnil. - Write a method
read_setting(hash, key)that usesbegin/rescue/else/ensure: fetch the value withhash.fetch(key), rescueKeyErrorto print a warning and return"unknown", useelseto print"Loaded #{key}"only when the key was found, and useensureto print"Lookup finished."every time. Try it with a hash that has:namebut not:age. - Write a method that simulates logging in using a counter and
retry: raise a customLoginErroron the first two attempts and succeed on the third, printing a message on each attempt. Cap it at 3 tries total so it can never loop forever.
Summary
begin ... rescue ... else ... ensure ... endsupervises code that might fail;def/class/modulealready provide an implicitbegin.- A raised exception is matched against
rescueclauses top to bottom using===, so subclasses match a parent class’s rescue. - A bare
rescueonly catchesStandardErrorand its subclasses — notException-level conditions likeInterruptorSystemExit. elseruns only when nothing was raised;ensurealways runs, even through areturn,break, or unhandled exception.retryjumps back to the top of the begin block, keeping local variable state, so always pair it with a bound to avoid infinite loops.- Custom exceptions should inherit from
StandardError, and the inlinerescuemodifier should be avoided outside of quick scripts because it hides bugs.
