without maintaining code twice
Since macOS Catalina, zsh has been the default login shell, while servers and CI environments keep relying on Bash. Anyone writing a script or a dotfile function meant for both shells has to know array indices, globbing and option names that differ fundamentally.
Table of Contents
- 1. Why Bash and zsh compatibility matters at all
- 2. Array indices: 0-based vs 1-based
- 3. Globbing differences and nomatch
- 4. Word splitting: implicit vs explicit
- 5. Option names and setopt vs shopt
- 6. emulate sh and POSIX mode in zsh
- 7. Detecting the active shell at runtime
- 8. Structuring dotfiles for both shells
- 9. Bash vs zsh side by side
- 10. Summary
- 11. FAQ
1. Why Bash and zsh compatibility matters at all
Since macOS Catalina in 2019, zsh has been the default login shell on every new Mac, while Linux servers, Docker containers and most CI environments continue to rely on Bash. Developers who switch daily between a macOS terminal and a Linux server therefore regularly run into scripts and functions that behave differently under Bash and zsh, even though both shells build on the same Bourne shell roots and share a large common feature set.
The practical trigger is usually a dotfile function or a small utility script meant to work in .zshrc exactly like it does in .bashrc or in a standalone deployment script. Achieving Bash and zsh compatibility does not mean giving up and settling for the smallest common POSIX denominator, it means knowing exactly the spots where both shells have different defaults and either avoiding those differences or handling them explicitly, instead of letting them work by accident.
2. Array indices: 0-based vs 1-based
The best known and most commonly underestimated difference between Bash and zsh concerns array indexing. Bash arrays are 0 based like in almost every programming language: ${array[0]} returns the first element. zsh arrays, by contrast, are 1 based by default: ${array[1]} returns the first element, while ${array[0]} reaches into nothing in the default configuration. A script developed under Bash that contains array accesses with index 0 returns either empty values or an off by one error under zsh that subtly propagates through the entire logic.
For genuine compatibility between Bash and zsh there are two strategies: either avoid direct numeric array indices entirely and work exclusively with iterations over all elements, which behaves identically in both shells, or explicitly set the KSH_ARRAYS option in zsh, which enables 0 based indexing for compatibility with Bash and ksh. The latter, however, changes behavior globally for the entire script and can unexpectedly affect other zsh specific constructs, which is why the iteration based solution is usually the more robust choice.
#!/usr/bin/env bash
# This file is sourced by both .bashrc and .zshrc
hosts=(web1 web2 web3)
# WRONG: relies on 0-based indexing, breaks under default zsh
# echo "First host: ${hosts[0]}"
# RIGHT: iterate instead of indexing directly — identical in bash and zsh
for host in "${hosts[@]}"; do
echo "Checking host: $host"
done
# If a single element is truly needed, detect the shell explicitly:
if [ -n "${ZSH_VERSION:-}" ]; then
first_host="${hosts[1]}" # zsh: 1-based by default
else
first_host="${hosts[0]}" # bash: 0-based
fi
echo "First host resolved as: $first_host"
3. Globbing differences and nomatch
A second key difference between Bash and zsh concerns behavior with file patterns that have no matches. When a glob pattern such as *.txt is used in a directory without matching files, Bash by default returns the pattern unchanged as a literal string. zsh, in contrast, aborts with an error: zsh: no matches found: *.txt, because the NOMATCH option is active by default. A script that relies on Bash's behavior and catches the literal string as a special case does not even work under zsh, because it aborts with an error first.
For compatibility between Bash and zsh, a script should either explicitly check whether files exist before using a glob result, or deliberately set setopt nullglob in zsh, which returns an empty result list instead of an error or a literal string and thereby matches the behavior scripts usually want from Bash's shopt -s nullglob. This explicit alignment is significantly more robust than relying on implicit shell behavior.
#!/usr/bin/env bash
# Portable globbing: works the same in bash and zsh
# Normalize glob behavior explicitly instead of relying on shell defaults
if [ -n "${ZSH_VERSION:-}" ]; then
setopt null_glob # zsh: empty match list instead of an error
else
shopt -s nullglob # bash: empty match list instead of literal string
fi
files=(*.txt)
if [ ${#files[@]} -eq 0 ]; then
echo "No .txt files found"
else
for f in "${files[@]}"; do
echo "Found: $f"
done
fi
4. Word splitting: implicit vs explicit
A third difference that surprises many Bash developers on their first contact with zsh is word splitting of unquoted variables. Bash automatically splits an unquoted variable containing several space separated words into multiple arguments, for example in for w in $words. zsh, by contrast, treats an unquoted scalar variable as a single argument by default, even if it contains spaces, and does not split it implicitly. A script relying on implicit word splitting therefore behaves fundamentally differently under zsh than under Bash.
The reliable solution is to never rely on implicit splitting in either shell, and instead work with arrays from the start instead of space separated strings. Where that is not possible for POSIX sh compatibility reasons, ${=variable} should be used explicitly in zsh, which forces word splitting for that one expression and thereby matches Bash's default behavior without changing global shell behavior.
5. Option names and setopt vs shopt
Bash and zsh also differ fundamentally in syntax when setting shell options, not just in the names of individual options. Bash uses shopt -s optionname for most extended options and set -o optionname for POSIX defined options. zsh uses exclusively setopt optionname and unsetopt optionname, regardless of whether the option is POSIX defined or zsh specific. A script containing shopt calls that gets sourced in zsh without adjustment fails with command not found, because shopt simply does not exist in zsh.
For a dotfile function meant to work in both shells, a small wrapper function that chooses the matching syntax depending on the detected shell is recommended, or consistently avoiding shell options in favor of portable POSIX code that needs neither shopt nor setopt. For options that genuinely need both shells, such as the nullglob behavior mentioned above, the explicit if branch with ZSH_VERSION is the most robust solution.
#!/usr/bin/env bash
# Setting shell options portably across bash and zsh
set_shell_option() {
local option="$1"
if [ -n "${ZSH_VERSION:-}" ]; then
setopt "$option" 2>/dev/null || true
else
shopt -s "$option" 2>/dev/null || set -o "$option" 2>/dev/null || true
fi
}
set_shell_option nullglob
set_shell_option extended_glob
6. emulate sh and POSIX mode in zsh
zsh comes with its own, very far reaching compatibility mechanism: the emulate command. emulate sh at the start of a script or function puts zsh into a mode that disables many zsh specific behaviors and instead enforces POSIX semantics, including 0 based array indexing and Bash like word splitting. emulate bash goes a step further and activates options specifically designed for Bash compatibility.
The advantage of emulate is that a single command sets many individual options at once, instead of matching each option manually. The downside: emulate changes behavior globally for the current function or script and can unintentionally disable other zsh specific features you actually want to keep. For clearly scoped compatibility functions in a dotfile, emulate -L sh inside a single function, with the -L flag for local scope, is often the most practical solution.
# zsh-only syntax, but the function still works when sourced from bash too
# because emulate is silently ignored / undefined there in a guarded call
portable_split() {
# Local emulation: only affects this function, not the whole shell session
if [ -n "${ZSH_VERSION:-}" ]; then
emulate -L sh
fi
local input="$1"
for word in $input; do
echo "word: $word"
done
}
portable_split "one two three"
7. Detecting the active shell at runtime
The foundation of any Bash and zsh compatibility solution is reliably detecting which shell is currently executing the script. Bash automatically sets the variable BASH_VERSION, zsh sets ZSH_VERSION accordingly. A simple check with [ -n "${ZSH_VERSION:-}" ] or [ -n "${BASH_VERSION:-}" ] works reliably in both shells, without external dependencies and without the comparatively error prone approach of checking the program name via $0, which can vary depending on invocation context.
This detection should sit at the top of every dotfile that gets sourced in both shells and serve as the foundation for all subsequent branching, for example choosing between shopt and setopt or handling array indexing. A clean setup encapsulates this detection in a single wrapper variable or function at the top of the file, instead of repeating the check at every individual spot in the script.
8. Structuring dotfiles for both shells
For developers who want to use the same aliases, functions and environment variables in Bash and zsh, a shared file with purely POSIX compatible code is the most maintainable solution. This shared file, for example ~/.shell_common, gets sourced at the end of both .bashrc and .zshrc and contains exclusively constructs that behave identically in both shells: POSIX function definitions, iteration instead of array indexing, printf instead of echo.
Shell specific customizations, such as prompt formatting or zsh specific completion functions, stay in the respective .bashrc or .zshrc instead and do not get mixed into the shared file. This clear separation between a shared, POSIX compatible core and shell specific configuration is the key to maintaining Bash and zsh compatibility permanently, without having to keep two nearly identical files in sync on every change.
9. Bash vs zsh side by side
The following table summarizes the most important differences between Bash and zsh that most commonly cause failures in shared scripts.
| Behavior | Bash | zsh (default) | Portable solution |
|---|---|---|---|
| Array index | 0-based | 1-based | iteration instead of index |
| Glob without match | literal string | error (NOMATCH) | set nullglob explicitly |
| Word splitting | implicit on $var | not implicit | arrays instead of strings |
| Setting options | shopt / set -o | setopt / unsetopt | wrapper function |
| Detecting the shell | $BASH_VERSION | $ZSH_VERSION | check both variables |
The comparison shows that Bash and zsh are very similar in their base syntax but differ in exactly the details that come up most often in dotfiles and short utility scripts. Anyone who knows these five differences and consistently applies portable patterns can use one and the same codebase in both shells without maintaining functions twice.
Mironsoft
Shell automation, dotfile maintenance and cross platform scripts
Dotfiles and scripts that behave the same in Bash and zsh?
We build shared shell libraries, cleanly solve array index and globbing issues, and separate shell specific configuration from the portable core.
Dotfile audit
Check existing .bashrc and .zshrc for duplicated and incompatible code
Refactoring
Build a shared, POSIX compatible shell library for team dotfiles
CI scripts
Secure utility scripts equally for macOS developers and Linux CI runners
10. Summary
Bash and zsh compatibility in practice almost always fails at the same five spots: array indexing, globbing without matches, implicit word splitting, the syntax for shell options, and the lack of a clear detection of which shell is currently active. All five differences can be solved with simple, portable patterns without maintaining code twice: iteration instead of index, explicit nullglob, arrays instead of strings, a small wrapper function for options, and a check for BASH_VERSION or ZSH_VERSION at the top of the file.
zsh's own emulate command additionally offers a quick way to enforce POSIX or Bash like behavior for a single function without changing global shell behavior. Anyone maintaining a shared file with pure, portable code for Bash and zsh and clearly separating shell specific configuration from it saves considerable maintenance effort over time compared to two parallel, nearly identical dotfile collections.
Bash and zsh: the essentials at a glance
Array indices
Bash is 0-based, zsh is 1-based by default. Iteration instead of a direct index avoids the difference entirely.
Globbing
zsh aborts with an error by default on patterns without matches. Set nullglob explicitly in both shells.
Options
shopt and set -o in Bash, setopt and unsetopt in zsh. A wrapper function encapsulates the difference.
Detecting the shell
Check $BASH_VERSION and $ZSH_VERSION as the foundation for all subsequent branching in the script.