Skip to content

Error Handling and Debugging

Writing Resilient Code and Finding Bugs

Version: 1.1 Year: 2026


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 "Configuration file not found";
# Output: Configuration file not found at script.pl line 12.

die can also throw a reference - an object, hash, or any scalar. This is the basis for structured exception handling:

die { code => 404, message => "Not found", path => $file };

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:

perl -d:NYTProf script.pl    # Profile
nytprofhtml --open            # Generate HTML report

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:

cover -test            # Run tests with coverage
cover cover_db         # Generate report

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:

use DDP;
p %config;    # Colored output to STDERR

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


Previous: Object-Oriented Perl | Next: Testing | Back to Index

Comments