Exceptions, die, and typed error objects#
This chapter covers how to raise, catch, and classify runtime
failures: die and eval, the
native try/catch, special-variable hygiene ($@, $!, $?),
$SIG{__DIE__}, and the design of exception classes.
Readers who know die and eval { } will find here the modern
replacements for string-dispatching on exception messages and the
hazards of $SIG{__DIE__} that the stock eval pattern silently
hides.
die and eval#
die throws an exception; the
nearest enclosing eval { } catches it and leaves the message
in $@:
eval {
risky();
};
if (my $err = $@) {
warn "caught: $err";
}
Without a trailing \n, die’s argument gets at FILE line N.
appended from the caller’s frame. With \n, no location is
appended — use for messages that will be shown to users, omit for
messages you want to trace.
Never use the string form eval "..." for exception handling.
It compiles its argument and has every hazard of user-supplied
code.
The native try/catch#
On Perl 5.34 and later, a dedicated try/catch is available
behind a feature flag. Since 5.40 it is non-experimental:
use v5.40;
use feature 'try';
try {
risky();
}
catch ($e) {
warn "caught: $e";
}
finally remains experimental as of 5.42. Expect:
use feature 'try';
no warnings 'experimental::try';
on perls between 5.34 and 5.40.
For code that must run on 5.34 or 5.36 and also on newer perls,
Feature::Compat::Try picks the core feature where available
and falls back to Syntax::Keyword::Try:
use Feature::Compat::Try;
try { risky(); }
catch ($e) { warn "caught: $e" }
Why native try over Try::Tiny#
Try::Tiny still works and remains common. Prefer the native
form for new code: it avoids the sub-call frame that Try::Tiny
introduces (which shows up in stack traces), has zero startup
cost, and composes with the isa operator cleanly.
These are retired — do not adopt:
TryCatch— abandoned.Error.pm— superseded.Exception::Class::TryCatch— superseded byThrowable.
$@ clobbering — the classic eval hazard#
$@ is set by eval; it is also observable globally. Any code
that runs between eval’s return and your check on $@ — a
DESTROY method, a tied-variable handler, an unrelated nested
eval — can overwrite it. Always copy first:
eval { risky(); };
my $err = $@;
return unless $err;
Native try/catch and Syntax::Keyword::Try avoid this pitfall
by giving you the error as the catch parameter; raw eval does
not. Prefer the former for any code that takes exceptions
seriously.
Special variables adjacent to exceptions#
Variable |
Set by |
Read immediately because… |
|---|---|---|
|
|
any intervening call can overwrite. |
|
Last failed system call |
any following syscall overwrites. |
|
|
the next child overwrites. |
|
Extended OS error |
on non-Unix; on Linux usually mirrors |
$? decoding after system / backticks:
if ($? == -1) { warn "child failed to start: $!" }
elsif ($? & 127) { warn "child killed by signal " . ($? & 127) }
elsif ($? & 128) { warn "core dumped" } # combined with above
else { my $exit = $? >> 8; ... }
Classifying exceptions: dispatch on type#
For non-trivial programs, distinguishing “user not found” from “database is down” from “programming error” by parsing error strings is fragile. Throw typed objects and match on them:
use v5.40;
use feature 'try';
use Scalar::Util qw(blessed);
try {
do_business();
}
catch ($e) {
if ($e isa MyApp::Err::NotFound) { return http_404($e) }
elsif ($e isa MyApp::Err::AuthFailure) { return http_403($e) }
elsif ($e isa MyApp::Err) { return http_500($e) }
elsif (blessed($e) && $e->isa('DBIx::Class::Exception')) {
return http_500($e);
}
else {
MyApp::Err::Unknown->throw(cause => "$e");
}
}
The isa operator (stable on 5.36+) does the right thing for
overloaded objects and is zero-cost when the left operand is not
a reference. Pair it with a typed wrapper at the outermost
boundary so everything downstream is a MyApp::Err.
Designing exception classes#
The 2026 default: Throwable role from CPAN, optionally via
Throwable::SugarFactory for declarative hierarchies.
Minimum-viable class:
package MyApp::Err::NotFound;
use Moo;
with 'Throwable';
has resource => ( is => 'ro', required => 1 );
has id => ( is => 'ro', required => 1 );
sub message {
my $self = shift;
sprintf 'not found: %s/%s', $self->resource, $self->id;
}
1;
Usage:
MyApp::Err::NotFound->throw(resource => 'user', id => $uid);
throw is provided by the role; it dies with $self (the
blessed object). ->new without throw builds without raising.
Declarative form via Throwable::SugarFactory:
package MyApp::Err;
use Throwable::SugarFactory;
exception 'GenericError' => 'something bad happened';
exception 'NotFound' => 'resource not found'
=> (has => [ id => (is => 'ro') ]);
exception 'AuthFailure' => 'auth failed'
=> (has => [ reason => (is => 'ro') ]);
exception 'AuthFailureExpired' => 'session expired'
=> (extends => 'AuthFailure');
1;
This generates: the class, a not_found(id => ...) constructor-
and-throw helper, a is_not_found($e) predicate, and ->to_hash
on every instance for JSON serialisation.
What a class must have#
A class name — so
$e isa MyApp::Err::Xdispatches.Structured fields —
id,resource,code.A human
message— without it,warn $eprintsMyApp::Err=HASH(0x...).Serialisation —
->to_hashfor logging / aggregators / JSON APIs.Origin — file + line captured at
throw.Throwablecaptures these automatically.A stack trace if the error is worth investigating later:
with 'StackTrace::Auto'adds->stack_trace.
When not to use a class#
Plain-string die remains correct for short scripts,
one-offs, and any case where no caller will dispatch on the
error:
die "permission denied: $path\n";
The trailing \n suppresses file-and-line appending; the message
is user-facing.
Hash-ref die — die { type => 'not_found', id => $uid } — is a
transitional form. The moment a caller writes
if (ref $e eq 'HASH' && $e->{type} eq 'not_found') three times,
refactor to a class.
Retired exception frameworks — do not adopt:
Error.pm— useThrowable(role-based) orException::Class.Class::Throwable— functional;Throwableis the modern choice.Exception::Class::TryCatch— superseded by nativetry.
$SIG{__DIE__} — handle with care#
$SIG{__DIE__} fires on every die, including die inside
eval. A naive install turns every caught exception into noise:
# WRONG: fires on every eval that catches an error
$SIG{__DIE__} = sub { Carp::confess(@_) };
The $^S special variable tells you whether you are inside an
eval: true means “yes, someone is going to catch this”; false
means “this is reaching the top”. Guard on it:
$SIG{__DIE__} = sub {
return if $^S; # inside eval — not for us
my ($err) = @_;
eval { ship_to_sentry($err) }; # never let the handler itself die
warn "reporter failed: $@" if $@;
die $err; # re-raise
};
For Sentry / aggregator integration, wrap the reporter call in
its own eval — a failing error reporter must not obscure the
original exception.
Before installing on top of an existing handler, chain:
my $previous = $SIG{__DIE__};
$SIG{__DIE__} = sub {
return if $^S;
my ($err) = @_;
eval { ship_to_sentry($err) };
warn "reporter failed: $@" if $@;
goto &$previous if $previous;
die $err;
};
$SIG{__WARN__} — turn warnings fatal, or route them#
Symmetric to $SIG{__DIE__}, fires on every warn:
$SIG{__WARN__} = sub { die $_[0] }; # promote warnings to exceptions
Useful during development as a tripwire; rarely wanted in production. More common production use: route warnings through a logger.
Find out more#
print-and-die — the Carp family and stack-trace-everywhere switches.
perlfunc/die,perlfunc/eval,perlfunc/warn,perlvar— reference pages for the language primitives.