PONYλM2Modula-2

Perl.CodeCompared.To/Nushell

An interactive executable cheatsheet comparing Perl and Nushell

Perl 5.44 Nushell 0.115.1
Output & Running
Hello, World
Perl needs use v5.38; before say exists, because say is a feature rather than a builtin and Perl will not enable a feature you did not ask for. Nushell's print is always there and always appends the newline.
use v5.38; say "Hello, World!";
print "Hello, World!"
The similarity ends sooner than it looks. Perl's say writes bytes to a stream, so everything downstream of it is text. Nushell's print takes a value and renders it — hand it a table and you get a drawn table, hand it a record and you get a record. That distinction is the whole page: in Perl the pipeline is made of characters, and in Nushell it is made of values that happen to be displayed as characters at the very end.
Only the last pipeline prints itself
Nushell prints the value of the final pipeline without being asked, and prints nothing for the ones before it. Perl prints only what you tell it to. This is the single most common reason a Nushell example seems to do nothing.
use v5.38; my @numbers = (1, 2, 3); say scalar @numbers; # every say prints say join ", ", @numbers; my @doubled = map { $_ * 2 } @numbers; # computed, and never printed
let numbers = [1 2 3] print ($numbers | length) # an explicit print print ($numbers | str join ", ") $numbers | each { |number| $number * 2 } # the LAST value, printed for you
Coming from Perl the rule reads backwards: in a Perl script an expression in void context is discarded silently, and in a Nushell script the last expression is the script's output. So a Nushell script that ends in let result = … prints nothing at all, and one that ends in a bare pipeline prints a table you may not have wanted. Use print for anything you want shown before the end, and null or a let as the last line when you want a script to be quiet.
Running a script, a one-liner, or nothing at all
Both are a scripting language and an interactive shell at once, so the same three modes exist. The interesting comparison is the third one — Perl's -ne is the flag that made it the standard tool for chewing through log files.
use v5.38; # Saved as report.pl, run with: perl report.pl # As a one-liner: perl -e 'print 1 + 1' # Line by line over input: perl -ne 'print if /error/' log.txt say 1 + 1;
# Saved as report.nu, run with: nu report.nu # As a one-liner: nu -c "print (1 + 1)" # Line by line over input: open log.txt | lines | where $it =~ "error" print (1 + 1)
perl -ne wraps your code in a loop over input lines and hands each one to you as $_. The Nushell equivalent has no wrapper and no implicit variable: lines turns the text into a list, and everything after it is ordinary list processing. That is a small thing that says something large — Perl gives you a special mode for the line-at-a-time case because lines are what it fundamentally has, while Nushell treats lines as one more way of getting a list and then leaves the special-casing behind.
Text You Parse vs Data You Have
The same job, with and without parsing
Start with the case where both languages have to parse, because it is the fairest possible comparison — and because it shows what Perl is good at. Given a line of text, the two are about equally short.
use v5.38; my $line = "ada,36,london"; my ($name, $age, $city) = split /,/, $line; $age += 1; # a string until arithmetic makes it a number say "$name is now $age in $city";
let line = "ada,36,london" let fields = $line | split row "," let age = ($fields.1 | into int) + 1 print $"($fields.0) is now ($age) in ($fields.2)"
Note where the type conversion happens in each. Perl has no separate string and number types at the value level, so $age + 1 just works and "36abc" + 1 quietly gives 37; that permissiveness is exactly what makes Perl fast to write and exactly what lets a malformed field pass unnoticed. Nushell makes you say into int, which fails loudly on input that is not a number. The rest of the page is about the situation where neither language should have been parsing at all.
The pipeline carries structure, so nothing is recovered
This is the argument the page exists to make. A Perl script that consumes another program's output has to reconstruct the structure that program already had and threw away when it printed. Nushell's commands hand back the structure itself.
use v5.38; # The classic shape: another program's text, re-parsed. my $output = "ada 36 london\ngrace 45 boston\n"; for my $line (split /\n/, $output) { next unless length $line; my ($name, $age, $city) = split /\s+/, $line; say "$name ($age)" if $age > 40; }
# The structure was never turned into text, so it is still here. let people = [ [name age city]; [ada 36 london] [grace 45 boston] ] $people | where age > 40 | each { |row| $"($row.name) \(($row.age)\)" } | str join ", "
Count what the Perl version has to get right that has nothing to do with the actual question. It must skip blank lines, choose between split /\s+/ and split ' ' (they differ on leading whitespace), assume the fields never contain a space, and know that field 1 is the age. Change the upstream program's column order and the script is silently wrong. The Nushell version names age, so the only way to break it is to remove the column — which fails loudly. That is the trade: Perl works on anything that has ever been printed, and Nushell works beautifully right up to the boundary where somebody hands you text.
A value in the pipe knows what it is
Perl's scalars are famously untyped, so sorting is where it bites: sort defaults to string comparison, and getting numeric order means remembering <=> rather than cmp. Nushell values carry their type, so sort has only one meaning.
use v5.38; my @sizes = ("1024", "2048", "512"); # All strings; the comparison below is numeric only because of <=> my $total = 0; $total += $_ for @sizes; say "total: $total"; say "sorted: ", join " ", sort { $a <=> $b } @sizes; say "string sort: ", join " ", sort @sizes;
let sizes = [1024 2048 512] # All integers; there is no other way to sort them let total = $sizes | math sum print $"total: ($total)" print $"sorted: ($sizes | sort | str join ' ')" print $"type: ($sizes.0 | describe)"
Read the third Perl line: sort @sizes gives 1024 2048 512, because "512" sorts after "2048" as a string. That is not a Perl bug, it is the honest consequence of a value that does not know whether it is a number — and every Perl programmer has shipped it at least once. Nushell has no such fork in the road, because 512 parsed as an integer the moment it entered the list. describe is the command that tells you what a value is, which is worth knowing early.
Variables & the Missing Sigils
One sigil, and it is only for reading
Perl's sigil says what you are taking out, not what the variable holds, which is why an array element is $friends[0] and not @friends[0]. Nushell has one sigil, $, it means "the value of this name", and it appears only when reading.
use v5.38; my $name = "Ada"; my @friends = ("Grace", "Alan"); my %ages = (Ada => 36, Grace => 45); say $name; say $friends[0]; # a scalar OUT of an array: $ not @ say $ages{Ada}; # a scalar out of a hash: $ not %
let name = "Ada" let friends = [Grace Alan] let ages = {Ada: 36, Grace: 45} print $name print $friends.0 # the same $ regardless of what is in it print $ages.Ada
Notice the declarations have no sigil at all: it is let name, not let $name. That catches Perl programmers constantly, and the rule is simple once seen — the $ is part of the reference, never part of the name. There is also no @ or % to choose between, because a list and a record are just two kinds of value a name can hold, the way an arrayref and a hashref already are in modern Perl.
let binds once; mut is the exception
A let in Nushell cannot be reassigned. Changing a value needs mut, which is the same distinction Rust makes and which Perl's my does not have at all.
use v5.38; my $count = 0; $count += 1; $count += 1; say $count;
mut count = 0 $count += 1 $count += 1 print $count
You will use mut less than you expect, because the accumulate-into-a-variable loop that Perl writes constantly is usually a pipeline here — math sum rather than $total += $_. There is also a scoping rule with no Perl equivalent: a mut variable cannot be captured by a closure, so you cannot write each { |item| $total += $item }. That is deliberate, because a closure may one day run in parallel, and it pushes you towards reduce, which is the right tool anyway.
Context sensitivity is gone
Context is the Perl feature with no counterpart anywhere on this page. The same expression @friends means the element count on one line and the list itself on the next, decided entirely by what surrounds it.
use v5.38; my @friends = ("Grace", "Alan", "Edsger"); my $count = @friends; # scalar context: the LENGTH my ($first) = @friends; # list context: the first element say "count: $count"; say "first: $first"; say "interpolated: @friends"; # the whole list, space-separated
let friends = [Grace Alan Edsger] let count = $friends | length # you ask for the length let first = $friends.0 # you ask for the element print $"count: ($count)" print $"first: ($first)" print $"interpolated: ($friends | str join ' ')"
Nushell has nothing like it, and the cost is that you say what you want: length for the count, .0 for the element, str join to flatten into a string. The gain is that an expression means the same thing wherever it appears, so a pipeline stage can be cut out and tested on its own — you cannot lift my $count = @friends out of its line without changing what it does. If you have ever been bitten by a subroutine returning a list where you wanted a count, this is the section that explains why that cannot happen here.
Strings
Interpolation needs parentheses
Perl interpolates any scalar inside double quotes with no delimiters, which is why the sigil earns its keep. Nushell needs an explicit $ on the string itself and parentheses around every hole.
use v5.38; my $name = "Ada"; my $age = 36; say "Name: $name, age: $age"; say 'Not interpolated: $name'; say "Next year: @{[ $age + 1 ]}"; # the arrayref-deref trick
let name = "Ada" let age = 36 print $"Name: ($name), age: ($age)" print "Not interpolated: $name" print $"Next year: ($age + 1)"
The third line is where Nushell wins outright. Perl's double-quoted strings interpolate variables but not expressions, so "@{[ $age + 1 ]}" is the well-known workaround: build a one-element anonymous array, dereference it, and let list interpolation do the rest. Nushell's hole takes any expression, including a whole pipeline — $"total: ($items | length)". Note also that a plain "…" string in Nushell never interpolates, which is what Perl's single quotes do; the $ prefix is the opt-in.
The string toolkit
Perl's string functions are builtins taking the subject as an argument; Nushell's are subcommands of str taking it from the pipeline. The one to notice is trimming, which Perl has never had a builtin for.
use v5.38; my $text = " Hello, World "; my $trimmed = $text =~ s/^\s+|\s+$//gr; say uc $trimmed; say length $trimmed; say index $trimmed, "World"; say substr $trimmed, 0, 5;
let text = " Hello, World " let trimmed = $text | str trim print ($trimmed | str uppercase) print ($trimmed | str length) print ($trimmed | str index-of "World") print ($trimmed | str substring 0..4)
That missing builtin is a small landmark: every Perl codebase contains some version of s/^\s+|\s+$//g, usually written slightly differently each time, and the /r flag that returns the modified copy instead of editing in place only arrived in 5.14. Two Nushell details worth carrying: str upcase and str downcase were deprecated in 0.114 in favor of str uppercase and str lowercase, so the old spellings print a warning; and str substring takes a range rather than an offset and length. That range is inclusive at both ends, so Perl's substr $text, 0, 5 is str substring 0..4 — a genuine off-by-one waiting for anyone translating code.
split and join
Both split a string into a list and join a list into a string. The Nushell names are longer on purpose: split row distinguishes itself from split column, which cuts a string into a table row instead of a list.
use v5.38; my $line = "ada:grace:alan"; my @names = split /:/, $line; say scalar @names; say join " and ", @names; say join ", ", map { ucfirst } @names;
let line = "ada:grace:alan" let names = $line | split row ":" print ($names | length) print ($names | str join " and ") print ($names | each { |name| $name | str capitalize } | str join ", ")
That distinction is a preview of the Tables section. "ada:36" | split column ":" name age produces a one-row table with named columns, which is the shape you actually wanted whenever you wrote my ($name, $age) = split in Perl and then had to remember which was which. Note that Nushell's split row takes a literal string by default, not a pattern; add --regex to make it one, where Perl's split takes a pattern by default and a literal only if you spell it out.
Lists
Building and slicing a list
Perl's .. builds a list immediately. Nushell's .. builds a lazy range, which is why the first line pipes it through each to force it into a list — a range and a list are different types here.
use v5.38; my @numbers = (1 .. 5); say scalar @numbers; say $numbers[0]; say $numbers[-1]; say join ",", @numbers[1 .. 3]; say join ",", reverse @numbers;
let numbers = 1..5 | each { |number| $number } print ($numbers | length) print ($numbers | first) print ($numbers | last) print ($numbers | slice 1..3 | str join ",") print ($numbers | reverse | str join ",")
Two things surprise Perl programmers. There is no negative indexing: $numbers.-1 is not a thing, and last is the answer. And slicing is a command, slice, rather than subscript syntax — which reads worse in isolation and better in a pipeline, since it can sit between two other stages without parentheses. first 3 and last 2 take counts, so the common cases rarely need slice at all. The range is inclusive at both ends, like Perl's @numbers[1 .. 3], and it clips silently rather than erroring when the top end runs past the list.
Growing a list without mutating one
Perl's push, pop, shift and unshift all modify the array they are given. Nushell has no in-place list operation at all: append and prepend return a new list.
use v5.38; my @queue = ("first"); push @queue, "second"; # modifies @queue in place unshift @queue, "zeroth"; say join ",", @queue; my $last = pop @queue; say "popped $last, left: ", join ",", @queue;
let queue = ["first"] let grown = $queue | append "second" | prepend "zeroth" print ($grown | str join ",") print $"popped ($grown | last), left: ($grown | drop 1 | str join ',')"
The practical effect is that a value has to be kept — $queue | append "second" on a line by itself computes a longer list and throws it away, silently. That is the most common beginner mistake here, and the habit that fixes it is to end every line either in a let or in a pipe. In exchange, the aliasing question Perl programmers know from passing \@array around simply does not arise: nothing you hand to a command can come back changed.
Hashes Become Records
A hash becomes a record
A record is Nushell's hash, written with braces and read with a dot. The % sigil, the {} subscript and the flattening behavior all go away.
use v5.38; my %person = (name => "Ada", age => 36); say $person{name}; say join ",", sort keys %person; say exists $person{email} ? "has email" : "no email"; my %older = (%person, age => 37); # copy with one key replaced say $older{age};
let person = {name: "Ada", age: 36} print $person.name print ($person | columns | sort | str join ",") print (if "email" in $person { "has email" } else { "no email" }) let older = $person | upsert age 37 # a NEW record with one key replaced print $older.age
The flattening is the part worth dwelling on. Perl's %person is a flat list of alternating keys and values, which is what makes (%person, age => 37) work as a copy-with-override and also what makes it so easy to lose a key by accident — an odd-length list is a warning, not an error. A Nushell record is a single value with an ordered key set, so upsert, merge and reject are explicit operations rather than emergent behavior of list flattening. Note columns, not keys: a record and a table row are the same thing here, so they share the vocabulary.
Nested data, without references
Perl cannot nest a hash inside a hash directly — a hash value is a scalar, so what is really stored is a reference, and -> is how you follow it. Nushell has no references and no arrow.
use v5.38; my $config = { server => { host => "localhost", port => 8080 }, tags => [ "web", "api" ], }; say $config->{server}{host}; say $config->{tags}[0]; say scalar @{ $config->{tags} };
let config = { server: {host: "localhost", port: 8080} tags: [web api] } print $config.server.host print $config.tags.0 print ($config.tags | length)
That third Perl line is the tax: getting the length of a nested array needs @{ ... } to turn the reference back into an array so that scalar context can count it. Nothing in Nushell corresponds, because a list is a value and length takes it directly. Perl's references buy real things a record does not offer — aliasing, cycles, weak references, code refs — but for the ordinary job of describing configuration or a decoded JSON document, they are pure ceremony, and that job is most of what either language does with nested data.
Tables — the Type Perl Lacks
A table is a first-class value
This is the type Perl does not have. A table is a list of records with the same keys, and Nushell renders one without being asked — the second column of this row is the entire program.
use v5.38; # Perl's nearest equivalent: an array of hashrefs, printed by hand. my @people = ( { name => "ada", age => 36 }, { name => "grace", age => 45 }, ); printf "%-8s %s\n", "name", "age"; printf "%-8s %d\n", $_->{name}, $_->{age} for @people;
let people = [ [name age]; [ada 36] [grace 45] ] $people
An array of hashrefs is the same data, and that is the point: Perl can hold it perfectly well, but the language has no opinion about it, so displaying it means choosing a format string and getting the widths right by hand. Every Nushell command that returns several things returns a table, so the rendering is written once, in the runtime, rather than once per script. The [[col1 col2]; [a b] [c d]] syntax is the literal form — the header row, a semicolon, then the data rows.
Picking rows and columns
Filtering rows and picking columns are two separate ideas, and Nushell has a word for each: where and select. Perl expresses both with grep and map over hashrefs.
use v5.38; my @people = ( { name => "ada", age => 36, city => "london" }, { name => "grace", age => 45, city => "boston" }, { name => "alan", age => 41, city => "london" }, ); my @londoners = grep { $_->{city} eq "london" } @people; say join ", ", map { "$_->{name} ($_->{age})" } @londoners;
let people = [ [name age city]; [ada 36 london] [grace 45 boston] [alan 41 london] ] $people | where city == "london" | select name age
The Nushell version reads close to SQL, and that is not an accident — a table is the same shape as a result set, so the vocabulary converges. What it buys over grep is that where city == "london" is checked against the column names: misspell citty and you get an error naming the column, while $_->{citty} in Perl is undef and the eq quietly fails to match, giving you an empty result and no clue. Turning on use strict does not help, because hash keys are not symbols.
Adding a computed column
Perl walks the array of hashrefs and writes a new key into each one, mutating as it goes. Nushell's insert takes a closure that is handed each row and returns a new table.
use v5.38; my @orders = ( { item => "book", price => 12, quantity => 2 }, { item => "pen", price => 3, quantity => 5 }, ); for my $order (@orders) { $order->{total} = $order->{price} * $order->{quantity}; } say "$_->{item}: $_->{total}" for @orders;
let orders = [ [item price quantity]; [book 12 2] [pen 3 5] ] $orders | insert total { |row| $row.price * $row.quantity }
The closure receives the whole row, so it can use any column, and the result is a table you can keep piping — | insert total {…} | where total > 20 | sort-by total reads as one sentence. Its siblings are worth learning together: insert adds a column and fails if it exists, update changes one and fails if it does not, and upsert does whichever applies. Perl's $order->{total} = … is all three at once, which is convenient right up to the typo that silently adds a column named totl.
map, grep and Their Replacements
map and grep become each and where
The mapping is direct: map is each, grep is where. The difference is $_, which Nushell does not have — every closure names its parameter.
use v5.38; my @numbers = (1 .. 10); my @doubled = map { $_ * 2 } @numbers; my @evens = grep { $_ % 2 == 0 } @numbers; my $total = 0; $total += $_ for @numbers; say join ",", @doubled; say join ",", @evens; say $total;
let numbers = 1..10 | each { |number| $number } let doubled = $numbers | each { |number| $number * 2 } let evens = $numbers | where { |number| $number mod 2 == 0 } let total = $numbers | math sum print ($doubled | str join ",") print ($evens | str join ",") print $total
Losing $_ costs a few characters and buys the thing Perl programmers work around constantly: nested maps. In Perl the inner block shadows $_, so the outer element has to be saved to a named variable first, and forgetting is a bug that produces plausible wrong output. In Nushell each { |outer| ... each { |inner| ... } } has both names in scope with nothing to remember. Note that where also accepts the shorthand form where age > 40 on a table, where the bare column name is understood — that form is not available on a plain list, which is why this row spells out the closure.
Folding a list down to one value
Perl's reduce comes from List::Util and communicates through the package globals $a and $b, the same pair sort uses. Nushell's is a builtin whose closure names both parameters.
use v5.38; use List::Util qw(reduce); my @words = ("perl", "is", "practical"); my $longest = reduce { length($a) >= length($b) ? $a : $b } @words; my $joined = reduce { "$a-$b" } @words; say $longest; say $joined;
let words = [perl is practical] let longest = $words | reduce { |word, best| if ($word | str length) >= ($best | str length) { $word } else { $best } } let joined = $words | reduce { |word, sofar| $"($sofar)-($word)" } print $longest print $joined
The argument order is the trap, and it is the reverse of what a Perl programmer expects: Nushell passes the element first and the accumulator second, so { |word, best| … } has the current word in word. Without --fold the first element becomes the initial accumulator, exactly like List::Util::reduce; with --fold 0 you supply a starting value, which is what you want when the list may be empty. Note also that $a and $b being globals is why a Perl reduce block cannot be a proper closure over them — a limitation with no analogue here.
Getting the index as well as the element
Perl's idiom for "the index too" is a C-style loop over 0 .. $#array, where $#array is the last index rather than the length. Nushell has a command that pairs each element with its position.
use v5.38; my @friends = ("Grace", "Alan", "Edsger"); for my $index (0 .. $#friends) { say "$index: $friends[$index]"; }
let friends = [Grace Alan Edsger] $friends | enumerate | each { |row| $"($row.index): ($row.item)" } | str join "\n"
enumerate turns a list into a table with index and item columns, which means the result is an ordinary table and every table command applies to it — you can where index > 1 or sort-by item without leaving the pipeline. The $#array notation is one of the corners of Perl that most reliably confuses newcomers, since it is off by one from the length; nothing here has that shape.
Sorting & Grouping
Sorting, with no comparator to write
Perl's sort takes a comparator block and the two operands arrive as the globals $a and $b. Nushell's sort-by takes the name of a column, and the values decide their own ordering.
use v5.38; my @people = ( { name => "grace", age => 45 }, { name => "ada", age => 36 }, { name => "alan", age => 41 }, ); my @by_age = sort { $a->{age} <=> $b->{age} } @people; my @by_name = sort { $a->{name} cmp $b->{name} } @people; say join ",", map { $_->{name} } @by_age; say join ",", map { $_->{name} } @by_name;
let people = [ [name age]; [grace 45] [ada 36] [alan 41] ] print ($people | sort-by age | get name | str join ",") print ($people | sort-by name | get name | str join ",")
This is where the typed pipeline pays a visible dividend. Perl needs two different comparison operators — <=> for numbers, cmp for strings — and choosing wrong gives you a wrong answer rather than an error. sort-by age sorts integers numerically because they are integers, and sort-by name sorts strings alphabetically for the same reason. Add --reverse for descending order, and give several columns to break ties: sort-by city age.
Grouping, and summarizing each group
Grouping is where Perl's autovivification shines: push @{ $hash{$key} }, $value creates the missing arrayref for you. Nushell has a command for the whole shape.
use v5.38; my @orders = ( { customer => "ada", total => 30 }, { customer => "grace", total => 45 }, { customer => "ada", total => 12 }, ); my %by_customer; push @{ $by_customer{ $_->{customer} } }, $_->{total} for @orders; for my $customer (sort keys %by_customer) { my $sum = 0; $sum += $_ for @{ $by_customer{$customer} }; say "$customer: $sum"; }
let orders = [ [customer total]; [ada 30] [grace 45] [ada 12] ] $orders | group-by customer | items { |customer, rows| {customer: $customer, sum: ($rows.total | math sum)} }
The Perl version is genuinely clever and genuinely opaque — @{ ... } to dereference, autovivification to create, and a nested loop to sum, with the dereference syntax repeated in both places. The cost of that cleverness is that a typo in the key silently creates a new group. group-by returns a record whose keys are the group names and whose values are tables, and items walks that record giving you the key and the value together. $rows.total pulls one column out of a whole table, which is the move that makes this a one-liner.
Unique values and a frequency count
Every Perl programmer knows grep { !$seen{$_}++ } by heart. It works by exploiting post-increment returning the old value, which is elegant and completely unreadable to anyone meeting it for the first time.
use v5.38; my @words = qw(apple pear apple fig pear apple); my %seen; my @unique = grep { !$seen{$_}++ } @words; # the classic idiom say join ",", @unique; my %counts; $counts{$_}++ for @words; say "$_=$counts{$_}" for sort keys %counts;
let words = [apple pear apple fig pear apple] print ($words | uniq | str join ",") $words | uniq --count | sort-by value
Nushell has both jobs as flags on one command: uniq for the distinct values and uniq --count for a table of value and count, which is what the second Perl block builds by hand. The counting version returns a table, so it sorts and filters like anything else. Note that uniq here does not require sorted input the way the Unix uniq program does — that similarity of name to a very differently behaved tool is worth flagging, since a Perl programmer reaching for sort | uniq out of shell habit will find the sort unnecessary.
Regular Expressions
Matching — still worth reaching for
Nushell has regular expressions and they are the Rust regex crate rather than Perl's engine. The match operator is =~, which will look familiar, and $it is the current value inside a bare where.
use v5.38; my @lines = ("ERROR disk full", "INFO started", "ERROR timeout"); for my $line (@lines) { say $line if $line =~ /^ERROR/; } say scalar( grep { /^ERROR/ } @lines ), " errors";
let lines = ["ERROR disk full" "INFO started" "ERROR timeout"] $lines | where $it =~ '^ERROR' | each { |line| print $line } print $"($lines | where $it =~ '^ERROR' | length) errors"
The section exists to say plainly: regex is still the right tool for genuinely unstructured text, and this page's argument is not that you should stop using it. What changes is how often you need it. In Perl a regex is also how you split a line into fields, pick out the third column and check that a value is a number, because there is no other structure to work with. Here those jobs belong to split column, get and into int, and the regex is left for the case it is actually good at. Note the single quotes: a Rust regex uses backslashes freely, and a single-quoted Nushell string passes them through untouched.
Capture groups, and a better answer
Perl's captures land in the numbered globals $1, $2 and so on, which is why the immediate next line is usually one that copies them into named variables before something else overwrites them. Nushell has parse, which names them as it goes.
use v5.38; my $line = "ada=36"; if ($line =~ /^(\w+)=(\d+)$/) { my ($name, $age) = ($1, $2); say "$name is $age"; }
let line = "ada=36" let matched = $line | parse "{name}={age}" print $"($matched.0.name) is ($matched.0.age)"
parse takes a template with named holes and returns a table, one row per match, with a column per hole. That is why the result is subscripted .0.name — it is a table even for one match, which means running it over a list of lines produces a table of everything, ready to where and sort-by. Perl's named captures (?<name>\w+) and %+ get halfway there, but you still write a loop to accumulate the results. When the template is not enough, parse --regex takes a full regex with named groups.
Substitution
Perl's s/// edits its target in place, so producing a modified copy means either the two-step (my $copy = $original) =~ s/// dance or the /r flag added in 5.14. Nushell has only the copy.
use v5.38; my $text = "the year 2019 and the year 2020"; (my $masked = $text) =~ s/\d{4}/YYYY/g; say $masked; my $first_only = $text =~ s/\d{4}/YYYY/r; say $first_only;
let text = "the year 2019 and the year 2020" print ($text | str replace --all --regex '\d{4}' "YYYY") print ($text | str replace --regex '\d{4}' "YYYY")
The flags line up: /g is --all, and a regex needs --regex because str replace takes a literal string by default. That default is the opposite of Perl's and it is the safer one — replacing a literal "1.2.3" in Perl requires remembering that . is a metacharacter, and here it requires nothing. Perl still wins outright on the harder end of this: /e to evaluate the replacement as code, and tr///, have no equivalent.
Control Flow
if is an expression, and there is no unless
Nushell's if produces a value, so you bind the whole construct rather than assigning inside each branch. It is else if, two words, and there is no unless and no statement modifier.
use v5.38; my $temperature = 18; my $advice; if ($temperature > 25) { $advice = "wear shorts" } elsif ($temperature > 15) { $advice = "a jacket will do" } else { $advice = "wear a coat" } say $advice; say "chilly" unless $temperature > 20;
let temperature = 18 let advice = if $temperature > 25 { "wear shorts" } else if $temperature > 15 { "a jacket will do" } else { "wear a coat" } print $advice if not ($temperature > 20) { print "chilly" }
Losing the statement-modifier forms — say "chilly" unless $cold, say for @list — is the change a Perl programmer feels most, because those are what make Perl short. What you get back is that the value cannot be left unset: there is no path through the Nushell version where advice is undefined, whereas the Perl version relies on the else being there. Nushell's conditions are also strictly boolean; an empty string or a zero is not a condition, which is the subject of a Gotchas row below.
Loops, and why you write fewer of them
Nushell has a real for with break and continue — the Perl keywords are last and next, which is the only thing to relearn.
use v5.38; my @numbers = (1 .. 5); for my $number (@numbers) { next if $number == 3; last if $number == 5; say $number; }
let numbers = 1..5 | each { |number| $number } for number in $numbers { if $number == 3 { continue } if $number == 5 { break } print $number }
The reason this row is short is that you will rarely write it. for is a statement: it produces no value, so its body has to print or mutate to be worth anything, and that puts it in the same category as Perl's for with a side effect. Nearly every loop that computes something is a pipeline instead, and the pipeline is what composes. Reach for for when you genuinely want the imperative version — early exit, or a side effect per item — and for each the rest of the time.
Subroutines Become Commands
sub becomes def, with a signature
A Perl subroutine receives its arguments as the flat list @_ and unpacks them by hand, which is why the first line of nearly every Perl sub is a my (...) = @_;. A Nushell command declares them.
use v5.38; sub greet { my ($name, $greeting) = @_; $greeting //= "Hello"; return "$greeting, $name!"; } say greet("Ada"); say greet("Ada", "Welcome");
def greet [name: string, greeting: string = "Hello"] { $"($greeting), ($name)!" } print (greet "Ada") print (greet "Ada" "Welcome")
Three things follow from that declaration. The parameters have types, so passing a string where an int is declared is an error at the call site rather than a surprise inside. Defaults are part of the signature, so //= is not needed. And help greet works — the signature is the documentation, where Perl needs POD written separately and kept in step by hand. The other visible change is the call: arguments are separated by spaces, not commas, because a command call is shell syntax rather than function syntax.
Named options are flags
Perl's named-argument idiom is a flat list unpacked into a hash, with // supplying each default. Nushell has real flags, declared with a leading --.
use v5.38; sub report { my (%options) = @_; my $separator = $options{separator} // ", "; my @rows = @{ $options{rows} }; my $text = join $separator, @rows; return $options{loud} ? uc $text : $text; } say report(rows => ["ada", "grace"]); say report(rows => ["ada", "grace"], separator => " | ", loud => 1);
def report [rows: list, --separator: string = ", ", --loud] { let text = $rows | str join $separator if $loud { $text | str uppercase } else { $text } } print (report [ada grace]) print (report [ada grace] --separator " | " --loud)
A flag declared with no type is a switch: --loud is true when passed and false otherwise, so there is no truthy sentinel to invent the way Perl's loud => 1 does. The larger point is that the command now behaves exactly like a builtin — it takes flags, it appears in help, it tab-completes, and a caller cannot tell it was not shipped with Nushell. A Perl sub taking %options is a convention that every caller and every reader has to recognize on sight.
A command that takes pipeline input
This has no Perl counterpart at all. A Nushell command can read from the pipeline, through $in, which is what lets your own commands sit between two builtins.
use v5.38; # Perl subs take arguments; there is no pipeline to receive from. sub shout { my (@words) = @_; return map { uc } @words; } say join " ", shout("ada", "grace");
def shout [] { $in | each { |word| $word | str uppercase } } print ([ada grace] | shout | str join " ")
That is the payoff of the whole design. [ada grace] | shout | str join " " puts your command in the middle of a pipeline exactly as where or sort-by would be, and it composes with everything because it speaks the same protocol — values in, values out. Perl's pipeline is the shell's, made of bytes between separate processes, so a Perl sub can never be a stage in one; the closest equivalent is a filter script, which pays a process launch and a round trip through text. You can declare the shape too, with def shout []: list -> list.
Closures
Perl closures capture the variable itself, so a counter that remembers between calls is four lines. Nushell closures capture the value, so that particular trick is not available — and this row shows what they are for instead.
use v5.38; sub make_counter { my $count = shift; return sub { return ++$count }; } my $next = make_counter(10); say $next->(); say $next->();
# A closure captures by VALUE, so it cannot count. let multiplier = 3 let scale = { |number| $number * $multiplier } print (do $scale 5) print ([1 2 3] | each $scale | str join ",")
A Nushell closure has no mutable captured state by design: it may run once per row of a large table, and a shared mutable cell would make the order of evaluation observable. So there is no counter, no memoization and no object-in-a-closure, which are all real Perl patterns. What a closure is for here is being passed to a command — the block you hand each, where, insert or sort-by is exactly this value, which is why $scale can be stored in a variable and reused across both calls above. do is how you invoke one directly.
Files & I/O
Reading a file
Perl's open takes a filehandle, a mode and a name, and every call is followed by or die $! because open returns false rather than raising. Nushell has open and save, and a failure is an error that stops the pipeline on its own.
use v5.38; open my $out, ">", "perlnushellread-notes.txt" or die $!; print $out "first line\nsecond line\n"; close $out; open my $in, "<", "perlnushellread-notes.txt" or die $!; my @lines = <$in>; close $in; chomp @lines; say scalar @lines, " lines"; say $lines[0];
"first line\nsecond line\n" | save --force perlnushellread-notes.txt let lines = open perlnushellread-notes.txt | lines print $"($lines | length) lines" print $lines.0
Both examples write the file first, because every run starts from an empty filesystem in the browser and each row here uses a filename prefix no other row uses. Two details on the Nushell side: lines splits the text and drops the trailing newline, so there is no chomp to forget; and save refuses to overwrite without --force, which is the opposite of > and has saved more files than it has annoyed people. The bigger surprise is in the next row.
open parses by extension
This is the row that most reliably surprises people. open in Nushell does not return text — it looks at the extension and returns the parsed value, so a .json file arrives as a record and a .csv file as a table.
use v5.38; # Perl reads bytes; the decoding is a separate, deliberate step. open my $out, ">", "perlnushellparse-config.json" or die $!; print $out '{"port": 8080, "debug": true}'; close $out; open my $in, "<", "perlnushellparse-config.json" or die $!; my $text = do { local $/; <$in> }; close $in; # Without JSON::PP this would be a regex; with it, one call: my ($port) = $text =~ /"port":\s*(\d+)/; say "port: $port";
'{"port": 8080, "debug": true}' | save --force perlnushellparse-config.json let config = open perlnushellparse-config.json print $"port: ($config.port)" print $"type: ($config | describe)"
The Perl column shows the honest alternative when a parser is not to hand: pull the field out with a regex, which works until the file is formatted differently. With JSON::PP — core since 5.14 — Perl is one decode_json call away, and the comparison becomes fair; the difference that remains is that Nushell decided for you based on the name, so there is no import and no chance of forgetting. When you want the raw text, open --raw gives it, and the from json family lets you parse text whose extension lied.
Listing files, and what ls returns
Perl's glob returns filenames as strings, so anything else about a file — its size, its age — needs a separate stat operator per question. Nushell's ls returns a table with the metadata already in it.
use v5.38; mkdir "perlnushellglob-dir" unless -d "perlnushellglob-dir"; for my $name ("alpha.txt", "beta.txt", "gamma.log") { open my $out, ">", "perlnushellglob-dir/$name" or die $!; print $out "x" x 10; close $out; } my @found = glob "perlnushellglob-dir/*.txt"; say scalar @found, " text files"; say -s $found[0], " bytes in the first";
mkdir perlnushellglob-dir for name in [alpha.txt beta.txt gamma.log] { "xxxxxxxxxx" | save --force $"perlnushellglob-dir/($name)" } let found = ls perlnushellglob-dir/*.txt print $"($found | length) text files" print $"($found.0.size) in the first"
This is the thesis in its most concrete form. In Perl, -s, -M and -d each ask the filesystem again about a name you already have; in Nushell the columns name, type, size and modified came back with the listing, so "the three biggest files modified this week" is ls | where modified > (date now) - 7day | sort-by size --reverse | first 3 with no stat calls written at all. Note the size prints as 10 B rather than 10: it is a filesize value, not an integer, so it compares against 1mb correctly and formats itself when shown.
JSON, CSV & Friends
JSON in and out
Perl needs JSON::PP, core since 5.14, and the decoded structure is references all the way down. Nushell's from json is a builtin and produces ordinary records and lists.
use v5.38; use JSON::PP; my $text = '{"name": "Ada", "tags": ["math", "engines"]}'; my $data = decode_json($text); say $data->{name}; say join ",", @{ $data->{tags} }; say encode_json({ ok => JSON::PP::true, count => 2 });
let text = '{"name": "Ada", "tags": ["math", "engines"]}' let data = $text | from json print $data.name print ($data.tags | str join ",") print ({ok: true, count: 2} | to json --raw)
The third line is where the difference has teeth. Perl has no boolean type, so encode_json cannot tell 1 meaning true from 1 meaning one, and every JSON module ships sentinel objects — JSON::PP::true — that you must remember to use or your API sends "ok": 1. Nushell has a real bool, so true round-trips as true. The same gap explains why numbers sometimes serialize as strings from Perl: a scalar that has been used as a string remembers, and the encoder can only report what it sees.
CSV becomes a table with no work
Splitting CSV on commas is the classic wrong answer — it breaks on any quoted field containing a comma — but it is what a Perl script without Text::CSV does, and it is worth seeing beside the alternative.
use v5.38; my $csv = "name,age\nada,36\ngrace,45\n"; my @lines = split /\n/, $csv; my @header = split /,/, shift @lines; my @rows; for my $line (@lines) { my @fields = split /,/, $line; my %row; @row{@header} = @fields; push @rows, \%row; } say $rows[1]{name}, " is ", $rows[1]{age};
let csv = "name,age\nada,36\ngrace,45\n" let rows = $csv | from csv print $"($rows.1.name) is ($rows.1.age)" print ($rows | describe)
The Perl column also shows the hash slice @row{@header} = @fields, which is a genuinely nice piece of the language: it assigns a whole list to a set of hash keys at once. It is still eight lines to produce what from csv produces in one, and it is still wrong on quoted fields. describe confirms the result is a table<name: string, age: int> — the ages parsed as integers, so they sort and sum correctly with nothing further asked. The whole from family works this way: from tsv, from yaml, from toml, from ssv for space-separated output from other tools.
Converting between formats
Once a format is a table, converting to another format is one more command. This row is short on the Nushell side and that is the entire content of it.
use v5.38; use JSON::PP; my $csv = "name,age\nada,36\ngrace,45\n"; my @lines = split /\n/, $csv; my @header = split /,/, shift @lines; my @rows = map { my @fields = split /,/, $_; my %row; @row{@header} = @fields; \%row; } @lines; say encode_json(\@rows);
let csv = "name,age\nada,36\ngrace,45\n" $csv | from csv | to json --raw
The from X | to Y shape works for every pair the two families cover, which is the practical reason people install Nushell in the first place. The Perl version is not badly written — it is what you get from doing it honestly without Text::CSV — and note that its JSON output has the ages as strings, because split produced strings and nothing said otherwise, while the Nushell output has them as numbers. That is the typed pipeline showing up one last time in the place it matters most: the boundary where the data leaves your program.
Error Handling
die and eval become error and try
Perl's exception mechanism is eval plus the global $@, and the block must end in a true value so the caller can tell success from failure. Nushell has try/catch and a structured error value.
use v5.38; my $result = eval { die "something went wrong\n"; 1; }; if (!$result) { print "caught: $@"; } say "carrying on";
try { error make {msg: "something went wrong"} } catch { |failure| print $"caught: ($failure.msg)" } print "carrying on"
The 1; at the end of the Perl block is not decoration — eval returns the block's last value, so a block that legitimately ends in 0 or "" is indistinguishable from one that died, and that is why the idiom exists. The $@ global has a matching hazard: anything running between the eval and the check can overwrite it, which is what Try::Tiny exists to fix. Nushell's catch receives the error as an ordinary value with msg, debug and raw fields, so there is no global and no sentinel return.
A failed conversion stops the pipeline
Perl converts a non-numeric string to zero and carries on, warning only if warnings are enabled. Nushell's into int raises, so mixed input has to be handled explicitly.
use v5.38; my @inputs = ("36", "abc", "45"); # Perl converts anything, quietly: my $total = 0; for my $input (@inputs) { no warnings "numeric"; $total += $input; } say "total: $total"; # 81 — "abc" silently became 0
let inputs = ["36" "abc" "45"] let numbers = $inputs | each { |input| try { $input | into int } catch { null } } print $"total: ($numbers | math sum)" print $"skipped: (($inputs | length) - ($numbers | length))"
Which behavior you want depends on the day, and it is worth being honest about that: Perl's is what makes a throwaway one-liner over messy data work at all, and Nushell's is what stops a bad row from silently changing a total in a report somebody acts on. The Nushell column shows the middle path — try per element, and a count of what did not survive, which is the thing the Perl version cannot tell you: its total is 81 and looks entirely plausible. One behavior here is worth committing to memory: each drops any element whose closure returns null, so the failed conversion removes itself from the list and no compact is needed. That is convenient exactly until you return null by accident and quietly lose rows.
⚠ Gotchas for Perl Developers
Only a boolean is a condition
Perl's truth rules are a list you have to know: 0, "" and the string "0" are false, while "0.0" and " " are true. Nushell has no such list, because a condition must already be a boolean.
use v5.38; my @values = (0, "", "0", "0.0", " ", 1); for my $value (@values) { say "'", $value, "' is ", ($value ? "true" : "false"); }
# A condition must BE a bool; there is no truthiness to memorize. let count = 0 let name = "" print (if $count > 0 { "has items" } else { "empty" }) print (if ($name | is-empty) { "no name" } else { $name }) # into bool takes numbers and the words true/false, and nothing else: print (0 | into bool) print ("true" | into bool)
So if $count { … } is not a shortcut, it is an error — can't convert int to boolean, at the moment the line runs. You write if $count > 0, or if ($list | is-empty). That is more typing and it removes a category of bug: the Perl line say "found" if $count is wrong when a legitimate count of zero should still be reported, and it reads as correct. Note the narrowness of into bool too: it accepts a number, or the literal strings "true" and "false", and refuses everything else"0" | into bool is an error rather than false. There is no coercion to fall back on, deliberately.
Shelling out is a different thing here
Perl's backticks, system and open-on-a-pipe all launch a process and hand you its bytes. Nushell has the same capability, spelled ^command, with one difference that matters and one that only matters here.
use v5.38; # Perl reaches the system three ways, all of them text: my $listing = `echo hello from the shell`; # backticks capture stdout chomp $listing; say $listing; system("true") == 0 or die "command failed"; say "exit status was 0";
# Nushell CAN call external commands with ^, but this build has no # processes to dispatch to, so the row shows what replaces them. let listing = "hello from a builtin" print $listing print "and no process was started"
The difference that matters everywhere: an external command returns text, so the moment you call one you are back in Perl's world and must parse. That is why Nushell ships builtin equivalents of so many familiar tools — ls, ps, du, http get — and why ^ls | from ssv exists as the escape hatch for the ones it does not. The difference that only matters on this page: the in-browser build has no processes at all, so ^anything has nothing to dispatch to, which is why the second column demonstrates the replacement rather than the call.
There is no par-each in this build
Nushell has par-each, which runs a closure over a list across threads and is one of the reasons people reach for it. The in-browser build has no threads, so it is not available on this page.
use v5.38; my @numbers = (1 .. 5); # Perl's parallelism is a module away (Parallel::ForkManager, threads), # and both are real processes or real threads. my @squares = map { $_ * $_ } @numbers; say join ",", @squares;
let numbers = 1..5 | each { |number| $number } # par-each exists in Nushell, but needs threads this build does not have. let squares = $numbers | each { |number| $number * $number } print ($squares | str join ",")
each does exactly the same work in the same order, so nothing here is wrong — only slower on a large list than the same script would be at a terminal. It is worth knowing what the real thing costs elsewhere: because closures capture by value and nothing is shared, par-each needs no lock and no coordination, which is a considerably easier promise than threads makes in Perl, where the entire interpreter is cloned per thread. Result order is not guaranteed with par-each; pipe through sort-by if it matters.
Arguments are separated by spaces
Nushell is a shell first, so calling a command is shell syntax: the name, then the arguments, separated by spaces. Commas in a list literal are optional and mostly omitted.
use v5.38; sub add { my ($left, $right) = @_; return $left + $right } say add(2, 3); say join ",", (1, 2, 3); # commas build a list too
def add [left: int, right: int] { $left + $right } print (add 2 3) # spaces, not commas print ([1 2 3] | str join ",") # a list literal has no commas either
The failure mode is quiet, which is why this is in Gotchas. add 2, 3 is not a syntax error — it passes the single string "2," and then 3, or fails on the type in this case, but in a command taking strings it would simply do the wrong thing. The other half of the rule is that a command call in an expression needs parentheses: print (add 2 3), because print add 2 3 would mean printing four separate things. Once both halves are internalized the syntax stops surprising you; until then it is the most common source of confusion on the page.
Nothing survives between runs
This is a property of the page rather than of the languages, and it is worth stating because it changes how you should read every file example above. Each click here starts a brand-new Nushell engine with an empty filesystem.
use v5.38; # A Perl script leaves its files behind for the next script to find: open my $out, ">", "perlnushellleftover-marker.txt" or die $!; print $out "written by this run\n"; close $out; say -e "perlnushellleftover-marker.txt" ? "the file exists now" : "missing";
# In the browser each run gets a FRESH engine and an EMPTY filesystem. "written by this run\n" | save --force perlnushellleftover-marker.txt print (if ("perlnushellleftover-marker.txt" | path exists) { "the file exists now" } else { "missing" })
So an example must create every file it reads — which is why each file row above writes before it opens — and nothing one row leaves behind can be seen by another. That mirrors what a fresh shell would give you and removes a real hazard: without it, what a row printed would depend on which rows you had clicked first. It also means no def, no let and no environment change carries over, so each example is genuinely self-contained. At a real terminal, of course, all of that persists for the length of the session.