Skip to content

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

  1. Write with_retries(times), a method that yields to its block, retries up to times times when the block raises a StandardError, 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.
  2. Inside an ensure clause, $! is the exception in flight, or nil when the block is leaving normally. Write a method whose ensure prints "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