Bash Error Guide: 'bad substitution' — Fix Shell Feature Mismatches
Fix Bash 'bad substitution' errors: they usually mean a Bash-only expansion ran under /bin/sh (dash). Learn the causes, how to diagnose the wrong interpreter, and how to fix it.
- #bash
- #automation
- #troubleshooting
- #errors
Stuck on this Bash & Python Automation error? Get the free incident triage checklist
A one-page PDF — the exact steps to isolate, fix, and verify a production error like this one. No spam, unsubscribe anytime.
Overview
Your script runs fine when you launch it one way and blows up another way with a terse message that names a line but not the real problem:
./build.sh: 14: ./build.sh: Bad substitution
Bad substitution means the shell hit a ${...} parameter-expansion syntax it does not understand. Nine times out of ten the code is correct Bash, but it is being executed by a POSIX shell that lacks that feature — most often /bin/sh, which on Debian/Ubuntu and Alpine is dash, not Bash. The remaining cases are genuine typos inside a ${...} expression. The fix is almost always to run the script with the right interpreter, not to rewrite the syntax.
Symptoms
- The error text is
Bad substitution(dash/POSIX phrasing) orbad substitution(Bash phrasing), naming a line number. - It appears when the script is invoked as
sh script.sh,. script.sh, or via ash -cwrapper, but works when run asbash script.shor./script.shwith a Bash shebang. - Common on Alpine containers (
/bin/shis BusyBox ash) and Debian/Ubuntu (/bin/shis dash). - It fires on Bash-only expansions such as
${var^^},${var,,},${var/find/replace},${var:offset:length},${!prefix*},${var@Q},$(( ))edge cases, or process substitution<(...). - CI reproduces it while local dev does not (different default
/bin/sh).
Common Root Causes
- Wrong interpreter — the script uses Bash features but is run under
sh(dash/ash/BusyBox), directly or through a Makefile/CI step that callssh. - A missing or POSIX shebang —
#!/bin/shat the top, so even./script.shuses a POSIX shell. - Case conversion
${var^^}/${var,,}— Bash 4+ only; unknown to dash and to Bash 3.2 (older macOS). - Pattern substitution
${var//old/new}— Bash-only. - Substring/offset
${var:2:3}with a syntax slip, or run under a shell that lacks it. - Indirect expansion
${!name}or${!prefix@}— Bash-only. - A literal typo inside the braces, e.g.
${var:=default extra}with a stray character, or${ var}with a leading space. $where you meant a plain brace — e.g. writing${(...)}(a zsh construct) in a Bash script.
Diagnostic Workflow
First, find out which shell is actually running the script and what the shebang says:
head -1 ./build.sh # what does the shebang request?
ls -l /bin/sh # on Debian/Ubuntu often -> dash; Alpine -> busybox
grep -rn 'sh .*build.sh\|sh -c' Makefile .github/ 2>/dev/null
Reproduce the difference between the two interpreters directly:
$ dash -c 'v=hello; echo "${v^^}"'
dash: 1: Bad substitution
$ bash -c 'v=hello; echo "${v^^}"'
HELLO
Locate the exact failing expansion — the reported line number points at it:
sed -n '14p' ./build.sh
grep -nE '\$\{[^}]*[\^,/!@]' ./build.sh # flag likely Bash-only expansions
Check your Bash version, since some features (^^, ,,) need Bash 4+:
bash --version | head -1
echo "$BASH_VERSION"
Confirm the fix by running with the correct interpreter:
bash ./build.sh && echo OK
Example Root Cause Analysis
A CI job normalizes a branch name to lowercase and fails only in the pipeline:
Step 4/9 : RUN sh ./scripts/tag.sh
---> Running in a1b2c3d4
./scripts/tag.sh: 6: ./scripts/tag.sh: Bad substitution
Line 6:
tag="${BRANCH,,}" # lowercase the branch for the image tag
${BRANCH,,} is a Bash 4 case-conversion expansion. Locally the developer runs ./scripts/tag.sh, and the file starts with #!/usr/bin/env bash, so Bash handles it. But the Dockerfile invokes it as RUN sh ./scripts/tag.sh, and the base image’s /bin/sh is BusyBox ash, which has no ,, operator — hence Bad substitution.
Two correct fixes:
- Run it with Bash (best when you want to keep the concise syntax) — change the Dockerfile step and install bash if the image lacks it:
RUN apk add --no-cache bash
RUN bash ./scripts/tag.sh
- Make it truly POSIX (best for minimal images without Bash) — replace the Bash-only expansion with a portable equivalent:
tag="$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]')"
The wrong “fix” is to keep calling it with sh and hope — the expansion will never work in a POSIX shell.
Prevention Best Practices
- Match the shebang to the features you use. If you use
${x^^},${x//a/b},<(...), or arrays, start the file with#!/usr/bin/env bashand run it as a program, not withsh. - Never invoke a Bash script with
sh. Audit Makefiles, Dockerfiles, and CI steps forsh script.shorsh -ccalls. - Run ShellCheck. With
#!/bin/sh, ShellCheck flags Bash-only constructs (SC3xxx “in POSIX sh, X is undefined”) before they reach production. - Test in the target image. If you ship on Alpine, run the script under BusyBox
shin CI, or ensurebashis installed. - Mind Bash version —
^^/,,need Bash 4+; older macOS ships 3.2. Usetrfor portable case conversion. - Keep expansions simple in scripts meant to be portable; reach for Bash features deliberately, not by accident.
Quick Command Reference
# What interpreter is /bin/sh here?
ls -l /bin/sh
# Reproduce the POSIX-vs-Bash difference
dash -c 'v=Hi; echo "${v^^}"' # Bad substitution
bash -c 'v=Hi; echo "${v^^}"' # HI
# Lint a POSIX script for accidental Bash-isms
shellcheck -s sh ./script.sh
# Portable lowercase (works in any POSIX shell)
printf '%s' "$s" | tr '[:upper:]' '[:lower:]'
# Run with the correct shell
bash ./script.sh
Conclusion
bad substitution is rarely about a bug in your logic — it is a mismatch between a Bash-only ${...} expansion and the POSIX shell that ended up executing it. Confirm which interpreter ran the script (ls -l /bin/sh, check the shebang and any sh script.sh callers), then choose one path: run it with Bash consistently, or rewrite the expansion in portable POSIX form. Add a correct shebang and a ShellCheck pass in CI, and this error stops surprising you across laptops, containers, and runners.
Fixed it? Get 500 Bash & Python Automation & DevOps AI prompts — free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.
Did this fix your issue?
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.