rescue, else and ensure run in a fixed order¶
Level: 101 · anyone writing their first begin block
One line: The body runs first; then rescue if it raised or else if it did not; then ensure, always — on a normal exit, on an exception, on return — and the whole begin … end is an expression whose value comes from the body, the rescue or the else, never from ensure.
begin … rescue … else … ensure … end is one statement with four parts, and Ruby runs them in a fixed order: the body, then exactly one of rescue (the body raised something the clause lists) or else (it did not), then ensure, unconditionally. A rescue clause names the classes it catches, separated by commas, and => e binds the exception object; a listed class also matches its subclasses. retry inside a rescue jumps back to the start of the body — that is how Ruby spells "try again", with no loop keyword involved, so a counter you increment yourself is the only thing that stops it. ensure runs even when the body leaves through return, and an explicit return inside ensure replaces whatever the block was leaving with, including an exception in flight, which it silently drops. A method body is an implicit begin, so def … rescue … end needs no begin of its own. The program prints from every clause, so the order is read off the output rather than remembered.
Verified output of rescue_ensure_else_and_retry_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. the full form, no exception
begin body runs
begin body finished
else runs: no exception was raised
ensure runs
2. the full form, an exception
begin body runs
rescue runs: ZeroDivisionError: divided by 0
ensure runs
3. rescue A, B => e catches either class, and their subclasses
caught ZeroDivisionError, which is_a?(IndexError): false
caught IndexError, which is_a?(IndexError): true
caught KeyError, which is_a?(IndexError): true
4. retry re-runs the begin body
attempt 1
attempt 2
attempt 3
succeeded on attempt 3
5. ensure runs on return
ensure ran on the way out
method returned "the return value"
6. return inside ensure swallows the exception (never do this)
method returned "ensure's return value"; no exception reached the caller
7. rescue in a def needs no begin
parse("12") -> 12
parse("abc") -> "rescued ArgumentError"
8. what the rescued object carries
e.class ZeroDivisionError
e.message "divided by 0"
e.backtrace an Array of Strings
e.full_message a String (paths inside, not printed)
9. begin/end is an expression
body raised, rescue ran: "the rescue clause's value"
body ok, else and ensure ran: 3 (ensure's 4 is discarded)
Reading the output¶
Rows 1 and 2 are the same method called twice. Without an exception the order is body, else, ensure; with one it is body (cut short at the raise), rescue, ensure — and no else line appears, because else means "the body completed". Row 3 raises three different classes at one rescue ZeroDivisionError, IndexError => e clause: all three are caught, and the is_a? column shows why the third one was — KeyError is a subclass of IndexError, and a rescue list matches by is_a?, not by exact class.
Row 4 is retry: the body runs three times, and the only reason it stops is the attempts < 3 guard in the rescue clause. Leave the guard out and a body that always raises retries forever. Row 5 shows ensure running on the way out of a return — the method still returns its value, and the ensure line prints before the caller sees it.
Row 6 is the trap. The body raises, ensure executes return, and the caller receives "ensure's return value" as if nothing had gone wrong: the exception is gone. Ruby lets you write it, and there is no situation in which you want it. Use ensure for cleanup only — close the file, release the lock — and never return from inside it.
Row 7 drops the begin: a method body is already one, so rescue sits directly in the def. Row 8 lists what the rescued object carries: its class, its message (Ruby's messages are stable, so divided by 0 is safe to print), a backtrace that is an Array of Strings (each holding a path, which is why the page prints the classes and not the contents), and full_message, the one-string form that an uncaught exception prints. Row 9 shows that the whole construct is an expression: with a rescue, the value is the rescue clause's last expression; with else and ensure, it is the else value — ensure's value is thrown away.
Compared with Python¶
Python's try has the same four parts under other names — try, except (A, B) as e, else, finally — and rows 1 to 3 print in exactly the same order, with isinstance doing the subclass match that is_a? did in Ruby. The differences start at row 4: Python has no retry, so "try again" is a for loop with a break on success and a continue in the except. Row 5 is identical (finally runs on return). Row 6, return inside finally, swallows the exception in Python too; the twin does not run it, because since Python 3.14 that construct is a SyntaxWarning at compile time (PEP 765), and a warning is a different result on the two CI runners. (Not machine-checked here.) Row 7 has no counterpart: a def body is not an implicit try, so the try is written out. Row 8 prints only type(e).__name__ — CPython rewords its messages between releases, so this library never keys them — plus the traceback object and the StackSummary that traceback.extract_tb turns it into. Row 9 is the structural difference: try is a statement, so v = try: … is a SyntaxError and there is no value to read from the block, where Ruby's begin … end is an expression.
Verified output of rescue_ensure_else_and_retry_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. the full form, no exception
try body runs
try body finished
else runs: no exception was raised
finally runs
2. the full form, an exception
try body runs
except runs: ZeroDivisionError
finally runs
3. except (A, B) as e catches either class, and their subclasses
caught ZeroDivisionError, isinstance(e, LookupError): False
caught LookupError, isinstance(e, LookupError): True
caught KeyError, isinstance(e, LookupError): True
4. no retry keyword: a loop around the try
attempt 1
attempt 2
attempt 3
succeeded on attempt 3
5. finally runs on return
finally ran on the way out
function returned 'the return value'
6. return inside finally swallows the exception (never do this)
not run here: it swallows the same way, and Python 3.14 warns at compile time
7. no def-level except: the try is written out
parse("12") -> 12
parse("abc") -> 'rescued ValueError'
8. what the caught object carries
type(e).__name__ ZeroDivisionError
str(e) a str (CPython rewords messages; not printed)
e.__traceback__ a traceback object
extract_tb(...) a StackSummary (paths inside, not printed)
9. try is a statement, not an expression
v = try: ... -> SyntaxError
(no else/ensure value to read: the block has no value at all)
The Python tutorial's chapter Errors and Exceptions ↗ walks the same four clauses; PEP 765 ↗ is the return-in-finally warning.
Try it¶
- Write
with_retries(times), a method that yields to its block, retries up totimestimes when the block raises aStandardError, prints each failed attempt, and re-raises the last failure to the caller. Show it succeeding on the third try and giving up after two. - Inside an
ensureclause,$!is the exception in flight, ornilwhen the block is leaving normally. Write a method whoseensureprints "cleaning up after ExceptionClass" or "cleaning up after a normal exit" accordingly, and call it both ways.
Solution to 1
rescue_ensure_else_and_retry_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Exercise 1: a with_retries helper that re-runs its block up to `times` times,
# reports every failed attempt, and re-raises the last failure to the caller.
def with_retries(times)
attempts = 0
begin
attempts += 1
yield attempts
rescue StandardError => e
puts " attempt #{attempts} failed with #{e.class}: #{e.message}"
retry if attempts < times
raise
end
end
puts "1. succeeds on the third try"
result = with_retries(5) do |n|
raise ArgumentError, "not yet" if n < 3
"done after #{n} attempts"
end
puts " #{result}"
puts "2. gives up after the limit and re-raises"
begin
with_retries(2) { |n| raise IOError, "still failing (attempt #{n})" }
rescue IOError => e
puts " caller saw #{e.class}: #{e.message}"
end
Verified output of rescue_ensure_else_and_retry_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. succeeds on the third try
attempt 1 failed with ArgumentError: not yet
attempt 2 failed with ArgumentError: not yet
done after 3 attempts
2. gives up after the limit and re-raises
attempt 1 failed with IOError: still failing (attempt 1)
attempt 2 failed with IOError: still failing (attempt 2)
caller saw IOError: still failing (attempt 2)
See also¶
- A bare
rescuecatchesStandardError, not everything — which classes therescuein row 8 would have missed raisehas four forms — the other end of every row here: how the exception was maderescueworks as a modifier — the one-line form, and where it is a syntax errorthrowandcatchare not exceptions — the control-flow jump thatrescuenever sees butensurestill runs fornext,breakandreturnin blocks — the other ways out of a block thatensurealso covers- Exceptions ↗ — the Ruby syntax reference for
begin,rescue,else,ensureandretry - Exceptions have a
cause— Araisethat runs while arescueclause is handling another exception records that exception as the… exitraisesSystemExit—exitis not a system call but araiseofSystemExit, soensureclauses andat_exithandlers run,…- Mutex guards, Queue hands over, ConditionVariable waits —
Mutex#synchronizemakescount += 1exact across ten threads, aQueuemoves work from one thread to… Timeout.timeoutinterrupts a thread from outside —Timeout.timeout(0.2) { sleep 5 }raisesTimeout::Errorafter a fifth of a second because a watcher…File.openwith a block closes the file for you —File.open(name, mode) { |f| … }closes the file when the block ends and evaluates to the block's value;…