Choosing an Exception Strategy for Production Perl: eval, Try::Tiny, or Core try/catch
A decision guide for Perl exception handling: compare eval/die, Try::Tiny, and the core try/catch feature, then validate your choice with a destructor-unwinding test.
27 Mar 2026, 20:03 UTC

Every production Perl codebase needs one consistent answer to a simple question: how do we throw and catch errors? Perl offers three viable options — core eval/die, the CPAN module Try::Tiny, and the newer core try/catch feature — and they differ in dependency cost, version compatibility, and how safely they handle $@. This guide compares them and shows how to validate whichever you pick.
The decision and its constraints
The constraints that actually drive this choice are:
- Version floor. A CPAN library supporting old perls cannot assume the core
tryfeature; an internal app pinned to one modern interpreter can. - Dependency policy. Some environments forbid non-core CPAN modules; others already pull in hundreds.
- Error taxonomy. If callers must distinguish error types (network vs. validation vs. conflict), you need structured exceptions — blessed objects — regardless of the catching syntax.
Pick one mechanism, write it into your style guide, and forbid the others in review. Mixing styles in one codebase is worse than any single choice.
Comparing the supported options
| Option | Availability | Dependencies | Key risk |
|---|---|---|---|
eval { } / die | Every Perl version | None (core) | $@ clobbered by destructors during unwinding; easy to mishandle |
Try::Tiny | Very old perls through current | One small pure-Perl CPAN module | Not core; check current maintenance metadata before pinning |
Core try/catch (use feature 'try') | Recent Perl 5 releases only | None | Marked experimental; emits a warning unless silenced; syntax has shifted across releases |
Trade-offs in practice
eval/die: maximum compatibility, manual discipline
Core eval works everywhere but has a well-known trap: while Perl unwinds the stack after a die, object destructors (DESTROY methods) can run, and if any of them executes its own eval, your $@ gets overwritten before you inspect it. The defensive pattern is to capture the error immediately and localize:
my $result = eval { dangerous_operation() };
if ( my $err = $@ ) {
# handle $err
}
Even this is fragile if dangerous_operation() triggers destructors that touch $@. For libraries targeting wide version ranges, this is still the zero-dependency baseline — but only with disciplined handling.
Try::Tiny: safe semantics for one small dependency
Try::Tiny exists precisely to fix the $@ pitfalls. It localizes the error variable, passes the exception as a proper parameter to the catch block, and supports finally:
use Try::Tiny;
try {
dangerous_operation();
}
catch {
warn "operation failed: $_";
}
finally {
cleanup();
};
Note the trailing semicolon — try here is a function call, not a block. For most applications and libraries, this is the pragmatic default: broad version support, correct semantics, minimal dependency cost.
Core try/catch: native syntax, modern perls only
Recent Perl 5 releases ship an experimental try/catch syntax (based on Syntax::Keyword::Try) enabled with use feature 'try';. It gives native-looking blocks with no dependencies, but because it is experimental it emits a warning unless you silence it with no warnings 'experimental::try';, and its exact behavior has changed between releases. It is reasonable for internal applications pinned to a modern interpreter where you have verified the behavior on that exact version.
Concrete implementation: structured exceptions with Try::Tiny
Whichever mechanism you choose, die with objects, not strings, when callers need to distinguish error types. A blessed hashref is enough — no exception framework required:
package MyApp::Error;
use overload q{""} => \u0026as_string;
sub new { bless { message => $_[1], kind => $_[2] }, $_[0] }
sub kind { $_[0]{kind} }
sub as_string { "[$_[0]{kind}] $_[0]{message}" }
package main;
use Try::Tiny;
use JSON::PP qw(decode_json);
sub load_config {
my ($raw) = @_;
my $data = try {
decode_json($raw);
}
catch {
die MyApp::Error->new("bad config JSON: $_", 'validation');
};
return $data;
}
try {
load_config('{not json');
}
catch {
if ( ref $_ && $_->isa('MyApp::Error') && $_->kind eq 'validation' ) {
# return a 400-style response
} else {
die $_; # rethrow unknown errors
}
};
Run this with any Perl that has Try::Tiny installed (cpanm Try::Tiny or your system package manager; JSON::PP is core). No special permissions are needed.
Validating the choice
Two checks are worth automating before standardizing.
1. Confirm the construct works on your target interpreter. For the core feature, run on the production host:
perl -Mfeature=try -e 'try { die "x" } catch ($e) { print "caught: $e" }'
Expected: caught: x printed, plus an experimental warning on versions where the feature is not yet stable. Check perl -v against perldoc feature on that machine — do not trust documentation from a different release.
2. Test that exceptions survive unwinding. Write a test (with core Test::More) that dies with a blessed object while a destructor runs, and asserts the catch handler receives the same class:
use Test::More;
use Try::Tiny;
{
package Noisy;
sub DESTROY { eval { die "destructor noise" } }
}
my $caught;
try {
my $tmp = bless {}, 'Noisy';
die bless { kind => 'validation' }, 'MyApp::Error';
}
catch {
$caught = $_;
};
isa_ok $caught, 'MyApp::Error';
is $caught->{kind}, 'validation', 'error object survived destructor unwinding';
done_testing;
Run with perl t/exception.t. If you substitute raw eval for try here, you can observe the $@-clobbering problem directly — a useful demonstration for the style-guide discussion.
Limitations and recommendation
CPAN module maintenance status and minimum Perl versions change over time; check Try::Tiny's current metadata before pinning it in cpanfile. The core try feature's experimental status may change in future releases, so re-verify when upgrading interpreters. For most teams today: Try::Tiny plus blessed error objects is the safest default, raw eval is acceptable only with the disciplined pattern above, and core try/catch is a reasonable opt-in for version-pinned internal apps.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.