Overview#

Nushell is a command interpreter built around structured data. Every command emits a typed value (a scalar, record, list, or table) and every pipeline stage transforms a typed value. The interpreter parses each command, evaluates expressions, calls the built-in or plugin command, and forwards the result.

As a language, Nu is closer to a typed functional scripting language than to a Unix shell. Variables are immutable by default, types are real (int, float, string, bool, date, duration, filesize, record, list, table, binary, closure, cellpath), and control flow uses expressions rather than exit-coded commands. The trade is that bash’s “everything is a string” escape hatches do not work; the operator has to think in types.

For the data-structure side (records, tables, lists), see Structures. For the wiring side (redirection, byte mode, host-shell boundary), see I/O and Pipelines.

Syntax#

A script lives in a .nu file and runs with nu.

$ cat hello.nu
$ # comments start with #
$ print "hello"

$ nu hello.nu
$ nu -c 'ls | first 5'

Comments#

Single-line comments start with #. There are no block comments; chain # on adjacent lines.

# a comment
# another comment

Identifiers#

Names are letters, digits, _, and -. Variables refer back to their names with $name. Environment variables live under $env ($env.PATH); shell metadata lives under $nu ($nu.config-path).

let my-var = 1
let _hidden = "secret"
print $my-var
print $env.HOME

Variables#

let binds an immutable name. mut opts in to mutability; the resulting variable still cannot leave its defining scope. const is a compile-time constant.

let name = "operator"
mut count = 0
$count = $count + 1                   # legal: mut

const VERSION = "1.0"                 # const

Reassigning a let is an error. The discipline is what makes Nu pipelines safe to refactor.

Literals#

Real types, real literals.

42                                    # int
3.14                                  # float
"string"                              # string
'single-quoted string'                # string, no interpolation
true / false                          # bool
2026-05-15                            # date
30sec / 5min / 2hr / 7day             # duration
1KiB / 1MB / 2GiB                     # filesize
[1 2 3]                               # list
{name: "operator", age: 36}           # record
[[a b]; [1 2] [3 4]]                  # table (header + rows)
null                                  # null

String interpolation#

Double-quoted strings interpolate with $(...) and bare $name references inside a special $"..." form. Single quotes never expand.

let name = "operator"
print $"hello ($name)"                # hello operator
print 'hello $name'                   # hello $name (literal)

Operators#

Standard arithmetic, comparison, logical, and string operators work as written. Regex match is =~; !~ for not-match. in and not-in test list / record membership.

Operator

Family

Effect

+ - * / mod

Arithmetic

As written; mod is the integer remainder

//

Arithmetic

Floor division

**

Arithmetic

Exponent

== != < <= > >=

Comparison

As written

and or not

Logical

Boolean combinators

=~ / !~

Regex

Match / not-match

starts-with / ends-with

String

Prefix / suffix test

in / not-in

Membership

Element-in-list, key-in-record

Control#

Branching and looping use expressions with explicit types, not exit codes. if returns a value; each is the pipeline form of foreach; for is the imperative form.

If#

if is an expression. The branches must produce the same type (or both produce nothing). else is optional but typical.

let status = if (sys host | get hostname) =~ "prod" {
    "production"
} else {
    "non-prod"
}
print $status
        flowchart LR
  C{cond?} -->|true| T[then expr] --> X([result])
  C -->|false| E[else expr] --> X
    

Each (pipeline)#

each applies a closure to every item in a list or table. The default reach for “do this for every row”.

ls | each { |row| {name: $row.name, size: $row.size} }
1..5 | each { |n| $n * $n }                          # [1 4 9 16 25]

For (imperative)#

for loops the way bash does; useful when the body has side effects rather than producing a transformed value.

for h in [web01 db01 cache01] {
    print $"connecting to ($h)"
}

While#

mut i = 0
while $i < 5 {
    print $i
    $i = $i + 1
}

Match#

match is the pattern-matching primitive; far more expressive than bash’s case. Patterns can match literals, types, list structures, and record fields.

match $verb {
    "get" => "read-only"
    "put" | "post" => $"write: ($verb)"
    _ => "unknown"
}

match $point {
    {x: 0, y: 0} => "origin"
    {x: 0, y: _} => "on y-axis"
    {x: _, y: 0} => "on x-axis"
    _            => "off-axis"
}

Custom Commands#

User-defined commands look like built-ins. Parameters take a type, multi-word names are allowed (quoted), and the result is a normal pipeline producer downstream verbs can filter or transform.

def "files larger than" [size: filesize] {
    ls | where size > $size | sort-by size --reverse
}

files larger than 10MB

Type annotations validate the input at the boundary. The operator gets a real error if a caller passes a string where a filesize is wanted.

def greet [
    name: string                              # required, typed
    --greeting: string = "hello"              # flag with default
    --shout(-s)                               # boolean flag
] {
    let msg = $"($greeting) ($name)"
    if $shout { $msg | str upcase } else { $msg }
}

greet "operator" --shout
greet "alice" --greeting "hi"

I/O#

Nu reads and writes structured data natively. open parses by file extension and returns the parsed value; save reverses the trip when paired with a to <format> step. Streams between commands carry typed values, not bytes; piping to an external program switches into byte mode at the boundary.

open data.json
open data.yaml
open data.toml
open data.csv
open data.xlsx
open data.parquet
open data.sqlite

ls | to csv | save inventory.csv
{a: 1, b: 2} | to json | save out.json

For the full surface (redirection, byte mode, the host-shell boundary), see I/O and Pipelines.

Errors#

Errors propagate as values. try catches them and returns either the result of the body or the result of the catch arm.

let result = try {
    open missing.json
} catch {
    {}                                        # default
}

try {
    http get https://target.example.com
} catch { |err|
    print -e $"failed: ($err.msg)"
    exit 1
}

error make raises a structured error.

def check [x: int] {
    if $x < 0 {
        error make {msg: "x must be non-negative", label: {text: "got"}}
    }
    $x
}

Modules#

A module is a .nu file with export def declarations. use path/to/module.nu imports its commands. Modules can also export environment changes (export-env) and constants.

# ~/.config/nushell/scripts/myutils.nu
export def hello [name: string] { $"hello ($name)" }
export def --env enter-project [path: path] {
    cd $path
    $env.PROJECT = ($path | path basename)
}

# In config
use ~/.config/nushell/scripts/myutils.nu *
hello "operator"
enter-project ~/src/coh

External Commands#

Anything not built into Nu (git, grep, ssh, docker) runs as an external command. Nu pipes raw bytes to externals and accepts raw bytes back. The trick is converting between Nu’s structured world and the external world.

^git status                              # explicit external
git status                               # implicit (when no built-in shadows)
ls | to text | grep .go                  # render to bytes for grep
(^uname -r) | str trim                   # parse external output

The ^ prefix forces external resolution when a Nu command shadows the name.

Runtime#

Nu is written in Rust and ships as a single binary. The nu process parses each pipeline into an AST, evaluates each stage in turn, and streams values through the next stage. There is no fork on Nu-to-Nu transitions; each stage runs in the same process. External commands fork and exec the way bash does.

Practical implications.

  • Startup is fast because there is no compile step. config.nu work still adds latency; profile it.

  • Streaming is real, large tables flow through pipelines without materialising in full unless a stage demands it (sort, for example).

  • Pre-1.0, breaking changes happen between minor versions; pin the version on shared scripts.

Library#

The standard library (std) ships built into Nu and covers logging, asserts, dates, formatting, environment manipulation, and operating-system probes.

use std

std log info "starting"
std assert ($x > 0) "x must be positive"
std datetime now
std help                                     # the in-shell help

For the broader catalog, see Libraries.

Tasks#

Filter rows by predicate.

ls | where size > 1MB and modified > (date now) - 1hr

Iterate a file’s lines.

open input.txt | lines | each { |line| $line | str trim }

Read a file into a list of rows.

let users = (open users.json)
$users.0.name

Run a step with a timeout. (Drops to external timeout since Nu has no native time bound on commands.)

timeout 30sec ^curl -fsSL https://target

Background work and wait for all jobs.

let pids = [
    (bg-task &)
    (bg-task &)
]
wait

(Nu does not have first-class job control; for parallel work use par-each.)

[web01 db01 cache01] | par-each { |h| ssh $h uptime }

Diff two pipelines.

diff (open a.json | sort | to text) (open b.json | sort | to text)

References#

  • Nushell book (the upstream manual; types, pipelines, custom commands, scripting).

  • Nu cookbook (recipe catalog).

  • Nushell for the surrounding Nu section (Setup, init files, history, completion).

  • Patterns for error handling, immutability discipline, and bash-interop idioms.

  • Tools for the toolchain (nu, plugins, debugging).

  • The Terminal for the terminal-level view of scope and process nesting.