Error Handling and Debugging¶
Writing Resilient Code and Finding Bugs¶
Version: 1.1 Year: 2026
Copyright Notice¶
Copyright (c) 2025-2026 Ryan Thomas Robson / Robworks Software LLC. Licensed under CC BY-NC-ND 4.0. You may share this material for non-commercial purposes with attribution, but you may not distribute modified versions.
Perl programs fail. Files go missing, networks drop, users provide garbage input, and code has bugs. The difference between a script that crashes cryptically and one that reports exactly what went wrong comes down to how you handle errors. This guide covers Perl's error handling mechanisms, the debugging toolkit, and the profiling tools that help you write resilient code.
die, warn, and the Carp Family¶
die and warn¶
die terminates the program (unless caught by eval) and prints to STDERR. warn prints to STDERR but does not terminate:
die "Configuration file not found\n"; # Program stops
warn "Disk usage above 90%\n"; # Program continues
If the string does not end with a newline, Perl appends the filename and line number automatically:
die can also throw a reference - an object, hash, or any scalar. This is the basis for structured exception handling:
Carp: Better Error Reporting¶
The built-in Carp module provides versions of warn and die that report errors from the caller's perspective:
use Carp;
sub validate_age {
my ($age) = @_;
croak "Age must be a positive number" unless $age && $age > 0;
}
validate_age(-5);
# Output: Age must be a positive number at caller.pl line 10.
| Function | Behavior |
|---|---|
carp |
Warns from caller's perspective |
croak |
Dies from caller's perspective |
cluck |
Warns with full stack trace |
confess |
Dies with full stack trace |
Use carp/croak in modules - the error should point to the caller's code, not your internal validation. Use confess/cluck when you need the complete call stack.
eval Blocks and $@¶
The eval block is Perl's built-in mechanism for catching exceptions. When code inside eval calls die, the error is caught and stored in $@:
eval {
open my $fh, '<', $file or die "Cannot open $file: $!";
process(<$fh>);
close $fh;
};
if ($@) {
warn "Processing failed: $@";
}
eval BLOCK vs. eval STRING
The block form (eval { ... }) compiles at compile time and is safe. The string form (eval "...") compiles arbitrary code at runtime - it is a security risk. Never use eval STRING unless you have an extremely specific reason.
The $@ Problem¶
$@ has a well-known flaw: it can be clobbered between the eval block and your error check. Destructors (DESTROY methods) that run when objects go out of scope can reset $@ if they use eval internally:
eval {
my $obj = SomeClass->new(); # $obj has a DESTROY method
die "Something failed";
};
# $obj's DESTROY runs here - may reset $@ to ""
if ($@) {
# This might not execute even though die was called!
}
flowchart TD
A[Code calls die] --> B{Inside eval block?}
B -->|No| C[Program terminates\nError printed to STDERR]
B -->|Yes| D[eval block exits]
D --> E[Error stored in $@]
E --> F{Destructors run\nbefore if check}
F -->|DESTROY uses eval| G[$@ may be clobbered\nError lost]
F -->|No interference| H[if $@ catches error]
H --> I[Error handled]
G --> J[Error silently ignored]
J --> K[Use Try::Tiny\nto avoid this]
Try::Tiny¶
Try::Tiny is the standard CPAN solution for safe exception handling. It avoids the $@ clobbering problem:
use Try::Tiny;
try {
open my $fh, '<', $file or die "Cannot open $file: $!";
process($fh);
close $fh;
}
catch {
warn "Failed to process $file: $_"; # Error is in $_, not $@
}
finally {
cleanup_resources();
}; # <-- Semicolon required! try/catch is a function call
Try::Tiny works by checking whether eval returned true (via a trailing 1) rather than checking $@ directly. This reliably detects errors even when $@ is clobbered.
The Trailing Semicolon
try/catch/finally are function calls, not language keywords. You must end the chain with a semicolon. Forgetting it produces confusing error messages.
| Situation | Use |
|---|---|
| Simple scripts with no objects | eval block is fine |
| Code with DESTROY methods | Try::Tiny |
| Libraries and modules | Try::Tiny |
| Performance-critical inner loops | eval block (slight overhead per call) |
Exception Objects¶
Since die accepts any scalar, you can throw objects that carry error codes and context:
package App::Error;
use overload '""' => sub {
sprintf "%s (code %d) at %s line %d",
$_[0]->{message}, $_[0]->{code}, $_[0]->{file}, $_[0]->{line};
};
sub new {
my ($class, %args) = @_;
bless {
message => $args{message} // 'Unknown error',
code => $args{code} // 500,
file => (caller(0))[1],
line => (caller(0))[2],
}, $class;
}
sub message { $_[0]->{message} }
sub code { $_[0]->{code} }
For larger systems, define a hierarchy and dispatch on type:
package App::Error::IO;
use parent -norequire, 'App::Error';
package App::Error::Auth;
use parent -norequire, 'App::Error';
# In a catch block:
catch {
if (ref $_ && $_->isa('App::Error::Auth')) {
redirect_to_login();
} elsif (ref $_ && $_->isa('App::Error::IO')) {
retry_with_fallback();
} else {
log_and_show_generic_error($_);
}
};
Exception Modules on CPAN
For production applications, consider Throwable (a Moo role) or Exception::Class (a hierarchy builder). Both handle stack traces and stringification without boilerplate.
use strict and use warnings Deep Dive¶
use strict¶
use strict enables three restrictions:
| Restriction | What It Catches |
|---|---|
strict 'vars' |
Undeclared variables (typos like $naem instead of $name) |
strict 'refs' |
Symbolic references ($$varname where $varname is a string) |
strict 'subs' |
Bareword strings used as values without quotes |
Warning Categories¶
use warnings enables runtime warnings organized into a category hierarchy:
flowchart TD
A[all] --> B[closure]
A --> C[deprecated]
A --> D[io]
A --> E[misc]
A --> F[numeric]
A --> G[once]
A --> H[overflow]
A --> I[redefine]
A --> J[recursion]
A --> K[uninitialized]
A --> L[void]
A --> M[syntax]
D --> D1[closed]
D --> D2[exec]
D --> D3[newline]
M --> M1[ambiguous]
M --> M2[precedence]
M --> M3[printf]
You can enable or disable specific categories:
use warnings; # Enable all
use warnings qw(uninitialized numeric); # Specific categories only
no warnings 'uninitialized'; # Suppress in current scope
| Category | Triggered By |
|---|---|
uninitialized |
Using undef in an operation |
numeric |
Non-numeric string in numeric context |
once |
Variable used only once |
redefine |
Redefining a subroutine |
recursion |
Deep recursion (100+ levels) |
void |
Useless expression in void context |
perl -w vs. use warnings¶
| Feature | perl -w |
use warnings |
|---|---|---|
| Scope | Global (entire program + all modules) | Lexical (current file/block only) |
| Granularity | All or nothing | Per-category control |
perl -w enables warnings everywhere, including inside modules that intentionally suppress them. use warnings affects only the current lexical scope. Always prefer use warnings.
Making Warnings Fatal
Promote warnings to errors with use warnings FATAL => 'all' or target specific categories with use warnings FATAL => 'uninitialized'. Useful in test suites but potentially disruptive in production.
The Perl Debugger¶
Perl ships with an interactive debugger invoked with perl -d script.pl. This drops you into a session where you can step through code, set breakpoints, and evaluate expressions.
Essential Debugger Commands¶
| Command | Action |
|---|---|
n |
Execute next line (step over) |
s |
Step into subroutine call |
c / c LINE |
Continue to next breakpoint or specific line |
r |
Return from current subroutine |
b LINE / b SUB |
Set breakpoint at line or subroutine |
B * |
Delete all breakpoints |
p EXPR |
Print expression value |
x EXPR |
Dump expression (like Data::Dumper) |
l / l SUB |
List source code |
T |
Print stack trace |
q |
Quit |
Conditional breakpoints trigger only when a condition is true: b 42 $count > 100. Watchpoints (w $total) break when a variable changes.
Profiling and Coverage¶
Devel::NYTProf¶
Devel::NYTProf is the gold standard Perl profiler. It records per-line timings, subroutine call counts, and generates HTML reports:
For large applications, control profiling programmatically with DB::disable_profile() and DB::enable_profile().
Profile Before Optimizing
Intuition about performance is unreliable. Always profile first - the bottleneck is rarely where you expect it.
Devel::Cover¶
Devel::Cover measures which parts of your code are exercised by tests:
It reports statement, branch, condition, and subroutine coverage. Target 80-90% for most projects.
Data::Dumper for Inspection¶
Data::Dumper prints any data structure in a readable format:
use Data::Dumper;
local $Data::Dumper::Sortkeys = 1;
warn Dumper(\%config); # Quick debug: warn goes to STDERR immediately
For colored, human-friendly output, Data::Printer is an alternative:
Logging Strategies¶
Log::Any¶
Log::Any separates the logging interface from the output destination. Modules log through Log::Any; the main script decides where messages go:
# In your module
use Log::Any '$log';
$log->info("Connecting to $dsn");
$log->error("Connection failed: $@");
# In your main script
use Log::Any::Adapter ('File', '/var/log/myapp.log');
Log::Log4perl¶
Log::Log4perl provides hierarchical loggers, multiple appenders, and pattern layouts (modeled after Java's Log4j):
use Log::Log4perl;
Log::Log4perl->init(\q{
log4perl.rootLogger = DEBUG, Screen
log4perl.appender.Screen = Log::Log4perl::Appender::Screen
log4perl.appender.Screen.layout = PatternLayout
log4perl.appender.Screen.layout.ConversionPattern = %d [%p] %F{1}:%L %m%n
});
my $log = Log::Log4perl->get_logger();
$log->info("Application started");
| Need | Solution |
|---|---|
| Script debugging | warn with Data::Dumper |
| Reusable module | Log::Any (no adapter dependency) |
| Complex log routing | Log::Log4perl |
Practical Patterns¶
The or die Idiom¶
open my $fh, '<', $file or die "Cannot open $file: $!\n";
chdir $dir or die "Cannot chdir to $dir: $!\n";
Always include $! - it contains the OS error message.
Layered Error Context¶
Each layer adds context so the final message traces back to the root cause:
sub read_user_data {
my ($user_id) = @_;
my $path = "/data/users/$user_id.json";
try {
open my $fh, '<', $path or die "Cannot open: $!";
local $/;
my $content = <$fh>;
close $fh;
return decode_json($content);
}
catch {
die "Failed to read user $user_id: $_";
};
}
# Error: "Failed to read user 42: Cannot open: No such file or directory"
Retry with Backoff¶
sub retry {
my (%args) = @_;
my $tries = $args{tries} // 3;
my $delay = $args{delay} // 1;
for my $attempt (1 .. $tries) {
my $result;
try { $result = $args{code}->() }
catch {
die "Failed after $tries attempts: $_" if $attempt == $tries;
warn "Attempt $attempt failed, retrying in ${delay}s\n";
sleep $delay;
$delay *= 2;
};
return $result if defined $result;
}
}
Further Reading¶
- perldiag - complete list of Perl diagnostic messages
- perldebtut - Perl debugging tutorial
- perldebug - full debugger reference
- Try::Tiny documentation - safe exception handling
- Devel::NYTProf documentation - profiling guide
- Log::Any documentation - logging API
- Perl Best Practices, Chapter 13 - error handling recommendations
Previous: Object-Oriented Perl | Next: Testing | Back to Index