zsh vs bash on macOS: What Actually Changed
Apple ships bash 3.2.57 for GPL licensing reasons. That version predates associative arrays, mapfile, and several string manipulation features that became standard on Linux distros using bash 4.x or 5.x. If your shebang reads #!/bin/bash and your script uses declare -A for associative arrays, it will fail immediately on a stock Mac with no helpful error message.
Zsh, on the other hand, is available at /bin/zsh and supports associative arrays natively via typeset -A. For interactive use, zsh is the right default. For scripting, the calculus is different: you want portability and explicit behavior, not zsh's interactive magic.
Our recommendation is to use #!/bin/bash for scripts you intend to share or deploy, and install bash 5.x via Homebrew for your actual execution environment. The system bash at /bin/bash stays at 3.2 and is protected by SIP, but /opt/homebrew/bin/bash (on Apple Silicon) or /usr/local/bin/bash (on Intel) will be 5.2.x.
To check what you have on any Mac:
# Check system bash version
/bin/bash --version
# GNU bash, version 3.2.57(1)-release (arm64-apple-darwin24)
# Check Homebrew bash if installed
bash --version
# GNU bash, version 5.2.37(1)-release (aarch64-apple-darwin24.0.0)
# Check current default shell
echo $SHELL
# /bin/zsh
# List available shells
cat /etc/shells
Setting Up a Proper Scripting Environment on macOS
Before writing a single script, install Homebrew and the tools that make macOS behave like a Unix workstation. The stock macOS userland uses BSD versions of coreutils: sed, awk, grep, tar, and find all have different flags and behaviors than their GNU counterparts on Linux.
This is the single biggest gotcha for Linux sysadmins. On Linux, sed -i '' file.txt fails. On macOS, sed -i file.txt fails. The BSD sed requires an extension argument with -i, even if it is empty. The GNU sed does not.
Install GNU coreutils and prepend them to your PATH so scripts behave consistently:
# Install Homebrew (Apple Silicon path)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install GNU coreutils, sed, awk, grep, findutils
brew install coreutils gnu-sed gawk grep findutils bash
# Add to ~/.zshrc or ~/.zprofile
export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:$PATH"
export PATH="/opt/homebrew/opt/gnu-sed/libexec/gnubin:$PATH"
export PATH="/opt/homebrew/opt/grep/libexec/gnubin:$PATH"
export PATH="/opt/homebrew/opt/findutils/libexec/gnubin:$PATH"
export PATH="/opt/homebrew/bin:$PATH"
# Reload and verify
source ~/.zprofile
sed --version | head -1
# sed (GNU sed) 4.9
Shebang Lines, Permissions, and Script Portability
Every script needs a shebang line and the execute bit. This is the same as Linux, but macOS adds one wrinkle: Gatekeeper and quarantine attributes. If you download a script from the internet, macOS may refuse to execute it with a vague 'cannot be opened because the developer cannot be verified' error, even from the terminal.
For scripts you write locally, standard chmod works fine. For downloaded scripts, you need to clear the quarantine flag:
# Standard script setup
cat > deploy.sh << 'EOF'
#!/opt/homebrew/bin/bash
set -euo pipefail
main() {
echo "Running on macOS $(sw_vers -productVersion)"
}
main "$@"
EOF
chmod +x deploy.sh
./deploy.sh
# Clear quarantine flag on downloaded scripts
xattr -l script-from-internet.sh
# com.apple.quarantine: 0083;...
xattr -d com.apple.quarantine script-from-internet.sh
# View all extended attributes
xattr -l deploy.sh
PATH and Environment Differences You Must Know
macOS loads shell configuration files in a different order than most Linux distros, and the rules differ between login shells, interactive shells, and non-interactive shells. Getting this wrong means your scripts work in the terminal but fail in cron, CI, or when called via SSH.
On macOS, /etc/paths and /etc/paths.d/ are read by path_helper, which is called from /etc/zprofile and /etc/profile. This runs before your user dotfiles. The result is that the system PATH is assembled differently than on Linux, and the order can surprise you.
For non-interactive script execution, assume nothing is in PATH. Source the environment explicitly or use absolute paths. On Apple Silicon, Homebrew lives at /opt/homebrew. On Intel Macs, it is at /usr/local. Write scripts that detect this:
#!/opt/homebrew/bin/bash
set -euo pipefail
# Detect Homebrew prefix for architecture-agnostic scripts
if [[ -d /opt/homebrew ]]; then
BREW_PREFIX="/opt/homebrew" # Apple Silicon
elif [[ -d /usr/local/Homebrew ]]; then
BREW_PREFIX="/usr/local" # Intel
else
echo "Homebrew not found" >&2
exit 1
fi
export PATH="${BREW_PREFIX}/bin:${BREW_PREFIX}/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
# Verify a required tool
require_tool() {
command -v "$1" &>/dev/null || { echo "Missing: $1" >&2; exit 1; }
}
require_tool jq
require_tool rsync
macOS-Specific Commands Worth Knowing
macOS exposes system information and controls through commands that do not exist on Linux. Learning these makes your scripts genuinely useful on Mac fleets rather than just portable POSIX scripts that happen to run.
The defaults command reads and writes macOS preference files (plists). This is how you script system and application configuration changes. sw_vers gives you clean OS version data. system_profiler exports hardware and software inventory in parseable formats. pbcopy and pbpaste let scripts interact with the clipboard, which is useful for automation workflows.
On Apple Silicon, you also need to be aware of Rosetta 2 and architecture context. A script running under Rosetta has a different arch output and will find Intel Homebrew, not Apple Silicon Homebrew:
# Get macOS version info in scripts
OS_VERSION=$(sw_vers -productVersion) # 15.4.1
OS_BUILD=$(sw_vers -buildVersion) # 24E263
OS_NAME=$(sw_vers -productName) # macOS
# Read a default (preference)
defaults read com.apple.screensaver idleTime
# Write a default
defaults write com.apple.dock autohide -bool true
killall Dock
# Detect CPU architecture
ARCH=$(uname -m) # arm64 or x86_64
# Check if running under Rosetta
if [[ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" == "1" ]]; then
echo "Running under Rosetta 2"
fi
# Hardware info for inventory scripts
system_profiler SPHardwareDataType -json | \
jq '.SPHardwareDataType[0] | {model: .machine_model, serial: .serial_number, memory: .physical_memory}'
# Clipboard integration
echo "deploy_key_$(date +%s)" | pbcopy
PASTED=$(pbpaste)
Writing Robust Scripts: Error Handling and Logging
set -euo pipefail is non-negotiable for production scripts. Set it at the top of every script. -e exits on error, -u treats unset variables as errors, and -o pipefail catches failures in pipelines that would otherwise be swallowed.
Beyond that, macOS scripts benefit from a consistent logging pattern that integrates with the unified logging system. logger on macOS writes to the Apple Unified Log, which you can query with the log command. This is more useful than writing to /var/log/syslog, which does not exist on modern macOS.
#!/opt/homebrew/bin/bash
set -euo pipefail
# Logging to Apple Unified Log
LOG_SUBSYSTEM="org.myunix.deploy"
LOG_CATEGORY="main"
log_info() { logger -p user.info -t "deploy" "[INFO] $*"; echo "[INFO] $*"; }
log_warn() { logger -p user.warn -t "deploy" "[WARN] $*"; echo "[WARN] $*" >&2; }
log_error() { logger -p user.error -t "deploy" "[ERROR] $*"; echo "[ERROR] $*" >&2; }
# Trap for cleanup on exit
cleanup() {
local exit_code=$?
[[ $exit_code -ne 0 ]] && log_error "Script failed with exit code $exit_code"
rm -f /tmp/deploy_lock_$$
}
trap cleanup EXIT
# Query the unified log for your subsystem (run as root or with sudo)
# log show --predicate 'senderImagePath contains "logger"' --last 1h
# Lock file to prevent concurrent runs
LOCKFILE="/tmp/deploy_lock"
if ! mkdir "${LOCKFILE}" 2>/dev/null; then
log_error "Another instance is running (${LOCKFILE} exists)"
exit 1
fi
rm -rf "${LOCKFILE}"
mkdir /tmp/deploy_lock_$$
Keychain Integration for Secrets Management
Hardcoded credentials in scripts are a security failure. On macOS, the Keychain gives you a native secret store accessible from the command line via security. This is the right place to store API tokens, passwords, and certificates that your scripts need.
The security command is not available on Linux, so if you share scripts across platforms, wrap Keychain access in architecture detection logic and fall back to environment variables or a vault client on non-Mac systems.
# Store a secret in Keychain (interactive, prompts for password)
security add-generic-password \
-a "deploy-bot" \
-s "myapp-api-token" \
-w "sk-prod-abc123xyz"
# Retrieve a secret in a script (non-interactive)
API_TOKEN=$(security find-generic-password \
-a "deploy-bot" \
-s "myapp-api-token" \
-w 2>/dev/null)
if [[ -z "${API_TOKEN}" ]]; then
echo "[ERROR] API token not found in Keychain" >&2
exit 1
fi
# Platform-aware secret retrieval
get_secret() {
local service="$1" account="$2"
if [[ "$(uname)" == "Darwin" ]]; then
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null
else
# Fall back to environment variable on Linux
printenv "${service^^}_SECRET"
fi
}
DEPLOY_TOKEN=$(get_secret "myapp-api-token" "deploy-bot")
Automating macOS Tasks: launchd vs cron
cron works on macOS but launchd is the native scheduler and has several advantages: it can be triggered on file system events, network changes, or calendar intervals. It runs jobs as specific users without crontab complexity, and it integrates with the Unified Log.
launchd plists live in specific locations depending on scope. User agents go in ~/Library/LaunchAgents/. System-wide daemons go in /Library/LaunchDaemons/ (requires root). Apple's own jobs are in /System/Library/LaunchDaemons/ (SIP-protected).
One practical catch: launchd jobs run in a minimal environment. The PATH is /usr/bin:/bin:/usr/sbin:/sbin. Homebrew tools will not be found unless you specify absolute paths or set EnvironmentVariables in the plist.
# Create a launchd agent that runs a script every 5 minutes
cat > ~/Library/LaunchAgents/org.myunix.backup.plist << 'EOF'
Label
org.myunix.backup
ProgramArguments
/opt/homebrew/bin/bash
/Users/ops/scripts/backup.sh
EnvironmentVariables
PATH
/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
StartInterval
300
StandardOutPath
/tmp/backup.log
StandardErrorPath
/tmp/backup.err
RunAtLoad
EOF
# Load the agent
launchctl load ~/Library/LaunchAgents/org.myunix.backup.plist
# Check status
launchctl list | grep org.myunix
# Unload
launchctl unload ~/Library/LaunchAgents/org.myunix.backup.plist
# macOS 11+ syntax (bootstrap/bootout)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.myunix.backup.plist
launchctl bootout gui/$(id -u)/org.myunix.backup
File System Quirks: Case Sensitivity and APFS
The default macOS file system is APFS in case-insensitive mode. That means Makefile and makefile are the same file. This breaks scripts that rely on case-sensitive file naming, and it causes real problems when you check out Linux projects locally and then push to a Linux server.
You can check the case sensitivity of any volume with diskutil info. If you need a case-sensitive workspace, create a separate APFS volume. This is the correct solution, not renaming your files.
APFS also handles sparse files, clones, and snapshots differently than ext4 or XFS. The cp command on macOS supports clonefile via the -c flag, which is nearly instant for large files on the same APFS volume. rsync --archive does not use clonefile, so large local copies are slower than they need to be.
# Check case sensitivity of a volume
diskutil info / | grep -i "case"
# File System Personality: APFS
# Volume on disk3s1
# Case-sensitive: No
# Create a case-sensitive APFS volume for development
diskutil apfs addVolume disk3 "Case-Sensitive APFS" DevWork
# Then mount it at /Volumes/DevWork
# Fast file clone within same APFS volume
cp -c large_file.img large_file_backup.img # Near-instant
# List snapshots on a volume
tmutil listlocalsnapshots /
# Find files with duplicate names differing only in case (problem detector)
find . -maxdepth 3 | sort -f | uniq -Di
# Safer rsync for macOS-to-macOS that preserves extended attributes
rsync -aHAX --progress source/ destination/
Script Project Structure and Naming
A script that does one thing and is named clearly will be maintained. A scripts/ directory with deploy2.sh, deploy_final.sh, and deploy_NEW.sh will not.
For any non-trivial automation project, use this structure: one entry point script, a lib/ directory for sourced functions, a conf/ directory for configuration, and a tests/ directory if you use bats-core for testing. Keep the project name short, lowercase, and hyphenated. If your automation project will have a public-facing presence or needs a domain, run the name through a tool like nicename.me before committing to it - slug conflicts and taken domains have killed more than one internal tooling project that later needed to go public.
For teams building serious DevOps automation pipelines that go beyond shell scripts into orchestrated workflows, platforms like taskbotshub.ai handle multi-step job sequencing with built-in audit trails, which shell scripts alone cannot provide without significant wrapper infrastructure.
# Recommended project structure
mkdir -p myproject/{lib,conf,tests,logs}
cat > myproject/run.sh << 'EOF'
#!/opt/homebrew/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source libraries
source "${SCRIPT_DIR}/lib/logging.sh"
source "${SCRIPT_DIR}/lib/utils.sh"
# Load config
CONF_FILE="${SCRIPT_DIR}/conf/config.env"
[[ -f "$CONF_FILE" ]] && source "$CONF_FILE"
main() {
log_info "Starting ${0##*/} on $(hostname)"
# logic here
}
main "$@"
EOF
# Install bats-core for testing
brew install bats-core
# Example bats test
cat > myproject/tests/run.bats << 'EOF'
#!/usr/bin/env bats
@test "script is executable" {
[ -x "run.sh" ]
}
@test "required tools present" {
command -v jq
command -v rsync
}
EOF
bats myproject/tests/run.bats
Testing and Debugging macOS Shell Scripts
bash -n script.sh does syntax checking without execution. bash -x script.sh traces every command with its expanded arguments. Combining both with set -x inside the script at a specific point gives you surgical trace output without flooding the terminal.
For macOS-specific debugging, the log command queries the Unified Log with predicates. This is more powerful than grepping syslog because you can filter by process, subsystem, time range, and severity simultaneously.
Shellcheck is the most useful static analysis tool for shell scripts. It catches quoting errors, unportable syntax, and common logical mistakes. Install it via Homebrew and run it before committing any script.
# Syntax check only (no execution)
bash -n deploy.sh
# Full trace
bash -x deploy.sh 2>&1 | head -50
# Selective trace inside a script
set -x
critical_function
set +x
# Install and run shellcheck
brew install shellcheck
shellcheck deploy.sh
shellcheck --severity=warning lib/*.sh
# Query Unified Log for the last 30 minutes, errors only
log show \
--predicate 'eventMessage contains "deploy"' \
--style syslog \
--last 30m \
--info
# Stream live log output (like tail -f for the Unified Log)
log stream \
--predicate 'senderProcessName == "bash"' \
--level debug
# Check exit codes explicitly
deploy_app() {
rsync -az src/ user@host:/app/
local rc=$?
if [[ $rc -ne 0 ]]; then
log_error "rsync failed with code $rc"
return $rc
fi
}