Shell to Perl#
You know the shell. You glue programs together with pipes, you reach for sed, awk, grep, and cut in a pipeline without thinking, you capture command output with $(...), and you script control flow with for f in *, while read line, and case. This guide maps each of those habits onto Perl, so you can keep the pipeline reflexes and pick up the things the shell makes painful.
When a shell script grows past about 50 lines of quoting and sed/awk/grep pipelines, Perl is the natural next step: it does all of it in one language, with real data structures instead of whitespace-delimited strings. The framing to carry in: Perl is the shell pipeline, but the filter language is a real language. You still open pipes, you still glob, you still run commands and read their output. What changes is that between the pipes you now have @arrays, %hashes, real regex, and arithmetic that needs no $(( )). The shell’s one data type is the string; Perl gives you actual containers.
Every runnable example here was executed on pperl, a Rust reimplementation of Perl 5.42; the # ... comments show its real output. Start scripts with:
use v5.42;
That one line turns on strict, warnings, and the say built-in (a print with a trailing newline), the modern feature set used throughout.
Note
One-liner switches on this pperl build The shell developer’s natural Perl twin is the one-liner: perl -ne and perl -pe, the in-the-pipeline filter. Those switches are correct perl5 5.42 and are shown throughout this guide as the idiomatic form. But the implicit-loop switch family (-n, -p, -a, -F, -i, -l) is not yet recognised by this pperl build - only -e is. So the one-liner examples below are shown without output, and each is paired with an explicit-loop full-script form (while (<$fh>) { ... }) that does run on pperl today and carries the verified output. Use the full-script form until the switches land.
How to read this#
The record loop -
while read linebecomeswhile (<$fh>)and$_.Fields and splitting -
cut/IFS/awkfields becomesplitand a real array.Regex and substitution - the shell’s
${var#...}/${var/a/b}parameter expansions become real regex.Addresses and ranges -
grep -n,awk NR==2, and line ranges.I/O, pipes, and in-place edit - the heart:
open,-|/|-, redirection, here-docs,$(...).One-liners and switches -
-n,-p,-e,BEGIN/END.Control flow -
for/while/case/if, file tests, exit status.Variables and data -
$varbecomesmy $var, plus the@arraysand%hashesthe shell never had.Gotchas coming from the shell - scan before you write.
The record loop#
The shell’s line-at-a-time idiom is while read line; do ... done, which reads one line per iteration into $line (and strips it according to IFS). Perl’s equivalent is while (<$fh>), which reads one line per iteration into $_, the implicit topic variable. The crucial difference: Perl keeps the trailing newline, so you chomp it yourself, exactly where the shell’s read would have eaten it.
while read line; do
echo "got: $line"
done < data.txt
use v5.42;
open my $fh, '<', '/tmp/data.txt' or die "open: $!";
while (my $line = <$fh>) { # each line, with its newline
chomp $line; # strip it, like read does
say "got: $line";
}
close $fh;
# got: apple
# got: banana
# got: cherry
(The input file held apple, banana, cherry.) Read with the bare <$fh> and the line lands in $_, the default that most line built-ins operate on. The line number is in $.. This is the form to reach for when you are translating a filter:
use v5.42;
open my $fh, '<', '/tmp/data.txt' or die "open: $!";
while (<$fh>) { # line lands in $_
chomp; # chomp $_ by default
say "$. : $_"; # $. is the line number
}
close $fh;
# 1 : apple
# 2 : banana
# 3 : cherry
The gotcha: read splits on IFS and trims; <$fh> does neither. A Perl line read is the whole raw line including the \n, so chomp is your reflex, and field-splitting is a separate, explicit step (the next section). What you lose in automatic trimming you gain in not having IFS quietly mangle your data.
Fields and splitting#
In the shell you reach fields with cut -d: -f1, with awk '{print $1}', or by setting IFS and letting read split for you. Perl’s one answer is split: give it a separator and a string, get a real array back. Positional fields that the shell numbers $1, $2 become array elements $f[0], $f[1] - 0-based, unlike awk’s 1-based $1.
line='root:x:0:0:root:/root:/bin/bash'
user=$(echo "$line" | cut -d: -f1)
shell=$(echo "$line" | cut -d: -f7)
echo "$user $shell"
use v5.42;
my $line = "root:x:0:0:root:/root:/bin/bash";
my @f = split /:/, $line;
say "user: $f[0]"; # user: root
say "shell: $f[-1]"; # shell: /bin/bash (negative index)
say "fields: ", scalar @f; # fields: 7
To split on runs of whitespace - the shell’s default word-splitting, and awk’s default - use the special pattern split ' ' (a literal single-space string, which also skips leading blanks). To join fields back, join:
use v5.42;
my @words = split ' ', " the quick brown ";
say scalar @words; # 3 (leading/inner blanks collapsed)
say join "|", @words; # the|quick|brown
The gotcha: split /:/ with a literal pattern keeps empty trailing fields only up to a point - by default Perl drops trailing empty fields, so split /:/, "a::" yields ("a"), not ("a", "", ""). Pass a negative limit (split /:/, $s, -1) to keep them, the equivalent of caring about trailing empty cut fields. And split ' ' (the magic-whitespace form) is not the same as split / / (a single literal space): the first collapses runs and trims, the second does not.
Regex and substitution#
The shell’s ${} parameter expansions become real regex - and this is a large upgrade. ${var#prefix}, ${var%suffix}, ${var/a/b}, and ${var//a/b} are each a special-purpose, limited string edit. Perl replaces the whole family with one operator, s///, that takes an actual regular expression and the full set of flags.
f='report.txt.bak'
base=${f%.bak} # strip suffix
path='/usr/local/bin'
tail=${path##*/} # basename
dir=${path%/*} # dirname
s='a-b-c'
one=${s/-/_} # replace first
all=${s//-/_} # replace all
use v5.42;
my $f = "report.txt.bak";
(my $base = $f) =~ s/\.bak$//; # ${f%.bak}
say $base; # report.txt
my $path = "/usr/local/bin";
(my $tail = $path) =~ s{.*/}{}; # ${path##*/} basename
say $tail; # bin
(my $dir = $path) =~ s{/[^/]*$}{}; # ${path%/*} dirname
say $dir; # /usr/local
my $s = "a-b-c";
(my $one = $s) =~ s/-/_/; # ${s/-/_} first only
say $one; # a_b-c
(my $all = $s) =~ s/-/_/g; # ${s//-/_} /g = global
say $all; # a_b_c
Because it is a real regex, the things the shell cannot do at all are trivial: capture groups land in $1, $2; the /e flag runs Perl code in the replacement; the /r flag returns a copy instead of editing in place; and /i, /x modify matching.
use v5.42;
my $s = "items: 3 and 4";
(my $t = $s) =~ s/(\d+)/$1 * 10/ge; # /e: replacement is Perl code
say $t; # items: 30 and 40
my $date = "2026-06-21";
my $us = $date =~ s{(\d+)-(\d+)-(\d+)}{$2/$3/$1}r; # /r: return a copy
say $us; # 06/21/2026
say $date; # 2026-06-21 (unchanged)
The gotcha: bare s/// edits its target and returns the count of substitutions, not the new string - so the examples copy first with (my $t = $s) =~ ... or use the /r flag. Writing my $new = ($s =~ s/a/b/) puts a number in $new. This is the single most common first-day surprise. Note also that the delimiters are free: s{...}{...} and s#...#...# save you backslashing the slashes in a path pattern, the way the shell’s ${path##*/} already avoids them.
Addresses and ranges#
Selecting lines by number or pattern - grep, grep -n, awk 'NR==2', sed -n '/a/,/b/p' - becomes a postfix if on a condition inside the loop. The line number is $.; the pattern test is /pat/ against $_.
grep -n an data.txt # number + matching lines
awk 'NR==2' data.txt # the second line
use v5.42;
open my $fh, '<', '/tmp/data.txt' or die "open: $!";
while (<$fh>) {
chomp;
say "$. : $_" if /an/; # grep -n 'an'
}
close $fh;
# 2 : banana
A line range - the sed/awk /start/,/end/ selector - becomes Perl’s flip-flop range operator /start/ .. /end/, true from the line matching the left pattern through the line matching the right, inclusive:
use v5.42;
open my $fh, '<', '/tmp/data.txt' or die "open: $!";
while (<$fh>) {
print if /banana/ .. /cherry/; # the /start/,/end/ range
}
close $fh;
# banana
# cherry
The gotcha: in a boolean test .. is the flip-flop (range) operator, not the list-range operator (which is 1 .. 10 building a list). Perl tells them apart by context - in the boolean position of an if, .. is the stateful line-range selector; in list context it builds a sequence. Same two dots, decided by where you write them.
I/O, pipes, and in-place edit#
This is the heart of the guide, because it is the heart of shell scripting. Everything you do with |, >, >>, 2>&1, <<EOF, and $(...) has a direct Perl spelling, and once a script outgrows a single pipeline this is the section that pays off.
Command substitution. The shell’s $(cmd) and backticks become Perl’s backticks or qx{...}. The capture is context-sensitive: in scalar context you get the whole output as one string; in list context you get one element per line.
out=$(echo hello)
mapfile -t lines < <(seq 1 3)
use v5.42;
my $out = `echo hello`; # scalar: whole output, one string
chomp $out;
say "got: $out"; # got: hello
my @lines = qx{seq 1 3}; # list: one element per line
chomp @lines;
say "lines: @lines"; # lines: 1 2 3
When you do not need the output, system() runs a command and gives you its exit status (see Control flow for $?). Pass system a list and no shell is involved - the safe form that avoids quoting hell:
use v5.42;
system('mkdir', '-p', '/tmp/sh_demo'); # list form: no shell, no quoting
say "made it" if -d '/tmp/sh_demo'; # made it
Pipes. A pipeline stage - reading a command’s output or feeding a command’s input - is an open with a pipe mode. open my $fh, '-|', LIST reads a command’s stdout; open my $fh, '|-', LIST writes to a command’s stdin. The list form (command plus args as separate strings) runs no shell, so there is nothing to quote.
seq 1 3 | while read n; do echo "n=$n"; done # read from a command
printf '%s\n' banana apple cherry | sort # write to a command
use v5.42;
open my $ph, '-|', 'seq', '1', '3' or die "pipe: $!"; # read command output
my @nums = <$ph>;
close $ph;
chomp @nums;
say "got: @nums"; # got: 1 2 3
use v5.42;
open my $sort, '|-', 'sort' or die "pipe: $!"; # write to command stdin
print {$sort} "$_\n" for qw(banana apple cherry);
close $sort;
# apple
# banana
# cherry
Redirection. > and >> are the open modes '>' (truncate) and '>>' (append); a three-arg open with a lexical handle is the idiom. print {$fh} ... writes to a handle. To merge a child’s stderr into the captured stream (2>&1), keep the redirection in the command you hand to backticks:
use v5.42;
open my $fh, '>', '/tmp/sh_out.txt' or die "write: $!";
print {$fh} "line $_\n" for 1 .. 3;
close $fh;
my $both = `sh -c 'echo to-out; echo to-err 1>&2' 2>&1`; # merge stderr
print $both;
# to-out
# to-err
Here-docs. The shell’s <<EOF (interpolating) and <<'EOF' (literal) map one-to-one to Perl’s <<"END" and <<'END'. Double quotes on the terminator interpolate variables; single quotes do not.
use v5.42;
my $name = "world";
print <<"END"; # interpolating, like <<EOF
hello $name
END
print <<'END'; # literal, like <<'EOF'
no $interpolation here
END
# hello world
# no $interpolation here
In-place edit. sed -i rewrites a file in place; the idiomatic Perl is the same switch on a -p one-liner. The runnable equivalent today is the explicit read-modify-write that -i performs under the hood - slurp, transform, write back:
use v5.42;
my $path = '/tmp/inplace_data.txt'; # holds: red green blue
open my $in, '<', $path or die "read: $!";
my @lines = <$in>; # slurp all lines
close $in;
s/e/E/g for @lines; # transform each line's $_
open my $out, '>', $path or die "write: $!";
print {$out} @lines; # write them back
close $out;
# file now holds:
# rEd
# grEEn
# bluE
The gotcha: open returns false on failure rather than raising, so the or die "...: $!" idiom is mandatory - $! is the system error string, the shell’s silent 2>/dev/null failure made loud and explicit. And when you build a command from variables, prefer the list form of system/open ('ls', '-l', $dir) over a single interpolated string: the list form bypasses the shell entirely, so there is no word splitting, no glob expansion, and no quoting to get wrong - the cure for the shell’s quoting hell.
One-liners and switches#
The one-liner is the shell developer’s home turf and Perl’s too: the filter you drop into a pipeline. The switch family that makes it work: -e supplies the program, -n wraps it in a no-auto-print loop, -p wraps it in an auto-print loop, -i edits files in place, -l handles line endings, and -a/-F autosplit each line into @F (the direct awk analogue, fields in @F instead of $1..$NF). BEGIN { } and END { } run before and after the loop, like awk’s BEGIN/END.
grep error log.txt # filter lines
awk -F: '{print $1}' /etc/passwd # first field
awk '{s += $NF} END {print s}' data.txt # sum last field
perl -ne 'print if /error/' log.txt # filter lines
perl -F: -ane 'print "$F[0]\n"' /etc/passwd # first field, -F sets split
perl -lane '$s += $F[-1]; END { say $s }' data.txt # sum last field
These are shown without output because the implicit-loop switches are not recognised by this pperl build yet (see the note at the top). Each maps to a verified explicit-loop form: perl -ne 'CODE' is while (<$fh>) { CODE }, and perl -pe 'CODE' is the same with a trailing print. The runnable “first field of each line” form:
use v5.42;
open my $fh, '<', '/tmp/passwd.txt' or die "open: $!";
while (<$fh>) { # the implicit loop of -ane
chomp;
my @F = split /:/; # what -F: gives you in @F
say $F[0]; # print the first field
}
close $fh;
# root
# daemon
# bin
The gotcha: until the switches land on pperl, write the explicit loop (it runs today and is what the switches expand to anyway), and consult the One-Liners guide for the full switch catalogue and dozens of recipes - it is the highest-leverage thing to read coming from a shell background, where the one-liner is the everyday reflex.
Control flow#
The shell’s for, while, case, and if/test map onto Perl loops and conditionals, and several of the mappings are pleasantly direct.
Loops. for f in *; do becomes for my $f (glob "*"); the file glob *.txt is glob "*.txt" or the readline form <*.txt>.
for f in /tmp/sh_*.txt; do
echo "file: $f"
done
use v5.42;
for my $f (sort glob "/tmp/sh_*.txt") {
say "file: $f";
}
# file: /tmp/sh_data.txt
# file: /tmp/sh_passwd.txt
Conditionals and test. This is a happy mapping: the shell’s [ -e ], [ -d ], [ -f ] file tests are the same -X operators in Perl. String and numeric comparison split into two families, as they do between [ "$a" = "$b" ] and [ "$a" -eq "$b" ]: Perl uses eq/ne for strings and ==/!= for numbers. The shell’s -z/-n (empty / non-empty string) become !length / length.
if [ -f "$f" ] && [ -n "$name" ]; then
echo ok
fi
[ "$a" = "$b" ] # string equality
[ "$x" -eq "$y" ] # numeric equality
use v5.42;
my $f = '/tmp/sh_data.txt';
my $name = 'set';
say "ok" if -f $f && length $name; # same -f, same idea
# ok
say "abc" eq "abc" ? "eq" : "ne"; # string compare
say 10 == 10.0 ? "num-eq" : "num-ne"; # numeric compare
# eq
# num-eq
case. The shell’s case has no single keyword twin; the idiomatic Perl is a for/if chain or a ternary cascade (or, for many branches, a dispatch hash mapping keys to code refs).
case "$cmd" in
start) echo starting ;;
stop) echo stopping ;;
*) echo "unknown: $cmd" ;;
esac
use v5.42;
for my $cmd (qw(start stop bogus)) {
my $r =
$cmd eq 'start' ? "starting"
: $cmd eq 'stop' ? "stopping"
: "unknown: $cmd";
say "$cmd -> $r";
}
# start -> starting
# stop -> stopping
# bogus -> unknown: bogus
Exit status. After a system(), the child’s status is in $?, exactly the shell variable. But $? holds the raw wait status, not the bare exit code: shift right by 8 to get the code the shell would show as $?. The loop keywords last and next are the shell’s break and continue.
use v5.42;
system('sh', '-c', 'exit 3');
say "raw \$? = $?"; # raw $? = 768
say "exit code = ", $? >> 8; # exit code = 3
The gotcha: $? >> 8 is the exit code; the low 8 bits hold the signal that killed the child (if any). The shell hides this behind its own $?; in Perl you do the shift yourself. Forgetting it is why if ($? == 1) after a command that exited 1 is almost always wrong - the raw value is 256, not 1.
Variables and data#
A shell variable is always a string - var=value, used as $var or ${var}, and quoted as "$var" to survive word splitting. Perl’s scalar is my $var, lexically scoped, and no quoting dance is needed: a Perl scalar holds one value and does not get re-split on use. Where the shell forces everything through strings, Perl adds the two containers the shell never had: ordered @arrays and key/value %hashes.
name="Ada"
greeting="Hello, ${name}"
langs="Perl Python" # a "list" is really a string + IFS
use v5.42;
my $name = "Ada";
my $greeting = "Hello, $name"; # interpolation, like "${name}"
my @langs = ("Perl", "Python"); # a real list, not a split string
say $greeting; # Hello, Ada
say "first: $langs[0]"; # first: Perl
say "count: ", scalar @langs; # count: 2
Script arguments. The shell’s positional parameters map to @ARGV. The quoting minefield of "$@" versus $* simply does not exist: @ARGV is a real array, each argument a clean element no matter what spaces or globs it contained. $1..$9 become $ARGV[0]..$ARGV[8], $# (the count) becomes scalar @ARGV, and $0 (the script name) becomes $0.
echo "count: $#"
echo "first: $1"
echo "all: $@"
use v5.42;
say "count: ", scalar @ARGV; # like $#
say "first: $ARGV[0]"; # like $1
say "all: @ARGV"; # like "$@"
# invoked as: pperl script.pl one two three
# count: 3
# first: one
# all: one two three
Associative arrays. A Perl hash is the shell’s declare -A done right - declared with %, accessed one value at a time with $h{key}, and far more ergonomic than the shell’s bolted-on associative arrays.
use v5.42;
my %count;
$count{$_}++ for qw(apple pear apple fig apple pear);
for my $k (sort keys %count) {
say "$k: $count{$k}";
}
# apple: 3
# fig: 1
# pear: 2
The gotcha: a shell variable is global by default and a Perl my variable is lexically scoped to its block - the opposite default. The shell’s local (dynamic scoping inside a function) is closest to Perl’s local, but for ordinary variables you want my, which is true lexical scope. And interpolation only happens in double quotes: "$name" interpolates, '$name' is the literal four characters, just like the shell.
Perl also has full subroutines, references, modules, and OO if your script outgrows a one-liner - see the Object-oriented Programming guide and the other coming-from guides.
Gotchas coming from the shell#
The traps, collected for a final scan before you write:
<$fh>keeps the trailing newline. Shellreadtrims; Perl does not.chompis your reflex on every line read.splitis a separate step, and it is 0-based. Fields are$f[0],$f[1], notawk’s 1-based$1.split ' '(magic whitespace) trims and collapses;split / /(a literal space) does not.s///returns a count, not the new string.my $x = $s =~ s/a/b/puts the substitution count in$x. Copy first ((my $t = $s) =~ s/.../.../) or use the/rflag.$? >> 8is the exit code. The raw$?aftersystemis the full wait status; the exit code is the high 8 bits.if ($? == 1)is almost always a bug; you wantif (($? >> 8) == 1).opendoes not raise on failure. Useopen my $fh, '<', $path or die "...: $!".$!is the system error string - the shell’s silent failure made explicit.Prefer the list form of
system/openover an interpolated string.system('ls', '-l', $dir)runs no shell: no word splitting, no glob surprise, no quoting hell. The string form (system "ls -l $dir") reintroduces every shell-quoting trap you came here to escape.myis lexical; shell variables are global. Declare withmyand scope to the block; reach forlocalonly for the shell’s dynamic-scoping behaviour, which is rarely what you want.String vs numeric comparison is two families.
eq/nefor strings,==/!=for numbers - the[ = ]vs[ -eq ]split, but the operators are reversed from the shell, so read them carefully.The implicit-loop switches (
-n/-p/-a/-F/-i/-l) are not yet recognised by thispperlbuild. Only-eworks today. Write the explicitwhile (<$fh>) { ... }loop, which is exactly what the switches expand to, until they land.