Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Bash & Python Automation By James Joyner IV · · 8 min read Last reviewed Jul 2026

Bash Error Guide: 'bad substitution' — Fix Shell Feature Mismatches

Quick answer

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
Free toolkit

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) or bad substitution (Bash phrasing), naming a line number.
  • It appears when the script is invoked as sh script.sh, . script.sh, or via a sh -c wrapper, but works when run as bash script.sh or ./script.sh with a Bash shebang.
  • Common on Alpine containers (/bin/sh is BusyBox ash) and Debian/Ubuntu (/bin/sh is 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 calls sh.
  • A missing or POSIX shebang#!/bin/sh at the top, so even ./script.sh uses 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:

  1. 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
  1. 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 bash and run it as a program, not with sh.
  • Never invoke a Bash script with sh. Audit Makefiles, Dockerfiles, and CI steps for sh script.sh or sh -c calls.
  • 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 sh in CI, or ensure bash is installed.
  • Mind Bash version^^/,, need Bash 4+; older macOS ships 3.2. Use tr for 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.

Free download · 368-page PDF

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?

Free download · 368-page PDF

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.