PONY λ M2 Modula-2

Bash.CodeCompared.To/Zsh

An interactive executable cheatsheet comparing Bash and Zsh

Bash 5.3 Zsh 5.9
Word Splitting & Quoting
Unquoted variables do not split
This is the single most important difference between the two shells, and the only one that silently changes what a working script does rather than raising an error. Bash performs word splitting on every unquoted parameter expansion; Zsh does not.
sentence="alpha beta gamma" # Bash splits an unquoted expansion on IFS, # so this loop runs three times. for word in $sentence; do echo "[$word]" done
sentence="alpha beta gamma" # Zsh does NOT split. The loop runs ONCE, # with the whole string as a single word. for word in $sentence; do echo "[$word]" done
A Bash script that relies on unquoted splitting will quietly stop working under Zsh, producing one iteration instead of many. Nothing warns you. The upside is that the whole class of bugs caused by forgetting to quote a variable containing spaces simply does not exist in Zsh.
Splitting on purpose
Because Zsh will not split for you, splitting becomes something you request. The (s) flag takes the separator between its delimiters — here a single space — and is the direct replacement for setting IFS in Bash.
csv="one two three" # Splitting is the default, so there is # nothing to ask for. parts=($csv) echo "${#parts[@]} parts" echo "second is ${parts[1]}"
csv="one two three" # The (s) flag asks for splitting explicitly, # naming the separator. parts=(${(s: :)csv}) echo "${#parts[@]} parts" echo "second is ${parts[2]}"
The Zsh form says what it does at the point where it does it, and the separator is local to that one expansion. The Bash form depends on the ambient value of IFS, which any earlier line in the script may have changed.
Empty variables vanish
Both shells drop an unquoted empty expansion from an argument list, so this much is portable. It is worth checking explicitly because it is one of the few places where Zsh's no-splitting rule does not change the answer.
empty="" words=(before $empty after) # The empty expansion contributes no word, # so the array has two elements. echo "${#words[@]}" echo "${words[@]}"
empty="" words=(before $empty after) # Zsh also drops it — but for a different # reason, and (@) preserves it if you ask. echo "${#words[@]}" echo "${words[@]}"
Where they differ is in how you opt out. Bash needs the expansion quoted to preserve an empty argument; Zsh offers the (@) flag, which keeps empty elements even in a nested expansion where quoting is awkward.
Turning Bash splitting back on
SH_WORD_SPLIT makes Zsh split unquoted expansions the way Bash does. It is the compatibility switch to reach for when porting a large Bash script that leans on splitting throughout, rather than rewriting every expansion.
# Bash always splits, so there is no option # to enable. The closest analogue is # controlling what it splits ON. data="a:b:c" IFS=":" parts=($data) echo "${#parts[@]} parts" IFS=$' \t\n'
setopt SH_WORD_SPLIT data="a b c" parts=($data) echo "${#parts[@]} parts" unsetopt SH_WORD_SPLIT same=($data) echo "${#same[@]} without it"
Setting it globally is a blunt instrument that gives up the safety Zsh was offering. It is better used scoped to a single function, where a Bash-derived block can keep its original behavior without changing the rest of the script.
print instead of echo
Zsh keeps echo for compatibility but adds print, which takes the flags echo lacks. The -r flag disables backslash escape interpretation and -- marks the end of options, so any string at all prints literally.
value="-n" # echo's handling of leading dashes and # backslashes varies. printf is the portable # way to print an arbitrary string safely. printf '%s\n' "$value" printf '%s\n' 'a\tb'
value="-n" # print -r -- is unambiguous: -r disables # escapes, -- ends option parsing. print -r -- "$value" print -r -- 'a\tb'
In Bash the equivalent guarantee requires printf with an explicit format string, because echo will happily interpret a leading -n or -e as its own option. Most Zsh style guides use print -r -- wherever the value is not a known literal.
Arrays
Arrays are 1-indexed
Zsh arrays start at 1, not 0 — the only difference in this row. (Zsh also lets you drop the braces and write $fruits[1], but that is an unrelated convenience and is left out here so the index is the one thing that changes.)
fruits=(apple banana cherry) echo "first: ${fruits[0]}" echo "second: ${fruits[1]}" echo "third: ${fruits[2]}"
fruits=(apple banana cherry) echo "first: ${fruits[1]}" echo "second: ${fruits[2]}" echo "third: ${fruits[3]}"
This is the second-most common porting bug after word splitting, and unlike word splitting it usually shows up immediately as an off-by-one. A Bash loop written as a C-style index walk from 0 will silently skip the first element and read one past the end.
Bare $array is the whole list
In Bash a bare $array is shorthand for ${array[0]} — the first element only. In Zsh it expands to every element. Likewise ${#array} is the element count in Zsh but the string length of the first element in Bash.
fruits=(apple banana cherry) # Bare $fruits is just the FIRST element. echo "bare: $fruits" echo "all: ${fruits[@]}" echo "count: ${#fruits[@]}"
fruits=(apple banana cherry) # Bare $fruits is the WHOLE array. echo "bare: $fruits" echo "all: ${fruits[@]}" echo "count: ${#fruits}"
The Bash form is a long-standing wart that catches people out in the other direction too. Writing ${fruits[@]} and ${#fruits[@]} explicitly works identically in both shells, which makes it the right habit if a script has to run under either.
Slicing a range
The two shells disagree about both the origin and the meaning of the second number. Bash slices are offset plus length; Zsh slices are a first and last index, both inclusive, and a negative end counts back from the tail.
fruits=(apple banana cherry date) # offset:length, counting from 0. echo "${fruits[@]:1:2}" echo "${fruits[@]:2}"
fruits=(apple banana cherry date) # start,end — inclusive, counting from 1. echo "${fruits[2,3]}" echo "${fruits[3,-1]}"
The Zsh form reads more naturally for the common "from here to the end" case, where -1 means the last element rather than requiring the length to be omitted.
The last element
Negative subscripts count back from the end in both shells, and here they mean the same thing — -1 is the last element either way, because the 1-indexed and 0-indexed origins only differ when counting forward.
fruits=(apple banana cherry) echo "last: ${fruits[-1]}" echo "second last: ${fruits[-2]}"
fruits=(apple banana cherry) echo "last: ${fruits[-1]}" echo "second last: ${fruits[-2]}"
This is one of the few array operations that ports between the shells unchanged, which makes it a safer idiom than computing the index arithmetically from the length.
Appending and concatenating
The += operator appends in both shells. The difference is what you have to write on the right-hand side to splice in another array.
fruits=(apple banana) fruits+=(cherry) more=(date elderberry) fruits+=("${more[@]}") echo "${fruits[@]}" echo "${#fruits[@]} items"
fruits=(apple banana) fruits+=(cherry) more=(date elderberry) fruits+=($more) echo "${fruits[@]}" echo "${#fruits[@]} items"
Because a bare $more already means every element in Zsh, the quoting ceremony Bash needs to avoid re-splitting is unnecessary. This is the no-word-splitting rule paying off rather than costing.
Empty elements survive
Both shells will happily store an empty string as an array element. The question is whether it survives being expanded into a new array, and the answer differs.
items=(alpha "" gamma) # The empty element is stored, but expanding # unquoted drops it again. echo "stored: ${#items[@]}" expanded=(${items[@]}) echo "expanded: ${#expanded[@]}"
items=(alpha "" gamma) echo "stored: ${#items[@]}" kept=("${(@)items}") echo "kept: ${#kept[@]}"
The (@) flag is Zsh's way of saying "treat this as an array even inside double quotes", which preserves empty elements. Bash has no equivalent for a nested expansion, so an array with holes in it needs an explicit index loop to copy faithfully.
Parameter Expansion Flags
Changing case
Zsh flags go in parentheses immediately after the opening brace, before the variable name. U, L and C are upper, lower and capitalize.
name="hello world" echo "${name^^}" # upper echo "${name,,}" # lower echo "${name^}" # first char up
name="hello world" echo "${(U)name}" # upper echo "${(L)name}" # lower echo "${(C)name}" # Capitalize Words
The two differ on what "capitalize" means: Bash's ${name^} raises only the first character of the whole string, while Zsh's (C) capitalizes every word. Bash cannot do the latter without a loop.
Joining an array
The (j) flag joins an array into a single string using the separator written between its delimiters. Any character can be the delimiter — colons are conventional, but (j., .) works too when the separator itself contains a colon.
fruits=(apple banana cherry) # Set IFS and expand with * inside quotes. old_ifs=$IFS IFS="," echo "${fruits[*]}" IFS=$old_ifs
fruits=(apple banana cherry) # The (j) flag names the separator inline. echo "${(j:,:)fruits}"
The Bash version has to save, mutate and restore a global, and only works with [*] inside double quotes. It is also limited to a single-character separator, because [*] joins on the FIRST character of IFS and ignores the rest — so a multi-character join needs a loop. The (j) flag takes a separator of any length.
Splitting a string
The (s) flag is the mirror of (j) — it splits a scalar on the given separator, producing a list. Wrapping it in parentheses assigns that list to an array.
csv="one,two,three" old_ifs=$IFS IFS="," parts=($csv) IFS=$old_ifs echo "${#parts[@]} parts" echo "${parts[1]}"
csv="one,two,three" parts=(${(s:,:)csv}) echo "${#parts[@]} parts" echo "${parts[2]}"
This is the operation most often reached for in shell scripts and the one Bash makes most awkward. The other common Bash spelling, IFS=, read -ra parts <<< "$csv", scopes IFS to the one command and is the better habit — but it still leaves you with an array where Zsh gives you a value you can keep piping.
Removing duplicates
The (u) flag keeps only the first occurrence of each element, preserving order.
letters=(a b a c b) # No builtin — walk the array and test # membership by hand. unique=() for letter in "${letters[@]}"; do found=0 for seen in "${unique[@]}"; do [[ $seen == "$letter" ]] && found=1 done (( found )) || unique+=("$letter") done echo "${unique[@]}"
letters=(a b a c b) unique=(${(u)letters}) echo "${unique[@]}"
A Bash script would normally shell out to sort -u here, which costs two processes and, worse, throws away the original ordering. The hand-written loop above is what it takes to keep that ordering without one, and it is quadratic.
Sorting
The (o) flag sorts ascending and (O) descending. Adding (n) sorts numerically rather than lexically, and (i) ignores case. Flags combine, so (on) is a numeric ascending sort.
numbers=(3 1 2 10) # Insertion sort, because there is no # builtin and no external sort available. sorted=() for number in "${numbers[@]}"; do position=${#sorted[@]} for (( index = 0; index < ${#sorted[@]}; index++ )); do if (( number < sorted[index] )); then position=$index break fi done sorted=("${sorted[@]:0:position}" "$number" "${sorted[@]:position}") done echo "${sorted[@]}"
numbers=(3 1 2 10) # (o) sorts, (n) makes it numeric. echo ${(on)numbers[@]} words=(pear Apple banana) echo ${(oi)words[@]} # case-insensitive
🚨 A sort flag does nothing inside double quotes. Writing "${(on)numbers}" joins the array to a single scalar first, so the sort has one element to order and returns it untouched — with no error. Leave it bare or write ${(on)numbers[@]}.
Padding to a width
The (l) and (r) flags pad on the left and right respectively. The first argument is the target width; the optional second is the fill character, defaulting to a space.
width=42 # printf -v writes into a variable # without a subshell. printf -v padded '%06d' "$width" echo "$padded" label="ok" printf -v boxed '%-6s|' "$label" echo "$boxed"
width=42 # (l) pads left, (r) pads right. echo "${(l:6::0:)width}" label="ok" echo "${(r:6:)label}|"
🚨 These flags take a parameter NAME, not a value. Writing ${(l:6::0:)42} pads the positional parameter $42, which is empty, and silently yields six zeros. The number has to be in a variable first.
Indirect reference
Both shells can read a variable whose name is held in another variable. Bash spells it with a leading exclamation mark; Zsh uses the (P) flag, which reads "parameter".
target_variable="the value" pointer="target_variable" # ${!name} dereferences by name. echo "${!pointer}" # Or declare a real nameref. declare -n alias_name=target_variable echo "$alias_name"
target_variable="the value" pointer="target_variable" # The (P) flag dereferences by name. echo "${(P)pointer}"
Bash additionally offers declare -n to create a true alias that can be assigned through, which Zsh does not have in the same form. For read-only dereferencing the two are equivalent.
Combining flags
Flags nest. Each ${(flag)...} wraps the expansion inside it, so the innermost runs first — here split, then unique, then sort, then join.
words="delta alpha charlie alpha" # Split, deduplicate, join — one step each. # (Sorting would need the insertion sort # from the Sorting row as well.) parts=($words) unique=() for word in "${parts[@]}"; do seen=0 for kept in "${unique[@]}"; do [[ $kept == "$word" ]] && seen=1 done (( seen )) || unique+=("$word") done IFS="," echo "${unique[*]}"
words="delta alpha charlie alpha" # Split, deduplicate, sort, join — one line. # NOTE: unquoted. See the afterword. echo ${(j:,:)${(o)${(u)${(s: :)words}}}}
🚨 The whole chain must be UNQUOTED. Wrapping it in double quotes joins each inner result to a scalar before the next flag sees it, so unique and sort both silently no-op and the original order comes back unchanged — exactly the trap from the Sorting row, compounded three times. Note also that the Bash column does not sort at all; adding that would mean pasting the insertion sort in as well.
Associative Arrays
Declaring a map
Both shells require the variable to be declared associative before assignment — declare -A in Bash, typeset -A in Zsh. The literal syntax then differs: Bash uses explicit [key]=value pairs, while Zsh takes a flat alternating list of keys and values.
declare -A colors colors=([apple]=red [banana]=yellow) echo "${colors[apple]}" echo "${#colors[@]} entries"
typeset -A colors colors=(apple red banana yellow) echo "${colors[apple]}" echo "${#colors[@]} entries"
The Zsh form is terser but offers no protection against an odd number of words, where the final key silently gets an empty value. The Bash form makes each pairing explicit at the cost of more punctuation.
Keys and values
Bash overloads the same exclamation mark it uses for indirection to mean "the keys of this array". Zsh uses the dedicated (k) and (v) flags.
declare -A colors=([apple]=red [banana]=yellow) echo "keys: ${!colors[@]}" echo "values: ${colors[@]}"
typeset -A colors=(apple red banana yellow) echo "keys: ${(k)colors[@]}" echo "values: ${(v)colors[@]}"
Neither shell guarantees any particular ordering for the keys of an associative array, so a script that needs stable output has to sort them — which is where ${(ok)colors[@]} earns its keep.
Iterating pairs
Zsh's for accepts more than one variable name, consuming that many words from the list per iteration. Paired with the (kv) flag, which flattens the map into alternating keys and values, this iterates entries directly.
declare -A colors=([apple]=red [banana]=yellow) # Loop the keys, look each value back up. for key in "${!colors[@]}"; do echo "$key is ${colors[$key]}" done
typeset -A colors=(apple red banana yellow) # (kv) flattens to key value key value, # and for takes two names at once. for key value in ${(kv)colors}; do echo "$key is $value" done
The Bash version needs a second subscript operation inside the loop body to recover each value. That is one more place for a quoting mistake, and it does redundant lookup work on every iteration.
Testing for a key
The question is whether a key exists, which is different from whether its value is empty. Bash spells it with the -v test; Zsh uses the ${+name} expansion, which yields 1 or 0 and so reads naturally inside an arithmetic test.
declare -A colors=([apple]=red) if [[ -v colors[apple] ]]; then echo "apple is present" fi if [[ ! -v colors[durian] ]]; then echo "durian is absent" fi
typeset -A colors=(apple red) if (( ${+colors[apple]} )); then echo "apple is present" fi if (( ! ${+colors[durian]} )); then echo "durian is absent" fi
Checking existence rather than truthiness matters whenever an empty string is a legitimate value. Both forms above get that right, whereas the common shortcut of testing ${colors[key]} for emptiness does not.
Deleting an entry
Deletion is unset with the subscript quoted. The quoting matters in both shells: without it, the brackets are a glob pattern that the shell may try to expand against the filesystem before unset ever sees them.
declare -A colors=([apple]=red [banana]=yellow) unset 'colors[apple]' echo "${#colors[@]} left" echo "${!colors[@]}"
typeset -A colors=(apple red banana yellow) unset 'colors[apple]' echo "${#colors[@]} left" echo "${(k)colors[@]}"
This is one of the rare operations that is spelled identically in both shells, including the quoting requirement that surprises people the first time they hit it.
String Operations
Search and replace
The ${var/old/new} and ${var//old/new} forms are identical in both shells. Zsh adds a second spelling borrowed from its history-expansion modifiers, where :gs stands for "globally substitute".
path="/usr/local/bin" echo "${path/local/opt}" # first echo "${path//\//-}" # all slashes
path="/usr/local/bin" echo "${path/local/opt}" # first echo "${path//\//-}" # all slashes echo "${path:gs/\//-}" # modifier form
The modifier form is worth recognizing because it chains with other modifiers such as :t for tail and :h for head, which is how Zsh users manipulate paths without calling basename or dirname.
Substrings
Zsh understands the Bash offset-and-length syntax and adds subscript ranges that work on scalars exactly as they do on arrays — 1-indexed, inclusive, negative from the end.
text="hello world" echo "${text:0:5}" # offset, length echo "${text:6}" echo "${text: -5}" # note the space
text="hello world" echo "${text:0:5}" # same as Bash echo "${text[1,5]}" # 1-indexed range echo "${text[-5,-1]}"
The subscript form avoids the awkward Bash requirement of a leading space before a negative offset, which exists only to stop the colon-minus being parsed as the default-value operator.
Stripping prefixes and suffixes
The four hash and percent operators behave identically in both shells: # strips from the front, % from the back, and doubling the character makes the match greedy. Zsh adds path modifiers, of which :r gives the name without its final extension and :e gives the extension alone.
filename="archive.tar.gz" echo "${filename%.gz}" # shortest suffix echo "${filename%%.*}" # longest suffix echo "${filename#*.}" # shortest prefix echo "${filename##*.}" # longest prefix
filename="archive.tar.gz" echo "${filename%.gz}" # shortest suffix echo "${filename%%.*}" # longest suffix echo "${filename#*.}" # shortest prefix echo "${filename##*.}" # longest prefix echo "${filename:r}" # root echo "${filename:e}" # extension
The modifiers say what they mean, whereas the ${filename##*.} form requires the reader to work out which end is being trimmed and how greedily. On a path the difference in legibility is larger still.
Defaults and fallbacks
The :- operator supplies a value when the variable is unset or empty without changing it, while := also assigns it. Both work identically in the two shells.
unset missing present="set" echo "${missing:-fallback}" echo "${present:-fallback}" echo "${missing:=assigned}" echo "missing is now: $missing"
unset missing present="set" echo "${missing:-fallback}" echo "${present:-fallback}" echo "${missing:=assigned}" echo "missing is now: $missing"
Omitting the colon in either form narrows the test from "unset or empty" to "unset only", which is the distinction that matters when an empty string is a meaningful value rather than an accident.
Length
On a scalar, ${#name} is the character count in both shells. On an array they disagree: Zsh gives the element count, Bash gives the length of the first element.
text="hello" fruits=(apple banana cherry) echo "chars: ${#text}" echo "elements: ${#fruits[@]}" echo "first el: ${#fruits}" # length of fruits[0]
text="hello" fruits=(apple banana cherry) echo "chars: ${#text}" echo "elements: ${#fruits}" echo "explicit: ${#fruits[@]}"
This is the same bare-name ambiguity as ${array} itself, and it has the same fix — write ${#array[@]}, which means the element count in both shells and is the form to use in anything portable.
Repeating a string
Neither shell has a string multiplication operator. Zsh gets one for free from the padding flag: padding an empty string to width ten with a dash fill produces ten dashes.
# printf can repeat a format, but building # a repeated string needs a loop or a # substitution trick. line="" for (( index = 0; index < 10; index++ )); do line+="-" done echo "$line"
# The (l) flag pads to a width with a # repeated fill character. line="" echo "${(l:10::-:)line}"
This is a small thing, but drawing a separator line is common enough in scripts that the Bash loop shows up constantly. The alternative Bash idiom, printf with a %*s format and a tr to replace the spaces, needs an external tool.
Pattern Matching & Regex
Regex capture groups
After a successful =~ test, Bash leaves the results in the BASH_REMATCH array with the whole match at index 0. Zsh splits them: MATCH holds the whole match, and the match array holds the capture groups starting at index 1.
date_text="2026-07-31" if [[ $date_text =~ ([0-9]+)-([0-9]+)-([0-9]+) ]]; then echo "whole: ${BASH_REMATCH[0]}" echo "year: ${BASH_REMATCH[1]}" echo "month: ${BASH_REMATCH[2]}" echo "day: ${BASH_REMATCH[3]}" fi
date_text="2026-07-31" if [[ $date_text =~ '([0-9]+)-([0-9]+)-([0-9]+)' ]]; then echo "whole: $MATCH" echo "year: ${match[1]}" echo "month: ${match[2]}" echo "day: ${match[3]}" fi
The Zsh split is the more useful arrangement, because the capture group numbers in the array match the numbers in the pattern rather than being offset by one. Note that Zsh wants the pattern quoted, where Bash requires it unquoted.
Matching against a glob
Inside [[ ]], the right-hand side of == is a glob pattern rather than a literal string, and an unquoted pattern is matched rather than compared. This works identically in both shells.
value="foobar" [[ $value == foo* ]] && echo "starts with foo" [[ $value == *bar ]] && echo "ends with bar" [[ $value == *oob* ]] && echo "contains oob"
value="foobar" [[ $value == foo* ]] && echo "starts with foo" [[ $value == *bar ]] && echo "ends with bar" [[ $value == *oob* ]] && echo "contains oob"
Quoting the right-hand side turns the pattern back into a literal in both shells, which is the usual cause of a test that mysteriously stops matching after someone adds defensive quotes.
Alternation
Both shells hide their richer glob syntax behind an option — shopt -s extglob in Bash, setopt EXTENDED_GLOB in Zsh. The alternation syntax then differs in whether a prefix character is required.
shopt -s extglob value="foobar" # @(...) is the extglob alternation form. [[ $value == @(foo|baz)bar ]] && echo "matched"
setopt EXTENDED_GLOB value="foobar" # Plain parentheses — no prefix character. [[ $value == (foo|baz)bar ]] && echo "matched"
Bash needs the @ prefix because bare parentheses already mean a subshell in most contexts. Zsh gets away with the plainer spelling inside a pattern, which is one fewer character to remember and closer to ordinary regex.
Negating a pattern
Negation is where the two extended-glob dialects diverge most. Bash wraps the negated pattern in !(...), while Zsh puts a caret in front of it.
shopt -s extglob value="foobar" # !(...) matches anything NOT matching. [[ $value == !(bar*) ]] && echo "does not start with bar"
setopt EXTENDED_GLOB value="foobar" # ^ negates the pattern that follows it. [[ $value == ^bar* ]] && echo "does not start with bar"
The Zsh caret composes more freely — it can appear partway through a larger pattern, so *.^(o|a) means "any extension except o or a". Bash's !(...) has to wrap a whole component.
The case statement
The case statement is POSIX and works the same way in both shells, matching a value against a series of glob patterns in order and running the first arm that matches.
for value in apple banana cherry; do case $value in a*) echo "$value: starts with a" ;; b*|c*) echo "$value: b or c" ;; *) echo "$value: something else" ;; esac done
for value in apple banana cherry; do case $value in a*) echo "$value: starts with a" ;; b*|c*) echo "$value: b or c" ;; *) echo "$value: something else" ;; esac done
Zsh accepts the Bash ;& and ;| terminators for falling through to the next arm or continuing to test subsequent patterns, so even the non-POSIX extensions port cleanly here.
Globbing & Qualifiers
Recursive globbing
Recursive globbing walks a directory tree from the shell rather than shelling out to find. Bash gates it behind the globstar option; in Zsh it is always on.
shopt -s globstar mkdir -p /tmp/ccrec-tree/nested : > /tmp/ccrec-tree/top.txt : > /tmp/ccrec-tree/nested/deep.txt # ** crosses directory boundaries only # after globstar is enabled. for file in /tmp/ccrec-tree/**/*.txt; do echo "${file##*/}" done
mkdir -p /tmp/ccrec-tree/nested : > /tmp/ccrec-tree/top.txt : > /tmp/ccrec-tree/nested/deep.txt # ** crosses directory boundaries with no # option to enable first. for file in /tmp/ccrec-tree/**/*.txt; do echo "${file##*/}" done
Both columns build their own tree first, since a glob has nothing to say about a directory that does not exist. The one line that differs is shopt -s globstar: without it Bash treats ** as an ordinary * that stops at the first slash, so the loop silently finds only the files at the top and never descends. Zsh needs no option and has none to forget.
Glob qualifiers
A glob qualifier is a parenthesized suffix on a glob pattern that filters the matches. (.) keeps regular files; the same slot also selects on permission, size and time, and orders the result.
: > /tmp/ccqual-report.txt : > /tmp/ccqual-archive.log # No equivalent. Filtering by type means a # loop and a test per file. for file in /tmp/ccqual-*; do [[ -f $file ]] || continue echo "${file##*/}" done
: > /tmp/ccqual-report.txt : > /tmp/ccqual-archive.log # A parenthesized suffix filters the match. for file in /tmp/ccqual-*(.); do echo "${file##*/}" done
Both columns print the same thing, which is the point — the difference is the mechanism, not the answer. The qualifier vocabulary goes far past (.): (/) is directories, (.x) executable regular files, (.Lm+1) larger than a megabyte, (.mh-24) modified in the last day, and (.om[1]) the newest one. Each of those is a find invocation in Bash.
When nothing matches
A glob that matches nothing is a long-standing shell hazard. Bash passes the unexpanded pattern through as a literal string, so a loop body runs once with a filename that does not exist.
# The unmatched PATTERN is passed through # as a literal, so the loop runs once. for file in /tmp/ccnull-*.missing; do echo "got: $file" done echo done
# Zsh errors on no match instead of passing # the pattern through; (N) asks for empty. for file in /tmp/ccnull-*.missing(N); do echo "got: $file" done echo done
Watch the first line of each column: Bash prints the pattern itself as though it were a file, while Zsh prints nothing. Zsh treats no-match as an error by default, and the (N) qualifier is how you say an empty result is expected — a per-glob decision rather than the global shopt -s nullglob mode, which changes every glob in the script at once.
Ordering glob results
The o and O qualifiers order the match, taking a sort key — n for name, m for modification time, L for size. (On) is reverse name order.
: > /tmp/ccsort-alpha.txt : > /tmp/ccsort-beta.txt : > /tmp/ccsort-gamma.txt # Glob results arrive in collation order. for file in /tmp/ccsort-*.txt; do echo "${file##*/}" done
: > /tmp/ccsort-alpha.txt : > /tmp/ccsort-beta.txt : > /tmp/ccsort-gamma.txt # Qualifiers order as well as filter. for file in /tmp/ccsort-*.txt(On); do echo "${file##*/}" done
Sorting inside the glob means the order survives into the loop with no temporary array and no external sort. Adding a subscript selects a slice, so *(om[1]) is the newest file and *(om[1,3]) the three newest — the idiom that has no concise Bash equivalent at all.
Pipelines & Redirection
The last stage runs in the current shell
Each stage of a pipeline normally runs in its own process, so anything a stage assigns is thrown away when that process exits. Zsh makes one exception: the LAST stage runs in the current shell. The two columns below are byte-identical, so the shell is the only thing that can explain a different answer.
lines=0 printf 'alpha\nbeta\ngamma\n' | while read -r word; do (( lines++ )) done echo "the loop saw $lines lines"
lines=0 printf 'alpha\nbeta\ngamma\n' | while read -r word; do (( lines++ )) done echo "the loop saw $lines lines"
Bash prints the loop saw 0 lines — the loop counted to three inside a subshell that then vanished. Zsh prints the loop saw 3 lines. Bash can opt in with shopt -s lastpipe, which only takes effect when job control is off (true in a script, false at an interactive prompt), and the portable fix is to feed the loop from a redirect instead of a pipe: done < <(printf …). The damage runs the other way too — a Zsh script that totals numbers in a | while read loop silently starts reporting zero the day it is run under Bash.
Capturing a pipeline into an array
Both shells need a way to get a pipeline's output back as a list of lines. Bash's mapfile reads standard input into an array, so the pipeline has to be attached to it with process substitution. Zsh captures the output with $( ) and splits it with the (f) flag, which means "split on newlines".
mapfile -t sorted < <(printf '%s\n' pear apple fig | sort) echo "${#sorted[@]} lines captured" echo "first is ${sorted[0]}"
sorted=("${(f)$(printf '%s\n' pear apple fig | sort)}") echo "${#sorted[@]} lines captured" echo "first is ${sorted[1]}"
The double quotes around "${(f)…}" are required and do the opposite of what the Sorting row warns about: here they stop ordinary word splitting from cutting the text at every space, while (f) does the only splitting wanted. Without them a filename with a space in it would arrive as two elements. The index difference — [0] against [1] — is the same 1-based rule as everywhere else in Zsh.
One redirection, two files
Two output redirections on one command. Bash performs them in order and keeps only the last, so the first file is created, truncated and then abandoned empty. Zsh's MULTIOS option, which is ON by default, sends the output to both. The columns are byte-identical again.
echo "saved twice" > /tmp/ccmultios-alpha.txt > /tmp/ccmultios-beta.txt cat /tmp/ccmultios-alpha.txt /tmp/ccmultios-beta.txt
echo "saved twice" > /tmp/ccmultios-alpha.txt > /tmp/ccmultios-beta.txt cat /tmp/ccmultios-alpha.txt /tmp/ccmultios-beta.txt
Bash prints saved twice once, because alpha is left empty; Zsh prints it twice. The same rule works in reverse on input, where cat < alpha < beta concatenates the two files in Zsh and reads only beta in Bash, and it composes with pipes: echo hi > log.txt | tr a-z A-Z writes the file AND feeds the pipeline, a tee for free. This is the one Zsh default that can quietly damage a ported Bash script rather than merely change its output, which is why unsetopt MULTIOS is worth knowing about.
The exit status of every stage
A pipeline reports the exit status of its LAST stage, so a failure in the middle is invisible to $?. Both shells keep every stage's status in an array — under a different name in each, upper case in Bash and lower case in Zsh, and 1-indexed in Zsh like all its arrays.
printf 'alpha\nbeta\n' | grep -q zzz | cat statuses=("${PIPESTATUS[@]}") echo "every stage: ${statuses[@]}" echo "the grep: ${statuses[1]}"
printf 'alpha\nbeta\n' | grep -q zzz | cat statuses=("${pipestatus[@]}") echo "every stage: ${statuses[@]}" echo "the grep: ${statuses[2]}"
Both columns print every stage: 0 1 0 and the grep: 1. Copying the array on the very next line is not tidiness: both shells overwrite it after every command, so the echo that was going to print it would have replaced it first. To make the pipeline itself report the failure instead of inspecting it afterwards, Zsh accepts both setopt PIPE_FAIL and Bash's own set -o pipefail.
A command's output as a filename
Process substitution turns a command's output into something that can be passed where a filename is expected. Both shells spell it <( ) and both hand over a named pipe. Zsh adds a second spelling, =( ), which writes the output to a real temporary file and deletes it once the command has finished.
# <( ) hands the command a named pipe. diff <(printf '%s\n' alpha beta) \ <(printf '%s\n' alpha gamma)
# =( ) hands the command a real file. diff =(printf '%s\n' alpha beta) \ =(printf '%s\n' alpha gamma)
Both columns print the same diff, because diff reads each input once from beginning to end, which is all a pipe allows. The difference appears with a command that seeks backwards, reopens the path, or refuses anything that is not a regular file — an archiver, an editor, or anything that memory-maps its input. Those choke on the /dev/fd/63 that a named pipe gives them and are perfectly happy with the /tmp/zsh… that =( ) gives them.
Control Flow
Conditionals
The (( )) arithmetic command evaluates its contents as an integer expression and succeeds when the result is non-zero. Inside it, variables need no dollar sign.
count=5 if (( count > 3 )); then echo "more than three" elif (( count == 3 )); then echo "exactly three" else echo "fewer than three" fi
count=5 if (( count > 3 )); then echo "more than three" elif (( count == 3 )); then echo "exactly three" else echo "fewer than three" fi
This is identical in both shells and is worth preferring over the [ -gt ] test form, which is a POSIX relic that treats its operands as strings and needs every one of them quoted.
String and file tests
The [[ ]] conditional is a shell keyword rather than a command, which is why the variables inside it need no quoting even when they might be empty or contain spaces.
name="brandon" empty="" [[ -n $name ]] && echo "name is non-empty" [[ -z $empty ]] && echo "empty is empty" [[ $name == brandon ]] && echo "exact match"
name="brandon" empty="" [[ -n $name ]] && echo "name is non-empty" [[ -z $empty ]] && echo "empty is empty" [[ $name == brandon ]] && echo "exact match"
Both shells implement [[ ]] compatibly for the common tests. The single-bracket [ ] form is a real command in both and does require quoting, which is why it is worth abandoning entirely once a script is shell-specific.
Ternary expressions
The C ternary operator is available inside arithmetic contexts in both shells, which is the cleanest way to select between two numeric values.
value=7 echo $(( value % 2 == 0 ? 0 : 1 )) # As a statement, use && and || (( value % 2 )) && echo "odd" || echo "even"
value=7 echo $(( value % 2 == 0 ? 0 : 1 )) # As a statement, use && and || (( value % 2 )) && echo "odd" || echo "even"
The && / || statement form is a common idiom but a trap: if the command after && fails, the || branch also runs. It is only safe when the first branch cannot fail, as with a bare print.
Arithmetic assignment
Declaring a variable with the -i attribute makes every assignment to it an arithmetic evaluation, so counter+=5 adds rather than concatenating.
declare -i counter=0 counter+=5 (( counter *= 2 )) (( counter++ )) echo "$counter"
typeset -i counter=0 counter+=5 (( counter *= 2 )) (( counter++ )) echo "$counter"
Without the -i attribute, += concatenates strings in both shells, so counter+=5 on an undeclared variable holding 0 produces the string 05 rather than the number 5.
Loops
Iterating an array
Because a bare $fruits already means every element and nothing is split, the Zsh loop needs neither the [@] subscript nor the surrounding quotes.
fruits=(apple banana cherry) for fruit in "${fruits[@]}"; do echo "$fruit" done
fruits=(apple banana cherry) for fruit in $fruits; do echo "$fruit" done
The Bash quoting here is mandatory, not stylistic: without it, any element containing a space becomes two iterations. That whole hazard is absent from the Zsh form.
Two variables per iteration
Zsh's for accepts a list of variable names and consumes that many words from the list on each pass. Bash's for takes exactly one name.
pairs=(one 1 two 2 three 3) # One name only, so step manually. for (( index = 0; index < ${#pairs[@]}; index += 2 )); do echo "${pairs[index]} = ${pairs[index+1]}" done
pairs=(one 1 two 2 three 3) # for takes as many names as you like. for name value in $pairs; do echo "$name = $value" done
This is what makes iterating an associative array by (kv) practical, and it removes the index arithmetic that the Bash version needs — along with the off-by-one risk that comes with it.
Repeating n times
Zsh adds a repeat loop for the case where the loop body does not care which iteration it is on. The count can be any arithmetic expression.
for (( index = 0; index < 3; index++ )); do echo -n "x" done echo # Or a brace range. for index in {1..3}; do echo -n "y" done echo
repeat 3; do echo -n "x" done echo for index in {1..3}; do echo -n "y" done echo
Both shells support brace ranges, and both support the C-style three-clause for. The repeat form simply removes the unused index variable, which is one less name to invent and one less thing to get wrong.
While loops
While and until loops take a command, and the arithmetic command (( )) succeeds when its expression is non-zero, so it reads as a condition.
countdown=3 while (( countdown > 0 )); do echo "$countdown" (( countdown-- )) done echo "liftoff"
countdown=3 while (( countdown > 0 )); do echo "$countdown" (( countdown-- )) done echo "liftoff"
These are identical in both shells. The difference only appears when the loop body reads from a pipeline, which in Bash famously runs the body in a subshell so its variable assignments are lost — and in Zsh does not. That is the last row of the Pipelines section.
Index and element together
Neither shell has an enumerate construct, so the index comes from a range over the array length. The range differs because the arrays do: Bash counts from 0 to length minus one, Zsh from 1 to length.
fruits=(apple banana cherry) for (( index = 0; index < ${#fruits[@]}; index++ )); do echo "$index: ${fruits[index]}" done
fruits=(apple banana cherry) for index in {1..${#fruits[@]}}; do echo "$index: ${fruits[index]}" done
This is where the indexing difference is most likely to bite during a port, because a loop transcribed literally from Bash will read $fruits[0] — which in Zsh is an error rather than the first element.
Functions & Scope
Defining a function
Both the POSIX name() form and the function keyword work in both shells, and positional parameters inside the body are the arguments.
greet() { echo "Hello, $1" } function greet_again { echo "Hello again, $1" } greet "world" greet_again "world"
greet() { echo "Hello, $1" } function greet_again { echo "Hello again, $1" } greet "world" greet_again "world"
Zsh additionally allows the two spellings to be combined as function name() { }, which Bash accepts as well. There is no behavioral difference between them in either shell.
Local variables
The local keyword confines a variable to the function and any function it calls, which is dynamic rather than lexical scoping in both shells.
outer="global" show() { local outer="local" echo "inside: $outer" } show echo "outside: $outer"
outer="global" show() { local outer="local" echo "inside: $outer" } show echo "outside: $outer"
In Zsh, local is a synonym for typeset, so every typeset attribute is available on a local variable — typeset -i for integers or typeset -A for a local map. Bash needs local -i and local -A as separate spellings.
Returning a value
A shell function has no return value in the usual sense — return sets an exit status, which is an integer from 0 to 255 where 0 means success. Anything richer is communicated by assigning to a variable or by printing.
double() { # No $() available, so write to a global. reply=$(( $1 * 2 )) } double 21 echo "$reply" # return sets the exit STATUS, not a value. is_even() { (( $1 % 2 == 0 )); } is_even 4 && echo "4 is even"
double() { reply=$(( $1 * 2 )) } double 21 echo "$reply" is_even() { (( $1 % 2 == 0 )); } is_even 4 && echo "4 is even"
Zsh scripts conventionally use the name reply for a scalar result and REPLY for an array, following the convention its own builtins use. Assigning to a global from inside a function is the only way to return a value without a subshell, which is why the convention survives in scripts where the cost of $( ) in a loop actually shows up.
Anonymous functions
A function definition with no name is executed immediately, with any words after the closing brace passed as its arguments. It is Zsh's version of an immediately-invoked function.
# No anonymous functions. To get a scope # for temporary variables, define, call # and unset a named one. scratch() { local temporary="only in here" echo "$temporary" } scratch unset -f scratch
# A function with no name runs immediately. () { local temporary="only in here" echo "$temporary" } # Arguments come after the body. () { echo "got $1"; } hello
The practical use is carving out a scope for local variables in the middle of a script, or for temporarily setting an option with setopt LOCAL_OPTIONS inside the body so it reverts on exit.
All the arguments
Both shells expose the argument count as $# and the arguments as $@. Zsh additionally makes them available as an ordinary array named argv, which can be sliced and passed to parameter flags like any other.
show_all() { echo "count: $#" echo "all: $*" for argument in "$@"; do echo " [$argument]" done } show_all one "two three" four
show_all() { echo "count: $#" echo "all: $*" for argument in $@; do echo " [$argument]" done # argv is a real array in Zsh. echo "second: ${argv[2]}" } show_all one "two three" four
The quoting around "$@" is load-bearing in Bash — without it, the argument containing a space becomes two iterations. In Zsh the bare form is already correct, which is the same no-splitting rule showing up one more time.
Options & Emulation
Setting options
Bash splits its options across two commands: shopt for its own and set -o for the POSIX set. Zsh has one, setopt, and prefixing a name with NO_ turns it off.
shopt -s extglob nocaseglob set -o pipefail # Two separate mechanisms: shopt for Bash # options, set -o for POSIX ones. shopt -q extglob && echo "extglob is on"
setopt EXTENDED_GLOB NO_CASE_GLOB setopt PIPE_FAIL # One mechanism. Names are case- and # underscore-insensitive. [[ -o extendedglob ]] && echo "extglob is on"
Zsh option names ignore case and underscores, so EXTENDED_GLOB, extendedglob and Extended_Glob are all the same option. The convention is capitals with underscores in scripts and lowercase interactively.
Scoping options to a function
LOCAL_OPTIONS makes every option change in the current function revert when the function returns, however it returns.
# No scoping. Save the old state, set the # new one, and restore it by hand. uses_extglob() { local was_set=0 shopt -q extglob && was_set=1 shopt -s extglob echo "working with extglob" (( was_set )) || shopt -u extglob } uses_extglob
uses_extglob() { setopt LOCAL_OPTIONS EXTENDED_GLOB echo "working with extglob" } uses_extglob [[ -o extendedglob ]] || echo "reverted on return"
This is the difference between a library function that is safe to call and one that quietly reconfigures the caller's shell. The Bash version above still leaks if the function returns early or the shell is interrupted between the set and the restore.
Emulating another shell
The emulate builtin switches Zsh wholesale into sh, ksh or zsh rules, changing word splitting, array indexing and a long list of options together. The -L flag scopes the change to the current function, like LOCAL_OPTIONS.
# Bash has --posix and set -o posix, which # only cover the POSIX rules — there is no # way to ask Bash to behave like Zsh. set -o posix echo "POSIX mode on" set +o posix
# emulate switches whole rule sets at once. emulate -L sh sentence="alpha beta gamma" for word in $sentence; do echo "[$word]" done
This is the pragmatic way to run a Bash-derived function unchanged inside a Zsh script: wrap it, put emulate -L sh at the top, and let the rest of the script keep Zsh semantics. Note the loop above splits, because sh emulation restores that behavior.
Variable attributes
The attribute letters are largely shared: -i integer, -r readonly, -a array, -A associative array, -l lowercase, -u uppercase. Bash calls the command declare and Zsh calls it typeset, though Zsh accepts declare as a synonym.
declare -i number=42 declare -r constant="fixed" declare -a list=(a b c) declare -A map=([k]=v) declare -l lowered="MIXED Case" echo "$number $constant ${list[1]} ${map[k]} $lowered"
typeset -i number=42 typeset -r constant="fixed" typeset -a list=(a b c) typeset -A map=(k v) typeset -l lowered="MIXED Case" echo "$number $constant ${list[2]} ${map[k]} $lowered"
Zsh adds attributes Bash lacks, notably -F and -E for floating point and -T for a variable tied to an array, which is how PATH and path stay in sync. Bash has no floating-point support at all.
Error Handling
Exit status
Every command leaves an exit status in $?, where 0 means success and anything else is a failure code chosen by the command. This is POSIX and identical in both shells.
succeeds() { return 0; } fails() { return 3; } succeeds echo "status: $?" fails echo "status: $?"
succeeds() { return 0; } fails() { return 3; } succeeds echo "status: $?" fails echo "status: $?"
The value of $? is replaced by the next command that runs, including the one inside a test, so it has to be captured immediately if it is needed more than once.
Guaranteed cleanup
Zsh's always block runs after the preceding block whether it succeeded, failed, or was exited early. It is the closest thing either shell has to try/finally.
# No try/finally. The usual approach is a # trap on EXIT, which is shell-wide rather # than scoped to a block. cleanup() { echo "cleanup ran"; } trap cleanup EXIT echo "trying" false echo "continued"
{ echo "trying" false } always { echo "cleanup ran" } echo "continued"
The Bash equivalent is an EXIT trap, which is global — installing a second one replaces the first, so two nested operations that both want cleanup have to cooperate manually. An always block is lexically scoped and nests freely.
Exiting on error
These three options together make a script abort on an unhandled failure, on an unset variable, and on a failure anywhere in a pipeline rather than only at its end. The Bash spellings are the well-known set -euo pipefail.
set -e set -u set -o pipefail # The conventional three-line preamble. value="defined" echo "$value" echo "still running"
setopt ERR_EXIT setopt NO_UNSET setopt PIPE_FAIL value="defined" echo "$value" echo "still running"
Zsh names the same options in words rather than letters. Both shells share the well-documented caveat that ERR_EXIT does not fire for a command whose status is consumed by a test, a && chain, or an if condition.
Traps
The trap builtin installs a handler for a signal or a pseudo-signal such as ERR or EXIT, and works the same way in both shells. The handler here is passed $?, the exit status of the command that tripped it.
handle_error() { echo "command failed with status $1" } trap 'handle_error $?' ERR failing_command() { return 3; } echo "before" failing_command echo "after"
handle_error() { echo "command failed with status $1" } trap 'handle_error $?' ERR failing_command() { return 3; } echo "before" failing_command echo "after" # Zsh also allows a function per signal. TRAPZERR() { echo "TRAPZERR fired"; } failing_command echo "done"
Zsh adds a second form where defining a function named TRAP followed by the signal name installs the handler, which gives the handler its own local scope. A trap string, by contrast, runs in the scope of wherever the signal happened to fire. Passing $LINENO instead of $? is the other common idiom, but the browser runtime prepends a preamble to every snippet, so the line number it reports would not match the code you can see.
Prompts
The prompt variable
Zsh accepts PS1 for compatibility but conventionally uses PROMPT, and its escape sequences start with a percent sign rather than a backslash.
# PS1 holds the primary prompt. PS1='\u@\h:\w\$ ' # \u user, \h host, \w working directory, # \$ becomes # for root and $ otherwise.
# PROMPT (or PS1) holds it in Zsh. PROMPT='%n@%m:%~%# ' # %n user, %m host, %~ working directory # with ~ substitution, %# is # or %.
The escapes do not correspond one to one. Zsh's %~ abbreviates the home directory to a tilde where Bash's \w does the same, but Zsh adds conditional forms such as %(?..%F{red}) that change the prompt based on the last exit status.
Color in the prompt
Both shells need the prompt to know which characters are non-printing, or the line editor miscalculates the cursor position and wrapping breaks. Bash requires explicit \[ \] delimiters around every escape sequence; Zsh handles it because %F is a prompt escape rather than a raw byte sequence.
# Raw escape sequences, wrapped in \[ \] # so readline does not count them toward # the line length. PS1='\[\033[32m\]\u\[\033[0m\]@\h\$ '
# %F{color} and %f, counted correctly # by the line editor with no wrapping. PROMPT='%F{green}%n%f@%m%# ' # 256 colors by number also work. PROMPT='%F{045}%n%f%# '
Forgetting the \[ \] wrapping in Bash produces the classic symptom of a shell that overwrites its own prompt when you edit a long command line. That failure mode does not exist in Zsh.
The right-hand prompt
RPROMPT is a second prompt drawn flush against the right edge of the terminal, which Zsh hides automatically when the command line grows long enough to reach it.
# No equivalent. Anything on the right of # the line has to be drawn manually with # cursor-movement escapes in PROMPT_COMMAND, # and it does not survive line editing.
# RPROMPT draws on the right of the line # and disappears when the cursor reaches it. RPROMPT='%F{240}%*%f' # %* is the time; %D is the date.
This is a genuinely Zsh-only feature, and it is where most themes put timestamps, git status and exit codes so the left prompt stays short. Bash has nothing comparable that survives line editing.
Startup Files
Which file runs when
This is the difference most likely to waste an afternoon during a migration, because a variable set in the wrong file appears to work in one terminal and not another.
# Login shell: # /etc/profile # then the FIRST of ~/.bash_profile, # ~/.bash_login, ~/.profile # # Interactive non-login shell: # ~/.bashrc # # The split is why so many ~/.bash_profile # files just source ~/.bashrc.
# Every shell: ~/.zshenv # Login shell: ~/.zprofile # Interactive shell: ~/.zshrc # Login shell, last: ~/.zlogin # On logout: ~/.zlogout # # They compose rather than competing, so # nothing needs to source anything else.
Zsh reads its files in a fixed order and each has one job, so PATH goes in ~/.zshenv, login-only setup in ~/.zprofile, and interactive settings in ~/.zshrc. Bash picks exactly one of three candidate login files, which is why the source-.bashrc-from-.bash_profile dance is nearly universal.
Enabling completion
Zsh's completion system is part of the shell and is initialized by autoloading and calling compinit, usually from ~/.zshrc. Behavior is then configured through zstyle rather than through options.
# Completion is a separate package that # has to be installed and sourced. if [[ -r /opt/homebrew/etc/profile.d/bash_completion.sh ]]; then source /opt/homebrew/etc/profile.d/bash_completion.sh fi
# Completion ships with the shell. autoload -Uz compinit compinit # Menu selection, one line. zstyle ':completion:*' menu select
This is the feature most often cited as the reason to switch, and it is why frameworks such as oh-my-zsh exist — they are mostly configuration on top of a completion system that is already there. Bash completion is a third-party package with a much smaller vocabulary of completers.
History configuration
Both shells keep an on-disk history file sized by a pair of variables — one for the in-memory list, one for the file. The option names for deduplication and sharing then differ.
HISTFILE=~/.bash_history HISTSIZE=10000 HISTFILESIZE=20000 HISTCONTROL=ignoredups:erasedups shopt -s histappend # Sharing history between concurrent shells # needs a PROMPT_COMMAND that writes and # re-reads the file on every prompt.
HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=20000 setopt HIST_IGNORE_ALL_DUPS setopt HIST_REDUCE_BLANKS setopt SHARE_HISTORY setopt EXTENDED_HISTORY # SHARE_HISTORY does the sharing for you.
SHARE_HISTORY is the notable win: concurrent Zsh sessions see each other's commands immediately, whereas the Bash equivalent requires a hand-written PROMPT_COMMAND that appends and re-reads the file on every prompt. EXTENDED_HISTORY additionally records a timestamp and duration per entry.