PONYλM2Modula-2

Ruby.CodeCompared.To/Nushell

An interactive executable cheatsheet for Rubyists learning Nushell

Ruby 4.0 Nushell 0.114.1
Basics & Output
Hello, World
Nushell is a shell, so a bare command at the top of a script is the program. print writes a value followed by a newline, exactly like Ruby's puts.
puts "Hello, World!"
print "Hello, World!"
Nushell is a shell whose pipelines carry structured data rather than bytes of text. That single decision is what makes the rest of this page look less like Bash and more like Ruby's Enumerable.
The Last Value Is Printed
A pipeline that ends without being consumed has its value rendered automatically — there is no print here. Nushell formats a list as a table with an index column.
numbers = [1, 2, 3] p numbers
[1 2 3]
This is the behavior a Rubyist knows from IRB, promoted to the language itself. A script's final pipeline value is rendered through the table command, which is why structured data shows up as a drawn table instead of an inspect string.
Comments
Nushell has only line comments, introduced with # — the same character Ruby uses.
# This is a comment puts "code" # trailing comment
# This is a comment print "code" # trailing comment
There is no block-comment syntax, so Nushell has no equivalent of Ruby's =begin/=end. A # inside a quoted string is a literal character, not a comment.
Printing Several Values
print accepts any number of arguments and renders each on its own line. Parentheses around an expression, as in (2 + 2), are how Nushell embeds a sub-expression in an argument position.
puts "one", "two", 2 + 2
print "one" "two" (2 + 2)
Bare words in argument position are strings, so 2 + 2 without parentheses would be read as three separate arguments. The parentheses tell Nushell to evaluate rather than to quote.
Inspecting a Value
describe reports a value's type, and it is the fastest way to learn what a pipeline is actually carrying.
value = [1, 2, 3] puts value.class puts({ name: "Alice" }.class)
print ([1 2 3] | describe) print ({name: "Alice"} | describe)
Nushell's types are structural rather than nominal, so describe reports a shape such as list<int> or record<name: string> instead of a class name.
Variables & Immutability
Immutable Binding
let creates an immutable binding. Reading it back requires a $ sigil — the name without the sigil would be parsed as a bare-word string.
greeting = "Hello" puts greeting
let greeting = "Hello" print $greeting
The sigil appears on use, not on definition — the reverse of Ruby's instance and global variables, where the sigil is part of the name everywhere. Rebinding with a second let is allowed and shadows the first.
Mutable Binding
Assigning to a let binding is an error. A variable you intend to reassign must be declared with mut.
counter = 0 counter += 1 counter += 1 puts counter
mut counter = 0 $counter = $counter + 1 $counter += 1 print $counter
Ruby has no such distinction — every local is mutable. Nushell making immutability the default is a functional-language habit, and it is what allows a closure to be handed safely to a parallel command such as par-each.
Compile-Time Constants
const binds a value at parse time, before anything runs. Only expressions the parser can fold are allowed.
MAXIMUM_RETRIES = 3 puts MAXIMUM_RETRIES
const MAXIMUM_RETRIES = 3 print $MAXIMUM_RETRIES
Because a const is known before evaluation, it can be used where a runtime value cannot — most importantly as the path in a source or use statement. Ruby constants, by contrast, are ordinary runtime values that merely warn when reassigned.
Block Scope
A binding lives until the end of the block that introduced it. The braces below open a new scope, and inner does not escape it.
outer = "visible" [1].each do inner = "hidden" puts inner end puts outer puts defined?(inner).inspect
let outer = "visible" do { let inner = "hidden" print $inner } print $outer print ("inner" in (scope variables | get name | each {|name| $name | str replace "$" ""}))
Nushell scoping is strictly lexical, and scope variables lets a script ask what is currently in scope. Ruby's block-local behavior is similar, but Ruby leaks a variable first assigned inside a block only when it was already defined outside.
Nothing (Ruby's nil)
Nushell's empty value is null, and its type is called nothing. The default command supplies a fallback when the piped value is null.
value = nil puts value.nil? puts (value || "default")
let value = null print ($value == null) print ($value | default "default") print ($value | describe)
Unlike Ruby, where only nil and false are falsy, Nushell has no general truthiness — a conditional requires an actual boolean, so if $value on a string is a type error rather than a truthy test.
Strings
String Interpolation
An interpolated string is prefixed with $, and the holes are parentheses rather than Ruby's #{}.
name = "World" puts "Hello, #{name}!" puts "Sum: #{2 + 3}"
let name = "World" print $"Hello, ($name)!" print $"Sum: (2 + 3)"
The parenthesis form is deliberate: a hole holds a full Nushell expression, including an entire pipeline, so $"total: ([1 2 3] | math sum)" is valid. A plain "..." without the $ prefix never interpolates.
Quoting Rules
Nushell has three string forms: double quotes process escapes, single quotes are fully literal, and a bare word with no spaces is also a string.
puts "escapes\tprocessed" puts 'literal \t backslash-t' puts "bare-ish"
print "escapes\tprocessed" print 'literal \t backslash-t' print bare-ish
The bare-word form is the shell heritage showing through — it is what lets str replace read as a command and two arguments rather than as a function call. Ruby has no equivalent; every Ruby string needs delimiters.
String Methods Are Commands
Nushell has no methods. String operations are commands under the str namespace, and the value arrives through the pipeline instead of before a dot.
text = "hello world" puts text.upcase puts text.capitalize puts text.length puts text.reverse
let text = "hello world" print ($text | str uppercase) print ($text | str capitalize) print ($text | str length) print ($text | str reverse)
The reordering is the single biggest adjustment for a Rubyist: text.upcase becomes $text | str uppercase. In exchange, every one of these commands works on a whole list or a table column without any change.
Splitting and Joining
split row turns a string into a list of rows; str join collapses a list back into one string.
sentence = "one,two,three" parts = sentence.split(",") p parts puts parts.join(" | ")
let sentence = "one,two,three" let parts = ($sentence | split row ",") print $parts print ($parts | str join " | ")
There is also split column, which splits into a table with named columns rather than a list — the command that turns line-oriented text output into queryable data.
Replacing Substrings
str replace substitutes the first match by default; the --all flag replaces every occurrence, and --regex treats the pattern as a regular expression.
text = "cat hat bat" puts text.sub("at", "og") puts text.gsub("at", "og") puts text.gsub(/[cb]at/, "pet")
let text = "cat hat bat" print ($text | str replace "at" "og") print ($text | str replace --all "at" "og") print ($text | str replace --all --regex "[cb]at" "pet")
Nushell's flags map cleanly onto Ruby's method pair: no flag behaves like sub, and --all behaves like gsub. Regular expressions are opt-in rather than implied by a literal's type.
Searching Within a String
Nushell spells substring tests as infix operators: =~ matches a regular expression, and in tests containment.
text = "hello world" puts text.include?("world") puts (text =~ /w.rld/ ? true : false) puts text.start_with?("hello")
let text = "hello world" print ("world" in $text) print ($text =~ "w.rld") print ($text | str starts-with "hello")
The in operator reads in the opposite order from Ruby's include? — the needle comes first, the haystack second. The same operator also tests list membership and record-key presence.
Trimming and Padding
str trim removes surrounding whitespace, and fill pads a value to a width with an alignment flag.
padded = " hello " puts padded.strip.inspect puts "7".rjust(3, "0") puts "hi".ljust(5, ".")
let padded = " hello " print ($padded | str trim) print ("7" | fill --alignment right --width 3 --character "0") print ("hi" | fill --alignment left --width 5 --character ".")
The fill command is width-aware in a way ljust is not: it counts printable width, so it aligns correctly even when a cell contains wide characters or ANSI escapes.
Numbers & Math
Arithmetic
Arithmetic operators are infix and familiar. Integer division uses //, and mod is spelled as a word.
puts 7 + 3 puts 7 / 3 puts 7.fdiv(3) puts 7 % 3 puts 2 ** 10
print (7 + 3) print (7 // 3) print (7 / 3) print (7 mod 3) print (2 ** 10)
Note the inversion relative to Ruby: / is true division in Nushell and yields a float, while // is the truncating one. In Ruby, 7 / 3 is the integer operation.
Aggregating a List
Aggregate functions live under the math namespace and consume a list from the pipeline.
numbers = [3, 1, 4, 1, 5] puts numbers.sum puts numbers.min puts numbers.max puts numbers.sum.fdiv(numbers.size)
let numbers = [3 1 4 1 5] print ($numbers | math sum) print ($numbers | math min) print ($numbers | math max) print ($numbers | math avg)
Because these are pipeline commands rather than methods, they compose directly with a query: $employees | get salary | math avg needs no intermediate variable, and reads like the SQL it replaces.
Converting Types
Conversions live under into, and the target type is the command name.
puts "42".to_i + 1 puts "3.5".to_f * 2 puts 42.to_s + "!"
print (("42" | into int) + 1) print (("3.5" | into float) * 2) print ((42 | into string) + "!")
Unlike Ruby's to_i, which silently returns 0 for unparseable input, into int raises an error. That strictness is deliberate — a shell pipeline that quietly turns bad data into zero is a bug waiting to be shipped.
Units Are Built Into the Language
Nushell has first-class filesize and duration literals. A number followed by a unit suffix is a distinct type, not a string.
kilobytes = 3 * 1024 puts "#{kilobytes} bytes" seconds = 90 puts "#{seconds / 60} min #{seconds % 60} sec"
let size = 3kB + 512B print $size let elapsed = 1min + 30sec print $elapsed print ($size | into int)
This is something Ruby has no equivalent of without a gem. Because the units are typed, 3kB > 500B compares correctly and ls | where size > 1MB is an ordinary numeric filter rather than a parsing exercise.
Lists
List Literals
A list literal uses square brackets, and the separating commas are optional — whitespace is enough.
numbers = [1, 2, 3] words = ["a", "b", "c"] p numbers p words
let numbers = [1 2 3] let words = [a b c] print $numbers print $words
Optional commas are another piece of shell heritage — a command's argument list already separates on whitespace, so a list literal does too. Commas are accepted, and mixing both styles is legal but unidiomatic.
Indexing
A list is indexed by appending . and the position to the variable — the same cell-path syntax used for record fields.
numbers = [10, 20, 30, 40] puts numbers[0] puts numbers[-1] p numbers[1..2]
let numbers = [10 20 30 40] print $numbers.0 print ($numbers | last) print ($numbers | slice 1..2)
Nushell has no negative-index syntax; last and first take that role. The slice command uses the same inclusive range literal Ruby writes as 1..2.
Appending and Prepending
append and prepend return a new list; they never modify the original, because a let binding is immutable.
numbers = [1, 2, 3] more = numbers + [4] p more p [0] + numbers
let numbers = [1 2 3] let more = ($numbers | append 4) print $more print ($numbers | prepend 0) print $numbers
The final line proves the original list is untouched. Ruby's << mutates in place and is the common idiom; Nushell has no in-place equivalent, so a loop that builds a list either rebinds a mut variable or — better — is rewritten as an each.
Length and Emptiness
length counts pipeline items; is-empty answers the emptiness question directly.
numbers = [1, 2, 3] puts numbers.size puts numbers.empty? puts [].empty?
let numbers = [1 2 3] print ($numbers | length) print ($numbers | is-empty) print ([] | is-empty)
The command is length for every container — lists, tables, and strings via str length. Ruby offers size, length, and count as near-synonyms; Nushell picks one name and keeps it.
Ranges
Range literals look much like Ruby's: .. is inclusive and ..< excludes the end. A three-part range gives the second element rather than a step size, so 1..3..9 counts by two.
p (1..5).to_a p (1...5).to_a p (1..9).step(2).to_a
print (1..5) print (1..<5) print (1..3..9)
Ruby writes the exclusive form as 1...5, with three dots; Nushell writes 1..<5, putting the comparison operator where it is visible. The stepped form differs more sharply — Ruby names the stride, Nushell names the next value.
Sorting and Reversing
sort orders a plain list, and --reverse flips the direction.
words = ["pear", "apple", "fig"] p words.sort p words.sort.reverse p words.sort_by(&:length)
let words = [pear apple fig] print ($words | sort) print ($words | sort --reverse) print ($words | sort-by {|word| $word | str length})
The distinction between sort and sort-by mirrors Ruby's sort and sort_by: the first compares values directly, the second compares the result of a closure. On a table, sort-by also accepts a bare column name.
Uniqueness and Flattening
uniq removes duplicates and flatten collapses one level of nesting.
p [1, 2, 2, 3, 1].uniq p [[1, 2], [3, 4]].flatten p [1, 2, 3].zip([4, 5, 6])
print ([1 2 2 3 1] | uniq) print ([[1 2] [3 4]] | flatten) print ([1 2 3] | zip [4 5 6])
There is also uniq --count, which returns a table of each distinct value with how often it occurred — the equivalent of Ruby's tally, but already in the table shape the rest of the pipeline expects.
Records
Record Literals
A record is Nushell's Hash. It uses braces with key: value pairs, and — as with lists — the commas are optional.
person = { name: "Alice", age: 30 } p person
let person = {name: "Alice", age: 30} print $person
A record's keys are always strings and its field order is preserved. It is closer to a Ruby Struct than to a general Hash, because Nushell's type system tracks the field names as part of the type.
Reading Fields
Fields are read with a cell path — a dotted trail of names — or with the get command, which takes the same path.
person = { name: "Alice", address: { city: "Ithaca" } } puts person[:name] puts person[:address][:city] puts person.dig(:address, :city)
let person = {name: "Alice", address: {city: "Ithaca"}} print $person.name print $person.address.city print ($person | get address.city)
The dotted path descends through records and lists alike, so $data.users.0.name is one expression rather than a chain of dig calls. Accessing a missing field is an error, not nilget --optional opts into the lenient behavior.
Updating a Field
update replaces an existing field and insert adds a new one. Both return a new record.
person = { name: "Alice", age: 30 } older = person.merge(age: 31) p older p person.merge(city: "Ithaca")
let person = {name: "Alice", age: 30} print ($person | update age 31) print ($person | insert city "Ithaca") print ($person | merge {age: 31, city: "Ithaca"})
The split between update and insert is a guard rail: update errors on a missing field and insert errors on an existing one, so a typo in a field name is caught rather than silently creating a new key the way Ruby's merge would.
Keys and Values
columns lists a record's field names and values lists its values — the same two commands used on a table.
person = { name: "Alice", age: 30 } p person.keys p person.values puts person.key?(:name)
let person = {name: "Alice", age: 30} print ($person | columns) print ($person | values) print ("name" in $person)
The vocabulary is table-flavored on purpose: a record is a one-row table, so columns means the same thing for both. Ruby keeps Hash and Struct vocabulary separate.
Iterating a Record
items runs a two-parameter closure over each key/value pair, which is the direct analogue of Ruby's each on a Hash.
person = { name: "Alice", age: 30 } person.each do |key, value| puts "#{key}: #{value}" end
let person = {name: "Alice", age: 30} $person | items {|key, value| print $"($key): ($value)" }
Piping a record into each instead would treat the whole record as a single item, because a record is one value rather than a collection. The items command is what explicitly opens it up.
Selecting and Rejecting Fields
select keeps only the named fields; reject removes them.
person = { name: "Alice", age: 30, secret: "x" } p person.slice(:name, :age) p person.except(:secret)
let person = {name: "Alice", age: 30, secret: "x"} print ($person | select name age) print ($person | reject secret)
These are the same two commands used to project columns out of a table, which is the recurring theme of this page — one vocabulary covers the single-row and many-row cases.
Tables
The Table — Nushell's Signature Type
A table is a list of records. The literal form puts the column names in the first bracket, then a semicolon, then one bracket per row.
employees = [ { name: "Alice", department: "Engineering" }, { name: "Bob", department: "Sales" }, ] employees.each { |row| puts "#{row[:name]} — #{row[:department]}" }
let employees = [[name, department]; ["Alice", "Engineering"], ["Bob", "Sales"]] $employees
This is the type that has no Ruby counterpart. Ruby models tabular data as an array of hashes and then prints it by hand; in Nushell the table is the native currency of every pipeline, and rendering it is the default behavior rather than a formatting chore.
A Table Is a List of Records
The literal syntax above is sugar. A plain list of records with matching keys is a table, and renders as one.
rows = [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }] p rows
let rows = [{name: "Alice", age: 30}, {name: "Bob", age: 25}] print ($rows | describe) $rows
Because the two forms are identical, any command that produces records — a from json of an API response, an each that builds records — produces something immediately queryable.
Projecting Columns
select narrows a table to the named columns; get extracts a single column as a plain list.
employees = [ { name: "Alice", age: 30, city: "Ithaca" }, { name: "Bob", age: 25, city: "Boston" }, ] p employees.map { |row| row.slice(:name, :age) } p employees.map { |row| row[:name] }
let employees = [[name, age, city]; ["Alice", 30, "Ithaca"], ["Bob", 25, "Boston"]] print ($employees | select name age) print ($employees | get name)
The difference matters: select keeps the table shape while get unwraps to a bare list. Ruby needs an explicit map with a block for either.
Selecting Rows
first, last, and skip slice rows off a table without touching its columns. Note that a bare range is its own lazy type, so it is materialized into a list here before being sliced.
numbers = (1..10).to_a p numbers.first(3) p numbers.last(2) p numbers.drop(7)
let numbers = (1..10 | each {|number| $number}) print ($numbers | first 3) print ($numbers | last 2) print ($numbers | skip 7)
These read the same on a list of integers and on a hundred-column table, which is the point — row operations and column operations are separate vocabularies that compose freely.
Computed Columns
Piping a table into insert with a closure adds a column computed per row. Inside the closure, $row is the whole record.
employees = [ { name: "Alice", salary: 100 }, { name: "Bob", salary: 80 }, ] withBonus = employees.map { |row| row.merge(bonus: row[:salary] * 0.1) } p withBonus
let employees = [[name, salary]; ["Alice", 100], ["Bob", 80]] $employees | insert bonus {|row| $row.salary * 0.1}
The result is still a table, so the new column is immediately available to where, sort-by, or another insert. This is the pipeline equivalent of adding a derived attribute in a SQL SELECT.
Transposing
transpose swaps rows and columns, and takes the new column names as arguments.
counts = { apples: 3, pears: 5 } rows = counts.map { |fruit, count| { fruit: fruit.to_s, count: count } } p rows
let counts = {apples: 3, pears: 5} $counts | transpose fruit count
This is the standard way to turn a record into a two-column table so the rest of the table vocabulary applies to it — a shape change that Ruby expresses as a map over a Hash.
Pipelines & Queries
Filtering With where
where takes a bare condition in which column names are used unqualified — the row is implicit, so you write age > 26, not $row.age > 26.
employees = [ { name: "Alice", age: 30 }, { name: "Bob", age: 25 }, ] p employees.select { |row| row[:age] > 26 }
let employees = [[name, age]; ["Alice", 30], ["Bob", 25]] $employees | where age > 26
This is the closest Nushell gets to SQL, and it is the command that most repays a Rubyist's attention: ls | where size > 1MB | sort-by modified is an ordinary query over real data, not a pipeline of text-mangling utilities.
where With a Closure
When the condition is more than a comparison, where also accepts a closure, and then the row is named explicitly.
words = ["apple", "fig", "cherry"] p words.select { |word| word.length > 3 }
let words = [apple fig cherry] print ($words | where {|word| ($word | str length) > 3})
The bare-condition form is sugar over this one. Reaching for the closure form is the right move whenever the predicate needs a command call rather than an operator.
Sorting a Table
On a table, sort-by takes bare column names — several of them, applied left to right.
employees = [ { name: "Cara", age: 30 }, { name: "Alice", age: 30 }, { name: "Bob", age: 25 }, ] p employees.sort_by { |row| [row[:age], row[:name]] }
let employees = [[name, age]; ["Cara", 30], ["Alice", 30], ["Bob", 25]] $employees | sort-by age name
Ruby needs an array-returning block to sort on two keys; Nushell just lists the columns. Adding --reverse flips every key at once.
Grouping
group-by returns a record whose keys are the distinct values and whose values are the matching sub-tables.
employees = [ { name: "Alice", department: "Engineering" }, { name: "Bob", department: "Sales" }, { name: "Cara", department: "Engineering" }, ] grouped = employees.group_by { |row| row[:department] } grouped.each { |department, rows| puts "#{department}: #{rows.size}" }
let employees = [[name, department]; ["Alice", "Engineering"], ["Bob", "Sales"], ["Cara", "Engineering"]] $employees | group-by department | items {|department, rows| print $"($department): ($rows | length)" }
The shape matches Ruby's group_by exactly — keys to collections. Following it with transpose turns the result back into a table, which is the usual next step.
Counting Occurrences
uniq --count produces a two-column table of each distinct value and how often it appeared.
votes = ["yes", "no", "yes", "yes"] p votes.tally
let votes = [yes no yes yes] $votes | uniq --count
Ruby's tally hands back a Hash that still needs formatting; Nushell hands back a table that is already sortable by the count column.
Chaining a Real Query
Everything above composes. This is the shape of a typical Nushell one-liner: filter, derive, sort, narrow.
employees = [ { name: "Alice", department: "Engineering", salary: 120 }, { name: "Bob", department: "Sales", salary: 90 }, { name: "Cara", department: "Engineering", salary: 140 }, ] result = employees .select { |row| row[:department] == "Engineering" } .map { |row| row.merge(monthly: row[:salary] / 12.0) } .sort_by { |row| -row[:salary] } .map { |row| row.slice(:name, :monthly) } p result
let employees = [[name, department, salary]; ["Alice", "Engineering", 120], ["Bob", "Sales", 90], ["Cara", "Engineering", 140]] $employees | where department == "Engineering" | insert monthly {|row| $row.salary / 12} | sort-by salary --reverse | select name monthly
A pipeline may be broken across lines at the |, which is what keeps a long query readable. The Ruby version needs an explicit block at every step; the Nushell version names the columns and lets the row stay implicit.
Reducing
reduce takes a two-parameter closure. The first parameter is the item and the second is the accumulator — the opposite order from Ruby's inject.
numbers = [1, 2, 3, 4] puts numbers.inject(0) { |accumulator, item| accumulator + item } puts numbers.inject(1) { |accumulator, item| accumulator * item }
let numbers = [1 2 3 4] print ($numbers | reduce --fold 0 {|item, accumulator| $accumulator + $item}) print ($numbers | reduce --fold 1 {|item, accumulator| $accumulator * $item})
The reversed parameter order is a genuine trip hazard for a Rubyist. The --fold flag supplies the initial value; without it, the first item seeds the accumulator, exactly as a bare inject does.
Closures & Iteration
Closures
A closure is written in braces with its parameters between pipes — visually almost identical to a Ruby block.
double = ->(number) { number * 2 } puts double.call(21)
let double = {|number| $number * 2} print (do $double 21)
The difference is in how it is invoked: Ruby's lambda has call, while Nushell uses the do command. A closure stored in a variable is a value, not a command, so it cannot be called by name alone.
each
each maps a closure over every pipeline item and returns the results — so it behaves like Ruby's map, not Ruby's each.
numbers = [1, 2, 3] p numbers.map { |number| number * 2 }
let numbers = [1 2 3] print ($numbers | each {|number| $number * 2})
The naming is the trap. Nushell has no separate map; each fills that role, and a genuinely side-effect-only loop is written with for instead.
The Implicit Parameter
A closure that declares no parameters can refer to the current item as $in.
numbers = [1, 2, 3] p numbers.map { |item| item * 10 } p numbers.map { _1 * 10 }
let numbers = [1 2 3] print ($numbers | each {|item| $item * 10}) print ($numbers | each { $in * 10 })
$in is the analogue of Ruby's _1 or it, and it also names the pipeline input inside a custom command — the one variable that always refers to "whatever arrived from the left".
Iterating With an Index
enumerate wraps each item in a record with index and item fields.
words = ["a", "b", "c"] words.each_with_index do |word, index| puts "#{index}: #{word}" end
let words = [a b c] $words | enumerate | each {|entry| print $"($entry.index): ($entry.item)" }
Because enumerate produces an ordinary table, the index is available to where and sort-by too, rather than being confined to the iteration that produced it.
Filtering While Mapping
An each closure that returns null contributes nothing to the result, which gives Nushell a direct filter_map.
numbers = [1, 2, 3, 4, 5, 6] p numbers.filter_map { |number| number * 10 if number.even? }
let numbers = [1 2 3 4 5 6] print ($numbers | each {|number| if ($number mod 2) == 0 { $number * 10 } })
An if with no else evaluates to null when the condition fails, so the filtering falls out of the language rather than needing its own command.
Parallel Iteration
Swapping each for par-each runs the closure across threads. Because bindings are immutable, this needs no other change.
numbers = [1, 2, 3, 4] results = numbers.map { |number| number * number } p results.sort
let numbers = [1 2 3 4] print ($numbers | par-each {|number| $number * $number} | sort)
The result order is not guaranteed, hence the trailing sort. That a one-word edit buys parallelism is a direct dividend of immutability — the same change in Ruby would demand a threading library and a mutable-state audit. This row is display-only because the browser build cannot start a thread pool; run it under a real nu to see it work.
Control Flow
Conditionals
if takes a condition and a brace-delimited block. It is an expression, so it evaluates to the value of the branch taken.
temperature = 30 if temperature > 25 puts "warm" elsif temperature > 15 puts "mild" else puts "cold" end
let temperature = 30 if $temperature > 25 { print "warm" } else if $temperature > 15 { print "mild" } else { print "cold" }
The chained keyword is else if as two words, not Ruby's elsif. There is no trailing end — braces delimit the blocks, and the condition needs no parentheses.
if as an Expression
Because if returns a value, it can be bound directly to a variable.
score = 85 grade = if score >= 90 then "A" elsif score >= 80 then "B" else "C" end puts grade
let score = 85 let grade = if $score >= 90 { "A" } else if $score >= 80 { "B" } else { "C" } print $grade
This works the same way in both languages. The one asymmetry is that an if without an else yields null in Nushell, which is exactly the property the filtering idiom above depends on.
Pattern Matching
match compares a value against patterns, using | for alternatives and _ for the catch-all.
value = 3 described = case value in 1 then "one" in 2 | 3 then "a couple" else "many" end puts described
let value = 3 let described = match $value { 1 => "one", 2 | 3 => "a couple", _ => "many" } print $described
Ruby 3's case/in and Nushell's match are close cousins; both destructure rather than merely compare. Nushell writes the arms with => and separates them with commas.
Destructuring in match
A pattern may describe a record's shape and bind its fields to names in one step.
event = { type: "click", x: 10, y: 20 } case event in { type: "click", x:, y: } puts "click at #{x},#{y}" in { type: "key", code: } puts "key #{code}" end
let event = {type: "click", x: 10, y: 20} match $event { {type: "click", x: $x, y: $y} => { print $"click at ($x),($y)" } {type: "key", code: $code} => { print $"key ($code)" } }
The bound names carry a $ in the pattern, which makes the binding sites obvious at a glance — Ruby's x: shorthand leaves them easier to miss.
for Loops
for is the side-effect loop. Unlike each, it produces no value, so it is the honest choice when nothing is being collected.
(1..3).each do |number| puts "count #{number}" end
for number in 1..3 { print $"count ($number)" }
Nushell deliberately keeps the two apart: each is for building a result and for is for doing something. Ruby's each covers both cases and returns the receiver.
while and loop
while repeats while a condition holds, and break leaves the loop — both need a mut variable to make progress.
countdown = 3 while countdown > 0 puts countdown countdown -= 1 end
mut countdown = 3 while $countdown > 0 { print $countdown $countdown -= 1 }
There is also a bare loop that runs forever until break, matching Ruby's loop do. In practice a Nushell script reaches for a pipeline far more often than for either.
Custom Commands
Defining a Command
def introduces a custom command. Parameters are listed in square brackets, and calling it uses space-separated arguments — no parentheses, no commas.
def greet(name) "Hello, #{name}!" end puts greet("Ruby")
def greet [name] { $"Hello, ($name)!" } print (greet "Ruby")
A custom command is indistinguishable from a built-in at the call site, which is the whole point — greet "Ruby" looks exactly like str uppercase. The last expression is the return value, as in Ruby.
Typed Parameters
A parameter may carry a type annotation after a colon, and the command may declare its input and output types with : input -> output.
def double(number) raise TypeError, "not an Integer" unless number.is_a?(Integer) number * 2 end puts double(21)
def double [number: int]: nothing -> int { $number * 2 } print (double 21)
The annotations are checked at parse time, so a wrong argument type is caught before the pipeline runs. Ruby has no equivalent without a runtime guard like the one above, or an external type checker.
Optional Parameters and Defaults
A default value goes after =, and a trailing ? marks a parameter optional (it arrives as null when omitted).
def greet(name, greeting = "Hello") "#{greeting}, #{name}!" end puts greet("Ruby") puts greet("Ruby", "Howdy")
def greet [name, greeting = "Hello"] { $"($greeting), ($name)!" } print (greet "Ruby") print (greet "Ruby" "Howdy")
Positional defaults work exactly as they do in Ruby. What differs is that Nushell also has real flags, which is where a shell-shaped API usually wants its optional arguments.
Flags
A parameter beginning with -- is a flag. A bare flag is a boolean switch; giving it a type makes it take a value, and (-x) adds a short form.
def announce(message, loud: false, times: 1) text = loud ? message.upcase : message times.times { puts text } end announce("hello") announce("hello", loud: true, times: 2)
def announce [message, --loud, --times (-t): int = 1] { let text = if $loud { $message | str uppercase } else { $message } for _ in 1..$times { print $text } } announce "hello" announce "hello" --loud --times 2
This is where the shell heritage pays off: Ruby keyword arguments read as loud: true, while Nushell flags read as --loud — the same spelling a user already types at a prompt, and help announce documents them automatically.
Rest Parameters
A parameter prefixed with ... collects the remaining arguments into a list — Ruby's splat, with the dots on the other side of the name.
def total(*numbers) numbers.sum end puts total(1, 2, 3, 4)
def total [...numbers: int] { $numbers | math sum } print (total 1 2 3 4)
Ruby writes *numbers and Nushell writes ...numbers, but the semantics match. The type annotation applies to each element rather than to the collected list.
Commands That Take Pipeline Input
A command reads whatever was piped into it through $in. Declaring the input type in the signature is what makes it compose like a built-in.
def shout(text) text.upcase + "!" end puts shout("hello")
def shout []: string -> string { $"($in | str uppercase)!" } print ("hello" | shout)
This is the idiom that makes a custom command a first-class citizen of the pipeline. Ruby's nearest equivalent is monkey-patching String, which is far more invasive than declaring an input type.
Documentation Comments
Comments immediately above a def, and after each parameter, become the command's built-in help text.
# Repeats a message. # # @param message [String] the text to repeat def repeat(message, times) Array.new(times, message).join(" ") end puts repeat("hi", 3)
# Repeats a message. def repeat [ message: string # The text to repeat. times: int # How many copies. ] { 1..$times | each { $message } | str join " " } print (repeat "hi" 3)
Running help repeat prints those comments alongside the generated signature. Ruby needs RDoc or YARD plus a separate tool to get the same result; in Nushell it is part of the runtime.
Types & Signatures
Structural Types
describe reports the full structural type, including the element type of a list and the field types of a record.
p [1, 2, 3].class p({ name: "Alice" }.class) p [{ name: "Alice" }].class
print ([1 2 3] | describe) print ({name: "Alice"} | describe) print ([[name]; ["Alice"]] | describe)
A Nushell type describes a value's shapetable<name: string> — rather than naming a class. Nothing is nominal, so there is no inheritance and no is_a?.
Converting Between Shapes
The into family converts values, and into record/into value reshape structured data.
pairs = [["a", 1], ["b", 2]] p pairs.to_h
let pairs = [[key, value]; [a, 1], [b, 2]] print ($pairs | transpose --header-row | into record)
Ruby's to_h on an array of pairs has no single-command Nushell equivalent, because the table is the more general shape — the conversion goes through transpose instead.
Missing Data
Accessing an absent field is an error by default. The ? suffix on a cell path makes it yield null instead.
person = { name: "Alice" } p person[:city] p person.fetch(:city, "unknown")
let person = {name: "Alice"} print ($person.city? | describe) print ($person | get --optional city | default "unknown")
Ruby's [] returns nil for a missing key and fetch raises; Nushell inverts the defaults, so the strict behavior is what you get unless you ask for leniency.
Inspecting a Signature
Every command carries its signature at runtime, and scope commands can be queried like any other table.
def greet(name, greeting = "Hello") "#{greeting}, #{name}" end p method(:greet).parameters
def greet [name, greeting = "Hello"] { $"($greeting), ($name)" } scope commands | where name == "greet" | get 0.signatures | to json
That the command table is itself queryable data is the recurring theme — Nushell's introspection needs no reflection API, because introspection results are ordinary tables.
Data Formats
Parsing JSON
from json turns a JSON string into records and tables. There is nothing to require.
require "json" parsed = JSON.parse('{"name": "Alice", "age": 30}') puts parsed["name"]
let parsed = ('{"name": "Alice", "age": 30}' | from json) print $parsed.name
The result is a native record, so the whole query vocabulary applies immediately. This is the command that most often replaces a curl | jq incantation.
Producing JSON
to json serializes any structured value; the --raw flag emits it on one line.
require "json" employees = [{ name: "Alice", age: 30 }] puts JSON.pretty_generate(employees)
let employees = [[name, age]; ["Alice", 30]] print ($employees | to json) print ($employees | to json --raw)
Every from command has a matching to, and the pair round-trips. Pretty-printing is the default rather than a separate method name.
Converting Between Formats
Because every format decodes to the same internal representation, converting between two of them is a from followed by a to.
require "json" require "yaml" data = JSON.parse('[{"name":"Alice"},{"name":"Bob"}]') puts data.to_yaml
'[{"name":"Alice"},{"name":"Bob"}]' | from json | to yaml
Nushell ships converters for JSON, YAML, TOML, CSV, TSV, XML, and more. The set is open-ended in the same way Ruby's serialization libraries are, but they all meet in one data model rather than each defining its own.
CSV as a Table
from csv reads a header row and produces a table directly.
require "csv" text = "name,age\nAlice,30\nBob,25" rows = CSV.parse(text, headers: true) rows.each { |row| puts "#{row["name"]} (#{row["age"]})" }
let text = "name,age\nAlice,30\nBob,25" $text | from csv | where age > 26
The age column is inferred as an integer, which is why the comparison works without a conversion. Ruby's CSV library hands back strings until told otherwise.
Parsing Unstructured Text
parse extracts named fields from lines using a template with {name} placeholders, producing a table.
lines = ["alice:30", "bob:25"] rows = lines.map do |line| name, age = line.split(":") { name: name, age: age.to_i } end p rows
["alice:30", "bob:25"] | parse "{name}:{age}"
This is how text from an external command becomes queryable data. The --regex flag accepts a full regular expression with named capture groups when the template form is not expressive enough.
Error Handling
try / catch
try takes a block, and catch takes a closure whose parameter is the error record.
begin raise ArgumentError, "something failed" rescue => failure puts "Caught: #{failure.message}" end
try { error make {msg: "something failed"} } catch {|failure| print $"Caught: ($failure.msg)" }
The error arrives as an ordinary record with fields such as msg, so inspecting it uses the same cell paths as any other data. There is no exception class hierarchy to match against.
Raising an Error
error make builds an error from a record. Adding a label gives it a source span, which is what produces Nushell's underlined diagnostics.
def divide(numerator, denominator) raise ZeroDivisionError, "denominator must not be zero" if denominator == 0 numerator / denominator end puts divide(10, 2)
def divide [numerator: int, denominator: int] { if $denominator == 0 { error make {msg: "denominator must not be zero"} } $numerator // $denominator } print (divide 10 2)
Nushell's error rendering is one of its most polished features — a raised error points at the exact span of source that produced it, in the style of a Rust compiler diagnostic.
try as an Expression
try evaluates to the block's value, so a catch that returns a value makes it a fallback expression.
value = begin Integer("not a number") rescue ArgumentError 0 end puts value
let value = try { "not a number" | into int } catch { 0 } print $value
This is the idiomatic way to give a failing conversion a default, and it reads much like Ruby's begin/rescue in an assignment position.
Inspecting the Error
The caught error is a record; columns shows which fields it carries.
begin raise ArgumentError, "bad input" rescue => failure puts failure.class puts failure.message end
try { error make {msg: "bad input"} } catch {|failure| print ($failure | columns) print $failure.msg }
Because there are no error classes, dispatching on the kind of failure means matching on the message or on a field you put there yourself — a real trade-off against Ruby's rescue SpecificError.
Assertions
The standard library ships an assert command, pulled in with use std/assert — the path form, which imports just that submodule.
total = [1, 2, 3].sum raise "expected 6" unless total == 6 puts "ok"
use std/assert let total = ([1 2 3] | math sum) assert ($total == 6) print "ok"
The std library is written in Nushell itself and loaded on demand, so importing one submodule costs only that submodule. The bare use std form, which loads everything, does not work in this page's browser build — it needs the $nu constant, which cannot be constructed in WebAssembly.
Modules & Scripts
Defining a Module
module groups commands, and only those marked export are visible outside it.
module Greetings def self.hello(name) = "Hello, #{name}!" def self.goodbye(name) = "Goodbye, #{name}!" end puts Greetings.hello("Ruby")
module greetings { export def hello [name] { $"Hello, ($name)!" } export def goodbye [name] { $"Goodbye, ($name)!" } } use greetings print (greetings hello "Ruby")
A module's commands are namespaced by the module name at the call site — greetings hello, with a space. That is a subcommand, exactly the shape of built-ins like str uppercase.
Importing Selectively
use module command pulls one command into the current scope unqualified; use module * imports everything.
module Greetings def self.hello(name) = "Hello, #{name}!" end include Greetings rescue nil puts Greetings.hello("Ruby")
module greetings { export def hello [name] { $"Hello, ($name)!" } } use greetings hello print (hello "Ruby")
The import is lexically scoped and takes effect at parse time, which is why the module path in a use must be a constant rather than a computed value.
Exporting Values
A module can export constants with export const, not just commands.
module Configuration MAXIMUM_RETRIES = 3 end puts Configuration::MAXIMUM_RETRIES
module configuration { export const MAXIMUM_RETRIES = 3 } use configuration print $configuration.MAXIMUM_RETRIES
The exported constants arrive as a record named after the module, so they are read with an ordinary cell path rather than Ruby's :: scope operator.
The Standard Library
Nushell's std library is itself a module, imported the same way as your own.
require "set" unique = Set.new([1, 2, 2, 3]) puts unique.size
use std/iter print ([1 2 2 3] | uniq | length) print ([1 2 3] | iter find {|item| $item > 1})
Because std is written in Nushell rather than in Rust, reading its source is an effective way to learn idiomatic style — something a Rubyist will recognize from Ruby's own largely-Ruby standard library.
Files & External Commands
Listing Files Returns a Table
ls returns a real table with typed columns — size is a filesize and modified is a date — so it can be queried rather than parsed.
entries = Dir.glob("*").map do |path| { name: path, size: File.size(path) } end p entries.select { |entry| entry[:size] > 1024 }.first(3)
# ls needs something to list, so: one big file # and one small one. (0..1199 | each { "x" } | str join) | save --force report.txt "ok" | save --force flag.txt # ls returns a TABLE, so this is a query rather # than text processing. ls *.txt | where size > 1kb | select name size
This is the example that sells Nushell. In Bash the same query is a fragile ls -l | awk pipeline; here it is a typed query, because ls never produced text in the first place — size is a filesize, so > 1kb is a comparison rather than string surgery, and modified would be a date. The Ruby column reads a directory it did not create, which is why it is shown rather than run.
Opening a File Parses It
open chooses a parser from the file extension, so a .json file arrives as records rather than as a string.
require "json" data = JSON.parse(File.read("config.json")) puts data["name"]
'{"name":"atlas","port":8080}' | save --force config.json # open infers the format from the extension # and returns a record, not text. open config.json | get name
Both columns print atlas, and the difference is the require "json" and the explicit JSON.parse. Passing --raw suppresses the parsing and yields the bytes. That the parsing is the default is the clearest statement of Nushell's design position: data should stay structured for as long as possible.
Calling External Commands
An external program is called by name, or with a leading ^ when the name collides with a built-in. Its output arrives as a string.
output = `echo hello` puts output.strip
let output = (^echo hello) print ($output | str trim)
The boundary is explicit: everything inside Nushell is structured, and crossing to an external process means falling back to text — which is exactly where parse, from json, and split column earn their place.
Environment Variables
$env is a record, so environment variables are read and written with ordinary cell paths.
puts ENV.fetch("CODECOMPARED_DEMO", "(unset)") ENV["CODECOMPARED_DEMO"] = "on" puts ENV["CODECOMPARED_DEMO"]
print ($env.CODECOMPARED_DEMO? | default "(unset)") $env.CODECOMPARED_DEMO = "on" print $env.CODECOMPARED_DEMO
Because $env is a record, $env | columns | sort lists every variable and $env.PATH is a real list rather than a colon-delimited string that has to be split. The variable here is one that is deliberately never set, so the first line prints (unset) everywhere; reading $HOME instead would print a different answer on every machine, and nothing at all in the browser, where the process environment is empty.