Apple switched the default macOS shell from bash to zsh in Catalina (2019). Most people changed nothing and have been on zsh ever since for interactive use. But if you look at /etc/shells, run which sh, or open a fresh launchd-spawned process, bash is still very much present on your system, and a few of your tools may still be quietly invoking it. Here’s how to actually finish the migration so bash isn’t part of your daily-use flow at all.
Why bother? Two reasons. One: if your .bashrc and .zshrc both exist, you’re maintaining two configs and inevitably they drift. Two: launchd Login Items, cron jobs, and some Homebrew-installed scripts use /bin/sh by default, which on macOS is bash — meaning environment variables you set in zsh aren’t there, and you debug for an hour wondering why.
Step 1 — check what your login shell actually is
echo $SHELL
# /bin/zsh ← this is what you want
dscl . -read ~/ UserShell
# UserShell: /bin/zsh ← also good
# If either says /bin/bash, change it:
chsh -s /bin/zshThe chsh change is per-user, recorded in DirectoryServices, and survives macOS updates. New Terminal / iTerm2 windows immediately use zsh.
Step 2 — clean up bash dotfiles
If you have any of these files, decide whether they should still exist:
ls -la ~/.bashrc ~/.bash_profile ~/.bash_login ~/.profile ~/.bash_history 2>/dev/null~/.bashrc/~/.bash_profile: if you’re zsh-only, delete these. They’re not used by zsh, and keeping them means future-you might edit them by mistake. Move them to~/.bash_legacy/if you can’t quite let go.~/.profile: trickier — zsh’s startup files are~/.zprofile(login) and~/.zshrc(interactive). If you have important PATH additions in~/.profile, copy them to~/.zprofileinstead.~/.bash_history: zsh writes to~/.zsh_history. The bash one is just an old artifact; safe to delete if you don’t miss it.
Step 3 — the launchd surface where bash hides
This is the part that’s genuinely subtle. macOS’s launchd spawns processes (background daemons, login items, scheduled jobs) with a minimal environment that doesn’t include your interactive zsh setup. Things you’ll hit:
- Your custom PATH from
~/.zshrcisn’t seen by launchd jobs. - Environment variables set in
~/.zshenvare seen, because.zshenvis sourced by every zsh invocation including non-interactive ones. - Anything that runs a script with shebang
#!/bin/shends up in bash — not zsh.
The fix is to put environment-y things in ~/.zshenv, not ~/.zshrc. .zshrc is for interactive shell setup — aliases, prompt, history config. .zshenv is for environment variables that should exist for every shell invocation.
# ~/.zshenv — the right place for these
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
export EDITOR=nvim
export VISUAL=nvim
export LANG=en_US.UTF-8
# ~/.zshrc — only interactive stuff
alias ll='ls -la'
alias g='git'
PS1='%n@%m %~ %# '
HISTSIZE=10000
SAVEHIST=10000
setopt SHARE_HISTORYStep 4 — tell launchd about your PATH for system-wide jobs
For launchd-spawned jobs at the system level (cron-like LaunchAgents in ~/Library/LaunchAgents/), you sometimes need to specify PATH explicitly:
# in your .plist
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>Or set launchctl setenv PATH "..." as a one-liner in a system-wide initializer. Without one of these, your LaunchAgents that need brew-installed binaries will mysteriously fail with “command not found.”
Step 5 — the shebang problem in your scripts
Look at the top line of every shell script in ~/bin/ or your dotfiles repo:
grep -r '^#!/' ~/bin/ ~/dotfiles/ 2>/dev/null | head#!/bin/sh— explicitly bash on macOS. If your script doesn’t use any bash-isms, this is fine and portable to any Unix. If it does, change to bash explicitly.#!/bin/bash— explicitly bash. Fine. macOS’s bash is 3.2 (yes, from 2007), so don’t use bash 4+ features unless you’ve installed a newer bash via brew and pointed at/opt/homebrew/bin/bash.#!/bin/zsh— explicitly zsh. Use this for scripts that lean on zsh features like associative arrays, glob qualifiers, or extended pattern matching.#!/usr/bin/env bash/#!/usr/bin/env zsh— the most portable. Use these.
Step 6 — the “is bash gone” verification
# Open a new shell. Run:
ps -p $$
# PID TTY TIME CMD
# 12345 ttys000 0:00.05 -zsh
# Confirm SHELL points at zsh
echo $SHELL
# Confirm /etc/shells lists zsh and that's what your user shell is set to
grep zsh /etc/shells
dscl . -read ~/ UserShell
# Look at running processes — should be 0 bash processes for your user
ps aux | grep -v grep | grep "^$USER" | grep -E '/bash|-bash'If that last grep returns nothing, you’re zsh-only for interactive use. The bash binary still exists (and a few system scripts will still invoke it), but your daily-use flow no longer touches it.
Don’t try to remove /bin/bash
You’d think to sudo rm /bin/bash for completeness. Don’t. macOS’s System Integrity Protection (SIP) protects /bin/bash, and a few system scripts (Time Machine, some launchd helpers) still invoke /bin/sh which IS bash. Removing it would either fail (SIP) or break macOS subtly.
The right outcome is: bash exists on disk, but YOUR processes — login shell, scripts you wrote, LaunchAgents you manage — never touch it. The system’s bash usage is Apple’s problem, not yours.
Once you’re set up like this, your shell environment is one config file (.zshrc + .zshenv), one history file, one set of aliases. Future-you opening a fresh Mac in 2027 has a lot less mental load.
Photo: Code on a dark screen by technobulka on Pexels.
