PONY λ M2 Modula-2

Bash.CodeCompared.To/Nushell

An interactive executable cheatsheet comparing Bash and Nushell

Bash 5.3 Nushell 0.114.1
Structured Pipelines
Columns have names, not numbers
This is the whole page in one row. A Bash pipeline carries bytes, so every stage re-parses text and refers to fields by position. A Nushell pipeline carries structured data, so a field has a name and a type.
printf 'x%.0s' {1..1200} > report.txt echo ok > flag.txt # Pull the size column out of ls output. # Column 5 is positional — it moves if the # format changes, and a filename with a # space breaks the count entirely. ls -l *.txt | awk '{print $5}'
(0..1199 | each { "x" } | str join) | save --force report.txt "ok" | save --force flag.txt # ls returns a TABLE. size is a real column # with a real type, addressed by name. ls *.txt | get size
Look at what each column prints. Bash gives you 1200 — a string of digits it happens to have found in the fifth position, which you would have to convert before comparing. Nushell gives you 1.2 kB, because size is a filesize, and where size > 1kb would work on it directly. The Bash version is also fragile in ways that are easy to forget: $5 is the fifth whitespace-separated field of whatever ls -l happened to print, so a filename containing a space silently shifts it, and parsing ls output is famously discouraged for exactly this reason. Naming a column cannot drift.
A pipeline of builtins
The shapes look almost identical, which is the point — Nushell kept the pipe. What changed is what flows through it and what the stages are.
# Each stage is a separate PROCESS, and # each one re-parses the text the last # one printed. printf '%s\n' banana apple cherry apple | sort | uniq | head -n 2
# Each stage is a builtin operating on a # list value. Nothing is serialized between # them. [banana apple cherry apple] | sort | uniq | first 2
The Bash version spawns four processes and converts the data to text three times. The Nushell version passes one list value through four function calls. That difference stops being merely academic the moment an element contains a newline.
Counting
Counting in Bash means counting lines of text, which is why wc -l is the answer to "how many items". Nushell counts elements of a list.
printf '%s\n' banana apple cherry | wc -l
[banana apple cherry] | length
The distinction matters when an item contains a newline: wc -l reports 4 for three items where one is two lines long, while length reports 3. The Bash answer is not wrong, it is answering a different question.
Capturing output into a variable
Both languages spell "run this and use the result" with parentheses, so the syntax transfers. What differs is that Bash hands you a string and Nushell hands you the value itself.
# $( ) captures TEXT, and the trailing # newline is stripped for you. first_word=$(printf '%s\n' alpha beta | head -n 1) echo "$first_word"
# Parentheses capture the VALUE, whatever # type it happens to be. let first_word = ([alpha beta] | first) print $first_word
Because the Bash form always produces text, capturing a list means capturing newline-separated text and splitting it again at the point of use. In Nushell a captured list is still a list.
Quoting & Word Splitting
Unquoted variables do not split
Word splitting is the single behavior most likely to have bitten a Bash user, and Nushell simply does not have it. A variable holding a string is a string, in every context.
sentence="alpha beta gamma" # Bash splits an unquoted expansion on IFS, # so this loop runs three times. for word in $sentence; do echo "[$word]" done
let sentence = "alpha beta gamma" # Nushell never splits. A string is one # value, so this loop runs once. for word in [$sentence] { print $"[($word)]" }
There is no IFS to set, no $IFS to save and restore, and no rule about which expansions split and which do not. Splitting is something you ask for with split row, shown in the Strings section.
Values with spaces need no armor
In Bash, quoting is not a style preference — it is what stops a value from being torn into pieces. In Nushell there is no tearing to prevent.
filename="my report.txt" # Every use has to be quoted, or the space # turns one argument into two. echo "$filename" echo "${#filename}"
let filename = "my report.txt" # Nothing to quote. The value is a value. print $filename print ($filename | str length)
This removes an entire category of bug rather than making it easier to avoid. The Bash habit of quoting everything is still correct Bash; it just has no counterpart to carry over.
Passing arguments through
A rest parameter declared with ... collects the remaining arguments into an ordinary list, which is then just a list.
show_all() { # "$@" is quoted-correct; $* and "$*" and # bare $@ each do something different. for argument in "$@"; do echo "[$argument]" done } show_all one "two three" four
def show_all [...arguments] { for argument in $arguments { print $"[($argument)]" } } show_all one "two three" four
The Bash form works, but only in exactly one spelling: "$@". Dropping the quotes re-splits every argument, and "$*" joins them into one string. Nushell has one spelling because there is nothing for a second one to mean.
Variables & Immutability
Assignment
Nushell uses let and ordinary spacing. The dollar sign is part of how you refer to a variable, so it appears when reading but not in the let that introduces the name.
# No spaces around = , and no sigil when # assigning but a $ when reading. greeting="hello" count=3 echo "$greeting $count"
# let, spaces are fine, and the $ is part # of the variable's name everywhere. let greeting = "hello" let count = 3 print $"($greeting) ($count)"
Bash's no-spaces-around-equals rule exists because greeting = hello would be a command named greeting with two arguments. Nushell parses let as a keyword, so the ambiguity never arises.
Variables are immutable by default
A let binding cannot be reassigned. When you genuinely want a counter, mut declares a mutable variable, and the mutation is then visible in the code rather than implicit.
count=1 count=2 # every variable is rebindable echo "$count" readonly limit=10 echo "$limit"
let count = 1 # count = 2 would be an error. print $count mut total = 0 $total = $total + 5 print $total
Bash inverts the defaults: everything is mutable and readonly is opt-in. The Nushell default catches the accidental reuse of a name, which in a long shell script is a real source of confusion.
Block scope
Nushell bindings are scoped to the block they appear in, and a custom command cannot see the caller's variables at all.
value="outer" show() { # Without 'local' this overwrites the # global, and dynamic scoping means it # is visible to everything it calls. local value="inner" echo "$value" } show echo "$value"
let value = "outer" def show [] { let value = "inner" print $value } show print $value
Bash is dynamically scoped: a local in one function is visible to every function it calls, which is why a helper can be surprised by its caller. Nushell commands are closed over their own scope, so a name collision is not possible.
Strings
Interpolation
An interpolated string starts with $" and the holes are parenthesized. Anything that fits in parentheses is allowed, so a whole pipeline can be interpolated, not just a bare name.
name="world" count=3 echo "Hello, $name — you have ${count} items"
let name = "world" let count = 3 print $"Hello, ($name) — you have ($count) items"
The parentheses mean interpolation and grouping are the same syntax, so $"total: (1 + 2)" works without a separate arithmetic construct. A plain "..." string in Nushell does no interpolation at all, which is the opposite of Bash's double quotes.
Changing case
String operations are commands in a pipeline rather than expansion syntax, so they read left to right and chain with everything else.
name="hello world" echo "${name^^}" echo "${name,,}"
let name = "hello world" print ($name | str uppercase) print ($name | str lowercase)
The Bash operators are terse but unmemorable, and they only arrived in Bash 4 — a script that must run on macOS's system Bash 3.2 cannot use them at all. Note that str upcase is the deprecated spelling in Nushell 0.114; str uppercase is current.
Splitting
split row turns a string into a list on a separator. It is a command taking an argument, so the separator is local to the call.
csv="one,two,three" old_ifs=$IFS IFS="," parts=($csv) IFS=$old_ifs echo "${#parts[@]}" echo "${parts[1]}"
let csv = "one,two,three" let parts = ($csv | split row ",") print ($parts | length) print ($parts | get 1)
The Bash version mutates a global to change how the shell parses, does the split as a side effect of array assignment, then restores the global. Forgetting the restore changes the behavior of unrelated code later in the script.
Joining
str join is the inverse of split row and takes the separator the same way.
fruits=(apple banana cherry) old_ifs=$IFS IFS="," echo "${fruits[*]}" IFS=$old_ifs
let fruits = [apple banana cherry] print ($fruits | str join ",")
Bash can only join on the first character of IFS, so a multi-character separator needs a loop. The Nushell separator is an ordinary string argument of any length.
Search and replace
str replace changes the first match; --all changes every match. A named flag replaces the doubled-slash convention.
path="/usr/local/bin" echo "${path/local/opt}" echo "${path//\//-}"
let path = "/usr/local/bin" print ($path | str replace "local" "opt") print ($path | str replace --all "/" "-")
The Bash forms are compact once memorized, but the difference between one slash and two is easy to misread, and a literal slash in the pattern has to be escaped. A flag named --all needs no decoding.
Trimming and length
str trim removes leading and trailing whitespace, and takes flags for one side only or for a specific character.
padded=" hello " # Bash has no trim. The idiom is a pair of # extglob strips, or an external tool. shopt -s extglob trimmed="${padded##+([[:space:]])}" trimmed="${trimmed%%+([[:space:]])}" echo "[$trimmed]" echo "${#trimmed}"
let padded = " hello " let trimmed = ($padded | str trim) print $"[($trimmed)]" print ($trimmed | str length)
Trimming is common enough that its absence from Bash is conspicuous — the extglob idiom above is the portable answer and few people remember it. Note also that str length counts characters while Bash's ${#name} counts bytes, which differ the moment the text is not ASCII.
Numbers & Math
Arithmetic
Arithmetic is ordinary expression syntax in Nushell — there is no separate arithmetic context to enter.
echo $(( 10 + 3 )) echo $(( 10 - 3 )) echo $(( 10 * 3 )) echo $(( 10 / 3 ))
print (10 + 3) print (10 - 3) print (10 * 3) print (10 / 3)
The last line is the interesting one: Bash prints 3 because its arithmetic is integer-only, while Nushell prints 3.3333333333333335 because / is real division. This is a genuine source of silently wrong results when porting.
Integer division and remainder
Because / is real division, Nushell spells integer division // and the remainder mod.
echo $(( 10 / 3 )) echo $(( 10 % 3 ))
print (10 // 3) print (10 mod 3)
Reaching for / out of Bash habit and getting a float is the most likely arithmetic surprise here, and it will not raise an error — it will quietly produce a fractional value that formats differently downstream.
Floating point exists
Nushell has real numbers as a first-class type, which for a Bash user is a genuine capability rather than a syntax change.
# Bash has no floating point at all. The # usual answer is to shell out to bc or awk. echo "scale=2; 7 / 2" | bc
print (7 / 2) print (0.1 + 0.2)
This is the single most common reason a Bash script shells out to bc or awk — arithmetic a pocket calculator does, needing a second program to do it. Nushell also has typed durations and file sizes, so 1kb + 1kb is meaningful arithmetic.
Lists
Making a list
A list literal uses square brackets, and the elements may be separated by spaces or commas. Indexing is zero-based, matching Bash.
fruits=(apple banana cherry) echo "${fruits[0]}" echo "${#fruits[@]}"
let fruits = [apple banana cherry] print ($fruits | get 0) print ($fruits | length)
Nushell lists nest and can hold any type, including other lists and records. A Bash array can only hold strings, which is why any structure beyond a flat list has to be encoded into one.
Iterating
The for loop takes a list and a block in braces. There is no do or done.
fruits=(apple banana cherry) for fruit in "${fruits[@]}"; do echo "$fruit" done
let fruits = [apple banana cherry] for fruit in $fruits { print $fruit }
The Bash quoting around "${fruits[@]}" is mandatory rather than stylistic — without it, an element containing a space becomes two iterations. That hazard has no Nushell counterpart.
Transforming every element
each runs a closure over every element and collects the results. The closure names its parameter between vertical bars.
numbers=(1 2 3) doubled=() for number in "${numbers[@]}"; do doubled+=( $(( number * 2 )) ) done echo "${doubled[@]}"
let numbers = [1 2 3] let doubled = ($numbers | each {|number| $number * 2 }) print $doubled
Bash has no map, so the loop-and-append shape above is the idiom. It works, but the accumulator has to be declared, initialized and appended to correctly, and none of those three steps is about the transformation.
Reducing to one value
reduce takes a closure of two parameters — the current element and the accumulator, in that order — and threads the accumulator through the list.
numbers=(1 2 3 4) total=0 for number in "${numbers[@]}"; do total=$(( total + number )) done echo "$total"
let numbers = [1 2 3 4] let total = ($numbers | reduce {|number, accumulator| $accumulator + $number }) print $total
Note the parameter order, which is the reverse of many languages: the element comes first and the accumulator second. Use --fold to supply a starting value when the list may be empty.
Appending
Because a let binding is immutable, appending produces a new list. Rebinding the same name in a new let is idiomatic and is not mutation.
fruits=(apple banana) fruits+=(cherry) echo "${fruits[@]}"
let fruits = [apple banana] let fruits = ($fruits | append cherry) print $fruits
A Bash array is mutated in place, which is cheaper but means any function that received it can change what the caller sees. The Nushell form makes the new value explicit at the cost of one more binding.
Records
A record is a real value
A record is written in braces as key/value pairs and is a first-class value: it can be returned from a command, stored in a list, or sent down a pipeline.
declare -A person=([name]=Ada [age]=36) echo "${person[name]}" echo "${person[age]}"
let person = {name: Ada, age: 36} print $person.name print $person.age
A Bash associative array is close in spirit but is a variable, not a value — it cannot be returned from a function, nested inside another array, or passed by value. That single limitation is what makes structured data in Bash so awkward.
Keys and values
columns lists a record's field names and values lists its values, in declaration order.
declare -A person=([name]=Ada [age]=36) echo "${!person[@]}" echo "${person[@]}"
let person = {name: Ada, age: 36} print ($person | columns) print ($person | values)
Bash associative arrays have no defined iteration order, so the two lines above are not guaranteed to correspond. A Nushell record preserves the order its fields were written in, so columns and values line up.
Nesting
Records nest arbitrarily, and a dotted path walks the nesting.
# Bash arrays cannot nest. The usual # workaround is to flatten the path into # the key itself. declare -A config=( [server.host]=localhost [server.port]=8080 ) echo "${config[server.host]}"
let config = { server: {host: localhost, port: 8080} } print $config.server.host
The Bash key server.host only looks like a path — it is one flat string, so there is no way to ask for "everything under server" or to hand the server block to a function. The dot in the Nushell version is real structure.
Updating a field
update returns a new record with one field changed. There is also insert for a field that does not exist yet, and merge to combine two records.
declare -A person=([name]=Ada [age]=36) person[age]=37 echo "${person[age]}"
let person = {name: Ada, age: 36} let person = ($person | update age 37) print $person.age
update can take a closure instead of a value, so update age {|row| $row.age + 1 } increments in place. The same command works on a whole table, changing that column in every row.
Tables
A table is a list of records
The literal syntax puts the column names in the first bracket, then a semicolon, then one bracket per row. The result is an ordinary list of records that happens to print as a grid.
# Bash has no table. The closest thing is # formatting columns by hand on output. printf '%-10s %s\n' name size printf '%-10s %s\n' alpha.txt 1200 printf '%-10s %s\n' beta.log 300
let files = [[name, size]; [alpha.txt, 1200] [beta.log, 300]] print $files
Nushell draws the box for you, but the important part is that this is data rather than formatting — every list and record command works on it, and the display is just what the table type looks like when printed.
Getting a column
get with a column name pulls that column out of every row and returns it as a list.
# Extracting one column from formatted text # means re-parsing it positionally. printf '%s %s\n' alpha.txt 1200 beta.log 300 | awk '{print $2}'
let files = [[name, size]; [alpha.txt, 1200] [beta.log, 300]] print ($files | get size)
This is the operation that awk exists to perform in a Bash pipeline, and it is the clearest illustration of the difference: $2 is a position in a line of text, while size is a name that cannot drift when the layout changes.
Choosing columns
select keeps the named columns and returns a narrower table, where get extracts one column as a bare list.
# cut -f works only on a known delimiter and # still hands back text. printf '%s\t%s\t%s\n' alpha.txt 1200 rw | cut -f1,3
let files = [[name, size, mode]; [alpha.txt, 1200, rw] [beta.log, 300, r]] print ($files | select name mode)
The difference between select and get is worth internalizing early: select preserves the table shape so the result can keep flowing through table commands, while get drops out of it into a plain list.
Getting one row
first and last take rows off either end, with an optional count. Indexing with get 0 also works and returns the row as a record.
printf '%s %s\n' alpha.txt 1200 beta.log 300 | head -n 1
let files = [[name, size]; [alpha.txt, 1200] [beta.log, 300]] print ($files | first)
A row taken from a table is a record, so it can be handed straight to anything that expects one. The Bash equivalent yields a line of text that the next stage has to take apart again.
Filtering & Sorting
Filtering rows
where takes a comparison written against the column names directly, with no closure syntax needed for the common case.
# grep matches TEXT, so a numeric comparison # needs awk and a field number. printf '%s %s\n' alpha.txt 1200 beta.log 300 | awk '$2 > 500 {print $1}'
let files = [[name, size]; [alpha.txt, 1200] [beta.log, 300]] print ($files | where size > 500 | get name)
Because size is a number rather than the characters that spell one, > 500 is a numeric comparison. In the Bash version awk has to be told to treat field two as a number, and grep could not have done it at all.
Filtering on text
When the test is not a simple comparison, where accepts a closure returning true or false.
printf '%s\n' alpha.txt beta.log gamma.txt | grep '\.txt$'
let names = [alpha.txt beta.log gamma.txt] print ($names | where {|name| $name | str ends-with ".txt" })
This is the case where Bash is genuinely more concise — grep with a regex is hard to beat for text matching, and it is the tool everyone already knows. Nushell trades that brevity for a test that reads as code and composes with the rest of the pipeline.
Sorting
sort orders a list, and sort-by orders a table by one or more columns.
printf '%s\n' banana apple cherry | sort
print ([banana apple cherry] | sort)
Because the values are typed, sorting a list of numbers sorts them numerically without a flag. The classic Bash trap of sort putting 10 before 9 because it is comparing text does not arise.
Sorting a table by a column
sort-by names the column to order on. Add --reverse for descending, and name more than one column to break ties.
printf '%s %s\n' alpha.txt 1200 beta.log 300 | sort -k2 -n | awk '{print $1}'
let files = [[name, size]; [alpha.txt, 1200] [beta.log, 300]] print ($files | sort-by size | get name)
The Bash version needs -k2 to say which field and -n to say it is numeric, both of which are assertions about text that sort cannot verify. Nushell already knows the column and its type.
Counting occurrences
uniq --count returns a table of each distinct value and how many times it occurred, with no pre-sorting required.
printf '%s\n' apple banana apple cherry apple | sort | uniq -c | sort -rn
print ([apple banana apple cherry apple] | uniq --count)
The sort | uniq -c | sort -rn chain is one of the most-typed idioms in shell scripting, and every part of it exists to work around uniq only collapsing adjacent duplicates. Nushell counts a list directly.
Grouping
group-by returns a record whose keys are the distinct values and whose values are the matching rows. items then walks that record with a closure taking the key and the value.
# awk with an associative array is the # standard answer, and the output is text # you would have to parse again. printf '%s %s\n' fruit apple veg carrot fruit banana | awk '{count[$1]++} END {for (key in count) print key, count[key]}'
let items = [[kind, name]; [fruit, apple] [veg, carrot] [fruit, banana]] print ($items | group-by kind | items {|kind, rows| {kind: $kind, count: ($rows | length)} })
The result is still structured, so it can be sorted, filtered or converted afterwards. The awk version prints its answer and ends the pipeline — anything further has to start by parsing that output.
Control Flow
Conditionals
The condition is an ordinary expression with no surrounding test construct, and the branches are brace blocks.
count=5 if (( count > 3 )); then echo "bigger" elif (( count == 3 )); then echo "equal" else echo "smaller" fi
let count = 5 if $count > 3 { print "bigger" } else if $count == 3 { print "equal" } else { print "smaller" }
Bash needs (( )) for numbers and [[ ]] for strings and files because a bare condition would be a command to run. Nushell parses if as a keyword, so one form covers everything.
Conditions are booleans
A Nushell condition must be a boolean. There is no exit-status-as-truth rule and no implicit conversion from a string or a number.
# A condition is a COMMAND, and success is # exit status 0 — so 0 means true here. if true; then echo "ran"; fi value="" if [[ -z $value ]]; then echo "empty"; fi
# A condition is a boolean VALUE. if true { print "ran" } let value = "" if ($value | is-empty) { print "empty" }
The inversion in Bash — where 0 means success and therefore true — is a lasting source of confusion, because 0 is falsy in nearly every other language. Nushell keeps exit status for external commands but does not conflate it with truth.
Matching
match is an expression, so it produces a value rather than only running statements. The wildcard arm is _.
value=2 case $value in 1) echo "one" ;; 2) echo "two" ;; *) echo "other" ;; esac
let value = 2 print (match $value { 1 => "one", 2 => "two", _ => "other" })
Because it is an expression, the result can be assigned or piped directly, with no accumulator variable. match also destructures lists and records, which case cannot do — it only matches glob patterns against a string.
While loops
A while loop needs a variable it can change, so the counter is declared with mut rather than let.
countdown=3 while (( countdown > 0 )); do echo "$countdown" countdown=$(( countdown - 1 )) done echo "liftoff"
mut countdown = 3 while $countdown > 0 { print $countdown $countdown = $countdown - 1 } print "liftoff"
This is where Nushell's immutable default costs a keyword, and it is deliberate: the mut marks the one binding in the block that changes underneath you. In practice most loops in Nushell are pipelines instead, and never need a counter.
Custom Commands
Defining a command
A custom command declares its parameters by name in square brackets. Calling it looks exactly like calling a builtin, because there is no distinction.
greet() { # Parameters are positional and unnamed. echo "Hello, $1" } greet "world"
def greet [name] { print $"Hello, ($name)" } greet "world"
The Bash function reads its arguments out of $1, $2 and so on, so the parameter list exists only in the author's head and in a comment. Naming them makes the signature checkable.
Returning a value
The value of a custom command is the value of its last expression — there is no return needed and no printing involved.
double() { # A function returns an exit STATUS, so a # value has to be printed and captured # with $( ), or written to a global as # here — $( ) needs a subprocess, which # the browser Bash runtime cannot spawn. result=$(( $1 * 2 )) } double 21 echo "$result"
def double [number] { $number * 2 } let result = (double 21) print $result
This is a bigger change than it looks. In Bash, returning a value normally means printing it and capturing the text with $( ), which conflates a function's output with its result, forces everything through strings, and costs a subprocess. A Nushell command can return a table.
Flags and named parameters
Flags are declared in the same bracket as positional parameters. A bare --loud is a boolean switch; --name: string = "world" is a typed flag with a default.
greet() { local loud=0 name="world" while (( $# )); do case $1 in --loud) loud=1 ;; --name) shift; name=$1 ;; esac shift done if (( loud )); then echo "HELLO, ${name^^}" else echo "Hello, $name" fi } greet --loud --name Ada
def greet [--loud, --name: string = "world"] { if $loud { print $"HELLO, ($name | str uppercase)" } else { print $"Hello, ($name)" } } greet --loud --name Ada
Hand-rolling the argument loop is one of the most tedious parts of writing a Bash script, and every script does it slightly differently. Nushell also generates --help from this signature at no extra cost.
Taking pipeline input
A command reads its pipeline input from the special variable $in. The signature after the colon declares the input and output types.
# A function reads a pipeline through stdin, # as text, one line at a time. shout() { while read -r line; do echo "${line^^}" done } printf '%s\n' hello world | shout
def shout []: list<string> -> list<string> { $in | each {|line| $line | str uppercase } } [hello world] | shout
Because $in is a value rather than a stream of bytes, the command receives the whole list and can index it, sort it, or count it. The Bash version can only ever see one line at a time unless it buffers by hand.
Types & Signatures
Everything has a type
describe reports a value's type. Nushell distinguishes integers, strings, lists, records, tables, durations, file sizes and more.
# Every value is a string. declare -i only # changes how assignment is evaluated. count=3 declare -p count declare -i number=3 declare -p number
print (3 | describe) print ("3" | describe) print ([1 2 3] | describe) print ({a: 1} | describe)
In Bash there is really only one type — the string — and everything else is a convention about how to interpret it. That is why [[ $a == $b ]] and (( a == b )) are different operators for the same question.
Typed parameters
Annotating a parameter makes Nushell check the argument before the body runs, and the error names the parameter and the expected type.
# No types. A wrong argument is discovered # when arithmetic silently treats it as 0. add() { echo $(( $1 + $2 )) } add 2 3 add 2 oops
def add [first: int, second: int] { $first + $second } print (add 2 3)
The second Bash call above prints 2, not an error: oops is evaluated as an arithmetic expression, an unset name is 0, and the script carries on with a wrong number. That failure mode is exactly what a parameter type removes.
Converting between types
into int, into string, into float and friends convert explicitly, and fail loudly when the value does not fit.
text="42" # There is nothing to convert — arithmetic # context reinterprets the string. echo $(( text + 1 ))
let text = "42" print (($text | into int) + 1)
Bash's implicit reinterpretation is convenient right up to the point where the string is not a number, at which point it becomes 0 without comment. Requiring the conversion is what lets Nushell report the problem.
Data Formats
Reading JSON
from json parses JSON into ordinary records and lists, after which every command on this page applies to it.
# jq is a separate language, installed # separately, for this one job. echo '{"name": "Ada", "age": 36}' | jq -r '.name'
print ('{"name": "Ada", "age": 36}' | from json | get name)
This is arguably the strongest single argument for Nushell to a Bash user. jq is excellent, but it is a whole second language with its own syntax, learned solely to reach into JSON. Here JSON is just another way to spell a record.
Writing JSON
to json serializes any value, and --raw puts it on one line instead of pretty-printing.
# Building JSON by hand is a quoting # exercise; jq -n is the safer answer. name=Ada age=36 printf '{"name": "%s", "age": %d}\n' "$name" "$age"
let person = {name: Ada, age: 36} print ($person | to json --raw)
The printf version is correct only while the values contain no quotes, backslashes or newlines — it is string concatenation pretending to be serialization. Every from converter has a matching to.
Reading CSV
from csv reads the header row as column names and returns a table.
# Splitting on commas is wrong for any CSV # with a quoted comma in a field, which is # why awk -F, is a known trap. printf 'name,size\nalpha.txt,1200\n' | awk -F, 'NR>1 {print $1}'
let text = "name,size\nalpha.txt,1200\nbeta.log,300" print ($text | from csv | get name)
A real CSV parser handles quoted fields, embedded commas and embedded newlines. Splitting on commas handles none of them, and the failure is silent and data-dependent — it works in testing and corrupts one row in production.
Converting between formats
Because every format converges on the same internal value, any from composes with any to — JSON in, YAML out, with nothing format-specific in between.
# Each pairing needs its own tool, and the # glue between them is text. echo '{"name": "Ada"}' | jq -r '[.name] | @csv'
let people = [[name, age]; [Ada, 36] [Grace, 45]] print ($people | to yaml)
The set includes JSON, YAML, TOML, CSV, TSV, XML, INI and more. In Bash each conversion is a different tool with a different query language, and the pipeline between them is untyped text.
Error Handling
Catching a failure
try and catch handle a failure as a value: the catch block receives a record describing the error, including its message.
# There is no try. The idiom is to test the # exit status after the fact. failing_command() { return 3; } failing_command if (( $? != 0 )); then echo "caught: status $?" fi
try { error make {msg: "boom"} } catch {|failure| print $"caught: ($failure.msg)" }
The Bash version has a subtle bug that is extremely common in the wild — the second $? is the status of the if test itself, not of failing_command, so it prints 0. The status has to be captured into a variable immediately.
Raising an error
error make raises a structured error. Given a span it will also underline the offending value in the source, the way a compiler does.
check_positive() { if (( $1 <= 0 )); then echo "must be positive" >&2 return 1 fi echo "ok" } check_positive 5
def check_positive [number: int] { if $number <= 0 { error make {msg: "must be positive"} } "ok" } print (check_positive 5)
A Bash failure is a number plus whatever the function chose to write to stderr, so the caller can only distinguish causes by convention. A Nushell error is a record that a catch block can inspect.
Stopping on failure
Nushell stops on error by default. There is no equivalent of set -e because there is no mode in which errors are ignored.
# Opt in, with well-known exceptions that # make it less reliable than it looks. set -e set -u set -o pipefail echo "still running"
# Nothing to opt into — a failure in a # pipeline stops the pipeline. print "still running"
set -euo pipefail is near-universal in careful Bash scripts precisely because the defaults are unsafe, and it still does not fire for a command whose status is consumed by a test or an && chain. Removing the option removes the exceptions too.
Environment & Scope
Reading the environment
$env is a record, so reading a variable is an ordinary field access and default supplies a fallback.
echo "${EDITOR:-vi}"
print ($env | get --optional EDITOR | default "vi")
Treating the environment as a record rather than a namespace of globals means it can be inspected, filtered and passed around like any other value — $env | columns lists every name that is set.
Environment changes are scoped
with-env runs a block with extra environment variables set, and restores the previous state when the block ends — however it ends.
# export mutates the shell's environment # for the rest of the script. export LOG_LEVEL=debug echo "$LOG_LEVEL"
# with-env sets a variable for one block # and restores it afterwards. with-env {LOG_LEVEL: debug} { print $env.LOG_LEVEL }
The Bash equivalent of a scoped change is to save the old value, set the new one, and restore it, which leaks whenever the script exits early. Nushell also scopes cd the same way inside a block.
External Commands & Files
Calling an external program
The caret prefix forces Nushell to run the external program rather than a builtin of the same name. It is only required when the names collide, but writing it makes the boundary visible.
# Every command is external unless it is a # builtin, and the result is always text. git log --oneline -n 3
# ^ marks an external explicitly, and the # result is still just text. ^git log --oneline -n 3
This is the honest limit of the whole approach: anything outside Nushell hands back bytes, and you have to say how to parse them. The structured world stops at the process boundary.
Structuring external output
lines splits text into a list of lines, and parse applies a pattern whose braces name the captured fields — turning unstructured output back into a table.
# Parsing stays positional forever. git log --oneline -n 3 | awk '{print $1}'
# parse turns text back into a table using # a pattern with named captures. ^git log --oneline -n 3 | lines | parse "{hash} {message}" | get hash
This is the bridge back into the structured world, and it is worth learning early because so much of a real shell session is external commands. Many common tools also have ready-made parsers in the community nu_scripts repository.
Reading a file
open reads a file and, when it recognizes the extension, parses it — so a .json file arrives as a record and a .csv as a table. open --raw opts out and returns the bytes.
echo '{"name":"atlas","port":8080}' > config.json # cat gives you bytes; the format is your # problem. cat config.json
'{"name":"atlas","port":8080}' | save --force config.json # open infers the format from the extension # and returns a record, not text. open config.json | get name
Both columns write the same file and then read it; only the reading differs. Bash gets the whole line back as text and would need jq to reach name. Nushell has already parsed it, so get name is an ordinary field access. This is the Data Formats section applied to files: the parse step is not something you remember to add, it is the default. save is the write direction and infers the format the same way.
Nushell is not POSIX
This row exists to be blunt about the cost. Nushell is a new language, not a POSIX shell with extras, so existing shell knowledge does not transfer the way Bash-to-Zsh does.
echo a > alpha.txt; echo b > beta.txt # Decades of one-liners, answers and # scripts assume this syntax. for f in *.txt; do mv "$f" "${f%.txt}.bak"; done ls *.bak
"a" | save --force alpha.txt "b" | save --force beta.txt # The same job, in a language that shares # almost none of that syntax. ls *.txt | each {|file| mv $file.name ($file.name | str replace ".txt" ".bak") } ls *.bak | get name
Both columns rename the same two files, and nothing about the second is guessable from the first: no for … in … do … done, no "$f", and ${f%.txt} — suffix removal, a thing Bash spells with a punctuation mark — becomes a named str replace. Every one-liner you have memorized, every Stack Overflow answer, and every install.sh on the internet is written in POSIX-ish shell and will not run here. Nushell can call out to bash -c for those, but if that is most of your work, the trade may not be worth it.