# 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: ```perl 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](#the-record-loop) - `while read line` becomes `while (<$fh>)` and `$_`. - [Fields and splitting](#fields-and-splitting) - `cut`/`IFS`/`awk` fields become `split` and a real array. - [Regex and substitution](#regex-and-substitution) - the shell's `${var#...}`/`${var/a/b}` parameter expansions become real regex. - [Addresses and ranges](#addresses-and-ranges) - `grep -n`, `awk NR==2`, and line ranges. - [I/O, pipes, and in-place edit](#io-pipes-and-in-place-edit) - the heart: `open`, `-|`/`|-`, redirection, here-docs, `$(...)`. - [One-liners and switches](#one-liners-and-switches) - `-n`, `-p`, `-e`, `BEGIN`/`END`. - [Control flow](#control-flow) - `for`/`while`/`case`/`if`, file tests, exit status. - [Variables and data](#variables-and-data) - `$var` becomes `my $var`, plus the `@arrays` and `%hashes` the shell never had. - [Gotchas coming from the shell](#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. ```bash while read line; do echo "got: $line" done < data.txt ``` ```perl 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: ```perl 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`. ```bash line='root:x:0:0:root:/root:/bin/bash' user=$(echo "$line" | cut -d: -f1) shell=$(echo "$line" | cut -d: -f7) echo "$user $shell" ``` ```perl 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`: ```perl 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. ```bash 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 ``` ```perl 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. ```perl 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 `$_`. ```bash grep -n an data.txt # number + matching lines awk 'NR==2' data.txt # the second line ``` ```perl 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: ```perl 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`, `<; close $ph; chomp @nums; say "got: @nums"; # got: 1 2 3 ``` ```perl 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: ```perl 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 `<; # 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`. ```bash 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 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: ```perl 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](../oneliner/index) 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>`. ```bash for f in /tmp/sh_*.txt; do echo "file: $f" done ``` ```perl 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`. ```bash if [ -f "$f" ] && [ -n "$name" ]; then echo ok fi [ "$a" = "$b" ] # string equality [ "$x" -eq "$y" ] # numeric equality ``` ```perl 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). ```bash case "$cmd" in start) echo starting ;; stop) echo stopping ;; *) echo "unknown: $cmd" ;; esac ``` ```perl 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`. ```perl 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`. ```bash name="Ada" greeting="Hello, ${name}" langs="Perl Python" # a "list" is really a string + IFS ``` ```perl 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`. ```bash echo "count: $#" echo "first: $1" echo "all: $@" ``` ```perl 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. ```perl 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](../oop/index) 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.** Shell `read` trims; Perl does not. `chomp` is your reflex on every line read. - **`split` is a separate step, and it is 0-based.** Fields are `$f[0]`, `$f[1]`, not `awk`'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 `/r` flag. - **`$? >> 8` is the exit code.** The raw `$?` after `system` is the full wait status; the exit code is the high 8 bits. `if ($? == 1)` is almost always a bug; you want `if (($? >> 8) == 1)`. - **`open` does not raise on failure.** Use `open my $fh, '<', $path or die "...: $!"`. `$!` is the system error string - the shell's silent failure made explicit. - **Prefer the list form of `system`/`open` over 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. - **`my` is lexical; shell variables are global.** Declare with `my` and scope to the block; reach for `local` only for the shell's dynamic-scoping behaviour, which is rarely what you want. - **String vs numeric comparison is two families.** `eq`/`ne` for 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 this `pperl` build.** Only `-e` works today. Write the explicit `while (<$fh>) { ... }` loop, which is exactly what the switches expand to, until they land.