Errors, and the library you already have
Exceptions as objects you can catch and raise, the begin/rescue/ensure shape, and a look at the standard library that ships with Ruby.
An exception is an object
When something goes wrong, Ruby raises an exception — an object of a class like ArgumentError or ZeroDivisionError — and normal execution stops, travelling up the call stack until something catches it. begin ... rescue ... end is the catch, and rescue may name the class it handles: rescue ArgumentError => e binds the object to e, so the message and the class are available to the handler.
Order matters when several rescues are listed: the first matching class wins, so the specific ones go first. A bare rescue catches everything, including mistakes in your own logic, which is why it is the one to avoid — the useful rescue names what it expects to go wrong.
Else, ensure, and raising your own
Three optional parts complete the shape. else runs when no exception was raised — it is the box for the normal path, kept out of the begin body so the rescue cannot accidentally swallow its errors. ensure runs last in every case, error or not, which is what cleanup code uses.
raise starts an exception, and the most useful form names a class and a message: raise ArgumentError, "age #{age} is out of range". A custom error class — class ValidationError < StandardError; end — lets a caller rescue your library's errors by name while letting genuinely unexpected ones keep travelling. retry is available inside a rescue to run the begin block again, and is used rarely and deliberately: on a transient network failure, not on a logic bug.
The standard library
Ruby ships with a large standard library, and requiring one is a single line. require 'json' gives JSON.parse and JSON.generate — the second produces text, and the first turns text into plain hashes and arrays with string keys. require 'date' gives real calendar dates with formatting and subtraction that understands months, and require 'set' gives a collection that keeps only one of each value.
The habit worth forming is to check whether the standard library already solves the problem before reaching for anything else. Parsing JSON, formatting a date, comparing versions, shuffling a list — these are all in the box, and a dependency saved on day one is a dependency maintained forever.
Where an exception goes
Raising an exception unwinds the call stack: each frame is abandoned until a rescue that names a matching class is found. That is why a rescue can be far from the raise and still catch it, and why ensure in the middle frames still runs. If nothing catches it, the program stops and prints the class, message and backtrace.
The class hierarchy decides what a rescue catches. StandardError is the parent of the errors ordinary code raises, so rescue => e catches those and not SignalException or SystemExit. Custom errors should inherit from StandardError for the same reason: callers can rescue your family without accidentally catching everything else.
A rescue is a decision about scope
A begin block should be as small as the failure it guards. Wrapping twenty lines means any of them can raise the error, and the handler cannot tell which. Wrapping the one conversion that can fail gives the rescue a single meaning and the message a single cause.
In a method, begin is optional: def ... rescue ... end protects the whole body. That is convenient for a small method and misleading for a large one, because the protected region is no longer visible at a glance.
Messages are for the next reader
An exception message is read by whoever sees the failure, often in a log with no other context. Name the value and the rule: "age -2 is out of range" is actionable, "invalid input" is not. The class says what kind of failure; the message says which value caused it.
A bare rescue with an empty body is the most expensive mistake in this chapter: it turns a crash into a wrong answer and hides the line that caused it. Rescue only what you can handle, and let the rest travel. ensure is not a place for return, because a return there overrides whatever the method was about to answer. And do not use exceptions as ordinary control flow; if a condition is expected, test it.
Catching, raising, and requiring
require 'json'
require 'date'
require 'set'
record = '{"name":"Ada","joined":"2026-01-15","tags":["admin","author"]}'
data = JSON.parse(record)
puts data['name']
puts data['tags'].join(', ')
joined = Date.parse(data['joined'])
puts "days in: #{(Date.today - joined).to_i}"
begin
JSON.parse('{"broken":')
rescue JSON::ParserError => e
puts "could not parse: #{e.class}"
end
class ValidationError < StandardError; end
def check_age(age)
raise ValidationError, "age #{age} is out of range" if age < 0 || age > 130
age
end
begin
check_age(-1)
rescue ValidationError => e
puts "refused: #{e.message}"
ensure
puts 'checked'
end
p Set.new([1, 2, 2, 3]).to_a
p JSON.generate({ ok: true, count: 2 })An import that validates
Run it and read each refusal — a parse error, a missing name, a bad age. Then add a field of your own — a score — with a validation rule, and a custom error class for it.
A rescue that catches everything and does nothing turns a loud failure into a quiet wrong answer. If the rescue cannot say what it is handling and what it does about it, the method is usually better without one.
Try these next
- Write
positive_integer(text)that raisesArgumentErrorfor empty text, non-digits and zero, and rescue each case at the call site. - Add an
ensurethat prints a closing line, and check that it runs on both the success and failure paths. - Parse a JSON string with a missing brace, rescue
JSON::ParserError, and print the message without the backtrace.
A quantity parser that refuses
Run it and read each refusal. Then add a value with a decimal point, decide whether it should be accepted, rejected or rounded, and make the code say so.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What does rescue ArgumentError => e do?
