LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Perl: Still Powering the World's Sysadmin Scripts

perlscriptingsysadminregexcpantext-processing
Contents

Perl is the language you inherit. You join a team, open the infrastructure repo, and somewhere in scripts/ there are three thousand lines of Perl that parse router configs, aggregate logs, and generate compliance reports. They’ve run reliably every night for fifteen years. Nobody wants to touch them. Nobody can quite explain all of them. But they work.

That’s the Perl reality in 2026. Not dead. Not irrelevant. But certainly not what it was in 2001, when it was the glue language of choice for the entire internet. Understanding what Perl still does well — and being honest about where it doesn’t — is genuinely useful for any engineer who operates production infrastructure.

This is not a nostalgia post. It’s a technical guide. We’ll cover modern Perl (5.38/5.40), the regex engine that every other language has copied, CPAN’s irreplaceable depth, and the practical cases where reaching for Perl still makes sense in 2026.


1. Perl’s Place in 2026

Who Still Uses Perl

Perl’s active user base is smaller than Python’s by an order of magnitude, but it’s not zero — and in specific domains, it remains deeply embedded:

Sysadmins and network engineers. Expect Networks, carriers, and large enterprises have decades of Perl-based tools for device config parsing, SNMP polling, and log processing. These aren’t going anywhere. The ROI of rewriting a working 10,000-line Perl script in Python is negative.

Bioinformatics. This is the domain where Perl’s grip is tightest. The Human Genome Project used Perl extensively. BioPerl remains a massive ecosystem. Bioinformatics workflows written in 2005 are still running on clusters in 2026 because the science is done and the code works.

Finance and data processing. Many banks and trading firms have Perl in their report generation, ETL pipelines, and FIX protocol parsers. The operational risk of rewriting these is too high.

Web infrastructure companies. Fastly’s Varnish configuration language VCL is Perl-adjacent. Large chunks of the web’s caching infrastructure were originally built around Perl-based tooling.

Legacy CGI and web. Yes, there are still CGI scripts in production. Some of them predate the engineers maintaining them.

Perl 5.38 and 5.40

Perl is not frozen. The language is under active development, with annual releases. Recent versions have brought meaningful improvements:

Perl 5.36 (2022): use v5.36 now automatically enables strict, warnings, and several other feature pragmas. The signatures feature graduated from experimental to stable. builtin module introduced.

Perl 5.38 (2023): Class syntax — a new built-in object system (class, method, field keywords). This is the biggest OOP change to core Perl in two decades. Unicode 15.0. use feature 'class'.

Perl 5.40 (2024): The class feature stabilized further. try/catch blocks became non-experimental. defer blocks for cleanup code (similar to Go’s defer). ^^ logical XOR operator.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Modern Perl with v5.40 features
use v5.40;

# Automatic strict + warnings + modern features
# try/catch is now stable
try {
    open(my $fh, '<', '/etc/nonexistent') or die "Cannot open: $!";
} catch ($e) {
    say "Caught: $e";
}

# defer block — runs at end of scope regardless of how we exit
sub process_file($path) {
    open(my $fh, '<', $path) or die "Cannot open $path: $!";
    defer { close($fh) }

    while (my $line = <$fh>) {
        chomp $line;
        say $line if $line =~ /ERROR/;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# New class syntax (5.38+)
use v5.38;
use feature 'class';

class NetworkDevice {
    field $hostname :param;
    field $ip       :param;
    field $vendor   :param = 'unknown';

    method describe() {
        return "$hostname ($ip) - vendor: $vendor";
    }

    method is_cisco() {
        return $vendor =~ /cisco/i;
    }
}

my $router = NetworkDevice->new(
    hostname => 'core-rtr-01',
    ip       => '10.0.0.1',
    vendor   => 'Cisco',
);

say $router->describe();
say "Is Cisco: " . ($router->is_cisco() ? 'yes' : 'no');

The “Perl is Dead” Narrative — True and False

The narrative has some truth: Perl’s market share in new development has collapsed. Python took the data science crown, Ruby took web application development (and then Node/Go took that), and Bash handles simple automation. The number of developers choosing Perl for new projects in 2026 is genuinely small.

Where the narrative is wrong: Perl’s installed base is enormous. The language isn’t going away. CPAN is still adding modules. The community around Perl is smaller but serious — conferences like The Perl and Raku Conference continue. The maintenance reality is that hundreds of thousands of critical Perl scripts are running in production at this moment.

The Perl 7 Saga

In 2020, the Perl community announced Perl 7 — a backwards-compatible-but-slightly-different release that would enable strict and warnings by default and retire some historical baggage. It was positioned as a marketing reset, not a language revolution.

Then it stalled. The use v5.36 approach (enabling modern defaults via version pragma) was adopted instead, effectively delivering the core value proposition of Perl 7 without the version number controversy. Perl 7 is currently shelved. The practical outcome: just use v5.36 or higher in new code.


2. Core Language Strengths

Context Sensitivity: Perl’s Defining Feature

Perl evaluates expressions differently depending on context — specifically, whether the expression is in list context or scalar context. This is the feature that makes Perl feel like natural language: the same function can return different things depending on how you ask.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
use strict;
use warnings;

my @array = (1, 2, 3, 4, 5);

# Scalar context: array returns its count
my $count = @array;
print "Count: $count\n";  # Count: 5

# List context: array returns its elements
my @copy = @array;

# Scalar context forced with scalar()
if (scalar(@array) > 3) {
    print "Array has more than 3 elements\n";
}

# localtime in list context vs scalar context
my @parts = localtime(time);   # (sec, min, hour, mday, mon, year, wday, yday, isdst)
my $when  = localtime(time);   # "Fri Apr 11 14:23:00 2026"

printf "Hour: %d\n", $parts[2];
print "When: $when\n";

# String repetition vs list repetition — context again
my $dashes = "-" x 20;          # scalar: "--------------------"
my @zeros  = (0) x 5;           # list:   (0, 0, 0, 0, 0)
print "$dashes\n";
print "@zeros\n";

# Regex match in list context returns captures
my $line = "user=admin group=ops uid=1001";
my @pairs = ($line =~ /(\w+)=(\w+)/g);  # list of all captures: (user, admin, group, ops, uid, 1001)
print "Pairs: @pairs\n";

# Same regex in scalar context returns 1/'' (match/no-match)
if ($line =~ /uid=(\d+)/) {
    print "UID: $1\n";
}

The Three Variable Types and Sigils

Perl has three fundamental data structures: scalars, arrays, and hashes. Each has a sigil ($, @, %), and the sigil changes based on what you’re accessing, not what type the variable is.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
use strict;
use warnings;

# Scalars — single values
my $name    = "Alice";
my $count   = 42;
my $pi      = 3.14159;
my $flag    = 1;          # true
my $nothing = undef;      # explicit undef

# Strings and numbers are the same type — context decides
my $num_str = "42";
my $result  = $num_str + 8;   # 50 — numeric context
my $concat  = $num_str . "!"; # "42!" — string context

# Arrays — ordered lists
my @servers  = ('web-01', 'web-02', 'db-01');
my @numbers  = (1..10);           # range operator
my $last     = $servers[-1];      # 'db-01' — negative index
my $length   = scalar @servers;   # 3
my @slice    = @servers[0, 2];    # ('web-01', 'db-01') — array slice
my ($first, @rest) = @servers;    # list assignment

# Note the sigil: $servers[0] not @servers[0]
# $ because we're getting ONE (scalar) element from the array
print "First server: $servers[0]\n";
print "Last server:  $servers[-1]\n";

# Modifying arrays
push    @servers, 'db-02';        # add to end
unshift @servers, 'lb-01';       # add to beginning
my $popped   = pop   @servers;   # remove from end
my $shifted  = shift @servers;   # remove from beginning
my @removed  = splice(@servers, 1, 2);  # remove 2 elements at index 1

# Sorting
my @sorted  = sort @servers;
my @by_len  = sort { length($a) <=> length($b) } @servers;
my @reverse = reverse sort @servers;

# Hashes — key-value pairs
my %config = (
    host    => 'localhost',
    port    => 5432,
    dbname  => 'production',
    timeout => 30,
);

# Access — $ sigil because ONE value
my $host = $config{host};
print "Host: $host\n";

# Hash slice — @ sigil because multiple values
my @conn_info = @config{qw(host port dbname)};
print "Connection: @conn_info\n";

# Testing existence and deletion
if (exists $config{timeout}) {
    print "Timeout: $config{timeout}\n";
}
delete $config{timeout};

# Iterating hashes
while (my ($key, $val) = each %config) {
    printf "  %-10s = %s\n", $key, $val;
}

# keys, values, each
my @keys   = sort keys %config;
my @values = map { $config{$_} } @keys;

# Counting with a hash
my @words = qw(apple banana apple cherry banana apple);
my %freq;
$freq{$_}++ for @words;
for my $word (sort { $freq{$b} <=> $freq{$a} } keys %freq) {
    printf "%s: %d\n", $word, $freq{$word};
}

References and Complex Data Structures

Perl’s reference system is what makes complex data structures possible. References are scalar values that point to other data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
use strict;
use warnings;

# Creating references
my @array  = (1, 2, 3);
my %hash   = (a => 1, b => 2);
my $scalar = 42;

my $aref = \@array;   # reference to array
my $href = \%hash;    # reference to hash
my $sref = \$scalar;  # reference to scalar

# Anonymous constructors — more common in practice
my $aref2 = [1, 2, 3];          # anonymous array ref
my $href2 = {a => 1, b => 2};   # anonymous hash ref
my $cref  = sub { return $_[0] + $_[1] };  # code ref

# Dereferencing — two syntaxes
# Arrow notation (preferred for readability):
my $first = $aref->[0];
my $val   = $href->{a};
my $sum   = $cref->(3, 4);

# Block dereference (older style):
my $also_first = $$aref[0];
my $also_val   = $$href{a};
my @array_copy = @{$aref};
my %hash_copy  = %{$href};

# Complex data structures — the real power
my %servers = (
    'web-01' => {
        ip       => '10.0.1.10',
        role     => 'web',
        services => ['nginx', 'php-fpm'],
        metrics  => { cpu => 23.5, mem => 67.2 },
    },
    'db-01' => {
        ip       => '10.0.2.10',
        role     => 'database',
        services => ['postgres'],
        metrics  => { cpu => 45.1, mem => 82.0 },
    },
);

# Deep access
print $servers{'web-01'}{ip}, "\n";
print $servers{'web-01'}{services}[0], "\n";
print $servers{'web-01'}{metrics}{cpu}, "\n";

# Iterating nested structures
for my $host (sort keys %servers) {
    my $info = $servers{$host};
    printf "%s (%s) - CPU: %.1f%%\n",
        $host, $info->{ip}, $info->{metrics}{cpu};
    printf "  Services: %s\n", join(', ', @{$info->{services}});
}

# Array of hashrefs — common pattern
my @records = (
    { name => 'Alice', dept => 'ops',   level => 5 },
    { name => 'Bob',   dept => 'dev',   level => 3 },
    { name => 'Carol', dept => 'ops',   level => 7 },
);

# Sort, filter, transform
my @ops_staff = grep { $_->{dept} eq 'ops' } @records;
my @names     = map  { $_->{name} }
                sort { $b->{level} <=> $a->{level} } @records;
print "By seniority: @names\n";

wantarray — Polymorphic Return Values

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
use strict;
use warnings;

sub get_servers {
    my @list = ('web-01', 'web-02', 'db-01');

    if (wantarray) {
        # List context — return the list
        return @list;
    } else {
        # Scalar context — return count or summary
        return scalar @list;
    }
}

my @servers = get_servers();   # list context: ('web-01', 'web-02', 'db-01')
my $count   = get_servers();   # scalar context: 3

printf "Got %d servers: %s\n", scalar @servers, join(', ', @servers);
printf "Count: %d\n", $count;

# More sophisticated: return different types based on context
sub query_db {
    my ($sql) = @_;
    # Simulate query results
    my @rows = (
        { id => 1, name => 'Alice' },
        { id => 2, name => 'Bob' },
    );

    if (wantarray) {
        return @rows;           # all rows as a list
    } elsif (defined wantarray) {
        return $rows[0];        # just first row
    } else {
        # void context — maybe just execute without returning
        return;
    }
}

local vs my — Lexical vs Dynamic Scope

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
use strict;
use warnings;

# my — lexical scope (compile-time, block-scoped)
# The right choice 99% of the time
{
    my $x = 10;
    print "Inside: $x\n";  # 10
}
# $x is gone here

# local — dynamic scope (runtime, restores on scope exit)
# Used for temporarily overriding special variables
our $separator = ':';

sub print_list {
    my @items = @_;
    print join($separator, @items), "\n";
}

print_list(qw(a b c));  # a:b:c

{
    local $separator = '|';  # temporarily override
    print_list(qw(a b c));   # a|b|c
    # Calls to print_list from HERE see '|'
}
print_list(qw(a b c));  # a:b:c — restored

# local is essential for Perl's special variables
sub slurp_file {
    my ($file) = @_;
    open(my $fh, '<', $file) or die "Cannot open $file: $!";
    local $/;  # undef the input record separator = slurp mode
    return <$fh>;  # reads entire file at once
}

# local $_ is common in utility functions to avoid clobbering
sub transform_lines {
    my ($text, $func) = @_;
    return join("\n",
        map {
            local $_ = $_;  # protect $_ from modification
            $func->($_);
        }
        split /\n/, $text
    );
}

3. Regex Power

Perl’s regular expression engine has been the gold standard since the 1990s. Every major language’s regex engine is either directly derived from Perl’s (PCRE — Perl Compatible Regular Expressions) or has borrowed heavily from it. Python’s re module, PHP’s preg_* functions, JavaScript’s regex engine, Java’s java.util.regex — all PCRE-derived.

What sets Perl apart is not just the engine but the integration. Regex isn’t a library you call; it’s a first-class language feature with dedicated syntax, operators, and modifiers.

Basic Matching and Captures

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use strict;
use warnings;

my $log_line = '2026-04-11 14:23:05 ERROR [auth] Failed login for user: alice from 192.168.1.42';

# Basic match — returns true/false in scalar context
if ($log_line =~ /ERROR/) {
    print "Found an error\n";
}

# Capture groups — $1, $2, etc.
if ($log_line =~ /(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (\w+)/) {
    my ($date, $time, $level) = ($1, $2, $3);
    print "Date: $date, Time: $time, Level: $level\n";
}

# Better: capture directly into variables
my ($date, $time, $level) = $log_line =~ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (\w+)/;

# Even better: named captures
if ($log_line =~ /
    ^(?<date>\d{4}-\d{2}-\d{2})\s+
    (?<time>\d{2}:\d{2}:\d{2})\s+
    (?<level>\w+)\s+
    \[(?<component>\w+)\]\s+
    (?<message>.+)$
/x) {
    printf "%-12s %s\n", "Date:",      $+{date};
    printf "%-12s %s\n", "Time:",      $+{time};
    printf "%-12s %s\n", "Level:",     $+{level};
    printf "%-12s %s\n", "Component:", $+{component};
    printf "%-12s %s\n", "Message:",   $+{message};
}

Named Captures and Complex Patterns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use strict;
use warnings;

# Named captures with (?<name>...) — access via %+
my $nginx_log = '10.0.1.5 - alice [11/Apr/2026:14:23:05 +0000] "GET /api/users HTTP/1.1" 200 1234 "https://example.com" "Mozilla/5.0"';

my $nginx_re = qr/
    ^(?<client_ip>\S+)\s+
    \S+\s+                          # ident (usually -)
    (?<user>\S+)\s+                 # remote user
    \[(?<time>[^\]]+)\]\s+          # timestamp
    "(?<method>\w+)\s+
     (?<path>\S+)\s+
     HTTP\/(?<http_ver>[\d.]+)"\s+
    (?<status>\d{3})\s+
    (?<bytes>\d+|-)\s+
    "(?<referer>[^"]*)"\s+
    "(?<ua>[^"]*)"
/x;

if ($nginx_log =~ $nginx_re) {
    my %hit = %+;  # copy named captures to a regular hash
    printf "Client: %-15s Method: %-6s Status: %s Path: %s\n",
        $hit{client_ip}, $hit{method}, $hit{status}, $hit{path};
}

# Non-destructive named captures — great for parsing many lines
sub parse_nginx_log {
    my ($line) = @_;
    return unless $line =~ $nginx_re;
    return {%+};  # return a copy of named captures as hashref
}

Lookahead and Lookbehind

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use strict;
use warnings;

# Lookahead — match X only if followed by Y (doesn't consume Y)
my @prices = ('$10.99', '$200', 'EUR 15.50', '$5');
my @dollar_amounts = map { /\$(\d+(?:\.\d{2})?)/; $1 // () } @prices;
print "Dollar amounts: @dollar_amounts\n";  # 10.99 200 5

# Positive lookahead (?=...)
my $text = "foo123 bar456 baz";
my @nums_after_word = ($text =~ /\d+(?=\s|$)/g);  # numbers at word end
print "Numbers: @nums_after_word\n";

# Negative lookahead (?!...)
my @lines = (
    'CRITICAL: disk usage at 95%',
    'CRITICAL_RESOLVED: disk usage normal',
    'WARNING: cpu spike',
);
my @active_crits = grep { /CRITICAL(?!_RESOLVED)/ } @lines;
print "Active criticals: ", scalar @active_crits, "\n";  # 1

# Positive lookbehind (?<=...)
my $config = "timeout=30 max_conn=100 timeout_ssl=60";
# Find values for keys ending in 'timeout'
my @timeout_vals = ($config =~ /(?<=timeout=)\d+/g);
# This only gets 30, not the ssl one -- lookbehind is fixed-width

# Variable-length lookbehind (Perl 5.30+)
my @after_equals = ($config =~ /(?<=\w+=)\d+/g);
print "Values: @after_equals\n";  # 30 100 60

# Negative lookbehind (?<!...)
# Extract numbers not preceded by 'max_'
my @non_max = ($config =~ /(?<!max_conn=)\b\d+\b/g);

Non-Greedy Matching

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use strict;
use warnings;

my $html = '<a href="https://example.com">Click here</a> and <a href="https://other.com">here</a>';

# Greedy — matches as much as possible
my ($greedy) = $html =~ /<a.*>/;
print "Greedy: $greedy\n";  # gets everything up to last >

# Non-greedy with ? — matches as little as possible
my @links = ($html =~ /<a\s+href="([^"]+)">/g);
print "Links: @links\n";  # https://example.com  https://other.com

# Non-greedy quantifiers: *? +? ?? {n,m}?
my $json = '{"name":"Alice","city":"Portland"}';
my @values = ($json =~ /"[^"]+":\s*"(.+?)"/g);
print "Values: @values\n";

# Practical: extract content between tags
my $text = "<note>First note</note> some text <note>Second note</note>";
my @notes = ($text =~ /<note>(.*?)<\/note>/gs);  # /s makes . match newline
print "Notes: @notes\n";

The //x Extended Mode

The x modifier makes regex readable by allowing whitespace and comments:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
use strict;
use warnings;

# Without /x — hard to read
my $ip_re_compact = qr/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;

# With /x — readable and maintainable
my $octet = qr/
    (?:
        25[0-5]       # 250-255
      | 2[0-4]\d      # 200-249
      | [01]?\d\d?    # 0-199
    )
/x;

my $ip_re = qr/
    ^                   # start of string
    $octet \. $octet \. $octet \. $octet
    $                   # end of string
/x;

my @test_ips = ('192.168.1.1', '10.0.0.256', '172.16.0.1', 'not-an-ip');
for my $ip (@test_ips) {
    printf "%-15s %s\n", $ip, ($ip =~ $ip_re ? 'valid' : 'invalid');
}

# Complex email regex with /x
my $email_re = qr/
    ^
    (?<local>
        [a-zA-Z0-9]            # must start with alphanumeric
        [a-zA-Z0-9._%+\-]*     # followed by valid chars
    )
    @
    (?<domain>
        [a-zA-Z0-9]
        [a-zA-Z0-9\-]*         # hostname part
        (?:\.[a-zA-Z0-9\-]+)*  # additional subdomains
        \.[a-zA-Z]{2,}         # TLD
    )
    $
/x;

for my $email (qw(alice@example.com bad@.com user.name+tag@domain.co.uk)) {
    if ($email =~ $email_re) {
        printf "%-30s local=%-20s domain=%s\n", $email, $+{local}, $+{domain};
    } else {
        printf "%-30s INVALID\n", $email;
    }
}

Substitution with s///

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
use strict;
use warnings;

my $text = "The quick brown fox jumps over the lazy dog";

# Basic substitution (modifies in place)
(my $modified = $text) =~ s/fox/cat/;  # non-destructive idiom
print "$modified\n";

# Global substitution with /g
(my $all_caps = $text) =~ s/\b(\w)/uc($1)/ge;  # /e evaluates replacement as code
print "$all_caps\n";

# Substitution with captures
my $date = "2026-04-11";
(my $us_date = $date) =~ s/^(\d{4})-(\d{2})-(\d{2})$/$2\/$3\/$1/;
print "US format: $us_date\n";  # 04/11/2026

# /r modifier (non-destructive, returns new string) — Perl 5.14+
my $cleaned = $text =~ s/\b(the|a|an)\b\s*//gir;  # /r returns modified copy
print "Without articles: $cleaned\n";

# Complex substitution with /e — execute replacement as code
my $template = "Server: {{hostname}}, IP: {{ip}}, Port: {{port}}";
my %vars = (hostname => 'web-01', ip => '10.0.1.10', port => 8080);

(my $rendered = $template) =~ s/\{\{(\w+)\}\}/$vars{$1} \/\/ "UNDEFINED"/ge;
print "$rendered\n";

# Log sanitization — mask sensitive data
my $log_entry = 'auth: user=alice password=s3cr3t token=abc123def456 ip=10.0.1.5';
(my $safe_log = $log_entry) =~ s/(?<=(?:password|token)=)\S+/'*' x 8/ge;
print "Safe: $safe_log\n";

# Multiline substitution
my $config_block = <<'END';
server {
    listen 80;
    server_name example.com;
    root /var/www/html;
}
END

# Replace listen port
$config_block =~ s/(?<=listen )\d+/443/;
print $config_block;

tr/// Transliteration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use strict;
use warnings;

my $text = "Hello, World! 123";

# tr/// for character-by-character translation
(my $lower = $text) =~ tr/A-Z/a-z/;
print "Lower: $lower\n";

# tr/// returns count of translations
my $count = ($text =~ tr/a-zA-Z//);  # count letters
print "Letter count: $count\n";

# Delete characters (no replacement list)
(my $no_punct = $text) =~ tr/!,//d;
print "No punct: $no_punct\n";

# Squeeze repeated characters
my $spaced = "too   many    spaces";
(my $squeezed = $spaced) =~ tr/ //s;  # squeeze runs of spaces
print "Squeezed: $squeezed\n";

# ROT13 — classic use case
(my $rot13 = "Hello World") =~ tr/A-Za-z/N-ZA-Mn-za-m/;
print "ROT13: $rot13\n";

# Count specific characters
my $csv_line = "field1,field2,,field4,field5";
my $comma_count = ($csv_line =~ tr/,//);
print "Fields: ", $comma_count + 1, "\n";

The /g Modifier with \G Anchor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use strict;
use warnings;

# /g in list context — finds all matches
my $text = "Error on line 42, warning on line 99, error on line 156";
my @line_nums = ($text =~ /line (\d+)/g);
print "Line numbers: @line_nums\n";  # 42 99 156

# /g in scalar context with pos() — iterative matching (lexer pattern)
my $input = "name=Alice age=30 city=Portland";

while ($input =~ /(\w+)=(\w+)/g) {
    printf "  key=%-10s val=%s\n", $1, $2;
}

# \G anchor — match only at current pos() position
# Essential for lexer/tokenizer patterns
my $token_input = "42 + foo * (3.14 - bar)";
my @tokens;
while ($token_input =~ /\G\s*(?:
    (\d+(?:\.\d+)?)     |   # number
    ([a-zA-Z_]\w*)      |   # identifier
    ([\+\-\*\/\(\)])        # operator/paren
)/gx) {
    if    (defined $1) { push @tokens, { type => 'NUM',  val => $1 } }
    elsif (defined $2) { push @tokens, { type => 'ID',   val => $2 } }
    elsif (defined $3) { push @tokens, { type => 'OP',   val => $3 } }
}
for my $tok (@tokens) {
    printf "  %-4s %s\n", $tok->{type}, $tok->{val};
}

4. CPAN: The World’s Largest Single-Language Repository

CPAN (Comprehensive Perl Archive Network) hosts over 200,000 modules from 14,000+ authors. More importantly, it covers domains that Python’s PyPI doesn’t match for depth: bioinformatics, telecom protocols, financial data formats, and niche sysadmin tasks. The ecosystem has decades of battle-tested code.

Modern Dependency Management

The old way (perl Makefile.PL && make install) is gone for serious projects. Use these tools:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Install cpanminus — the modern CPAN client
curl -L https://cpanmin.us | perl - App::cpanminus

# Install a module
cpanm Moo
cpanm DBI DBD::Pg    # DBI with PostgreSQL driver
cpanm --quiet JSON::XS Path::Tiny Try::Tiny

# Install from cpanfile (like requirements.txt / Gemfile)
cpanm --installdeps .

# Carton — like Bundler for Perl, pins exact versions
cpanm Carton
carton install         # installs from cpanfile.snapshot
carton exec perl app.pl  # runs with exact deps

A cpanfile looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# cpanfile
requires 'Moo',           '2.000000';
requires 'DBI',           '1.643';
requires 'DBD::Pg',       '3.14.2';
requires 'JSON::XS',      '4.03';
requires 'Path::Tiny',    '0.144';
requires 'Try::Tiny',     '0.31';
requires 'LWP::UserAgent','6.67';
requires 'HTTP::Tiny',    '0.080';

on 'test' => sub {
    requires 'Test::More',     '1.302190';
    requires 'Test::Exception', '0.43';
};

on 'develop' => sub {
    requires 'Perl::Critic',   '1.150';
    requires 'Perl::Tidy',     '20230912';
};

Key Modules Every Perl Developer Should Know

Moo — Lightweight OOP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
use strict;
use warnings;
package Server;
use Moo;
use Types::Standard qw(Str Int Bool ArrayRef);

# Attributes with type constraints and defaults
has 'hostname' => (
    is       => 'ro',         # read-only
    isa      => Str,
    required => 1,
);

has 'ip' => (
    is       => 'ro',
    isa      => Str,
    required => 1,
);

has 'port' => (
    is      => 'rw',          # read-write
    isa     => Int,
    default => 22,
);

has 'tags' => (
    is      => 'ro',
    isa     => ArrayRef[Str],
    default => sub { [] },    # default must be a coderef for refs
);

has '_connection' => (
    is        => 'lazy',      # built on first access
    builder   => '_build_connection',
    predicate => 'is_connected',
    clearer   => 'disconnect',
);

sub _build_connection {
    my ($self) = @_;
    # Return a mock connection object
    return { host => $self->hostname, port => $self->port };
}

sub description {
    my ($self) = @_;
    my $tags = @{$self->tags} ? join(', ', @{$self->tags}) : 'none';
    return sprintf "%s (%s:%d) tags=[%s]",
        $self->hostname, $self->ip, $self->port, $tags;
}

package main;

my $srv = Server->new(
    hostname => 'web-01',
    ip       => '10.0.1.10',
    port     => 80,
    tags     => ['web', 'production'],
);

print $srv->description(), "\n";
print "Connected: ", $srv->is_connected ? "yes" : "no", "\n";
my $conn = $srv->_connection;  # triggers lazy build
print "Connected: ", $srv->is_connected ? "yes" : "no", "\n";

Moo with Roles

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package Role::Loggable;
use Moo::Role;

requires 'name';  # consuming class must provide this

has 'log_level' => (
    is      => 'rw',
    default => 'info',
);

sub log_msg {
    my ($self, $level, $msg) = @_;
    return if $self->_level_num($level) < $self->_level_num($self->log_level);
    printf "[%s] [%-5s] %s: %s\n",
        scalar localtime, uc $level, $self->name, $msg;
}

sub _level_num {
    my ($self, $l) = @_;
    return { debug => 0, info => 1, warn => 2, error => 3 }->{lc $l} // 1;
}

package Role::Configurable;
use Moo::Role;

has 'config' => (
    is      => 'ro',
    default => sub { {} },
);

sub get_config {
    my ($self, $key, $default) = @_;
    return exists $self->config->{$key}
        ? $self->config->{$key}
        : $default;
}

package Worker;
use Moo;
with 'Role::Loggable', 'Role::Configurable';

has 'name' => (is => 'ro', required => 1);

sub do_work {
    my ($self) = @_;
    $self->log_msg('info', "Starting work");
    my $batch_size = $self->get_config('batch_size', 100);
    $self->log_msg('debug', "Batch size: $batch_size");
    # ... do actual work ...
    $self->log_msg('info', "Work complete");
}

package main;
my $worker = Worker->new(
    name   => 'import-worker',
    config => { batch_size => 500 },
);
$worker->do_work();

DBI — Database Interface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
use strict;
use warnings;
use DBI;

# Connect (works with any DBD driver: Pg, mysql, SQLite, Oracle...)
my $dbh = DBI->connect(
    "dbi:Pg:dbname=production;host=db-01;port=5432",
    "appuser",
    $ENV{DB_PASSWORD},
    {
        RaiseError    => 1,   # throw exceptions on error
        AutoCommit    => 1,
        PrintError    => 0,
        pg_enable_utf8 => 1,
    }
) or die DBI->errstr;

# Prepared statement (parameterized — no SQL injection)
my $sth = $dbh->prepare(
    "SELECT id, username, email, created_at FROM users WHERE status = ? AND created_at > ?"
);
$sth->execute('active', '2025-01-01');

# Fetch all as array of hashrefs
my $rows = $sth->fetchall_arrayref({});  # {} = fetch as hashrefs
for my $row (@$rows) {
    printf "%-5d %-20s %s\n", $row->{id}, $row->{username}, $row->{email};
}

# Column binding — more efficient for large result sets
my ($id, $username, $email, $created_at);
$sth->execute('active', '2025-01-01');
$sth->bind_columns(\$id, \$username, \$email, \$created_at);
while ($sth->fetch) {
    # Process each row without allocating a hash per row
    printf "%d: %s\n", $id, $username;
}

# Transactions
eval {
    $dbh->begin_work;
    $dbh->do("UPDATE accounts SET balance = balance - ? WHERE id = ?", undef, 100, 1);
    $dbh->do("UPDATE accounts SET balance = balance + ? WHERE id = ?", undef, 100, 2);
    $dbh->commit;
};
if ($@) {
    $dbh->rollback;
    die "Transaction failed: $@";
}

# selectall_hashref — indexed by a column value
my $users_by_id = $dbh->selectall_hashref(
    "SELECT id, username, email FROM users WHERE status = 'active'",
    'id'  # index column
);
print $users_by_id->{42}{username}, "\n";  # direct access by ID

$dbh->disconnect;

HTTP::Tiny and LWP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
use strict;
use warnings;
use HTTP::Tiny;
use JSON::XS qw(decode_json encode_json);

# HTTP::Tiny — core module since Perl 5.14, no dependencies
my $http = HTTP::Tiny->new(
    timeout   => 30,
    agent     => 'MyApp/1.0',
    default_headers => { 'Accept' => 'application/json' },
);

# GET request
my $response = $http->get('https://api.example.com/v1/servers');
if ($response->{success}) {
    my $data = decode_json($response->{content});
    for my $server (@{$data->{servers}}) {
        printf "%-20s %s\n", $server->{hostname}, $server->{status};
    }
} else {
    die "Request failed: $response->{status} $response->{reason}";
}

# POST with JSON body
my $payload = encode_json({
    hostname => 'new-server-01',
    ip       => '10.0.1.50',
    tags     => ['production', 'web'],
});

my $create_resp = $http->post(
    'https://api.example.com/v1/servers',
    {
        headers => { 'Content-Type' => 'application/json', 'Authorization' => "Bearer $ENV{API_TOKEN}" },
        content => $payload,
    }
);
die "Create failed: $create_resp->{status}" unless $create_resp->{success};

# For more complex HTTP needs: LWP::UserAgent
use LWP::UserAgent;
use HTTP::Request::Common qw(POST GET);

my $ua = LWP::UserAgent->new(
    timeout    => 60,
    keep_alive => 4,
);
$ua->default_header('Authorization' => "Bearer $ENV{API_TOKEN}");

# Follow redirects, handle cookies, TLS...
$ua->ssl_opts(SSL_verify_mode => 1, SSL_ca_file => '/etc/ssl/certs/ca-certificates.crt');

my $req = POST('https://api.example.com/v1/deploy',
    Content_Type => 'application/json',
    Content      => encode_json({ environment => 'staging', tag => 'v1.2.3' }),
);
my $res = $ua->request($req);
die $res->status_line unless $res->is_success;
print decode_json($res->decoded_content)->{job_id}, "\n";

Path::Tiny — Modern File Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
use strict;
use warnings;
use Path::Tiny;

# Path objects are smart strings
my $base    = path('/var/log/nginx');
my $logfile = $base->child('access.log');
my $archive = $base->child('archive', '2026-04');

# Read operations
my $content  = $logfile->slurp;         # entire file as string
my @lines    = $logfile->lines;         # array of lines (with newlines)
my @chomped  = $logfile->lines({chomp => 1});  # without newlines
my $decoded  = $logfile->slurp_utf8;    # UTF-8 decoded

# Write operations
path('/tmp/output.txt')->spew("content here\n");
path('/tmp/output.txt')->spew_utf8("UTF-8 content: \x{263A}\n");
path('/tmp/append.txt')->append("new line\n");

# Directory operations
$archive->mkpath;   # mkdir -p
for my $file ($base->children(qr/\.log$/)) {
    my $dest = $archive->child($file->basename);
    $file->copy($dest);
}

# Iteration
my $iter = $base->iterator({ recurse => 1 });
while (my $path = $iter->()) {
    next unless $path->is_file && $path->basename =~ /\.log$/;
    printf "%-50s %s\n", $path->stringify, $path->stat->size;
}

# Temp files
my $tmpfile = Path::Tiny->tempfile(SUFFIX => '.tmp');
$tmpfile->spew("temporary data\n");
# Auto-cleaned up when $tmpfile goes out of scope

# JSON/config files (works great with JSON::XS)
use JSON::XS;
my $config_file = path('/etc/myapp/config.json');
my $config = decode_json($config_file->slurp_utf8);
$config->{updated_at} = time;
$config_file->spew_utf8(JSON::XS->new->utf8->pretty->encode($config));

JSON::XS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use strict;
use warnings;
use JSON::XS;

# Create a configured encoder
my $json = JSON::XS->new
    ->utf8           # encode to UTF-8 bytes
    ->pretty         # pretty-print output
    ->canonical      # sort keys (consistent output for diffs/tests)
    ->allow_nonref;  # allow encoding non-reference scalars

# Encode
my $data = {
    servers  => ['web-01', 'web-02'],
    config   => { port => 8080, debug => JSON::XS::false },
    count    => 42,
    pi       => 3.14159,
};
my $json_text = $json->encode($data);
print $json_text;

# Decode
my $parsed = $json->decode($json_text);
print "Port: ", $parsed->{config}{port}, "\n";

# Streaming decode for large files
my $decoder = JSON::XS->new->utf8;
open(my $fh, '<:raw', '/var/data/large.json') or die $!;
while (my $line = <$fh>) {
    my $obj = eval { $decoder->decode($line) };
    next if $@;
    process_record($obj);
}

sub process_record { }

Try::Tiny — Exception Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use strict;
use warnings;
use Try::Tiny;

# Basic try/catch/finally
try {
    open(my $fh, '<', '/etc/shadow') or die "Permission denied: $!";
    process($fh);
} catch {
    if (/Permission denied/) {
        warn "Cannot read shadow file: $_";
    } else {
        die $_;  # re-throw unknown exceptions
    }
} finally {
    # Runs whether try succeeded or failed
    cleanup();
};

sub process { }
sub cleanup { warn "Cleanup called\n" }

# Exception objects work too
package MyException;
sub new { bless { message => $_[1] }, $_[0] }
sub message { $_[0]->{message} }
sub throw { die __PACKAGE__->new($_[1]) }

package main;

try {
    MyException->throw("Something went wrong");
} catch {
    if (ref $_ && $_->isa('MyException')) {
        print "Caught MyException: ", $_->message, "\n";
    } else {
        die $_;
    }
};

# Note: Perl 5.40 has native try/catch — Try::Tiny is for 5.36 and earlier

5. Modern Perl Practices

The Non-Negotiables: use strict and use warnings

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env perl
use strict;
use warnings;

# strict enforces:
# - All variables must be declared (my, our, local)
# - No barewords (strings without quotes)
# - Symbolic references are forbidden (no $$varname magic)

# warnings catches:
# - Use of uninitialized values
# - Numeric and string conversion issues
# - Deprecated features
# - Many common mistakes

# With use v5.36 or later, these are automatic:
# use v5.36;  # implies strict + warnings + many features

# Additional warnings categories
use warnings qw(FATAL uninitialized);  # make uninitialized value a fatal error

# Useful pragmas to add to most scripts:
use autodie qw(:all);  # makes system calls throw exceptions on failure
use feature qw(say state);

# say is print with automatic newline
say "Hello";           # "Hello\n"
say STDERR "Error";    # to STDERR with newline

# state — persistent lexical variable (like static in C)
sub counter {
    state $count = 0;
    return ++$count;
}
say counter() for 1..5;  # 1 2 3 4 5

Moo vs Moose vs Mouse — Choosing Your OOP Framework

The Perl OOP landscape is fragmented but well-understood:

Framework Load time Features When to use
Moose Slow (~50ms) Full-featured Long-running daemons, complex class hierarchies
Mouse Medium Moose-compatible subset CLI tools where startup matters
Moo Fast (~5ms) Modern, clean Most new code — best balance
Moxie Fastest Minimal Libraries, when overhead matters a lot
Raw Perl Zero Manual Simple scripts, maximum control
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# Moose — the full-featured option
package Server::Moose;
use Moose;
use Moose::Util::TypeConstraints;

# Custom type
subtype 'IPAddress',
    as 'Str',
    where { /^(\d{1,3}\.){3}\d{1,3}$/ },
    message { "'$_' is not a valid IP address" };

subtype 'PortNumber',
    as 'Int',
    where { $_ >= 1 && $_ <= 65535 },
    message { "'$_' is not a valid port number" };

has 'hostname' => (is => 'ro', isa => 'Str', required => 1);
has 'ip'       => (is => 'ro', isa => 'IPAddress', required => 1);
has 'port'     => (is => 'rw', isa => 'PortNumber', default => 22);

# Method modifiers — AOP-style
before 'port' => sub {
    my ($self, $new_port) = @_;
    warn "Changing port from " . $self->port . " to $new_port\n" if defined $new_port;
};

after 'port' => sub {
    my $self = shift;
    # Post-action hook
};

__PACKAGE__->meta->make_immutable;  # optimize after class definition
1;

# Moo with Types::Standard — recommended for most new code
package Server::Moo;
use Moo;
use Types::Standard qw(Str Int InstanceOf ConsumerOf);

has 'hostname' => (is => 'ro', isa => Str, required => 1);
has 'port'     => (
    is      => 'rw',
    isa     => Int->where(sub { $_ >= 1 && $_ <= 65535 }),
    default => 22,
    coerce  => 1,
);

1;

The use feature Pragma and Feature Bundles

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
use strict;
use warnings;

# Individual features
use feature 'say';          # say()
use feature 'state';        # state variables
use feature 'switch';       # given/when (deprecated!)
use feature 'unicode_strings'; # Unicode semantics for string ops
use feature 'fc';           # Unicode fold-case (for case-insensitive compare)
use feature 'postderef';    # $aref->@*, $href->%*, etc.
use feature 'signatures';   # sub foo($x, $y) {}

# Feature bundles — use a version's full feature set
use feature ':5.36';  # all features available in 5.36

# Or just declare a minimum version:
use v5.36;            # same as use feature ':5.36' + strict + warnings

# Postfix dereference (cleaner syntax for dereferencing)
my $aref = [1, 2, 3, 4, 5];
my $href = {a => 1, b => 2};
my $cref = sub { $_[0] * 2 };

# Old style:
my @arr = @{$aref};
my %hsh = %{$href};

# With postderef (5.20+, stable in 5.24+):
my @arr2 = $aref->@*;
my %hsh2 = $href->%*;
my @keys  = $href->@{qw(a b)};  # hash slice via ref

# Subroutine signatures (stable in 5.36)
use v5.36;

sub greet($name, $greeting = "Hello") {
    say "$greeting, $name!";
}

sub process_servers(@servers) {
    for my $srv (@servers) {
        say "Processing: $srv";
    }
}

greet("Alice");
greet("Bob", "Hey");
process_servers("web-01", "web-02", "db-01");

Perl::Critic — Linting Your Perl

1
2
3
4
5
6
7
8
# Install
cpanm Perl::Critic Perl::Tidy

# Run at default severity (5=gentle to 1=harsh)
perlcritic --severity 4 myscript.pl

# Run with specific profile
perlcritic --profile ~/.perlcriticrc myscript.pl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ~/.perlcriticrc
severity  = 4
verbose   = %f:%l:%c: [%p] %m\n

[Variables::ProhibitPackageVars]
severity = 3

[BuiltinFunctions::ProhibitStringyEval]
severity = 5

[-Documentation::RequirePodCoverage]
# Disable — we don't require POD for internal scripts

6. File and Text Processing

This is where Perl still genuinely dominates. The combination of the regex engine, special variables, and built-in text manipulation operators makes Perl the fastest path from “raw text” to “structured data” for complex formats.

In-Place Editing with -i

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Command line — edit files in place
perl -i -pe 's/foo/bar/g' *.conf

# With backup
perl -i.bak -pe 's/old_host/new_host/g' /etc/app/*.conf

# Multiple operations
perl -i -pe '
    s/\r\n/\n/g;          # convert CRLF to LF
    s/\s+$//;             # trim trailing whitespace
    s/^#.*\n//g;          # remove comment lines (imperfect)
' config.txt

In Perl code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use strict;
use warnings;

# In-place editing of files from within a script
local @ARGV = glob('/etc/app/*.conf');
local $^I = '.bak';  # $^I is the in-place edit suffix

while (<>) {
    s/old_host/new_host/g;
    s/port\s*=\s*8080/port = 9090/;
    print;
}

The Input Record Separator $/

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
use strict;
use warnings;

# Default: $/ = "\n" — reads line by line
open(my $fh, '<', '/var/log/app.log') or die $!;
while (my $line = <$fh>) {
    chomp $line;  # removes the trailing $/
    process($line);
}

# Slurp mode: undef $/ reads entire file
{
    local $/;  # undef inside this block
    open(my $fh2, '<', '/etc/config.conf') or die $!;
    my $entire_file = <$fh2>;
    # Now process the whole file as one string
    my @sections = split /\n(?=\[)/, $entire_file;  # split on section headers
    for my $section (@sections) {
        my ($name) = $section =~ /^\[([^\]]+)\]/;
        print "Section: $name\n" if defined $name;
    }
}

# Paragraph mode: $/ = "" — reads paragraph by paragraph
{
    local $/ = "";  # paragraph mode
    open(my $fh3, '<', '/var/log/multiline.log') or die $!;
    while (my $para = <$fh3>) {
        # Each $para is a blank-line-separated block
        chomp $para;
        process_event($para) if $para =~ /ERROR/;
    }
}

# Custom record separator — great for config files
{
    local $/ = "-----\n";  # records delimited by "-----\n"
    open(my $fh4, '<', '/etc/firewall.rules') or die $!;
    while (my $rule_block = <$fh4>) {
        chomp $rule_block;
        my %rule;
        for my $line (split /\n/, $rule_block) {
            my ($k, $v) = split /\s*=\s*/, $line, 2;
            $rule{$k} = $v if defined $k && defined $v;
        }
        print_rule(\%rule) if $rule{action};
    }
}

sub process      { }
sub process_event { }
sub print_rule   { }

Field Splitting and Output Separators

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
use strict;
use warnings;

# $, (output field separator) and $\ (output record separator)
{
    local $, = "\t";   # separate print args with tab
    local $\ = "\n";   # end each print with newline

    print "col1", "col2", "col3";  # col1\tcol2\tcol3\n
    print "val1", "val2", "val3";
}

# Processing CSV/TSV
open(my $fh, '<', '/var/data/servers.csv') or die $!;
while (<$fh>) {
    chomp;
    next if /^#/;  # skip comments

    my @fields = split /,/, $_, -1;  # -1 preserves trailing empty fields
    my ($hostname, $ip, $role, $datacenter) = @fields;

    printf "%-20s %-15s %-10s %s\n",
        $hostname // '', $ip // '', $role // '', $datacenter // '';
}

# The split function with various delimiters
my $line = "root:x:0:0:root:/root:/bin/bash";
my @passwd = split /:/, $line;
my ($user, $pass, $uid, $gid, $gecos, $home, $shell) = @passwd;

# Split on regex
my $text = "one  two\tthree\nfour";
my @words = split /[\s]+/, $text;  # split on any whitespace

# Split with limit
my $config_line = "key=value=with=equals";
my ($key, $value) = split /=/, $config_line, 2;  # only split once
print "Key: $key, Value: $value\n";  # Key: key, Value: value=with=equals

Processing Logs with Multiline Records

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
use strict;
use warnings;

# Parsing multiline Java/application stack traces
sub parse_log_events {
    my ($logfile) = @_;
    my @events;
    my $current;

    open(my $fh, '<', $logfile) or die "Cannot open $logfile: $!";

    while (my $line = <$fh>) {
        chomp $line;

        # New event starts with timestamp
        if ($line =~ /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d{3}\s+(\w+)\s+(.+)/) {
            push @events, $current if $current;
            $current = {
                timestamp => $1,
                level     => $2,
                message   => $3,
                trace     => [],
            };
        } elsif ($current && $line =~ /^\s+at /) {
            # Stack trace continuation
            push @{$current->{trace}}, $line;
        } elsif ($current && $line =~ /^Caused by:/) {
            push @{$current->{trace}}, $line;
        }
    }
    push @events, $current if $current;

    return @events;
}

# The .. range operator for extracting sections
# Extract lines between "BEGIN TRANSACTION" and "COMMIT"
sub extract_transactions {
    my ($logfile) = @_;
    my @transactions;
    my @current_txn;
    my $in_txn = 0;

    open(my $fh, '<', $logfile) or die $!;
    while (<$fh>) {
        if (/BEGIN TRANSACTION/) {
            $in_txn = 1;
            @current_txn = ($_);
        } elsif ($in_txn) {
            push @current_txn, $_;
            if (/COMMIT|ROLLBACK/) {
                push @transactions, [@current_txn];
                $in_txn = 0;
            }
        }
    }
    return @transactions;
}

# The .. flip-flop operator — elegant line range extraction
open(my $access_log, '<', '/var/log/nginx/access.log') or die $!;
while (<$access_log>) {
    # Print lines between timestamps (inclusive) using the flip-flop
    # This is a stateful operator — it "flips" on first match, "flops" on second
    print if /2026-04-11 08:00:00/ .. /2026-04-11 09:00:00/;
}
close($access_log);

7. One-Liners: Perl’s Killer Feature

The Perl one-liner is genuinely irreplaceable. The -n, -p, -e, -i, -a, -F flags compose into a powerful stream editor that handles things sed and awk can’t do cleanly — especially regex-heavy or stateful processing.

The Flags

Flag Behavior
-e 'code' Execute code
-n Loop over input lines (no automatic print)
-p Loop over input lines (with automatic print)
-i[ext] Edit files in place (optional backup extension)
-a Autosplit mode — splits $_ into @F
-F/re/ Set autosplit field separator
-l Auto-chomp input, add \n to print
-0[octal] Set input record separator (-0777 = slurp)
-M Module Load a module (-MJSON::XS, etc.)

Practical One-Liners

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# Print line numbers
perl -ne 'printf "%4d: %s", $., $_' file.txt

# Count lines matching a pattern
perl -ne '$c++ if /ERROR/; END { print "$c\n" }' app.log

# Print lines 10-20
perl -ne 'print if 10 .. 20' file.txt

# Sum a column (e.g., column 3 of space-separated data)
perl -lane '$sum += $F[2]; END { print $sum }' data.txt

# Print unique lines (like sort | uniq but without sorting)
perl -ne 'print unless $seen{$_}++' file.txt

# Reverse the order of lines (like tac)
perl -e 'print reverse <>' file.txt

# Delete blank lines
perl -ne 'print unless /^\s*$/' file.txt

# Print only lines between two patterns (inclusive)
perl -ne 'print if /START/ .. /END/' file.txt

# Extract and sum nginx response sizes (field 10 in combined log format)
perl -lane '$total += $F[9] if $F[9] =~ /^\d+$/; END { printf "Total bytes: %d\n", $total }' /var/log/nginx/access.log

# Count HTTP status codes from nginx access log
perl -lane '
    $F[8] =~ /^(\d{3})$/ and $status{$1}++
} END {
    printf "%s: %d\n", $_, $status{$_} for sort keys %status
' /var/log/nginx/access.log

# Find top 10 IP addresses in a log file
perl -lane '
    $ip{$F[0]}++
} END {
    print "$ip{$_} $_" for (sort { $ip{$b} <=> $ip{$a} } keys %ip)[0..9]
' /var/log/nginx/access.log

# In-place: add a line after a match
perl -i -pe 'print "    proxy_read_timeout 300;\n" if /proxy_connect_timeout/' nginx.conf

# In-place: delete lines matching a pattern
perl -i -ne 'print unless /^\s*#/' config.ini  # remove comment lines

# Convert CSV to TSV
perl -pe 's/,/\t/g' data.csv > data.tsv  # naive, breaks on quoted fields

# Proper CSV field extraction with -F
perl -F',' -lane 'print $F[0], "\t", $F[3]' data.csv  # print columns 1 and 4

# Parse /proc/net/dev — extract interface stats
perl -lane '
    next unless $F[0] =~ s/://;
    printf "%-12s RX: %12d TX: %12d\n", $F[0], $F[1], $F[9]
' /proc/net/dev

# Find files with more than N lines (alternative to wc -l | sort)
perl -e '
    for my $f (@ARGV) {
        open my $fh, "<", $f or next;
        my $c = () = <$fh>;
        print "$c $f\n" if $c > 100;
    }
' *.log

# JSON processing with -MJSON::XS
perl -MJSON::XS -e '
    local $/;
    my $data = decode_json(<STDIN>);
    print "$_->{hostname}\n" for @{$data->{servers}}
' < api_response.json

# Generate a password
perl -e '
    my @chars = ("A".."Z", "a".."z", "0".."9", split //, q{!@#$%^&*});
    print join("", map { $chars[rand @chars] } 1..20), "\n"
'

# One-liner to decode base64 (no base64 command available?)
perl -MMIME::Base64 -e 'print decode_base64(<STDIN>)'

# URL decode
perl -MURI::Escape -pe '$_ = uri_unescape($_)' encoded.txt

# Compare two sorted files (like comm but with regex filtering)
perl -e '
    open my $f1, "<", $ARGV[0] or die $!;
    open my $f2, "<", $ARGV[1] or die $!;
    my %a = map { chomp; $_ => 1 } <$f1>;
    my %b = map { chomp; $_ => 1 } <$f2>;
    print "Only in $ARGV[0]:\n";
    print "  $_\n" for sort grep { !$b{$_} } keys %a;
    print "Only in $ARGV[1]:\n";
    print "  $_\n" for sort grep { !$a{$_} } keys %b;
' file1.txt file2.txt

When One-Liners Become Scripts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# This one-liner is getting complex — time to make it a script:
perl -lane '
    next if /^#/;
    my ($host, $port) = split /:/;
    $port //= 80;
    $services{$host} //= [];
    push @{$services{$host}}, $port;
} END {
    for my $host (sort keys %services) {
        print "$host: ", join(",", sort { $a <=> $b } @{$services{$host}}), "\n";
    }
' services.txt

When you find yourself reaching for END {}, complex data structures, or multiple passes, convert to a script. The threshold is roughly “would I want to debug this at 2am?” — if yes, make it a proper script.


8. Bioinformatics: Perl’s Last Stronghold

Bioinformatics is the domain where Perl’s dominance was most complete and where it remains most deeply embedded. The reason is historical: when genome sequencing took off in the 1990s, Perl was the scripting language. The Human Genome Project, BLAST, the original ENSEMBL infrastructure — all Perl.

BioPerl

BioPerl is a collection of modules for biological data processing. It’s enormous, covering sequence analysis, format parsing, database access, alignment, phylogenetics, and more.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
use strict;
use warnings;
use Bio::Seq;
use Bio::SeqIO;
use Bio::Tools::SeqStats;

# Creating and manipulating sequences
my $seq = Bio::Seq->new(
    -id       => 'example_gene',
    -seq      => 'ATGGCAACGTTAGCTATCGATCGATCGATC',
    -alphabet => 'dna',
    -desc     => 'Example gene sequence',
);

printf "ID:     %s\n", $seq->id;
printf "Length: %d bp\n", $seq->length;
printf "Seq:    %s\n", $seq->seq;

# Complement and reverse complement
my $revcomp = $seq->revcom;
printf "RevComp: %s\n", $revcomp->seq;

# Translate DNA to protein
my $protein = $seq->translate;
printf "Protein: %s\n", $protein->seq;

# Reading FASTA files
my $in = Bio::SeqIO->new(
    -file   => 'sequences.fasta',
    -format => 'fasta',
);

my %by_length;
while (my $seq_obj = $in->next_seq) {
    my $len = $seq_obj->length;
    push @{$by_length{$len > 1000 ? 'long' : 'short'}}, $seq_obj->id;
}

printf "Long sequences (%d): %s\n",
    scalar @{$by_length{long} // []},
    join(', ', @{$by_length{long} // []});

# Computing sequence statistics
my $stats = Bio::Tools::SeqStats->new(-seq => $seq);
my $mono_ref = $stats->count_monomers;
printf "Base counts: A=%d T=%d G=%d C=%d\n",
    $mono_ref->{A} // 0,
    $mono_ref->{T} // 0,
    $mono_ref->{G} // 0,
    $mono_ref->{C} // 0;

my $mol_wt = $stats->get_mol_wt;
printf "Molecular weight: %.2f - %.2f\n", $mol_wt->[0], $mol_wt->[1];

Sequence Processing Without BioPerl

Much bioinformatics Perl code doesn’t use BioPerl at all — it’s raw text processing on FASTA/FASTQ/BED/VCF formats:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
use strict;
use warnings;

# FASTA parser — fast and simple
sub read_fasta {
    my ($file) = @_;
    my %seqs;
    my $current_id;

    open(my $fh, '<', $file) or die "Cannot open $file: $!";
    while (<$fh>) {
        chomp;
        if (/^>(.*)/) {
            $current_id = $1;
            $current_id =~ s/\s.*//;  # trim description, keep ID only
        } elsif (defined $current_id) {
            $seqs{$current_id} .= $_;
        }
    }
    return %seqs;
}

# FASTQ parser (4 lines per record)
sub process_fastq {
    my ($file, $min_quality) = @_;
    $min_quality //= 20;

    open(my $fh, '<', $file) or die $!;
    my $passed = 0;
    my $total  = 0;

    while (1) {
        my $header  = <$fh>; last unless defined $header;
        my $seq     = <$fh>; last unless defined $seq;
        my $plus    = <$fh>; last unless defined $plus;
        my $quality = <$fh>; last unless defined $quality;

        chomp for ($header, $seq, $plus, $quality);
        $total++;

        # Phred quality scores: ASCII - 33
        my @quals = map { ord($_) - 33 } split //, $quality;
        my $mean_qual = (grep { $_ >= $min_quality } @quals) / @quals;

        if ($mean_qual >= 0.8) {
            $passed++;
            # Write to output, do further processing, etc.
        }
    }

    printf "Passed: %d/%d (%.1f%%)\n", $passed, $total, 100*$passed/($total||1);
}

# GC content calculation
sub gc_content {
    my ($seq) = @_;
    $seq = uc $seq;
    my $gc = () = $seq =~ /[GC]/g;
    return $gc / length($seq) * 100;
}

# Finding ORFs (Open Reading Frames)
sub find_orfs {
    my ($seq, $min_length) = @_;
    $min_length //= 100;
    my @orfs;

    # Search all 6 reading frames (3 forward, 3 reverse)
    my $revcomp = reverse $seq;
    $revcomp =~ tr/ACGTacgt/TGCAtgca/;

    for my $strand_seq ($seq, $revcomp) {
        for my $frame (0, 1, 2) {
            my $reading = substr($strand_seq, $frame);
            while ($reading =~ /ATG(?:(?!TAA|TAG|TGA)...)*(?:TAA|TAG|TGA)/gi) {
                push @orfs, $& if length($&) >= $min_length;
            }
        }
    }
    return @orfs;
}

# VCF format parsing (Variant Call Format — genome variants)
sub parse_vcf_variants {
    my ($vcf_file, $min_qual) = @_;
    $min_qual //= 30;
    my @variants;

    open(my $fh, '<', $vcf_file) or die $!;
    while (<$fh>) {
        next if /^#/;  # skip header lines
        chomp;
        my ($chrom, $pos, $id, $ref, $alt, $qual, $filter, $info, @rest) = split /\t/;

        next unless defined $qual && $qual ne '.' && $qual >= $min_qual;
        next unless $filter eq 'PASS' || $filter eq '.';

        # Parse INFO field
        my %info_hash = map { split /=/, $_, 2 } split /;/, $info;

        push @variants, {
            chrom  => $chrom,
            pos    => int($pos),
            ref    => $ref,
            alt    => $alt,
            qual   => $qual,
            depth  => $info_hash{DP} // 0,
            af     => $info_hash{AF} // 0,
        };
    }

    return @variants;
}

Why Perl Still Dominates Bioinformatics Legacy Code

The bioinformatics reality in 2026: Python (via Biopython) and R (via Bioconductor) have taken new development. But there are millions of lines of Perl in production pipelines at sequencing centers, hospitals, and research institutions that process real patient data. These pipelines:

  • Run correctly and have been validated
  • Have accumulated years of edge-case handling
  • Are often embedded in SGE/SLURM cluster submission scripts
  • Interface with databases via DBI in ways that are hard to migrate

If you’re hired at a genomics company and told “improve the variant calling pipeline,” that pipeline is probably Perl.


9. System Administration Use Cases

Network Device Config Parsing

Parsing Cisco IOS, JunOS, and similar formats is a classic Perl use case:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
use strict;
use warnings;

# Parse Cisco IOS 'show running-config' output
sub parse_cisco_config {
    my ($config_text) = @_;
    my %parsed;
    my @context_stack;  # tracks nested config context

    for my $line (split /\n/, $config_text) {
        # Skip empty lines and basic comments
        next if $line =~ /^!$/;
        next if $line =~ /^\s*$/;

        # Indentation level tells us context depth
        my ($indent) = $line =~ /^(\s*)/;
        my $depth = length($indent) / 1;  # Cisco uses 1-space indent

        # Pop context stack if we've dedented
        while (@context_stack > $depth) {
            pop @context_stack;
        }

        my $content = $line;
        $content =~ s/^\s+//;

        if ($content =~ /^interface (.+)/) {
            push @context_stack, "interface:$1";
            $parsed{interfaces}{$1} //= {};
        } elsif ($content =~ /^router (ospf|bgp|eigrp) (.+)/) {
            push @context_stack, "routing:$1:$2";
            $parsed{routing}{$1}{$2} //= {};
        } elsif ($content =~ /^ip address ([\d.]+) ([\d.]+)/) {
            if (my $iface_ctx = (grep { /^interface:/ } @context_stack)[-1]) {
                my ($iface) = $iface_ctx =~ /^interface:(.+)/;
                $parsed{interfaces}{$iface}{ip}      = $1;
                $parsed{interfaces}{$iface}{netmask} = $2;
            }
        } elsif ($content =~ /^description (.+)/) {
            if (my $iface_ctx = (grep { /^interface:/ } @context_stack)[-1]) {
                my ($iface) = $iface_ctx =~ /^interface:(.+)/;
                $parsed{interfaces}{$iface}{description} = $1;
            }
        } elsif ($content =~ /^shutdown$/) {
            if (my $iface_ctx = (grep { /^interface:/ } @context_stack)[-1]) {
                my ($iface) = $iface_ctx =~ /^interface:(.+)/;
                $parsed{interfaces}{$iface}{shutdown} = 1;
            }
        } elsif ($content =~ /^hostname (.+)/) {
            $parsed{hostname} = $1;
        } elsif ($content =~ /^ip route ([\d.]+) ([\d.]+) (.+)/) {
            push @{$parsed{static_routes}}, {
                network  => $1,
                mask     => $2,
                next_hop => $3,
            };
        }
    }

    return %parsed;
}

# Report generation
sub generate_interface_report {
    my (%config) = @_;

    printf "%-30s %-15s %-15s %s\n",
        "Interface", "IP Address", "Netmask", "Description";
    print "-" x 80, "\n";

    for my $iface (sort keys %{$config{interfaces}}) {
        my $info = $config{interfaces}{$iface};
        next if $info->{shutdown};  # skip shutdown interfaces

        printf "%-30s %-15s %-15s %s\n",
            $iface,
            $info->{ip}          // 'N/A',
            $info->{netmask}     // 'N/A',
            $info->{description} // '';
    }
}

# Parse multiple device configs
my @device_files = glob('/var/configs/devices/*.conf');
my %all_devices;

for my $file (@device_files) {
    open(my $fh, '<', $file) or do { warn "Cannot open $file: $!"; next };
    local $/;
    my $content = <$fh>;
    close $fh;

    my %config = parse_cisco_config($content);
    $all_devices{ $config{hostname} // $file } = \%config;
}

# Cross-device analysis: find all /24 networks in use
my %networks;
for my $hostname (sort keys %all_devices) {
    my $config = $all_devices{$hostname};
    while (my ($iface, $info) = each %{$config->{interfaces} // {}}) {
        next unless $info->{ip} && $info->{netmask};
        next if $info->{shutdown};

        # Simple /24 detection
        if ($info->{netmask} eq '255.255.255.0') {
            (my $network = $info->{ip}) =~ s/\.\d+$/.0/;
            push @{$networks{$network}}, "$hostname/$iface";
        }
    }
}

for my $net (sort keys %networks) {
    printf "%s: %s\n", $net, join(', ', @{$networks{$net}});
}

Log Aggregation and Report Generation

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
use strict;
use warnings;
use POSIX qw(strftime);

# Aggregate logs from multiple servers and generate a report
sub aggregate_nginx_logs {
    my (@log_files) = @_;

    my %stats = (
        total_requests => 0,
        status_codes   => {},
        top_paths      => {},
        top_clients    => {},
        bytes_total    => 0,
        errors_by_hour => {},
        slow_requests  => [],
    );

    my $log_re = qr/
        ^(?<client>\S+)\s+
        \S+\s+\S+\s+
        \[(?<time>[^\]]+)\]\s+
        "(?<method>\w+)\s+(?<path>\S+)\s+HTTP\/[\d.]+"\s+
        (?<status>\d{3})\s+
        (?<bytes>\d+|-)\s+
        "(?<referer>[^"]*)"\s+
        "(?<ua>[^"]*)"\s*
        (?:rt=(?<rt>[\d.]+))?  # request time (if present)
    /x;

    for my $file (@log_files) {
        open(my $fh, '<', $file) or do { warn "Cannot open $file: $!"; next };

        while (my $line = <$fh>) {
            next unless $line =~ $log_re;
            my %h = %+;

            $stats{total_requests}++;
            $stats{status_codes}{ $h{status} }++;
            $stats{top_paths}{ $h{path} }++;
            $stats{top_clients}{ $h{client} }++;
            $stats{bytes_total} += ($h{bytes} =~ /^\d+$/ ? $h{bytes} : 0);

            if ($h{status} =~ /^[45]/) {
                my ($hour) = $h{time} =~ m{:(\d{2}):\d{2}:\d{2}};
                $stats{errors_by_hour}{$hour}++ if defined $hour;
            }

            if (defined $h{rt} && $h{rt} > 5.0) {
                push @{$stats{slow_requests}}, {
                    path   => $h{path},
                    rt     => $h{rt},
                    time   => $h{time},
                    status => $h{status},
                };
            }
        }
    }

    return %stats;
}

sub print_report {
    my (%stats) = @_;

    my $sep = "=" x 60;

    print "$sep\n";
    printf "NGINX Log Report - %s\n", strftime('%Y-%m-%d %H:%M:%S', localtime);
    print "$sep\n\n";

    printf "Total Requests: %d\n", $stats{total_requests};
    printf "Total Bytes:    %s\n", format_bytes($stats{bytes_total});
    print "\n";

    print "Status Codes:\n";
    for my $code (sort keys %{$stats{status_codes}}) {
        my $pct = 100 * $stats{status_codes}{$code} / ($stats{total_requests} || 1);
        printf "  %s: %6d  (%5.1f%%)\n", $code, $stats{status_codes}{$code}, $pct;
    }

    print "\nTop 10 Paths:\n";
    my @top_paths = (sort { $stats{top_paths}{$b} <=> $stats{top_paths}{$a} }
                     keys %{$stats{top_paths}})[0..9];
    for my $path (@top_paths) {
        printf "  %6d  %s\n", $stats{top_paths}{$path}, $path;
    }

    print "\nTop 10 Clients:\n";
    my @top_clients = (sort { $stats{top_clients}{$b} <=> $stats{top_clients}{$a} }
                       keys %{$stats{top_clients}})[0..9];
    for my $client (@top_clients) {
        printf "  %6d  %s\n", $stats{top_clients}{$client}, $client;
    }

    if (@{$stats{slow_requests}}) {
        print "\nSlowest Requests (> 5s):\n";
        my @sorted_slow = sort { $b->{rt} <=> $a->{rt} } @{$stats{slow_requests}};
        for my $req (@sorted_slow[0..4]) {
            printf "  %.2fs  [%s]  %s  %s\n",
                $req->{rt}, $req->{time}, $req->{status}, $req->{path};
        }
    }

    if (%{$stats{errors_by_hour}}) {
        print "\nErrors by Hour:\n";
        for my $hour (sort keys %{$stats{errors_by_hour}}) {
            my $bar = '#' x int($stats{errors_by_hour}{$hour} / 10);
            printf "  %02d:00  %5d  %s\n",
                $hour, $stats{errors_by_hour}{$hour}, $bar;
        }
    }
}

sub format_bytes {
    my ($bytes) = @_;
    my @units = ('B', 'KB', 'MB', 'GB', 'TB');
    my $i = 0;
    while ($bytes >= 1024 && $i < $#units) {
        $bytes /= 1024;
        $i++;
    }
    return sprintf "%.2f %s", $bytes, $units[$i];
}

# Main
my @logs = glob('/var/log/nginx/access.log*');
if (@logs) {
    my %report = aggregate_nginx_logs(@logs);
    print_report(%report);
}

Nagios/Monitoring Plugin Development

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env perl
use strict;
use warnings;
use Getopt::Long;
use LWP::UserAgent;
use JSON::XS;

# Nagios/compatible monitoring plugin
# Exit codes: 0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN

use constant {
    OK       => 0,
    WARNING  => 1,
    CRITICAL => 2,
    UNKNOWN  => 3,
};

my %opt = (
    host     => 'localhost',
    port     => 9090,
    timeout  => 10,
    warn_pct => 80,
    crit_pct => 90,
);

GetOptions(
    'H|host=s'     => \$opt{host},
    'p|port=i'     => \$opt{port},
    't|timeout=i'  => \$opt{timeout},
    'w|warning=i'  => \$opt{warn_pct},
    'c|critical=i' => \$opt{crit_pct},
    'h|help'       => sub { usage(); exit OK },
) or do { usage(); exit UNKNOWN };

sub usage {
    print "Usage: $0 -H host [-p port] [-w warn%] [-c crit%]\n";
}

my $ua = LWP::UserAgent->new(timeout => $opt{timeout});

my $url = "http://$opt{host}:$opt{port}/api/v1/query?query=node_memory_MemFree_bytes/node_memory_MemTotal_bytes*100";

my $response = eval { $ua->get($url) };
if ($@ || !$response->is_success) {
    printf "UNKNOWN: Cannot connect to Prometheus at %s:%d: %s\n",
        $opt{host}, $opt{port}, ($@ || $response->status_line);
    exit UNKNOWN;
}

my $data = eval { decode_json($response->decoded_content) };
if ($@) {
    print "UNKNOWN: Cannot parse JSON response: $@\n";
    exit UNKNOWN;
}

my $results = $data->{data}{result};
unless ($results && @$results) {
    print "UNKNOWN: No data returned from Prometheus\n";
    exit UNKNOWN;
}

my $free_pct = $results->[0]{value}[1];
my $used_pct = 100 - $free_pct;

# Performance data format: 'label'=value[unit][;warn;crit;min;max]
my $perfdata = sprintf "memory_used=%.1f%%;%d;%d;0;100",
    $used_pct, $opt{warn_pct}, $opt{crit_pct};

if ($used_pct >= $opt{crit_pct}) {
    printf "CRITICAL: Memory usage %.1f%% | %s\n", $used_pct, $perfdata;
    exit CRITICAL;
} elsif ($used_pct >= $opt{warn_pct}) {
    printf "WARNING: Memory usage %.1f%% | %s\n", $used_pct, $perfdata;
    exit WARNING;
} else {
    printf "OK: Memory usage %.1f%% | %s\n", $used_pct, $perfdata;
    exit OK;
}

10. Perl in 2026: The Honest Assessment

Let’s be specific about where Python has won, where Perl still holds, and what you should actually do with Perl code in 2026.

Where Python Has Clearly Won

Data science and machine learning. There is no Perl equivalent to NumPy, pandas, scikit-learn, PyTorch, or the broader ML ecosystem. If you’re doing data science, you’re using Python (or R, or Julia). Full stop.

Web backends. FastAPI, Django, Flask — Python’s web frameworks are mature, well-documented, and widely deployed. The Perl web framework ecosystem (Mojolicious, Dancer2, Catalyst) still exists and works well, but the talent pool is much smaller.

Cloud automation and tooling. boto3 (AWS SDK), the Azure Python SDK, Terraform’s Python-based ecosystem — if your infrastructure lives in cloud APIs, Python’s SDK coverage is far broader.

Container tooling. Docker, Kubernetes, Helm, Flux — the tooling around modern container infrastructure is Python and Go. Perl has no meaningful foothold here.

Cross-team collaboration. On a team of 20 engineers where 18 know Python, writing new tools in Perl is an organizational mistake regardless of technical merits.

1
2
3
4
5
# Python's clear win: data analysis in 5 lines
import pandas as pd
df = pd.read_csv('server_metrics.csv')
print(df.groupby('datacenter')['cpu_pct'].describe())
print(df[df['cpu_pct'] > 90][['hostname', 'cpu_pct', 'timestamp']])

The equivalent Perl would work, but would be 30+ lines and you’d need to explain the CPAN dependencies to everyone on the team.

Where Perl Still Wins

Complex text munging with regex. When you’re parsing 50GB of log files with complex patterns, nested captures, and context-sensitive logic, Perl’s integrated regex with first-class variables is still faster to write than Python’s re module with all its function calls. One-liners on the command line are unmatched.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Perl: extract, transform, aggregate — terse and readable to those who know it
perl -lane '
    next unless /\[(\w+)\].*took (\d+)ms/;
    push @{$t{$1}}, $2
} END {
    printf "%s: avg=%.0fms max=%dms count=%d\n",
        $_, (do { my $s = 0; $s += $_ for @{$t{$_}}; $s }) / @{$t{$_}},
        (sort { $b <=> $a } @{$t{$_}})[0],
        scalar @{$t{$_}}
    for sort keys %t
' /var/log/app/performance.log

CPAN for niche domains. Need to parse SNMP MIB files? There’s a CPAN module. Parse SS7 telecom protocols? CPAN. Parse EDIFACT financial documents? CPAN. Interface with old Oracle databases via obscure drivers? CPAN. For niche enterprise protocols and formats, CPAN’s depth in some areas simply has no Python equivalent.

Maintaining existing Perl. This one is obvious but important. If you have 50,000 lines of working Perl, the correct decision in 2026 is overwhelmingly “maintain it in Perl.” Rewriting it in Python:

  • Takes 6-18 months of engineering time
  • Introduces new bugs in code that currently has none
  • Requires a person who knows both languages during transition
  • Provides no additional features (the original feature set already worked)

The only valid reason to rewrite is if the Perl is actively preventing new features that can’t be added without a full rewrite — and that’s a software architecture problem, not a language problem.

System integration scripts in heterogeneous environments. Perl is pre-installed on virtually every Unix/Linux system. On a network of 500 servers running a mix of RHEL 8, RHEL 9, Debian 11, Ubuntu 22.04, and some AIX boxes, perl is available everywhere. python3 may not be, or may be different versions.

One-off data transformation tasks. When you have a pile of malformed data that needs to go from format A to format B and you need to be done in an hour, Perl’s combination of regex, hash tables, and text operators is often the fastest path.

The Maintenance Reality

If you’re hired to maintain Perl code, here’s the reality:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# You will inherit code like this:
$_ = shift; s/(\s*[,;]\s*)/ /g; s/\s+/ /g; s/^\s+|\s+$//g; split;

# Your job is to make it like this:
sub normalize_fields {
    my ($input) = @_;
    $input =~ s/\s*[,;]\s*/ /g;  # replace commas/semicolons with spaces
    $input =~ s/\s+/ /g;          # collapse whitespace
    $input =~ s/^\s+|\s+$//g;     # trim
    return split ' ', $input;
}

Legibility work is valuable. Add use strict; use warnings. Convert bare string evals to proper code. Add comments. Replace $_ chains with named variables. Don’t rewrite the algorithm — just make it maintainable.

When to Write New Perl

It’s reasonable to write new Perl in 2026 when:

  1. You’re adding to an existing Perl codebase
  2. You’re writing a script that will be deployed to heterogeneous systems where Python availability isn’t guaranteed
  3. The problem is fundamentally about text processing and regex
  4. You need a CPAN module with no Python equivalent
  5. You personally know Perl well and need something done quickly for personal infrastructure

It’s not reasonable to start a new Perl project when:

  1. Building a web API — use Python/FastAPI or Go
  2. Doing ML/data science — use Python
  3. Building a CLI tool for broad distribution — use Go (static binary)
  4. Working on a team that doesn’t know Perl
  5. Building anything that will need to integrate with cloud-native tooling

The Raku Situation

Raku (formerly Perl 6) is a separate language, not the next version of Perl. It’s genuinely interesting — it has an amazing type system, an excellent grammar system for parsing, and some beautiful abstractions. It also has a very small user base, limited CPAN compatibility, and slower runtime performance. If you’re interested, explore it, but don’t expect it to revitalize the Perl ecosystem.

Modern Perl Best Practices Summary

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/usr/bin/env perl
use v5.36;           # Enables strict, warnings, and modern features

# Structured error handling (5.40+)
use feature 'try';

# Always use exceptions for error handling, not return codes
sub connect_to_db {
    my ($dsn, $user, $pass) = @_;

    my $dbh = try {
        DBI->connect($dsn, $user, $pass, { RaiseError => 1, AutoCommit => 0 })
    } catch ($e) {
        die "Database connection failed: $e";
    };

    return $dbh;
}

# Use signatures (5.36+ stable)
sub process_servers(@servers) {
    return map { analyze_server($_) } @servers;
}

sub analyze_server($server) {
    my $name = $server->{hostname} // die "Server missing hostname";
    # ...
}

# Postfix dereference for clarity
sub get_active_hosts($server_list_ref) {
    return grep { $_->{active} } $server_list_ref->@*;
}

# Use state for memoization
sub get_compiled_regex($pattern) {
    state %cache;
    return $cache{$pattern} //= qr/$pattern/;
}

The Bottom Line

Perl in 2026 is a language with a well-defined niche. It’s not dead — it’s running in production at scale at this moment at banks, biotech companies, ISPs, and Fortune 500 infrastructure teams. It’s not a language you should start new large projects in for most domains. It is a language worth knowing if you work in infrastructure, bioinformatics, or network operations, because you will encounter it.

The engineers who get tripped up are those who dismiss it entirely and can’t read the scripts they’re supposed to maintain, and those who are so attached to it that they write new Perl where Python would be far more practical.

The right frame: Perl is a specialist tool with decades of accumulated solutions in CPAN, a regex engine that every other language has copied, and text manipulation capabilities that are genuinely best-in-class for the right problems. Know it, use it where it wins, and know when to reach for something else.

For further reading: perldoc perltoc, Modern Perl (free online), PerlMonks for community Q&A, MetaCPAN for module documentation.

Comments