Writing Scripts That Work in Both Bash and zsh
AI generated
$_
#!/
Bash · zsh · macOS · Dotfiles
Writing scripts for Bash and zsh
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.

17 min read Array index · globbing · emulate sh · dotfiles macOS · zsh 5.x · Bash 5.x

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.

11. FAQ: Writing Scripts for Bash and zsh

1Why does Bash and zsh compatibility matter more today?
Because macOS defaults to zsh since Catalina, while servers and CI keep using Bash.
2What index do zsh arrays start at?
1 by default, not 0 as in Bash. Iterating instead of indexing avoids the difference.
3What happens with a glob without matches in zsh?
zsh errors out by default. nullglob normalizes to an empty result list in both shells.
4Why does for w in $words behave differently?
Bash splits implicitly on spaces, zsh does not. Arrays instead of strings avoid the difference.
5What does emulate sh do?
Puts zsh into a more POSIX close mode, with -L flag only locally within a function.
6How do I detect Bash vs zsh at runtime?
By checking BASH_VERSION and ZSH_VERSION at the top of the file.
7Why doesn't shopt work in zsh?
shopt is Bash specific, zsh uses setopt and unsetopt instead.
8How should I structure dotfiles best?
Shared file with portable code, shell specific configuration kept separate.
9Are Bash and zsh very different overall?
No, the base syntax is very similar, differences concentrate on a few details.
10POSIX sh instead of Bash and zsh together?
Often sensible for standalone scripts, for interactive dotfiles deliberate compatibility is usually more practical.