
Nushell Pro
- 944 installs
- 12 repo stars
- Updated August 4, 2026
- hustcer/nushell-pro
nushell-pro is a Claude Code skill that teaches idiomatic, secure, and performant Nushell scripting patterns for developers who write, review, refactor, or convert Bash and POSIX shell scripts to .nu files.
About
nushell-pro is a comprehensive Claude Code skill for Nushell (.nu) scripting best practices, idioms, security hardening, and code review. The skill triggers on tasks involving Nushell scripts, modules, custom commands, pipelines, or any .nu file editing, and also supports converting Bash and POSIX scripts to idiomatic Nushell. Coverage spans the Nushell type system, functional-style data manipulation, performance optimization, naming conventions, type annotations, security best practices, and common gotchas. Developers reach for nushell-pro when authoring new automation scripts, auditing existing .nu files for idiomatic patterns, or migrating legacy shell scripts to Nushell's structured data model.
- Enforces Nushell-specific idioms, naming conventions, type annotations, and functional style
- Catches security issues around untrusted input, external commands, paths, and destructive operations
- Supports 7 distinct task types: new script, code review, refactor, Bash conversion, module design, security audit, debug
- Guides conversion of Bash/POSIX scripts into idiomatic Nushell pipelines and custom commands
- Hard-gate: always read existing .nu files and project conventions before editing
Nushell Pro by the numbers
- 944 all-time installs (skills.sh)
- +40 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #307 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hustcer/nushell-pro --skill nushell-proAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 944 |
|---|---|
| repo stars | ★ 12 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | hustcer/nushell-pro ↗ |
How do you write idiomatic secure Nushell scripts?
Write, review, refactor, and convert scripts to idiomatic, secure, and performant Nushell code.
Who is it for?
Developers writing, reviewing, or migrating shell automation to Nushell who need idiomatic patterns, type safety, and security guidance.
Skip if: Developers working exclusively in Bash, PowerShell, or Python without any .nu files to create or convert.
When should I use this skill?
A developer edits, reviews, audits, or converts a Nushell .nu script, module, custom command, or pipeline.
What you get
Reviewed or refactored .nu scripts with idiomatic patterns, type annotations, security fixes, and optional Bash-to-Nushell conversions.
- Idiomatic .nu scripts
- Security-reviewed shell automation
- Bash-to-Nushell conversions
Files
Nushell Pro — Best Practices & Security Skill
Write idiomatic, performant, secure, and maintainable Nushell scripts. This skill enforces Nushell conventions, catches security issues, and helps avoid common pitfalls.
Operating Workflow
Use this sequence whenever writing, reviewing, refactoring, or converting Nushell code:
1. Identify the task type: new script, code review, refactor, Bash conversion, module design, security audit, or debugging. 2. Read the existing .nu files and nearby project conventions before changing code. 3. Load only the reference files needed for the task:
- String quoting, interpolation, escaping, regex, or globs -> String Formats
- Security, untrusted input, external commands, paths, credentials, or destructive operations -> Security
- Review-only requests -> Script Review
- Bash/POSIX conversion -> Bash to Nushell
- Modules, exports, scripts, or tests -> Modules & Scripts
- Types, records, tables, lists, or conversions -> Data & Type System
- Streaming,
peek,parse,par-each, performance, closures, or version-sensitive command behavior -> Advanced Patterns - Large datasets, Polars dataframes, columnar analytics, heavy group-by/join -> Dataframes
- Common mistakes and fixes -> Anti-Patterns
4. Apply the critical checks in this file first, then use references for details. 5. Validate parseability with nu -c 'source path/to/file.nu' for modules or nu path/to/script.nu for scripts when the command is safe to run. 6. Summarize security findings first, then correctness/style/performance changes.
If validation fails -> read the Nushell diagnostic, fix the syntax or type signature, and rerun the smallest safe validation command. If a command has side effects -> validate only parseability or use a fixture/temp directory. If a reference conflicts with local project style -> keep project style unless it violates safety, parseability, or Nushell semantics.
STOP CHECKPOINT: Before approving code that runs external commands, deletes files, reads credentials, mutates $env, or executes user-provided paths/patterns, explicitly confirm the safe argument boundaries and failure behavior.
Core Principles
1. Think in pipelines — Data flows through pipelines; prefer functional transformations over imperative loops 2. Immutability first — Use let by default; only use mut when functional alternatives don't apply 3. Structured data — Nushell works with tables, records, and lists natively; leverage structured data over string parsing 4. Static parsing — All code is parsed before execution; source/use require parse-time constants 5. Implicit return — The last expression's value is the return value; no need for echo or return 6. Scoped environment — Environment changes are local to their block; use def --env when caller-side changes are needed 7. Type safety — Annotate parameter types and input/output signatures for better error detection and documentation 8. Prefer `match` for branching — Use match instead of long if/else if chains when dispatching on one value or handling many branches 9. Parallel ready — Immutable code enables easy par-each parallelization
Critical: Pipeline Input vs Parameters
Pipeline input (`$in`) is NOT interchangeable with function parameters!
# WRONG — treats pipeline data as first parameter
def my-func [items: list, value: any] {
$items | append $value
}
# CORRECT — declares pipeline signature
def my-func [value: any]: list -> list {
$in | append $value
}
# Usage
[1 2 3] | my-func 4 # Works correctlyWhy this matters:
- Pipeline input can be lazily evaluated (streaming)
- Parameters are eagerly evaluated (loaded into memory)
- Different calling conventions entirely —
$list | func argvsfunc $list arg
Type signature forms
def func [x: int] { ... } # params only
def func []: string -> int { ... } # pipeline only
def func [x: int]: string -> int { ... } # both pipeline and params
def func []: [list -> list, string -> list] { ... } # multiple I/O typesNaming Conventions
| Entity | Convention | Example |
|---|---|---|
| Commands | kebab-case | fetch-user, build-all |
| Subcommands | kebab-case | "str my-cmd", date list-timezone |
| Flags | kebab-case | --all-caps, --output-dir |
| Variables/Params | snake_case | $user_id, $file_path |
| Environment vars | SCREAMING_SNAKE_CASE | $env.APP_VERSION |
| Constants | snake_case | const max_retries = 3 |
- Prefer full words over abbreviations unless widely known (
urlok,usrnot ok) - Flag variable access replaces dashes with underscores:
--all-caps->$all_caps
Formatting Rules
One-line format (default for short expressions)
[1 2 3] | each {|x| $x * 2 }
{name: 'Alice', age: 30}Multi-line format (scripts, >80 chars, nested structures)
[1 2 3 4] | each {|x|
$x * 2
}
[
{name: 'Alice', age: 30}
{name: 'Bob', age: 25}
]Spacing rules
- One space before and after
| - No space before
|params|in closures:{|x| ...}not{ |x| ...} - One space after
:in records:{x: 1}not{x:1} - Omit commas in lists:
[1 2 3]not[1, 2, 3] - No trailing spaces
- One space after
,when used (closure params, etc.)
Custom Commands Best Practices
Type annotations and I/O signatures
# Fully typed with I/O signature
def add-prefix [text: string, --prefix (-p): string = 'INFO']: nothing -> string {
$'($prefix): ($text)'
}
# Multiple I/O signatures
def to-list []: [
list -> list
string -> list
] {
# implementation
}Documentation with comments and attributes
# Fetch user data from the API
#
# Retrieves user information by ID and returns
# a structured record with all available fields.
@example 'Fetch user by ID' { fetch-user 42 }
@category 'network'
def fetch-user [
id: int # The user's unique identifier
--verbose (-v) # Show detailed request info
]: nothing -> record {
# implementation
}Parameter guidelines
- Maximum 2 positional parameters; use flags for the rest
- Provide both long and short flag names:
--output (-o): string - Use default values:
def greet [name: string = 'World'] - Use
?for optional positional params:def greet [name?: string] - Use rest params for variadic input:
def multi-greet [...names: string] - Use
def --wrappedto wrap external commands and forward unknown flags
Calling commands with named flags
Keep short custom command calls with named flags on one line. If the invocation must span lines, wrap the whole command in parentheses so the continuation is explicit. A bare newline can terminate the command, leaving following --flags as separate statements that produce plain output or parse errors.
# Prefer for short calls
build-report $target --format json --strict
# Good — explicit multiline invocation
let report = (
build-report $target
--format json
--strict
)
# Bad — flags start new statements
build-report $target
--format json
--strictEnvironment-modifying commands
def --env setup-project [] {
cd project-dir
$env.PROJECT_ROOT = (pwd)
}Data Manipulation Patterns
Working with records
{name: 'Alice', age: 30} # Create record
$rec1 | merge $rec2 # Merge (right-biased)
[$r1 $r2 $r3] | into record # Merge many records
$rec | update name {|r| $'Dr. ($r.name)' } # Update field
$rec | insert active true # Insert field
$rec | upsert count {|r| ($r.count? | default 0) + 1 } # Update or insert
$rec | reject password secret_key # Remove fields
$rec | select name age email # Keep only these fields
$rec | items {|k, v| $'($k): ($v)' } # Iterate key-value pairs
$rec | transpose key val # Convert to tableWorking with tables
$table | where age > 25 # Filter rows
$table | insert retired {|row| $row.age > 65 } # Add column
$table | rename -c {age: years} # Rename column
$table | group-by status --to-table # Group by field
$table | transpose name data # Transpose rows/columns
$table | join $other_table user_id # Inner join
$table | join --left $other user_id # Left joinWorking with lists
$list | enumerate | where {|e| $e.index > 5 } # Filter with index
$list | reduce --fold 0 {|it, acc| $acc + $it } # Accumulate
$list | window 3 # Sliding window
$list | chunks 100 # Process in batches
$list | flatten # Flatten nested listsNull safety
$record.field? # Returns null if missing (no error)
$record.field? | default 'N/A' # Provide fallback
if ($record.field? != null) { } # Check existence
$list | default -e $fallback # Default for empty collectionsdefault's argument is eager: $x | default $rec.maybe_missing evaluates (and can error on) the fallback even when $x is non-null. Make the fallback null-safe (default ($rec.maybe? | default 0)) or branch with if. See Anti-Patterns.
Large data: Polars dataframes
Native list/table ops are row-oriented — ideal for small, interactive data. For large datasets and heavy analytics (multi-million row group-by, joins, aggregations, big CSV/Parquet), push the work into the polars plugin's columnar dataframes instead of each/reduce. They are lazy by default.
# For large data, let Polars plan and execute the whole pipeline:
polars open big.csv # lazy by default (a plan, not yet run)
| polars group-by category
| polars agg (polars col amount | polars sum | polars as total)
| polars sort-by total -r [true]
| polars collect # execute the optimized plan onceBeyond group-by/join, Polars covers window/sequence ops (over, shift, cumulative, rolling), nested list/struct data (explode, unnest), reshaping (pivot/unpivot), time zones, SQL (polars query), and a Nushell-closure escape hatch (map-batches). Choose native vs Polars (and eager vs lazy), and see the full command set, in Dataframes.
Pipeline & Functional Patterns
Prefer functional over imperative
# Bad — imperative with mutable variable
mut total = 0
for item in $items { $total += $item.price }
# Good — functional pipeline
$items | get price | math sum
# Bad — mutable counter
mut i = 0
for file in (ls) { print $'($i): ($file.name)'; $i += 1 }
# Good — enumerate
ls | enumerate | each {|it| $'($it.index): ($it.item.name)' }Iteration patterns
# each: transform each element
$list | each {|item| $item * 2 }
# each --flatten: stream outputs (turns list<list<T>> into list<T>)
ls *.txt | each --flatten {|f| open $f.name | lines } | find 'TODO'
# each --keep-empty: preserve null results
[1 2 3] | each --keep-empty {|e| if $e == 2 { 'found' } }
# par-each: parallel processing (I/O or CPU-bound)
$urls | par-each {|url| http get $url }
$urls | par-each --threads 4 {|url| http get $url }
# reduce: accumulate (first element is initial acc if no --fold)
[1 2 3 4] | reduce {|it, acc| $acc + $it }
# generate: create values from arbitrary sources without mut
generate {|state| { out: ($state * 2), next: ($state + 1) } } 1 | first 5Row conditions vs closures
# Row conditions — short-hand syntax, auto-expands $it
ls | where type == file # Simple and readable
$table | where size > 100 # Expands to: $it.size > 100
# Closures — full flexibility, can be stored and reused
let big_files = {|row| $row.size > 1mb }
ls | where $big_files
$list | where {$in > 10} # Use $in or parameterUse row conditions for simple field comparisons; use closures for complex logic or reusable conditions.
Branching with match
Nushell's match is a parser keyword: match <value> <match_block>. Prefer it over if/else if chains when multiple branches depend on the same value, such as status dispatch, command routing, type guards, or enum-like options. Use if for a single boolean condition or when each branch has a different predicate.
# Less clear — repeated checks against the same value
if $status == 'ok' {
handle-ok
} else if $status == 'error' {
handle-error
} else if $status == 'pending' {
handle-pending
} else {
handle-unknown
}
# Preferred — one dispatch expression with an explicit fallback
match $status {
ok => { handle-ok }
error => { handle-error }
pending => { handle-pending }
_ => { handle-unknown }
}Pipeline input with $in
def double-all []: list<int> -> list<int> {
$in | each {|x| $x * 2 }
}
# Capture $in early when needed later (it's consumed on first use)
def process []: table -> table {
let input = $in
let count = $input | length
$input | first ($count // 2)
}Variable Best Practices
Prefer immutability
let config = (open config.toml)
let names = $config.users | get name
# Acceptable — mut when no functional alternative
mut retries = 0
loop {
if (try-connect) { break }
$retries += 1
if $retries >= 3 { error make {msg: 'Connection failed'} }
sleep 1sec
}Constants for parse-time values
const lib_path = 'src/lib.nu'
source $lib_path # Works: const is resolved at parse time
let lib_path = 'src/lib.nu'
source $lib_path # Error: let is runtime onlyWhen a parse-time path needs to point inside the user's home directory, compute it from $nu.home-dir instead of hardcoding a machine-specific absolute path.
# Good — portable across users and machines
const work_dir = $'($nu.home-dir)/work/dir'
# Bad — hardcoded user home path
const work_dir = '/user/name/work/dir'Closures cannot capture mut
mut count = 0
ls | each {|f| $count += 1 } # Error! Closures can't capture mut
# Solutions:
ls | length # Use built-in commands
[1 2 3] | reduce {|x, acc| $acc + $x } # Use reduce
for f in (ls) { $count += 1 } # Use a loop if mutation truly neededString Conventions
Refer to String Formats Reference for the full priority and rules.
String form mistakes are common and high impact. Decide with this table before writing or reviewing any string:
| Need | Use | Do not use |
|---|---|---|
| Simple literal text | 'hello world' | "hello world" or $"hello world" |
| Simple values inside arrays or match arms | [foo bar baz], match $x { ok => ... } | ["foo" "bar" "baz"] when quotes add no meaning |
| Interpolation without escape sequences | $'User: ($name)' | $"User: ($name)" |
| Literal escape sequence such as newline or tab | "line1\nline2" | 'line1\nline2' |
| Interpolation plus real escape sequences | $"($name)\n" | $'($name)\n' |
| Regex pattern or text with many quotes/backslashes | r#'name="[^"]+"'# | "name=\"[^\"]+\"" |
| Path or glob argument with spaces | ` ./My Dir/*.nu ` | Escaped Bash-style strings |
Decision order:
1. Bare words in arrays: [foo bar baz] 2. Raw strings for regex: r#'(?:pattern)'# 3. Single quotes: 'simple string' 4. Single-quoted interpolation: $'Hello, ($name)!' 5. Backticks for path/glob arguments containing spaces: ` ./My Dir/*.nu 6. Double quotes only for real escapes: "line1\nline2" 7. Double-quoted interpolation only when both interpolation and escapes are required: $"tab:\t($value)\n"`
Non-negotiable string rules:
$'...'and'...'do not process escapes.\n,\t, and\'remain literal characters.- A string containing
(...)only evaluates it when prefixed with$:$'(ansi g)OK(ansi rst)', not'(ansi g)OK(ansi rst)'. - Use
char nlinside$'...'when interpolation is needed but the only escape-like value is a newline:$'(char nl)Done'. - Use
$"..."only when the final string truly needs escape processing and interpolation in the same literal. - Do not build shell command strings for execution. Pass arguments separately:
^git commit -m $msg, not^sh -c $'git commit -m "($msg)"'. - For external command format strings, prefer double-quoted strings with a single escape backslash for simple escapes (
^git log --format="%H\t%an"). Do not double the backslash ("%H\\t%an"), because that produces a literal\t. Use(char tab)/(char nl)when interpolation is needed or explicit separators are clearer. - For regex literals, prefer raw strings and add
#delimiters when the pattern contains quotes:r#'name="[^"]+"'#.
Common corrections:
# Wrong: interpolation is missing because there is no $ prefix
print 'User: ($name)'
# Right
print $'User: ($name)'
# Wrong: $'...' does not turn \n into a newline
print $'Done\n'
# Right: command interpolation, no escape processing needed
print $'Done(char nl)'
# Right: escape processing is required
print $"Done\n"
# Wrong: double-quoted interpolation without escapes
let label = $"($pkg.name)@($pkg.version)"
# Right
let label = $'($pkg.name)@($pkg.version)'
# Wrong: regex backslashes are hard to audit
let pattern = "(?:src|lib)/.*\\.nu"
# Right
let pattern = r#'(?:src|lib)/.*\.nu'#
# Wrong: single quotes pass literal \t to the external formatter
^git log --format='%H\t%an'
# Wrong: double backslash preserves literal \t
^git log --format="%H\\t%an"
# Preferred: Nushell turns \t into an actual tab before calling git
^git log --format="%H\t%an"
# Also right: explicit separator, useful with interpolation
let tab = (char tab)
^git log --format=$'%H($tab)%an'Modules & Scripts
Module structure
my-module/
├── mod.nu # Module entry point
├── utils.nu # Submodule
└── tests/
└── mod.nu # Test moduleExport rules
- Only
exportdefinitions are public; non-exported are private - Use
export def mainwhen command name matches module name - Use
export use submodule.nu *to re-export submodule commands - Use
export-envfor environment setup blocks
Script with main command and subcommands
#!/usr/bin/env nu
# Build the project
def "main build" [--release (-r)] {
print 'Building...'
}
# Run tests
def "main test" [--verbose (-v)] {
print 'Testing...'
}
def main [] {
print 'Usage: script.nu <build|test>'
}For stdin access in shebang scripts: #!/usr/bin/env -S nu --stdin
Error Handling
Custom errors with span info
def validate-age [age: int] {
if $age < 0 or $age > 150 {
error make {
msg: 'Invalid age value'
label: {
text: $'Age must be between 0 and 150, got ($age)'
span: (metadata $age).span
}
}
}
$age
}try/catch and graceful degradation
let result = try {
http get $url
} catch {|err|
print -e $'Request failed: ($err.msg)'
null
}
# Use complete for detailed external command error info
let result = (^some-external-cmd | complete)
if $result.exit_code != 0 {
print -e $'Error: ($result.stderr)'
}Suppress errors with do -i
do -i (ignore errors) runs a closure and suppresses any errors, returning null on failure. do -c (capture errors) catches errors and returns them as values.
# Ignore errors — returns null if the closure fails
do -i { rm non_existent_file }
# Use as a concise fallback
let val = (do -i { open config.toml | get setting } | default 'fallback')
# Capture errors as values (instead of aborting the pipeline)
let result = (do -c { ^some-cmd })When to use each approach:
do -i— Fire-and-forget, or when you only need a default on failuredo -c— Catch errors as values to abort downstream pipeline on failuretry/catch— When you need to inspect or log the errorcomplete— When you need exit code + stdout + stderr from external commands
Testing
Using std assert
use std/assert
for t in [[input expected]; [0 0] [1 1] [2 1] [5 5]] {
assert equal (fib $t.input) $t.expected
}Custom assertions
def "assert even" [number: int] {
assert ($number mod 2 == 0) --error-label {
text: $'($number) is not an even number'
span: (metadata $number).span
}
}Debugging Techniques
$value | describe # Inspect type
$data | each {|x| print $x; $x } # Print intermediate values (pass-through)
timeit { expensive-command } # Measure execution time
metadata $value # Inspect span and other metadataSecurity Best Practices
Refer to Security Reference for the full guide.
Nushell is safer than Bash by design (no eval, arguments passed as arrays not through shell), but security risks remain.
Never execute untrusted input as code
# DANGEROUS — arbitrary code execution
^nu -c $user_input
source $user_provided_file
# DANGEROUS — shell interprets the string
^sh -c $'echo ($user_input)'
^bash -c $user_inputValidating a command _string_ (e.g. an allowlist regex) before nu -c is not sufficient: \s matches newlines, regex shortcuts like \b/\w are not command-token rules, and the string is still re-interpreted. Prefer validated argv/list data and run the binary with separated args instead. See Security.
Separate commands from arguments (prevent injection)
# Bad — constructing command strings
let cmd = $'ls ($user_path)'
^sh -c $cmd
# Good — pass arguments directly (no shell interpretation)
^ls $user_path
run-external 'ls' $user_pathValidate and sanitize paths
# Bad — path traversal possible
def read-file [name: string] { open $name }
# Good — validate against traversal
def read-file [name: string, --base-dir: string = '.'] {
let full = ($base_dir | path join $name | path expand)
let base = ($base_dir | path expand)
if not ($full | str starts-with $base) {
error make {msg: $'Path traversal detected: ($name)'}
}
open $full
}Protect credentials
# Bad — credential visible to all child processes and in env
$env.API_KEY = 'secret-key-123'
^curl -H $'Authorization: Bearer ($env.API_KEY)' $url
# Good — scope credentials, use with-env
with-env {API_KEY: (open ~/.secrets/api_key | str trim)} {
^curl -H $'Authorization: Bearer ($env.API_KEY)' $url
}Safe file operations
# Bad — predictable temp file, race condition
let tmp = '/tmp/my-script-tmp'
'data' | save $tmp
# Good — use mktemp for unique temp files
let tmp = (^mktemp | str trim)
'data' | save $tmp
# ... use $tmp ...
rm $tmpHandle external command errors
let result = (^cargo build o+e>| complete)
if $result.exit_code != 0 {
error make {msg: $'Build failed: ($result.stderr)'}
}Safe rm operations
# Bad — glob from variable, could match unintended files
^rm $'($user_dir)/*'
# Good — validate then use trash or explicit paths
if ($user_dir | path type) == 'dir' {
rm -r $user_dir
}Script Review Checklist
Refer to Script Review Reference for the full checklist.
When reviewing a Nushell script, check these categories in order:
1. Security review (highest priority)
- [ ] No
nu -c/source/^sh -cwith untrusted input - [ ] No credential hardcoding or env leaking
- [ ] Paths from user input are validated (no traversal)
- [ ] Home-directory paths are computed from
$nu.home-dir, not hardcoded user paths - [ ] External commands use argument separation (not string concatenation)
- [ ] Temp files use
mktemp, not predictable paths - [ ]
rmoperations are guarded and intentional
2. Correctness review
- [ ] Type annotations on all exported commands
- [ ] I/O pipeline signatures match actual behavior
- [ ] Error handling with
try/catchfor fallible operations - [ ] External commands checked with
completewhen error handling matters - [ ] Optional fields accessed with
?operator - [ ] No
foras final expression (useeachinstead) - [ ] Long
if/else ifchains on one value prefermatchunlessifis clearer - [ ]
mutnot captured in closures - [ ]
parsegetslinesfirst when line-by-line parsing of stream input is intended - [ ] Multiline custom command calls with named flags are one-line or wrapped in parentheses
3. Style review
- [ ] Naming: kebab-case commands, snake_case variables
- [ ] String format priority followed: simple literal ->
'...'; interpolation without escapes ->$'...'; escapes ->"..."; interpolation plus escapes ->$"..."; regex ->r#'...'# - [ ] Strings containing
(...)that should run Nushell expressions have$prefix - [ ]
$'...'is not used for\n,\t,\', or other escape processing - [ ] External command format strings use double quotes for simple escapes, or
char tab/char nlwhen interpolation is needed - [ ] Formatting: spacing, line length, multi-line rules
- [ ] Documentation comments on exported commands
- [ ]
^prefix on external commands - [ ] Functional style preferred over imperative
4. Performance review
- [ ]
par-eachfor I/O or CPU-bound parallel work - [ ]
each --flattenfor streaming when appropriate - [ ]
peekused when inspecting stream metadata/sample values without collecting - [ ] Expensive computations cached in
letbindings - [ ] Large files streamed (lazy), not loaded entirely
- [ ] Large/columnar datasets and heavy group-by/join use Polars dataframes (lazy), not native loops
Common Pitfalls
Refer to Anti-Patterns Reference for detailed explanations.
| Anti-Pattern | Fix |
|---|---|
echo $value | Just $value (implicit return) |
echo 'msg' to print output | print 'msg' (echo returns a value; non-final value is dropped) |
$"simple text" | 'simple text' (no interpolation needed) |
'User: ($name)' | $'User: ($name)' |
$'line\n' | $"line\n" or $'line(char nl)' |
"[a-z]+\\.nu" | r#'[a-z]+\.nu'# |
for as final expression | Use each (for doesn't return a value) |
mut for accumulation | Use reduce or math sum |
Long if/else if chains on one value | Prefer match with _ fallback |
let path = ...; source $path | const path = ...; source $path |
const work_dir = '/user/name/work/dir' | const work_dir = $'($nu.home-dir)/work/dir' |
"hello" > file.txt | `'hello' \ |
grep pattern | where $it =~ pattern or built-in find |
| Parsing string output | Use structured commands (ls, ps, http get) |
$env.FOO = bar inside def | Use def --env |
| `{ \ | x \ |
$record.missing (error) | $record.missing? (returns null) |
| `val \ | default $rec.missing` (arg is eager) |
each on single record | Use items or transpose instead |
External cmd without ^ | Use ^grep to be explicit about externals |
Native loop/group-by on huge data | Use polars dataframes (lazy open + group-by + collect) |
parse on stream expecting old line splitting | Insert lines before parse for line-by-line parsing |
| Custom command flags on new lines | Keep one line or wrap the invocation in (...) |
Single-quoted or double-escaped external format \t | Use "%H\t%an" or (char tab) / (char nl) |
Best Practices Summary
1. Use type signatures — Catch errors early, improve documentation 2. Prefer pipelines — More idiomatic, composable, and streamable 3. Document with comments — # above def for help integration 4. Export selectively — Don't pollute namespace 5. Use `default` — Handle null/missing gracefully 6. Validate inputs — Check types/ranges at function start 7. Return consistent types — Don't mix null and values unexpectedly 8. Use modules — Organize related functions 9. Prefix external commands with `^` — ^grep not grep; Nushell builtins take precedence (e.g., find is Nushell's, not Unix find) 10. Use external tools when faster — ^rg for large file search, ^jq for giant JSON 11. Group multiline command calls — Keep named-flag invocations one-line or wrap them in (...) 12. Use clear separators in external formats — Prefer double-quoted escapes when no interpolation is needed; use (char tab) / (char nl) when interpolation or explicit separators are needed
Do Not Do These Things
- Do not replace every quoted value with double quotes. Double quotes are for escape processing.
- Do not use
$"..."just because a string contains interpolation; use$'...'unless escapes are required. - Do not assume
\',\n, or\twork inside single-quoted or single-interpolated strings. - Do not expect single-quoted or double-escaped external format strings to process
\tor\n; use one backslash in double quotes,(char tab), or(char nl). - Do not remove
$from strings containing command interpolation such as(ansi g),(char nl), or($value). - Do not use
echoto print user-facing messages —echohas no print side effect; its returned value is shown only when it becomes final output. Useprintfor side-effect output (echo 'msg'; exit 1prints nothing). - Do not split named flags for a custom command onto new statement lines; keep one line or wrap the invocation in parentheses.
- Do not convert user input into
nu -c,source,^sh -c,^bash -c, or^cmd.exe /Cstrings. - Do not parse structured Nushell output as plain strings when a record/table/list operation exists.
- Do not run destructive examples during validation; parse-check them or use a temp fixture.
Workflow
When writing or reviewing Nushell code:
1. Read existing code to understand the context 2. Security audit — Check for injection, path traversal, credential leaks (see Security) 3. Check naming — kebab-case commands, snake_case variables 4. Check types — Add/verify type annotations and I/O signatures 5. Check strings — Follow the string format priority 6. Check branching — Prefer match over long if/else if chains on one value 7. Check patterns — Prefer functional pipelines over imperative loops 8. Check formatting — Spacing, line length, multi-line rules 9. Check documentation — Comments for exported commands, parameter descriptions 10. Check error handling — try/catch, complete for externals, validate inputs 11. Run validation if possible — nu -c 'source file.nu' or nu file.nu 12. Summarize changes made with security findings highlighted
References
- Security — Security hardening, threat model, safe patterns
- Script Review — Comprehensive review checklist
- String Formats — String type priority and conversion rules
- Anti-Patterns — Common mistakes with detailed fixes
- Data & Type System — Type hierarchy, collections, conversions, type guards
- Dataframes — Polars dataframes: lazy/eager, group-by, joins, window/sequence ops, nested list/struct data, reshaping, time zones, SQL, large-data processing
- Advanced Patterns — Performance, streaming, closures, memory efficiency
- Modules & Scripts — Module system, testing, attributes
- Bash to Nushell — Conversion guide from Bash/POSIX
Getting Help
- Use
nu -c 'help <command>'to check command signatures and examples - Use Nushell MCP tools for evaluating and testing Nushell code
- Consult the Nushell Book for in-depth documentation
timestamp commit skill old_score new_score status dimension note eval_mode
2026-06-01T06:46 baseline nushell-pro - 65.1 baseline - runtime_warn=1; strong content, weak explicit workflow/fallback/checkpoint; string rules too compact for frequent agent mistakes dry_run
2026-06-01T06:49 staged nushell-pro 65.1 84.1 keep string-conventions added executable string decision table, interpolation/escape failure rules, checkpoint, blacklist, runtime-neutral README dry_run
nushell-pro
Nushell best practices, security hardening, and code review skill for Agents.
Write idiomatic, performant, secure, and maintainable Nushell scripts — with built-in code review, anti-pattern detection, and Bash-to-Nushell conversion.
Features
- Best Practices — Naming conventions, type annotations, I/O signatures, functional pipeline style, string format priority, and formatting rules
- Security Hardening — Injection prevention, path traversal protection, credential scoping, safe file/temp operations, environment sanitization
- Code Review — Comprehensive checklist covering security, correctness, style, performance, and robustness
- Anti-Pattern Detection — 27 common mistakes with idiomatic fixes
- Type System — Type hierarchy, complex types, type guards, null safety patterns
- Bash Conversion — Side-by-side Bash-to-Nushell translation guide
- Performance — Parallel processing with
par-each, streaming patterns, memory-efficient techniques - Data Processing — Polars dataframes (lazy/eager), group-by, joins, window/sequence ops, nested list/struct data, reshaping, binning, time zones, SQL, column selectors, and large-data / columnar analytics
Install
# Install by npx skills
npx skills add hustcer/nushell-pro
# OR Install for Claude by claude cli
claude skill add --name nushell-pro hustcer/nushell-proOr clone manually into a skills-compatible runtime directory:
git clone https://github.com/hustcer/nushell-pro.git /path/to/skills/nushell-proStructure
nushell-pro/
├── SKILL.md # Main skill (core rules, always loaded)
└── references/
├── security.md # Threat model, safe patterns, Windows risks
├── script-review.md # Full review checklist (5 categories)
├── anti-patterns.md # 27 anti-patterns with fixes
├── data-and-types.md # Type system, collections, conversions
├── dataframes.md # Polars dataframes: lazy/eager, group-by, joins, large data
├── advanced-patterns.md # Streaming, closures, parallel, debugging
├── modules-and-scripts.md # Modules, exports, testing, attributes
├── string-formats.md # String type priority and rules
└── bash-to-nushell.md # Bash/POSIX conversion guideSKILL.md is always loaded into context. Reference files are loaded on demand when the task requires deeper knowledge on a specific topic.
What It Covers
Core Principles
1. Think in pipelines — data flows through functional transformations 2. Immutability first — let by default, mut only when necessary 3. Structured data — tables, records, and lists over string parsing 4. Static parsing — source/use require parse-time constants 5. Implicit return — last expression is the return value 6. Scoped environment — def --env when caller-side changes are needed 7. Type safety — annotate parameters and I/O signatures 8. Prefer match for branching — avoid long if/else if chains when dispatching on one value 9. Parallel ready — immutable code enables easy par-each
Security Model
Nushell is safer than Bash by design (no eval, arguments passed as arrays), but risks remain:
| Risk Level | Threats |
|---|---|
| Critical | Code injection via nu -c, ^sh -c, plugin injection |
| High | Path traversal, credential leaks, PATH hijacking, glob injection |
| Medium | TOCTOU races, temp file races, unhandled errors, config tampering |
Script Review
The skill includes a 5-category review checklist:
1. Security (critical) — injection, paths, credentials, destructive ops 2. Correctness — types, errors, null safety, logic 3. Style — naming, strings, formatting, documentation 4. Performance — parallelism, streaming, caching 5. Robustness — input validation, file safety, process management
License
MIT
Advanced Nushell Patterns Reference
Performance Optimization
Lazy vs eager evaluation
# Lazy (streaming) — memory efficient for large data, single-pass
open large.csv | where status == 'active' | first 10
# Eager (load all) — faster for small data with multiple operations
let data = (open large.csv | where status == 'active')
$data | first 10
$data | last 10
$data | lengthUse lazy when: Large files/streams, single-pass operations, memory constrained Use eager when: Small datasets (<10k rows), multiple operations on same data, random access needed
The lazy/eager split above is about native row-oriented streaming. For
columnar large-data work (heavy group-by/join/aggregation over big
CSV/Parquet), use the Polars plugin's lazy dataframes instead — see
Dataframes.
Inspect streams without collecting: peek
Use peek when a command needs to inspect whether input is a stream, or sample the first few values, without forcing the whole stream into memory. It exposes the information through pipeline metadata, which you read with metadata access.
.. | peek 1 | metadata access {|md|
if $md.peek.stream {
# Keep the stream lazy; do not call `collect` just to inspect it.
$in
} else if ($md.peek.value?.0? != null) {
$in
}
}This is useful for commands that want different behavior for lists vs streams, or that need to decide based on a small sample while preserving streaming.
Avoid repeated computation
# Bad — computes expensive-func 3 times
if (expensive-func) > 10 {
print (expensive-func)
save-to-file (expensive-func)
}
# Good — compute once
let result = expensive-func
if $result > 10 {
print $result
save-to-file $result
}Parallel processing
# Sequential
$urls | each {|url| http get $url }
# Parallel — faster for I/O operations
$urls | par-each {|url| http get $url }
# With thread pool size
$urls | par-each --threads 4 {|url| http get $url }Best for: I/O operations, CPU-intensive transforms, independent operations Avoid for: Small lists (overhead > benefit), side effects, order-dependent processing
Stream flattening with each --flatten
# Without --flatten: waits for each stream to complete, returns list<list<string>>
ls *.txt | each {|f| open $f.name | lines }
# With --flatten: streams items as they arrive, returns list<string>
ls *.txt | each --flatten {|f| open $f.name | lines }
# Practical: search across files without waiting for all to load
ls **/*.nu | each --flatten {|f|
open $f.name | lines | find 'export def'
} | str join (char nl)Memory-Efficient Patterns
Processing large files
# Bad — loads entire file into memory then filters
open large.log | lines | where {$in =~ 'ERROR'}
# Good — streams line by line
open large.log | lines | each --flatten {|line|
if ($line =~ 'ERROR') { $line }
}Batched processing
# Process in chunks of 1000
open large.csv | chunks 1000 | each {|batch|
$batch | process-batch
} | flattenparse stream behavior in 0.113+
parse no longer implicitly splits byte/string streams into lines. It collects the stream and parses it as one input value. If you intend line-by-line parsing, insert lines explicitly:
# Whole-input parse: useful for multiline regexes
open file.txt | parse -r r#'(?ms)^(?<id>\d+): (?<body>.*)$'#
# Line-by-line parse
open file.txt | lines | parse -r '^(?<level>\w+) (?<message>.*)$'Command Behavior Notes (0.113+)
mkdir -v,mv -v, andrm -vreturn structured tables. Script against
columns such as path, created, deleted, error, and message instead of parsing human text.
rmandmkdirtry the remaining path arguments even when one path errors.
Review partial-success behavior for destructive or setup scripts.
from mdnow defaults to concise output. Usefrom md --verbosewhen code
needs the full AST-style detail.
watch's optional closure argument is deprecated. Pipe events intoeachor
iterate with for event in (watch ...).
gridno longer should rely on the implicitnamecolumn. Pass the column
explicitly, for example ls | grid name.
metadata set --datasource-lswas removed. Use
metadata set --path-columns [name] for path metadata on table columns.
- On Unix-like systems,
kill -9 pidshorthand is no longer accepted. Use
kill -s 9 <pid> (or kill -s 0 <pid> for signal 0 checks).
finallyruns for cleanup but its return value does not overridetryor
catch. Use the try/catch result for values and keep finally for side effects such as cleanup.
- In 0.113.1,
to yamlemits more idiomatic plain scalars where safe and uses
block style for multiline strings. Do not assert exact quotes around every string in YAML golden tests.
Advanced Closure Patterns
Closure composition
let double = {|x| $x * 2 }
let add_ten = {|x| $x + 10 }
# Compose manually
[1 2 3] | each {|x| do $add_ten (do $double $x) }
# Result: [12, 14, 16]
# Or build a composed closure
let transform = {|x| do $double $x | do $add_ten $in }
[1 2 3] | each $transformClosure currying pattern
def make-multiplier [factor: int] {
{|x| $x * $factor }
}
let triple = (make-multiplier 3)
let quadruple = (make-multiplier 4)
[1 2 3] | each $triple # [3, 6, 9]
[1 2 3] | each $quadruple # [4, 8, 12]Closures capture environment (immutable only)
let multiplier = 10
let compute = {|x| ($x * 2) + $multiplier }
do $compute 5 # 20
# Mutable variables CANNOT be captured in closures
mut sum = 0
[1 2 3] | each {|x| $sum += $x } # Error!
# Use reduce instead
let sum = [1 2 3] | reduce {|x, acc| $acc + $x }Stream Patterns
Generate infinite sequences
# Fibonacci using generate
generate {|state|
let a = $state.0
let b = $state.1
{out: $a, next: [$b, ($a + $b)]}
} [0, 1] | first 10Stream control
$stream | skip while {|x| $x < 100 } # Skip until condition false
$stream | take while {|x| $x < 1000 } # Take until condition false
[1 2 3 4 5] | window 3 # Sliding window: [[1,2,3], [2,3,4], [3,4,5]]
$data | chunks 100 # Fixed-size batchesAdvanced Error Handling
Graceful degradation
def robust-fetch [url: string] {
try {
http get $url
} catch {
try {
^curl -s $url | from json
} catch {
{error: 'All fetch methods failed'}
}
}
}External command error handling with complete
let result = (^cargo build o+e>| complete)
if $result.exit_code != 0 {
print -e $'Build failed:\n($result.stderr)'
} else {
print 'Build succeeded'
}Suppress errors with do -i / do -c
do -i (ignore errors) runs a closure and suppresses errors, returning null on failure. do -c (capture errors) catches errors and returns them as values.
# Fire-and-forget — silently ignore failure
do -i { rm $old_file }
# Concise default value pattern
let config = (do -i { open settings.toml } | default {})
# Capture errors as values (useful to abort downstream pipeline)
let result = (do -c { ^failing-cmd })
# Compare error handling approaches:
# do -i — suppress error, return null (simplest)
# do -c — catch error as value, abort downstream pipeline on failure
# try/catch — inspect/log/recover from errors
# complete — full exit_code + stdout + stderr for externalsAdvanced Glob Patterns
# Multiple extensions
glob **/*.{rs,toml,md}
# Exclusions
glob **/*.rs --exclude [**/target/** **/tests/**]
glob **/tsconfig.json --exclude [**/node_modules/**]
# Character classes
glob '[Cc]*' # Files starting with C or c
glob '[!0-9]*' # Files NOT starting with digit
glob 'src/[a-m]*.rs' # Files starting with a-m
# Depth limit
glob **/*.rs --depth 2 # Max 2 directories deep
# Directory only
glob '[A-Z]*' --no-file --no-symlink # Only directories starting with uppercase
# Follow symlinks
glob '**/*.txt' --follow-symlinks
# Case-insensitive (wax syntax)
glob '(?i)readme*'Custom Data Types with Structured Output
def make-report [title: string, data: table]: nothing -> record {
{
title: $title
generated: (date now)
row_count: ($data | length)
columns: ($data | columns)
data: $data
}
}Row Conditions vs Closures (Deep Dive)
Row conditions — short-hand syntax
# Left side auto-expands to $it.field
$table | where size > 100 # $it.size > 100
$table | where name =~ 'test' # $it.name =~ 'test'
ls | where type == file # Simple and readable
# Limitation: subexpressions need explicit $it
ls | where ($it.name | str downcase) =~ readmeClosures — full flexibility
$table | where {|row| $row.size > 100 }
$table | where {$in.size > 100 }
# Can be stored and reused
let big_files = {|row| $row.size > 1mb }
ls | where $big_files
# Works anywhere
$list | each {|x| $x * 2 }Row conditions: Simple field comparisons (cleaner syntax), cannot be stored in variables Closures: Complex logic, reusable conditions, nested operations
Iteration Pitfalls
each on single records
# Bad — runs only once, not iterating fields!
let rec = {a: 1, b: 2}
$rec | each {|field| print $field } # Only runs once
# Good — use items, values, or transpose
$rec | items {|key, val| print $'($key): ($val)' }
$rec | transpose key val | each {|row| ... }Pipe vs call ambiguity
# These are different!
$list | my-func arg1 arg2 # $list piped as input, arg1 & arg2 as params
my-func $list arg1 arg2 # All three as positional params (if signature allows)Debugging Techniques
# Inspect type
$value | describe
# Print intermediate values without breaking pipeline
$data | each {|x| print $x; $x }
# Measure execution time
timeit { expensive-command }
# Inspect metadata (span info for error reporting)
metadata $value
# View full command signature
help my-command
scope commands | where name == 'my-command'Nushell Anti-Patterns Reference
Common mistakes and their idiomatic fixes when writing Nushell scripts.
1. Using echo Instead of Implicit Return
Nushell implicitly returns the last expression's value. echo is almost never needed.
# Bad
def greet [name: string]: nothing -> string {
echo $'Hello, ($name)!'
}
# Good
def greet [name: string]: nothing -> string {
$'Hello, ($name)!'
}Use print when you want to display a message as a side effect (not as return value):
def process [] {
print 'Processing...' # Side effect: displayed to user
do-work # Return value: result of do-work
}Bash-migration trap — `echo` is not `print`. Unlike Bash's echo, Nushell's echo only produces a pipeline value; it does not print as a side effect. That value is rendered only when it becomes final output. Used as a non-final statement, the value is silently discarded — so echo 'msg' meant as a "print this" statement displays nothing:
# Bad — nothing is printed; the echo value is dropped before exit
if (has-ref $version) {
echo $'Version ($version) already exists' # silently discarded!
exit 1
}
# Good — print writes to stdout as a side effect
if (has-ref $version) {
print $'Version ($version) already exists'
exit 1
}2. Using for as Final Expression
for is a statement that returns null. Use each for transformations.
# Bad — returns nothing
def squares []: nothing -> list<int> {
for x in [1 2 3 4] { $x ** 2 }
}
# Good — returns the list
def squares []: nothing -> list<int> {
[1 2 3 4] | each {|x| $x ** 2 }
}3. Mutable Variables for Accumulation
# Bad — imperative accumulation
mut total = 0
for item in $items { $total += $item.price }
# Good — math sum
$items | get price | math sum
# Bad — building a list with mutation
mut result = []
for f in (ls) {
if ($f.size > 1mb) { $result = ($result | append $f.name) }
}
# Good — filter pipeline
ls | where size > 1mb | get name4. Dynamic source/use Paths
Nushell parses all code before evaluation. source/use require parse-time constant paths.
# Bad — let is evaluated at runtime
let my_path = '~/scripts'
source $'($my_path)/utils.nu' # Error!
# Good — use const for parse-time resolution
const my_path = '~/scripts'
source $'($my_path)/utils.nu'5. Bash-Style Redirection
# Bad — > is the comparison operator in Nushell
'hello' > file.txt # This is a boolean comparison, not redirection!
# Good — use save command
'hello' | save file.txt
'hello' | save --append file.txt6. String Parsing External Commands
# Bad — parsing ls output as strings
^ls -la | lines | each {|l| $l | split column ' ' }
# Good — use Nushell's structured ls
ls -la
# Bad — parsing JSON from curl
^curl -s https://api.example.com | from json
# Good — use http get (returns structured data directly)
http get https://api.example.com7. Ignoring Type Annotations
# Bad — untyped, hard to catch errors
def process [data] { $data | get name }
# Good — typed, catches misuse at parse time
def process [data: record<name: string, age: int>]: nothing -> string {
$data.name
}
# Good — I/O signature
def double []: int -> int { $in * 2 }8. Space Before Closure Parameters
# Bad — space before |params|
ls | each { |f| $f.name }
# Good — no space before |params|
ls | each {|f| $f.name }9. Environment Changes in Regular def
# Bad — cd change is lost after command returns
def go-project [] { cd ~/projects/my-app }
go-project
pwd # Still in original directory!
# Good — use def --env to propagate environment changes
def --env go-project [] { cd ~/projects/my-app }
go-project
pwd # Now in ~/projects/my-app10. Unnecessary String Interpolation
# Bad — interpolation with no variables
let msg = $"hello world"
# Good — simple string
let msg = 'hello world'
# Bad — double-quoted interpolation without escapes
let greeting = $"Hello, ($name)!"
# Good — single-quoted interpolation (no escapes needed)
let greeting = $'Hello, ($name)!'11. Using each When par-each Works
# Suboptimal — sequential file processing
ls **/*.json | each {|f| open $f.name | get version }
# Better — parallel processing for I/O bound work
ls **/*.json | par-each {|f| open $f.name | get version }Use each only when: order must be preserved, side effects must be sequential, or list is very small.
12. Missing Command Documentation
# Bad — no documentation
def deploy [env, --force] { ... }
# Good — documented command
# Deploy the application to the specified environment
#
# Handles building, testing, and deployment in one step.
@example 'Deploy to staging' { deploy staging }
def deploy [
env: string # Target environment (staging, production)
--force (-f) # Skip confirmation prompts
] { ... }13. Not Using default for Optional Values
# Bad — verbose null check
let name = if $input == null { 'anonymous' } else { $input }
# Good — use default command
let name = $input | default 'anonymous'*Gotcha — the default argument is evaluated eagerly*, even when the input is non-null, because it is an ordinary argument (not a closure). A "fallback" that can itself error or is expensive runs regardless:
# Bad — $rec.maybe_missing is evaluated even when $primary is non-null,
# and throws if that column is absent
$primary | default $rec.maybe_missing
# Bad — coalesce-style fallback still throws when the key is missing,
# because default's argument is evaluated before default runs
$delta.reasoning? | default $delta.content # errors if there is no `content` key
# Good — make the fallback itself null-safe
$delta.reasoning? | default ($delta.content? | default '')
# Good — branch explicitly when the fallback is expensive
if ($primary | is-not-empty) { $primary } else { compute-fallback }14. Manual JSON/YAML/TOML Parsing
# Bad — manual string manipulation
let version = (open Cargo.toml | lines | where $it =~ '^version' | first | split column '=' | get column2.0 | str trim)
# Good — native structured data support
let version = (open Cargo.toml | get package.version)15. Not Using match for Multi-Branch Logic
Prefer match whenever several branches dispatch on the same value. It keeps the compared value in one place, makes the fallback explicit with _, and avoids drifting conditions in long if/else if ladders. Keep if for one-off boolean predicates or branches that genuinely compare different expressions.
# Less clear — chain of if/else
if $status == 'ok' { handle-ok }
else if $status == 'error' { handle-error }
else if $status == 'pending' { handle-pending }
else { handle-unknown }
# Preferred — pattern matching
match $status {
ok => { handle-ok }
error => { handle-error }
pending => { handle-pending }
_ => { handle-unknown }
}16. Incorrect Shebang for stdin Scripts
# Bad — script won't receive stdin
#!/usr/bin/env nu
# Good — add --stdin flag
#!/usr/bin/env -S nu --stdin
def main [] { $in | process }17. Forgetting export in Modules
# Bad — command is private, can't be imported
# my-module.nu
def helper [] { 'hello' }
# Good — export makes it public
export def helper [] { 'hello' }
# Also good — keep internal helpers private intentionally
def internal-helper [] { 'private' }
export def public-cmd [] { internal-helper }18. Confusing Pipeline Input with Parameters
# Bad — treats pipeline data as positional parameter
def my-func [items: list, value: any] {
$items | append $value
}
# Good — declares pipeline input signature
def my-func [value: any]: list -> list {
$in | append $value
}
# Usage: [1 2 3] | my-func 4Why: Pipeline input is lazily evaluated (streaming); parameters are eagerly loaded. Different calling conventions entirely.
19. Using each on Single Records
# Bad — runs only once, not iterating fields!
let rec = {a: 1, b: 2}
$rec | each {|field| print $field }
# Good — iterate key-value pairs
$rec | items {|key, val| print $'($key): ($val)' }
$rec | transpose key val | each {|row| ... }20. Accessing Missing Fields Without ?
# Bad — error if field doesn't exist
$record.missing_field # Error!
# Good — use ? for optional access
$record.missing_field? # Returns null
$record.missing_field? | default 0 # Provide fallback21. Not Prefixing External Commands with ^
# Ambiguous — could be Nushell builtin or external
find pattern # This is Nushell's find, NOT Unix find!
sort # This is Nushell's sort, NOT Unix sort!
# Clear — explicitly calls external
^find . -name '*.rs' # Unix find
^sort file.txt # Unix sort
^grep pattern file # External grepRule: Nushell builtins always take precedence. Use ^ to unambiguously call external commands.
22. Ignoring complete for External Command Errors
# Bad — no error handling for external commands
let output = (^cargo build)
# Good — use complete for full error info
let result = (^cargo build o+e>| complete)
if $result.exit_code != 0 {
print -e $'Build failed:\n($result.stderr)'
}23. Empty Collection Checks
# Bad — comparing length
if ($list | length) == 0 { ... }
# Good — use is-empty / is-not-empty
if ($list | is-empty) { ... }
if ($list | is-not-empty) { ... }24. Native Loops or group-by on Huge Datasets
Native each/reduce/group-by process data row by row. For very large datasets this is slow and memory-hungry. Push the work into the polars plugin's columnar dataframes instead.
# Bad — native group-by + manual aggregation over millions of rows
open huge.csv
| group-by category --to-table
| update items {|g| $g.items.amount | math sum }
# Good — Polars: lazy by default, optimized columnar aggregation
polars open huge.csv
| polars group-by category
| polars agg (polars col amount | polars sum | polars as total)
| polars collectAlso remember to `collect` a lazy frame — without it you hold a plan, not results. See Dataframes.
25. Assuming parse Splits Streams into Lines
Since Nushell 0.113, parse no longer implicitly splits byte/string streams from files or external commands into lines. It collects the stream and parses it as one input value. Add lines when you want the old line-by-line behavior.
# Bad — expects one match per line, but parses the stream as one input value
open app.log | parse -r '^(?<level>\w+) (?<message>.*)$'
# Good — explicit line-by-line parsing
open app.log | lines | parse -r '^(?<level>\w+) (?<message>.*)$'
# Also good — intentionally parse the whole file with a multiline regex
open changelog.txt | parse -r r#'(?ms)^## (?<version>.*?)\n(?<body>.*)'#26. Splitting Custom Command Flags Across Lines Without Grouping
A newline can terminate a command invocation. If the next line starts with a named flag, Nushell may parse it as a separate statement that emits a plain string or produces a parse error instead of passing the flag to the command.
# Bad — flags start new statements
build-report $target
--format json
--strict
# Good — short calls stay on one line
build-report $target --format json --strict
# Good — explicit multiline invocation
let report = (
build-report $target
--format json
--strict
)Use the same pattern when reviewing scripts generated from Bash-style wrapped commands: either keep named flags on the command line or group the whole call.
27. Expecting Single-Quoted External Format Strings to Interpret Backslash Escapes
Single-quoted Nushell strings do not process backslash escapes. Double-quoted strings do, but \\t means "literal backslash followed by t". Prefer one backslash in double quotes for simple escapes so Nushell passes a real tab or newline. Use char tab / char nl when the string also needs Nushell interpolation.
# Bad — single quotes pass literal "\t"
^git log --format='%H\t%an'
# Bad — double backslash also passes literal "\t"
^git log --format="%H\\t%an"
# Preferred — simple and Nushell passes an actual tab
^git log --format="%H\t%an"
# Good — explicit separator, useful with interpolation
let tab = (char tab)
^git log --format=$'%H($tab)%an'
# Good — use real line delimiters when that makes parsing simpler
let nl = (char nl)
^some-tool --format=$'name=%n($nl)email=%e'
| lines
| parse '{key}={value}'Bash to Nushell Conversion Reference
Quick reference for converting common Bash patterns to idiomatic Nushell.
Redirections & Pipes
| Bash | Nushell | Notes |
|---|---|---|
echo "text" > file | `'text' \ | save file` |
echo "text" >> file | `'text' \ | save --append file` |
cmd 2>/dev/null | `cmd e>\ | ignore` |
cmd > /dev/null 2>&1 | `cmd o+e>\ | ignore` |
cmd 2>&1 | `cmd o+e>\ | ...` |
| `cmd1 \ | tee log.txt \ | cmd2` |
| `cmd \ | head -5` | `cmd \ |
| `cmd \ | tail -3` | `cmd \ |
Variables
| Bash | Nushell | Notes |
|---|---|---|
FOO="bar" | let foo = 'bar' | Immutable by default |
FOO="bar" | mut foo = 'bar' | When mutation needed |
readonly FOO="bar" | const foo = 'bar' | Parse-time constant |
export FOO="bar" | $env.FOO = 'bar' | Environment variable |
echo $FOO | $env.FOO | Access env var |
echo ${FOO:-default} | `$env.FOO? \ | default 'default'` |
echo $? | $env.LAST_EXIT_CODE | Last exit code |
echo $RANDOM | random int | Random number |
String Operations
| Bash | Nushell |
|---|---|
${var^^} | `$var \ |
${var,,} | `$var \ |
${var:0:5} | `$var \ |
${#var} | `$var \ |
${var/old/new} | `$var \ |
${var//old/new} | `$var \ |
${var%.ext} | `$var \ |
Conditionals
Use if for single boolean predicates and short threshold checks. When several branches dispatch on the same value, prefer match over an if/else if chain.
# Bash
if [ "$x" -gt 10 ]; then
echo "big"
elif [ "$x" -gt 5 ]; then
echo "medium"
else
echo "small"
fi# Nushell
if $x > 10 {
'big'
} else if $x > 5 {
'medium'
} else {
'small'
}# Bash case dispatch
case "$status" in
ok) handle_ok ;;
error) handle_error ;;
pending) handle_pending ;;
*) handle_unknown ;;
esac# Nushell — prefer match for enum-like or multi-branch dispatch
match $status {
ok => { handle-ok }
error => { handle-error }
pending => { handle-pending }
_ => { handle-unknown }
}Loops
# Bash — iterate files
for f in *.txt; do
echo "$f"
done# Nushell — functional
ls *.txt | get name | each {|f| print $f }
# Or simply
ls *.txt | get name# Bash — C-style loop
for ((i=0; i<10; i++)); do
echo $i
done# Nushell
0..9 | each {|i| print $i }
# Or
for i in 0..9 { print $i }# Bash — while loop
while read -r line; do
echo "$line"
done < file.txt# Nushell
open file.txt | lines | each {|line| $line }File Operations
| Bash | Nushell |
|---|---|
cat file | open file or open --raw file |
wc -l file | `open file \ |
touch file | touch file |
mkdir -p dir | mkdir dir |
rm -rf dir | rm -r dir |
cp src dst | cp src dst |
mv src dst | mv src dst |
find . -name "*.rs" | glob **/*.rs |
test -f file | `('file' \ |
test -d dir | `('dir' \ |
basename path | `'path' \ |
dirname path | `'path' \ |
Command Substitution
# Bash
FILES=$(ls *.txt)
COUNT=$(wc -l < file.txt)# Nushell — no special syntax needed
let files = (ls *.txt)
let count = (open file.txt | lines | length)Functions → Custom Commands
# Bash
greet() {
local name="$1"
local greeting="${2:-Hello}"
echo "${greeting}, ${name}!"
}# Nushell
def greet [
name: string
--greeting (-g): string = 'Hello'
]: nothing -> string {
$'($greeting), ($name)!'
}Arrays → Lists
# Bash
arr=(one two three)
echo ${arr[0]}
echo ${#arr[@]}
arr+=("four")# Nushell
let arr = [one two three]
$arr | get 0 # or $arr.0
$arr | length
$arr | append four # Returns new list (immutable)Associative Arrays → Records
# Bash
declare -A config
config[host]="localhost"
config[port]="8080"
echo "${config[host]}"# Nushell
let config = {host: localhost, port: 8080}
$config.hostProcess Management
| Bash | Nushell |
|---|---|
command & | job spawn { command } |
jobs | job list |
kill $PID | kill $pid or job kill $id |
ps aux | ps |
command1 && command2 | command1; command2 |
| `command1 \ | \ |
JSON Processing (jq → Nushell)
# Bash + jq
curl -s api | jq '.users[] | {name, email}'
curl -s api | jq '.items | length'
curl -s api | jq '.data | sort_by(.date)'# Nushell — native structured data
http get api | get users | select name email
http get api | get items | length
http get api | get data | sort-by dateError Handling
# Bash
set -e # Exit on error
trap cleanup EXIT
if ! command; then
echo "Failed" >&2
exit 1
fi# Nushell
try {
some-command
} catch {|err|
print -e $'Failed: ($err.msg)'
exit 1
}Common Patterns
Check if command exists
# Bash
if command -v git &> /dev/null; then echo "found"; fi# Nushell
if (which git | is-not-empty) { print 'found' }Read environment with default
# Bash
PORT="${PORT:-8080}"# Nushell
let port = ($env.PORT? | default 8080)Multiline strings
# Bash heredoc
cat << 'EOF'
line 1
line 2
EOF# Nushell raw string
r#'line 1
line 2'#Nushell Data & Type System Reference
Type Hierarchy
any
├── nothing (null/void)
├── bool
├── int
├── float
├── number (int | float)
├── string
├── datetime
├── duration
├── filesize
├── binary
├── range
├── glob
├── list<T>
├── record<K: V, ...>
├── table<T> (list<record<T>>)
├── closure
├── cell-path
└── errorType Annotations
Function signatures
# No types (accepts any)
def func [x] { ... }
# Typed parameters
def func [x: int, y: string] { ... }
# Pipeline types
def func []: string -> int { ... }
# Both pipeline and parameters
def func [multiplier: int]: list<int> -> list<int> {
$in | each {|x| $x * $multiplier }
}
# Optional parameters with defaults
def func [
x: int
y: int = 10 # Default value
--flag # Named flag (boolean switch)
--option: string # Named param (null if not passed)
--foo: string = 'bar' # Named param with default value
] { ... }Complex types
def func [items: list<string>] { ... }
def func [matrix: list<list<int>>] { ... }
def func [config: record<host: string, port: int>] { ... }
def func [data: table] { ... }
def func [transform: closure] { ... }Type annotations for custom commands
# Shapes valid for parameter annotations
any, binary, bool, cell-path, closure, datetime, duration, filesize,
float, glob, int, list, nothing, number, range, record, string, table
# Special shapes
path # String with ~ and . expansion
directory # Subset of path, only directories for tab-completionBuilt-in Scalar Types
Numbers
42 # int
0xFF # hexadecimal
0o77 # octal
0b1010 # binary
10_000 # underscore separator
3.14 # float
1.5e-3 # scientific notation
inf # infinity
-inf # negative infinity
NaN # not a numberDates and durations
date now # Current datetime
'2024-01-15' | into datetime
'2024-01-15T10:30:00Z' | into datetime
1sec, 5min, 2hr, 3day, 1wk # Duration literals
500ms + 2sec # 2sec 500ms
(date now) + 5day # 5 days from now
(date now) - 1wk # 1 week agoFilesizes
1kb, 500mb, 2gb, 1tb # Filesize literals
1024 | into filesize # 1.0 KiB
100mb + 50mb # 150mb
1gb / 4 # 250mbBinary data
0x[01 FF 3A 00] # Binary literal
'hello' | into binary # String to binary
0x[68656c6c6f] | decode utf-8 # Binary to string
open --raw file.bin # Read raw binaryGlob patterns
glob *.rs # Current dir
glob **/*.rs # Recursive
glob **/*.{rs,toml} # Multiple extensions
glob **/*.rs --exclude [**/target/**] # With exclusions
glob **/*.rs --depth 2 # Max depth
glob '[A-Z]*' --no-file --no-symlink # Only directories
glob '(?i)readme*' # Case-insensitive (wax syntax)Collection Types
Lists
[1 2 3] # list<int>
['Alice' 'Bob'] # list<string>
[[1 2] [3 4]] # list<list<int>>
[] # Empty listRecords
{name: 'Alice', age: 30} # Create
{} # Empty record
{user: {name: 'Alice', contact: {email: 'a@b.com'}}} # NestedTables (list of records)
let users = [
{name: 'Alice', age: 30}
{name: 'Bob', age: 25}
]Ranges
1..5 # Inclusive: [1, 2, 3, 4, 5]
1..<5 # Exclusive end: [1, 2, 3, 4]
1..2..10 # With step: [1, 3, 5, 7, 9]
5..1 # Reverse: [5, 4, 3, 2, 1]
seq char a e # Character sequence: [a, b, c, d, e]Type Conversions
42 | into string # '42'
'42' | into int # 42
3.7 | into int # 3 (truncates)
'3.14' | into float # 3.14
42 | into float # 42.0
'true' | into bool # true
1 | into bool # true
0 | into bool # false
'hello' | into binary # binary representation
'2024-01-15' | into datetime # datetime
1024 | into filesize # 1.0 KiB
60 | into duration --unit sec # 1min
'*.rs' | into glob # glob typeType Checking and Guards
42 | describe # 'int'
[1 2 3] | describe # 'list<int>'
{a: 1} | describe # 'record<a: int>'
# Type guard pattern
def safe-process [value: any] {
match ($value | describe) {
'int' => ($value * 2)
'string' => ($value | str upcase)
_ => null
}
}
# Type predicates
def is-list [] { ($in | describe) starts-with 'list' }Type Coercion Rules
Nushell does NOT auto-coerce types except in string interpolation:
'5' + 3 # TYPE ERROR
$'Value: (42)' # 'Value: 42' (interpolation auto-converts)
42 == '42' # false (different types, no coercion)
42 == ('42' | into int) # true (explicit conversion)Record Operations
$rec1 | merge $rec2 # Merge (right-biased)
[$r1 $r2 $r3] | into record # Merge many records
$rec | update name {|r| $'Dr. ($r.name)' } # Update field
$rec | insert active true # Insert new field
$rec | insert z {|r| $r.x + $r.y } # Computed field
$rec | upsert count {|r| ($r.count? | default 0) + 1 } # Update or insert
$rec | reject password secret_key # Remove fields
$rec | select name age email # Keep only these fields
$rec | items {|k, v| ... } # Iterate key-value pairs
$rec | transpose key val # Convert to tableDynamic field access
let field_name = 'age'
$record | get $field_name # Access by variable
$record | update $field_name 42 # Update by variable
# Dynamic field selection
let fields = if $detailed { [id name email] } else { [id name] }
$table | select ...$fieldsTable Operations
$table | where age > 25 # Filter rows
$table | insert retired {|row| $row.age > 65 } # Add column
$table | rename -c {age: years} # Rename column
$table | group-by status --to-table # Group by
$table | transpose name data # Transpose
# Joins
$users | join $orders user_id # Inner join
$users | join --left $orders user_id # Left join
$users | join --outer $orders user_id # Outer join
# Group-by aggregations
$sales | group-by category --to-table | insert stats {|g| {
count: ($g.items | length)
total: ($g.items | get price | math sum)
avg: ($g.items | get price | math avg)
}}List Operations
$list | enumerate | where {|e| $e.index > 5 } # Filter with index
$list | reduce --fold 0 {|it, acc| $acc + $it } # Accumulate with initial
[1 2 3 4] | reduce {|it, acc| $acc - $it } # Without fold: ((1-2)-3)-4
$list | window 3 # Sliding window
$list | chunks 100 # Batched processing
$list | flatten # Flatten nested lists
$list | skip while {|x| $x < 100 } # Skip while condition true
$list | take while {|x| $x < 1000 } # Take while condition true
$list | uniq # Remove duplicates
$list | sort-by field # Sort
$list | reverse # Reverse orderNull Safety Patterns
$record.field? # null if field missing (no error)
$record.field? | default 'N/A' # Provide fallback
if ($record.field? != null) { ... } # Check existence
$list | default -e $fallback # Default for empty collections
$input | default 'anonymous' # Default for null values`default`'s argument is eager.$x | default <expr>evaluates<expr>
whether or not$xis null —defaultis a normal command, not lazy. So
$x | default $rec.maybe_missing throws when that column is absent, even when$xis non-null. Make the fallback null-safe (... | default ($rec.maybe? | default 0))
or branch with if when it is expensive.Discriminated Unions Pattern
let result = {type: 'success', value: 42}
let error = {type: 'error', message: 'Failed'}
match $result.type {
'success' => $result.value
'error' => { print -e $result.message; null }
}Dataframes (Polars)
Everything above describes Nushell's native, row-oriented record / table / list. For large datasets and heavy columnar analytics (multi-million row group-by, joins, aggregations, big CSV/Parquet), Nushell also offers Polars dataframes through the polars plugin — a separate, column-oriented data structure (polars_dataframe / polars_lazyframe), not a native table.
Rule of thumb: native table for small/interactive data and tight integration with the Nushell command set; Polars dataframes for large-scale or columnar work. Convert with polars into-df / polars into-lazy (in) and polars into-nu (back out). See Dataframes for the full guide.
Nushell Dataframes (Polars) Reference
Nushell's native list/record/table are row-oriented and great for small, interactive data. For large datasets and heavy analytics (multi-million row group-by, joins, aggregations, columnar math, big CSV/Parquet files), use the Polars dataframes provided by the polars plugin. Dataframes store data column-wise (Apache Arrow) and run on the Polars engine, which is dramatically faster and more memory-efficient than native loops for this class of work.
All commands and outputs below were verified against nu_plugin_polars 0.113(Nushell 0.113). Polars evolves quickly; when in doubt, confirm a command'ssignature with scope commands | where name == 'polars <cmd>' orhelp polars <cmd> rather than trusting older docs. The plugin currentlyships ~150polars *subcommands —help polarslists them all.
When to Use Dataframes
Use native table/list when… | Use Polars dataframes when… |
|---|---|
| Data is small (≲ 100k rows) | Data is large (100k–billions of rows) |
| You need random access or row-by-row logic | You do columnar ops: group-by, join, aggregate, window |
| You compose with the broader Nushell command set | You read/write big CSV/Parquet/Arrow/JSON files |
| You stream line-by-line (logs) | You want a query optimizer to plan the whole pipeline |
Eager vs lazy:
- Lazy (
polars into-lazy,polars open) builds a logical plan that is only
executed on polars collect. The optimizer can prune columns, push down filters, and fuse steps. Prefer lazy for large files and multi-step pipelines.
- Eager (
polars into-df,polars open --eager) materializes immediately.
Prefer eager for small data you inspect or reuse many times.
Some commands only work on one shape (e.g. polars unique --subset, polars shift --fill are documented as lazy-only; bare reductions like polars sum work eager). When a command errors with a shape complaint, switch the frame with polars into-lazy / polars collect.
Plugin Setup
The polars plugin ships as nu_plugin_polars (install via cargo install nu_plugin_polars or your package manager, matching your Nushell version).
plugin add ~/.cargo/bin/nu_plugin_polars # Register once (path to the binary)
plugin use polars # Load into the current scope/config
help polars # Verify: lists all polars subcommands
plugin stop polars # Reset the plugin + clear its object storeplugin use polars must run at parse time (top of a script or in your config), just like use — it cannot be created from a runtime let.
Eager, Lazy, collect, and cache
[[a b]; [1 2] [3 4]] | polars into-df | describe # => polars_dataframe (eager)
[[a b]; [1 2] [3 4]] | polars into-lazy | describe # => polars_lazyframe
polars open data.csv | describe # => polars_lazyframe (LAZY by default!)
polars open --eager data.csv | describe # => polars_dataframe
# A lazy frame is just a plan until you collect it
polars open data.csv
| polars filter ((polars col status) == 'active')
| polars select name status
| polars collect # executes the optimized plan
# polars cache materializes the plan up to that point into a new lazy frame,
# so a shared sub-plan is computed once instead of re-run by each branch.
polars open data.csv | polars filter ((polars col ok) == true) | polars cacheGotcha:polars openis lazy by default (since 0.97). Pass--eager
only when you truly need an in-memory dataframe up front. Note also that
describereturnspolars_dataframe/polars_lazyframe— not the bare
DataFrame string used in older docs.The Object Store
Dataframes live in the plugin's object store, not as plain Nushell values. A Nushell variable holds a reference; the store persists across commands for the plugin's lifetime.
polars store-ls | select key type columns rows estimated_size # List stored objects
polars store-ls | get key | first | polars store-get $in # Re-fetch an object by key
polars store-ls | get key | first | polars store-rm $in # Drop one object by key
plugin stop polars # Drop everything (full reset)polars store-get <key> returns any stored object (dataframe, lazyframe, expression, group-by, schema, selector…) — useful for reconnecting to a frame by key, e.g. across do/closure boundaries.
Gotcha: A lazy frame from polars open references the source file. Ifyou delete or move that file beforecollect(or beforepolars store-ls,
which inspects every stored frame), execution fails with
Error collecting lazy frame: No such file or directory. Collect to an eagerframe first if the source may disappear.
Loading, Creating, and Bridging Back
# Create from native Nushell data
[[a b]; [1 2] [3 4] [5 6]] | polars into-df # table -> eager dataframe
[1 2 3 4] | polars into-df # list -> single-column dataframe
[[a b]; [1 a] [2 b]] | polars into-lazy # table -> lazy frame
# Pin a schema explicitly with -s (dtypes, including nested struct/list types)
[[id person]; [1 {name: Bob, age: 36}]]
| polars into-df -s {id: i64, person: {name: str, age: u8}}
# Open files: csv, tsv, parquet, json, jsonl/ndjson, arrow, avro
polars open sales.parquet # lazy
polars open --eager sales.csv
polars open data.csv --infer-schema 1000 --skip-rows 2 --delimiter ';' --no-header
polars open data.csv --schema {a: i64, b: str} # force column dtypes
# Bridge back to native Nushell for further piping or display
$df | polars into-nu # dataframe/expression -> native valuepolars open flags include --eager, --type, --delimiter, --no-header, --infer-schema <n>, --skip-rows <n>, --columns, --schema, --truncate-ragged-lines, plus Hive-partition flags (--hive-enabled, --hive-schema, …). Use polars into-nu whenever you want to hand results back to ordinary Nushell commands (to json, save, table, etc.).
Inspecting
$df | polars schema # column -> dtype map, e.g. {a: i64, b: str}
$df | polars shape # => {rows: 3, columns: 2}
$df | polars columns # list of column names
$df | polars first 5 # first n rows (also: polars last)
$df | polars summary # descriptive stats (count/mean/std/min/quantiles/max) for numeric cols
$df | polars into-repr # the native Polars text table (with shape + dtypes), as a stringpolars into-repr is handy in messages/logs — it renders the familiar Polars box table (including the shape: (rows, cols) header and per-column dtypes).
Expressions
Expressions describe column operations. Build them with polars col and combine with arithmetic, comparisons, and polars as (alias). They are consumed by select, with-column, filter, agg, etc.
polars col price # reference a column
polars col '*' # all columns
(polars col price) * 1.1 # arithmetic on a column
polars lit 100 # a literal value
(polars col qty | polars sum | polars as total) # aggregate + alias
# Conditional (if/else) expression: when ... otherwise ...
$df | polars with-column (
polars when ((polars col score) >= 60) 'pass'
| polars otherwise 'fail'
| polars as grade
)polars when can be chained for multi-branch logic (when A a | when B b | otherwise c). Negate a boolean expression with polars expr-not; invert a boolean mask/series with polars not.
Column Selectors
Selectors pick columns by property instead of by exact name — handy for wide frames. They produce a polars_selector usable anywhere a column set is expected (select, with-column, is-in, etc.).
$df | polars select (polars selector numeric) # all int/float columns
$df | polars select (polars selector float) # all float columns
$df | polars select (polars selector by-name a c) # explicit names
$df | polars select (polars selector starts-with user) # user_id, user_name, ...
$df | polars select (polars selector ends-with _id) # account_id, user_id, ...
$df | polars select (polars selector contains name) # columns containing substring
$df | polars select (polars selector matches '_name$') # regex on column names
$df | polars select (polars selector by-index 0 -1) # first and last columns
$df | polars select (polars selector alpha) # names made of letters
$df | polars select (polars selector alphanumeric) # names made of letters/digits
$df | polars select (polars selector by-dtype str) # by dtype
$df | polars select (polars selector numeric | polars selector exclude id) # compose/excludeThe full selector family: all, alpha, alphanumeric, array, binary, boolean, by-dtype, by-index, by-name, categorical, contains, date, datetime, decimal, digit, duration, empty, ends-with, enum, exclude, first, float, integer, last, list, matches, nested, not, numeric, object, signed-integer, starts-with, string, struct, temporal, unsigned-integer.
Select, Filter, With-Column, Rename, Drop
# select: keep/compute columns (string names or expressions)
$df | polars select a b
$df | polars select (polars col a) ((polars col b) * 2 | polars as b2)
$df | polars get a b # like select but returns those columns directly
# filter: keep rows matching an expression
$df | polars filter ((polars col age) > 25)
$df | polars filter ((polars col status) == 'active')
$df | polars filter-with (polars col flag) # filter by a boolean mask/expression
# with-column: add/replace a derived column
$df | polars with-column ((polars col a) + (polars col b) | polars as ab)
$df | polars with-column {ab: ((polars col a) + (polars col b))} # record form: name -> expr
# rename / drop columns
$df | polars rename a alpha # rename one column (also takes lists)
$df | polars drop b c # drop columns by nameColumns are immutable and shared between frames for efficiency — you cannot
mutate a value in place. Derive a new column with with-column instead.Group-by and Aggregation
The core analytics pattern: group-by → agg [...] → collect. Use a lazy frame so the optimizer only touches the columns you aggregate.
[[name value]; [one 1] [two 2] [one 1] [two 3]]
| polars into-lazy
| polars group-by name
| polars agg [
(polars col value | polars sum | polars as total)
(polars col value | polars mean | polars as mean)
(polars col value | polars len | polars as n)
(polars col value | polars quantile 0.5 | polars as median)
]
| polars sort-by name
| polars collect
# => name=one total=2 mean=1.0 n=2 median=1.0 ; name=two total=5 mean=2.5 n=2 median=2.5Aggregation expressions usable inside agg (and as standalone reductions): sum, mean, min, max, median, quantile, std, var, len (row count), count (non-null count), n-unique, implode (collect into a list), first, last, entropy. On an eager frame, a bare polars sum (and friends mean/min/max/median/std/var/quantile) reduces every numeric column at once:
$df | polars sum # one-row frame: sum of each numeric column (text -> null)
$col | polars value-counts # frequency table: distinct values + countpolars value-counts also accepts --sort (order by count) and --normalize (return proportions instead of raw counts).
Window and Sequence Functions
polars over computes an aggregation per partition and broadcasts the result back to every row — no collapsing, unlike group-by.
[[g v]; [a 1] [a 2] [b 3]]
| polars into-df
| polars with-column ((polars col v | polars sum | polars over g) | polars as g_sum)
# => g=a v=1 g_sum=3 ; g=a v=2 g_sum=3 ; g=b v=3 g_sum=3polars shift lags/leads a column by a period; polars cumulative runs a running min/max/sum; polars rolling runs a fixed-window reduction.
# shift (lag by 2; --fill replaces the leading nulls, lazy frame)
[[a]; [1] [2] [2] [3] [3]]
| polars into-lazy
| polars with-column {b: (polars col a | polars shift 2 --fill 0)}
| polars collect
# => b column = 0 0 1 2 2
# cumulative sum (also: min / max ; --reverse to run end-to-start)
[[a]; [1] [2] [3]]
| polars into-df
| polars select (polars col a | polars cumulative sum | polars as cum)
| polars collect
# => cum = 1 3 6
# rolling window over a series (type = sum / min / max / mean)
[1 2 3 4 5] | polars into-df | polars rolling sum 2 | polars drop-nulls
# => 0_rolling_sum = 3 5 7 9Joins
$left | polars join $right id id # inner join (default), key on both sides
$left | polars join --left $right id id # left join (unmatched right -> null)
$left | polars join --full $right id id # full outer join
$left | polars join --cross $right # cross join
$left | polars join $right [id region] [id region] # multi-column key
# Useful flags
--coalesce-columns # merge key columns (esp. with --full)
--suffix '_r' # rename overlapping non-key columns from the right
--nulls-equal # treat nulls as matching
# Non-equi / conditional join: match on arbitrary predicates, not just equality
$left | polars join-where $right ((polars col amount) > (polars col threshold))Join types are--inner(default) /--left/--full/--cross. The old
--outerflag was renamed to--full.polars joinoperates on lazy frames.
Combining Frames: Concat and Append
# concat: stack two or more frames (vertically by default). Clear and explicit —
# prefer this for "union all" style row stacking.
[[a b]; [1 2]] | polars into-df
| polars concat ([[a b]; [3 4]] | polars into-df) ([[a b]; [5 6]] | polars into-df)
| polars collect
# => 3 rows. Flags: --diagonal (union mismatched columns), --to-supertypes
# (reconcile dtypes), --rechunk, --no-maintain-order.
# append: note the counter-intuitive flag semantics (verified on 0.113) —
let a = ([[a b]; [1 2] [3 4]] | polars into-df)
$a | polars append $a # DEFAULT: adds the other frame as NEW COLUMNS -> a b a_x b_x
$a | polars append $a --col # --col: stacks ROWS -> 4 rows of (a b)Gotcha:polars append's default appends columns (with_xsuffixes on
name clashes); --col appends rows. This is the opposite of what the flagname suggests. For row stacking, prefer `polars concat` — it reads clearly.
Reshaping: Unpivot and Pivot
# unpivot: wide -> long (formerly "melt")
[[id m1 m2]; [a 1 2] [b 3 4]]
| polars into-df
| polars unpivot --index [id] --on [m1 m2] --variable-name metric --value-name val
# => id=a metric=m1 val=1 ; id=a metric=m2 val=2 ; id=b ...
# pivot: long -> wide
[[g k v]; [a x 1] [a y 2] [b x 3] [b y 4]]
| polars into-df
| polars pivot --on [k] --index [g] --values [v]
# => g=a x=1 y=2 ; g=b x=3 y=4polars pivot also takes --aggregate (-a) (first/sum/min/max/mean/median/count/ last or a custom expression) when multiple rows collapse into one cell, plus --separator, --maintain-order, --stable, and --streamable.
Nested Data: Lists and Structs
Polars columns can hold list<…> and struct<…> values. These commands move between nested and flat layouts.
# explode / flatten: one list element per row (flatten is an alias for explode)
[[id hobbies]; [1 [Cycling Knitting]] [2 [Skiing]]]
| polars into-df
| polars explode hobbies
| polars collect
# => (1, Cycling) (1, Knitting) (2, Skiing)
# implode: the inverse — aggregate a column's values into a single list (in agg/select)
$df | polars select (polars col v | polars implode)
# unnest: split a struct column into one column per field (inserted in place)
[[id person]; [1 {name: Bob, age: 36}] [2 {name: Betty, age: 63}]]
| polars into-df -s {id: i64, person: {name: str, age: u8}}
| polars unnest person # -> columns id, name, age (-s '_' to prefix names)
# struct-json-encode: serialize a struct column to a JSON string column
$df | polars select id (polars col person | polars struct-json-encode | polars as json)
# membership tests
$df | polars with-column (polars col a | polars is-in [one two] | polars as a_in) # scalar in set
$df | polars with-column (polars col tags | polars list-contains (polars lit urgent) | polars as has) # element in list columnBinning and Encoding
# cut: bin a numeric series by explicit break points (n breaks -> n+1 categories)
[-2 -1 0 1 2] | polars into-df | polars cut [-1 1] --labels [low mid high]
# => category column: low low mid high high (--left_closed, --include_breaks)
# qcut: bin by quantile probabilities instead of fixed breaks
[-2 -1 0 1 2] | polars into-df | polars qcut [0.25 0.75] --labels [a b c] --allow_duplicates
# dummies: one-hot encode (--drop-first to avoid the dummy-variable trap)
[[a b]; [1 2] [3 4]] | polars into-df | polars dummies
# => columns a_1 a_3 b_2 b_4 (0/1)Math Functions
# polars math: scalar math over column expressions
# abs, sign, sqrt, exp, log <base; default e>, log1p, sin, cos, dot <expr>
[[a]; [-1] [4]]
| polars into-df
| polars select (polars col a | polars math abs | polars as a_abs)
| polars collect
# polars horizontal: reduce ACROSS columns per row (all/any/min/max/sum/mean).
# Nulls are skipped by default; pass --nulls to make any null produce null.
[[a b]; [1 2] [3 4]]
| polars into-df
| polars select (polars horizontal sum a b | polars as row_sum)
| polars collect
# => row_sum = 3 7String Columns
String ops live under polars str-* (regrouped since older versions; old substring replacement examples that used generic names should now use the string-specific commands):
$df | polars select (polars col w | polars str-replace -p '[0-9]+' -r 'N') # regex replace (leftmost)
$df | polars select (polars col w | polars str-replace-all -p '[0-9]+' -r 'N') # regex replace all
$df | polars with-column (polars concat-str '-' [(polars col a) (polars col b)] | polars as ab)
$df | polars select (polars col s | polars str-split ',') # -> list<str> column
$df | polars select (polars col s | polars str-strip-chars 'x') # trim chars from both ends
# also: str-slice, str-lengths, str-join, lowercase, uppercase, containsDate, Time, and Time Zones
$df
| polars with-column (polars col d | polars as-datetime '%Y-%m-%d' --naive | polars as ts)
| polars with-column (polars col ts | polars get-year | polars as year)
| polars with-column (polars col ts | polars datepart month | polars as month)
| polars with-column (polars col ts | polars strftime '%B' | polars as month_name)- Parsing:
polars as-date/polars as-datetime '<fmt>'. Pass--naive
for timezone-naive timestamps; --time-zone, --time-unit, --ambiguous refine the parse.
- Extracting parts: the
polars get-*family (get-year,get-month,
get-day, get-hour, get-minute, get-second, get-nanosecond, get-week, get-weekday, get-ordinal) — or the unified polars datepart <part> (year/quarter/month/week/weekday/day/ hour/minute/second/millisecond/microsecond/nanosecond).
- Formatting:
polars strftime '<fmt>'. - Time zones:
polars convert-time-zone 'America/New_York'shifts the
instant to another zone; polars replace-time-zone 'America/New_York' relabels the wall-clock time without shifting it (--ambiguous/--nonexistent handle DST edge cases; pass null to unset the zone).
- Bucketing:
polars truncate 1h(or5d,1mo,1wk…) floors each
timestamp to the start of its bucket — the basis for time-series grouping.
Null Handling
$df | polars count-null # per-column null counts
$df | polars fill-null 0 # replace nulls with a value/expression
$df | polars fill-nan 0 # replace float NaN (distinct from null)
$df | polars drop-nulls # drop rows with any null
$df | polars filter (polars col x | polars is-null) # keep only rows where x is null
# also: is-not-nullSampling, Slicing, and Dedup
$df | polars sample --n-rows 100 # random subset (--fraction, --replace, --shuffle, --seed)
$df | polars slice 10 20 # 20 rows starting at offset 10
$df | polars take [0 2 4] # rows at the given indices
$df | polars first 5 # head (also: polars last 5)
$df | polars reverse # reverse row order
$df | polars into-lazy | polars unique --subset [a] | polars collect # dedup by column subset (lazy)
$df | polars drop-duplicates # drop fully-duplicate rowsReplace and Conditional Values
# polars replace: value mapping (old -> new) via two positional args.
# `old` is a list/record of values to match; `new` is the list of replacements.
$df | polars with-column (polars col grade | polars replace [A B] [4 3] | polars as gpa)
# Flags: --strict (every value must match), --default <expr> (value for unmatched).
# polars set / set-with-idx / filter-with: mask- or index-based value assignment (eager series)polars replace does value mapping (different from polars str-replace, which does regex substring replacement on string columns).
Custom Logic Escape Hatch: map-batches
When an operation has no native Polars expression, polars map-batches runs a Nushell closure over one or more columns. The closure receives a list of single-column dataframes and returns a series-like value (list, scalar, or single-column dataframe).
[[a b]; [1 4] [2 5] [3 6]]
| polars into-df
| polars map-batches --name out { |cols| $cols | first | polars get a | each { |v| $v * 2 } } a
# => out = 2 4 6Use sparingly: dropping into a Nushell closure forfeits Polars' vectorized
speed. Reach for it only when no native expression (math,str-*,
when/otherwise, arithmetic) can express the transform.SQL Queries
$df | polars query 'select category, sum(amount) as total from df group by category order by total desc'The source frame is always referenced as df in the from clause.
Type Casting
Use Polars dtype names (i64, f64, str, bool, date, datetime, …), not Nushell type names:
$df | polars cast f64 price # cast one column (column arg required on a dataframe)
$df | polars cast i64 a | polars cast str b
$df | polars schema # => {a: i64, b: str}
# Build dtype / schema objects when a command needs them
'i64' | polars into-dtype # dtype-name string -> dtype object
{a: i64, b: str} | polars into-schema # record -> schema objectDedicated string→number converters also exist: polars integer and polars decimal parse a string column into integer/decimal columns.
Saving
polars save writes a dataframe to disk by extension. For a lazy frame it performs a streaming sink when the format supports it (parquet, ipc/arrow, csv, ndjson) — efficient for results too large to fit in memory.
$df | polars save out.parquet
polars open big.csv | polars filter ((polars col ok) == true) | polars save filtered.parquet
$df | polars save out.csv --csv-delimiter ';' --csv-no-headerFlags include --type (force format), --csv-delimiter, --csv-no-header, and --avro-compression.
Migration Notes (older docs → 0.113)
| Old | Now |
|---|---|
polars melt | polars unpivot (and polars pivot for the reverse) |
polars join --outer | polars join --full |
old substring replacement examples using replace / replace-all | polars str-replace / polars str-replace-all (polars replace is value mapping) |
polars concatenate | polars str-join / polars concat-str (string concat), or polars concat (stack frames) |
polars fetch | removed (use polars collect / polars first) |
describe → DataFrame | describe → polars_dataframe / polars_lazyframe |
polars open (eager) | polars open is lazy; use --eager for eager |
polars count for row count | polars len (polars count = non-null count, SQL COUNT(col)) |
New since older docs: polars over (window), polars shift/cumulative/ rolling (sequence ops), polars selector * (column-select DSL), polars when/otherwise, polars join-where (non-equi join), polars concat, polars cut/qcut/dummies (binning + one-hot), polars math/horizontal (numeric ops), polars explode/implode/unnest/struct-json-encode (nested data), polars convert-time-zone/replace-time-zone/truncate/datepart (time zones + buckets), polars map-batches (Nushell-closure escape hatch), polars profile, polars cache, polars store-get, polars into-repr, polars query (SQL).
Performance Notes
- Polars uses a columnar layout (Apache Arrow) plus a query optimizer, so
heavy group-by/join/aggregation on large data can run substantially faster than native Nushell pipelines, and often faster than pandas as well. (See the official Dataframes chapter for benchmarks.)
- Stay lazy end-to-end and
collectonce at the end — that lets the
optimizer prune unused columns and push filters down to the file reader.
- Reuse a stored frame for multiple aggregations instead of re-reading the file;
use polars cache to compute a shared sub-plan once when several branches build on it.
- Prefer native Polars expressions over
polars map-batches; the closure path
gives up vectorization.
- For results that don't fit in memory,
polars savefrom a lazy frame to
Parquet/Arrow streams to disk.
- Bridge to native (
polars into-nu) only at the boundaries; keep the heavy work
inside Polars.
See Also
- Data & Type System — native
record/table/listops - Advanced Patterns — native lazy/eager streaming,
par-each - Nushell Dataframes book chapter
Nushell Modules & Scripts Reference
Module Organization
File-form (simple modules)
my-command.nu # Single-file module, module name = filenameDirectory-form (larger modules)
my-module/
├── mod.nu # Module entry point (required)
├── utils.nu # Submodule
├── config.nu # Submodule
└── tests/
├── mod.nu # Test module entry point
└── utils_test.nu # Test fileBoth forms behave identically once imported; only the path changes.
Export Types
| Export | Keyword | Example |
|---|---|---|
| Commands | export def | export def my-cmd [] { ... } |
| Env commands | export def --env | export def --env setup [] { ... } |
| Aliases | export alias | export alias ll = ls -l |
| Constants | export const | export const version = '1.0.0' |
| Externals | export extern | export extern "git push" [...] |
| Submodules | export module | export module utils.nu |
| Re-exports | export use | export use utils.nu * |
| Env setup | export-env | export-env { $env.FOO = 'bar' } |
Only export-ed definitions are public. Non-exported definitions are private (local to the module).
The main Convention
When a command name matches the module name, use export def main:
# increment.nu
export def main []: int -> int {
$in + 1
}
export def by [amount: int]: int -> int {
$in + $amount
}use increment
5 | increment # => 6
5 | increment by 3 # => 8Submodule Patterns
export module — Preserves submodule namespace
# mod.nu
export module utils.nu # Commands accessed as: my-module utils <cmd>export use — Flattens into parent namespace
# mod.nu
export use utils.nu * # Commands accessed as: my-module <cmd>Environment Setup
# mod.nu
export-env {
$env.MY_MODULE_PATH = ($env.CURRENT_FILE | path dirname)
$env.MY_MODULE_VERSION = '2.0.0'
}Inline Module Definition
module my_module {
export def public-func [] { 'hello' }
def private-func [] { 'private' }
export const MY_CONST = 42
}
use my_module *
use my_module [public-func MY_CONST]Import Patterns
use my-module # Import module namespace
use my-module * # Import all exports into current scope
use my-module [func-a func-b] # Import specific exports
use lib/helpers.nu * # Import from file pathScripts
Basic script
# myscript.nu
def greet [name] {
$'Hello, ($name)!'
}
greet 'World'Definitions run first (regardless of position in file), then the script body runs top-to-bottom.
Parameterized scripts with main
#!/usr/bin/env nu
# Build the project
def "main build" [
--release (-r) # Build in release mode
] {
print 'Building...'
}
# Run tests
def "main test" [
--verbose (-v) # Show test details
] {
print 'Testing...'
}
def main [] {
print 'Usage: script.nu <build|test>'
}nu myscript.nu # => Usage: script.nu <build|test>
nu myscript.nu build # => Building...
nu myscript.nu test # => Testing...Important: You must define a main command for subcommands to be accessible. An empty def main [] {} suffices.
Multiline calls with named flags
When calling custom commands with named flags, keep short invocations on one line. If a call must span lines, wrap the whole invocation in parentheses. A bare newline can end the command before the flags are parsed.
# Good
deploy staging --target api --dry-run
# Good
let plan = (
deploy staging
--target api
--dry-run
)
# Bad
deploy staging
--target api
--dry-runShebang
#!/usr/bin/env nu
'Hello World!'For stdin access: #!/usr/bin/env -S nu --stdin
Attribute System (v0.103+)
@example 'Greet a user' { greet 'Alice' } --result 'Hello, Alice!'
@deprecated 'Use new-command instead.'
@category 'network'
@search-terms ['http' 'web' 'api']Parse-Time vs Runtime
| Feature | Parse-time | Runtime |
|---|---|---|
const values | Yes | No (already resolved) |
let values | No | Yes |
source / use paths | Must be known | N/A |
| Type checking | Yes | Some |
def names | Must be literal | N/A |
| Syntax errors | Caught here | N/A |
# Works — const is resolved at parse time
const path = 'scripts/utils.nu'
source $path
# Error — let is runtime only
let path = 'scripts/utils.nu'
source $path # Error: not a parse-time constantTesting
Nupm package tests
my-package/
├── nupm.nuon
├── mod.nu
└── tests/
├── mod.nu # Test entry point
└── utils_test.nu # Test fileOnly fully exported commands from tests module are run by nupm test.
Standalone tests with std assert
use std/assert
for t in [
[input expected];
[0 0]
[1 1]
[2 1]
[3 2]
] {
assert equal (fib $t.input) $t.expected
}Available assert commands
use std/assert
assert (condition) # Basic assertion
assert equal $actual $expected # Equality check
assert not equal $a $b # Inequality check
assert str contains $haystack $needle # String containment
assert length $list $expected_len # List length
assert error { failing-command } # Expect an errorCustom assertions
def "assert positive" [n: int] {
assert ($n > 0) --error-label {
text: $'Expected positive number, got ($n)'
span: (metadata $n).span
}
}Basic test framework (without Nupm)
use std/assert
source fib.nu
def main [] {
print 'Running tests...'
let test_commands = (
scope commands
| where ($it.type == 'custom')
and ($it.name | str starts-with 'test ')
and not ($it.description | str starts-with 'ignore')
| get name
| each {|test| [$'print \'Running test: ($test)\'' $test] } | flatten
| str join '; '
)
nu --commands $'source ($env.CURRENT_FILE); ($test_commands)'
print 'Tests completed successfully'
}
def "test fib" [] {
for t in [[input expected]; [0 0] [1 1] [2 1] [5 5]] {
assert equal (fib $t.input) $t.expected
}
}
# ignore
def "test skipped" [] {
print 'This test will not be executed'
}Nushell Script Review Checklist
Comprehensive checklist for reviewing Nushell scripts. Check items in order of priority.
---
1. Security (Critical)
Code injection
- [ ] No
nu -c $variablewith untrusted input - [ ] No
source $variablewith runtime paths (must beconst) - [ ] No
^sh -c,^bash -c, or^cmd.exe /Cwith interpolated user input - [ ] No
run-externalwith user-controlled command names
Path safety
- [ ] User-provided paths validated with
path expand+ prefix check - [ ] No raw
open $user_inputwithout path traversal guard - [ ]
..sequences in user paths detected and rejected - [ ] Base directory enforcement for file operations
Credential handling
- [ ] No hardcoded secrets in source code
- [ ] Credentials scoped with
with-env, not set on$envdirectly - [ ] Secrets read from files/stdin, not passed as command-line arguments
- [ ] No credentials logged via
printor written to non-secure files
Destructive operations
- [ ]
rmoperations validate target path (not/, not$nu.home-path) - [ ] Glob patterns from user input are validated (no unintended expansion)
- [ ]
--depthlimits onglobto prevent DoS on large trees
Temp files
- [ ] Temp files created with
^mktemp, not predictable paths - [ ] Temp files cleaned up in
try/catchor equivalent - [ ] Temp directories use
^mktemp -d
Environment safety
- [ ]
$env.PATHnot modifiable by untrusted input - [ ] Dangerous env vars (
LD_PRELOAD,DYLD_INSERT_LIBRARIES) cleared before running untrusted commands - [ ]
with-envused for scoped environment changes in security-sensitive contexts
---
2. Correctness
Type safety
- [ ] All exported commands have type annotations on parameters
- [ ] I/O pipeline signatures (
]: type -> type {) match actual behavior - [ ] Complex types use proper syntax:
record<name: string>,list<int>,table<col: type> - [ ] Optional parameters use
?suffix:name?: string - [ ] Rest parameters typed:
...args: string
Error handling
- [ ] Fallible operations wrapped in
try/catch - [ ] External commands checked with
completewhen exit code matters - [ ]
catchblocks include meaningful error context (not empty) - [ ]
finallyused for cleanup side effects, not as the returned value - [ ] Custom errors include
labelwithspanfor good error messages - [ ] No bare
error make {msg: '...'}without span when metadata is available
Null safety
- [ ] Optional record fields accessed with
?:$rec.field? - [ ]
defaultused for fallback values:$val | default 'N/A' - [ ] No bare field access on records from external/untrusted sources
- [ ]
$incaptured early withletwhen used multiple times
Logic correctness
- [ ]
fornot used as final expression (returns null, useeach) - [ ]
mutvariables not captured in closures (will error) - [ ]
source/usepaths areconst, notlet - [ ] Long
if/else ifchains on one value prefermatchunlessifis clearer - [ ]
eachnot used on single records (useitemsortranspose) - [ ]
parsegetslinesfirst when line-by-line parsing of stream input is intended - [ ] Correct operator:
>in non-pipeline context is comparison, not redirect - [ ] Multiline custom command calls with named flags are one-line or wrapped in parentheses
External commands
- [ ] External commands prefixed with
^when name conflicts with builtins - [ ]
find(Nushell builtin) vs^find(Unix) distinction maintained - [ ]
sort(Nushell builtin) vs^sort(Unix) distinction maintained - [ ] Arguments to external commands separated (not concatenated strings)
- [ ] Format strings passed to external commands use double quotes for simple escapes, or
char tab/char nlwhen interpolation is needed
---
3. Style & Idiom
Naming
- [ ] Commands:
kebab-case(fetch-user, notfetchUserorfetch_user) - [ ] Variables/params:
snake_case($user_id, not$userId) - [ ] Env vars:
SCREAMING_SNAKE_CASE($env.APP_VERSION) - [ ] Flags:
kebab-case(--output-dir, not--output_dir) - [ ] Full words preferred (
$user_name, not$usr_nm)
String format priority
- [ ] Bare words in arrays:
[foo bar]not["foo" "bar"] - [ ] Single quotes for simple strings:
'hello'not"hello" - [ ] Single-quoted interpolation preferred:
$'val: ($x)'not$"val: ($x)" - [ ] Double quotes only when escape sequences needed:
"\n","\t" - [ ] Raw strings for regex:
r#'pattern'#
Pipeline & functional style
- [ ] Pipelines preferred over imperative loops
- [ ]
$items | get price | math suminstead ofmut total; for ... - [ ]
ls | where size > 1mbinstead of manual filtering - [ ]
enumerateinstead of manual index counters - [ ]
reduceinstead ofmutaccumulator +for - [ ]
matchpreferred for multi-branch dispatch on a single value
Formatting
- [ ] No space before
|params|in closures:{|x| ...}not{ |x| ...} - [ ] Spaces around pipe:
cmd | cmdnotcmd|cmd - [ ] Commas omitted in lists:
[1 2 3]not[1, 2, 3] - [ ] One space after
:in records:{x: 1}not{x:1} - [ ] Multi-line format for expressions >80 chars
Documentation
- [ ] Exported commands have
#comment abovedef - [ ] Parameter descriptions as inline
#comments - [ ]
@exampleattributes for non-trivial commands - [ ]
@categoryfor organization when applicable
Modules
- [ ] Only necessary definitions are
export-ed - [ ]
export def mainused when command matches module name - [ ] Private helpers are not exported
- [ ]
export-envfor environment setup blocks
---
4. Performance
Parallelism
- [ ]
par-eachused for I/O-bound work (file reads, HTTP requests) - [ ]
par-eachused for CPU-bound work (data processing) - [ ]
--threadsspecified when controlling concurrency matters - [ ]
eachused only when order matters or list is tiny
Streaming & memory
- [ ]
each --flattenfor streaming nested results - [ ] Large files not loaded entirely when streaming suffices
- [ ]
lines+ pipeline for line-by-line processing of large files - [ ]
peekused for stream metadata/sample inspection instead of collecting - [ ]
first N/take whileto limit processing early
Caching & computation
- [ ] Expensive results cached in
letbindings, not recomputed - [ ]
globwith--depthto avoid scanning huge trees - [ ] Built-in commands preferred over external for small data
- [ ] External tools (
^rg,^jq) used for large-scale operations
---
5. Robustness
Input validation
- [ ] Parameter types annotated (catches misuse at parse time)
- [ ] Range/value checks at function entry for critical params
- [ ] User-facing commands validate inputs before processing
- [ ] Consistent return types (don't mix null and value unexpectedly)
File operations
- [ ]
path existschecked beforeopenwhen file may not exist - [ ]
save --forceused intentionally (overwrites without warning) - [ ] File encoding handled appropriately (
open --rawfor binary) - [ ]
mkdir -v/mv -v/rm -voutputs treated as tables, not parsed text - [ ]
rm/mkdirpartial-success behavior considered when multiple paths are passed
Process management
- [ ] Long-running external processes have timeouts or cancellation
- [ ] Background jobs (
job spawn) tracked and cleaned up - [ ] Exit codes checked for critical external commands
---
Review Workflow
1. Skim the entire script — Understand purpose, entry points, data flow 2. Security pass — Check Section 1 items systematically 3. Correctness pass — Verify types, error handling, null safety 4. Style pass — Naming, strings, formatting, documentation 5. Performance pass — Parallelism, streaming, caching opportunities 6. Robustness pass — Input validation, file safety, process management 7. Summarize findings — Group by severity, highlight security issues first
Nushell String Formats Reference
String Format Priority (High to Low)
1. Bare word — Simple word-character-only strings in data contexts 2. Raw string r#'...'# — Regex patterns, paths with quotes, multi-line content 3. Single-quoted '...' — Simple strings without embedded single quotes 4. Single-quoted interpolation $'...' — Interpolation without escape sequences 5. Backtick ` ... — Paths/globs with spaces 6. **Double-quoted** "..." — Only when escape sequences are needed (\n, \t, \", etc.) 7. **Double-quoted interpolation** $"..."` — Only when both interpolation AND escapes are needed
Conversion Rules
Use bare words when:
- Inside arrays:
[foo bar baz]not["foo" "bar" "baz"] - Path join arrays:
[$dir patches]not[$dir "patches"] - Match patterns:
match $x { absolute => ... }notmatch $x { "absolute" => ... }
Use raw strings when:
- Regex patterns with special chars:
r#'(?:a/|b/)?'#not"(?:a/|b/)?" - Strings containing both single and double quotes
- Multi-line content without interpolation
Use single quotes when:
- Simple strings:
'hello world'not"hello world" - No escape sequences or interpolation needed
Use single-quoted interpolation when:
- Variables/expressions present but NO escape sequences:
$'Package: ($pkg.name)'not$"Package: ($pkg.name)"$'Error: ($msg)'not$"Error: ($msg)"
Keep double quotes ONLY when:
- Escape sequences present:
"\n","\t","\r","\"" - Need both interpolation and escapes:
$"Line: ($n)\n"
YAML output in 0.113.1+
to yaml no longer quotes every string. It emits plain scalars when they are safe to round-trip as strings, keeps quotes for values that YAML could reinterpret (for example 'off'), and emits multiline strings as block scalars.
{value: 'off', path: '/dev/stdout', name: 'kong'} | to yaml
# value: 'off'
# path: /dev/stdout
# name: kongWhen testing YAML output, assert parsed structure where possible instead of exact quote style.
Important: Single quotes don't escape
In Nushell, \' inside $'...' is NOT an escape — it's a literal backslash + quote.
# Correct — use double quotes when literal single quotes needed
let marker = $"'($pkg)@($ver)':"
# Wrong — backslash doesn't escape in single quotes
let marker = $'\'($pkg)@($ver)\':' # Produces literal backslashes!Important: Command expressions require $ prefix
Strings containing Nushell command expressions wrapped in () MUST keep the $ prefix:
# Correct — $ prefix required for command expressions
print $'(char nl)Done:'
print $'(ansi g)Success!(ansi rst)'
# Wrong — without $ these are literal text
print '(char nl)Done:' # Prints literal "(char nl)"
print '(ansi g)Success!' # Prints literal "(ansi g)"Rule: If a string contains (...) that should be evaluated as a command, always use $'...' or $"...".
External command format strings
For external command format strings, prefer double quotes with a single escape backslash for simple escape sequences so Nushell materializes the separator before calling the external tool. "%H\t%an" passes an actual tab; "%H\\t%an" passes a literal \t. Use char tab / char nl when the format string also needs Nushell interpolation or when explicit separators make parsing clearer.
# Wrong — single quotes pass literal "\t" to git
^git log --format='%H\t%an'
# Wrong — double backslash also passes literal "\t"
^git log --format="%H\\t%an"
# Preferred — simple and Nushell passes an actual tab
^git log --format="%H\t%an"
# Also good — explicit separator, useful with interpolation
let tab = (char tab)
^git log --format=$'%H($tab)%an'
# Also good — use a real newline delimiter, then parse with lines/parse
let nl = (char nl)
^some-tool --format=$'field1=%a($nl)field2=%b'
| lines
| parse '{key}={value}'String Type Overview
| Format | Syntax | Escapes | Interpolation | Use case |
|---|---|---|---|---|
| Single-quoted | '...' | None | No | Simple strings, Windows paths |
| Double-quoted | "..." | \n \t \" \\ etc. | No | Strings needing escape sequences |
| Raw string | r#'...'# | None | No | Regex, strings with quotes, multi-line |
| Bare word | hello | None | No | Command arguments, list items |
| Backtick | ` ... ` | None | No | Paths/args with spaces, globs |
| Single-interpolated | $'...' | None | Yes | Embedding variables (preferred) |
| Double-interpolated | $"..." | Yes | Yes | Variables + escape sequences |
Examples
Array optimization
# Before
let dirs = [$root, "node_modules", ".pnpm"]
let tools = ["git", "patch"]
# After
let dirs = [$root node_modules .pnpm]
let tools = [git patch]Interpolation optimization
# Before
print $"Package: ($pkg.name)"
print $"Error: ($tool) not found"
# After
print $'Package: ($pkg.name)'
print $'Error: ($tool) not found'Keep double quotes for escapes
# Keep — has \n escape
print $"\nNext steps:"
let content = "line1\nline2"
# Keep — contains literal single quote and interpolation
let marker = $"'($name)@($ver)':"Regex with raw strings
# Before
let pattern = "(?:a/|b/)?"
# After
let pattern = r#'(?:a/|b/)?'#[
{
"id": 1,
"prompt": "Review this Nushell snippet and fix the string quoting/interpolation mistakes: let msg = $\"hello\"; print \"User: ($name)\"; print $'\\nDone'; let pattern = \"(?:src|lib)/.*\\.nu\"",
"expected": "Detect incorrect double quotes, missing interpolation prefix, invalid single-quote escaping, and regex quoting; return corrected Nushell with explanations."
},
{
"id": 2,
"prompt": "Convert a Bash script that builds command strings, writes files with >, and uses grep into idiomatic Nushell, preserving security and string escaping behavior.",
"expected": "Use argument separation, save/save --append, structured Nushell commands where possible, and correct string forms for literals, interpolation, escapes, and regex."
},
{
"id": 3,
"prompt": "Write a Nushell function that formats a colored status line containing a package name/version, a literal newline, and a regex filter supplied as configuration.",
"expected": "Choose $'...' for ansi/char interpolation without escapes, $\"...\\n\" only when escape sequences are required, and raw strings for regex examples."
},
{
"id": 4,
"prompt": "I have a 5-million-row sales.csv. Group by category, sum the amount, and return the top categories. Write idiomatic Nushell.",
"expected": "Use the Polars plugin instead of native loops for data this large: polars open (lazy by default), polars group-by + agg (polars col amount | polars sum | polars as total), sort-by total -r [true] for top categories, then collect once."
},
{
"id": 5,
"prompt": "Review a Nushell pipeline that does `open app.log | parse -r '^(?<level>\\\\w+) (?<message>.*)$'` and expects one parsed row per log line.",
"expected": "Flag the 0.113+ parse behavior change: parse no longer implicitly line-splits stream input, so insert `lines` before `parse` for line-by-line parsing."
},
{
"id": 6,
"prompt": "Review a Nushell script where a custom command call puts `--format json` and `--strict` on following lines, and external examples use `git log --format='%H\\t%an'` or `git log --format=\"%H\\\\t%an\"` while expecting tab-separated output.",
"expected": "Flag the multiline named-flag call unless it is grouped with parentheses, and recommend `--format=\"%H\\t%an\"` as the simple preferred fix: one backslash in double quotes. Use `(char tab)` / `(char nl)` when interpolation or explicit separators are needed."
}
]
Related skills
How it compares
Choose nushell-pro for Nushell-specific idioms and .nu review; use generic shell skills for Bash or POSIX-only workflows.
FAQ
Can nushell-pro convert Bash scripts to Nushell?
nushell-pro helps convert Bash and POSIX shell scripts to idiomatic Nushell .nu files. The skill applies Nushell's type system, functional pipeline style, and naming conventions during migration.
What file types does nushell-pro cover?
nushell-pro triggers on Nushell scripts, modules, custom commands, pipelines, and any .nu file editing task. The skill also covers security hardening, performance optimization, and code review for existing scripts.
Is Nushell Pro safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.