why /bin/sh is not what you expect
A script with #!/bin/sh runs fine on your own machine and fails on a Debian server with a cryptic error. The reason is the gap between Dash and Bash: two shells with a shared POSIX base but very different feature sets.
Table of Contents
- 1. Why /bin/sh is not Bash
- 2. What Dash actually is
- 3. Missing features: arrays, [[ ]] and more
- 4. echo, printf and escape sequences
- 5. Function definitions and local variables
- 6. Arithmetic and test operators
- 7. Finding bashisms with checkbashisms
- 8. Migration strategy for teams
- 9. Dash vs Bash side by side
- 10. Summary
- 11. FAQ
1. Why /bin/sh is not Bash
Plenty of scripts start with #!/bin/sh in the shebang, and anyone who assumes that grants access to Bash features is in for a surprise on any Debian derived system. Since 2009, /bin/sh on Debian and Ubuntu no longer points to Bash but to Dash, the Debian Almquist Shell. The reason is simple: Dash starts noticeably faster than Bash, which saves measurable time during boot and init scripts. A script that uses #!/bin/sh while relying on Bash specific syntax gets a cryptic error on these systems instead of a working program.
The gap between Dash and Bash goes far beyond Debian itself. Alpine Linux points /bin/sh to BusyBox ash, another minimal, POSIX adjacent interpreter. On macOS and most RPM based distributions, /bin/sh is often a Bash symlink or its own implementation. This inconsistency means, in practice, that a script running flawlessly on a developer machine can fail immediately inside a Docker container or on a freshly provisioned Debian server. Understanding Dash and Bash as two distinct interpreters with overlapping but not identical syntax is the foundation of any portable automation.
2. What Dash actually is
Dash descends from the Almquist Shell, a lean reimplementation of the original Bourne Shell from the late 1980s. Debian adopted it in 1997 as an alternative and made it the official /bin/sh in 2006 to cut boot time. The design goal of Dash is radical simplicity: a small memory footprint, a minimal feature set, and strict alignment with POSIX rather than convenience features. Bash, by contrast, grew as an interactive, comfortable shell for developers and administrators and accumulated GNU specific extensions over decades.
This difference in purpose explains why Dash and Bash feel so different in practice despite sharing a common POSIX base. Where Bash offers arrays, process substitution and extended test operators, Dash sticks to exactly what the POSIX standard requires and nothing more. For system scripts that run at boot, that is entirely sufficient. For more complex automation involving data structures and error handling, Dash quickly reaches limits that developers should know before starting a script with #!/bin/sh.
3. Missing features: arrays, [[ ]] and more
The most noticeable difference between Dash and Bash is arrays. Bash offers indexed and associative arrays as a native language feature, Dash has neither. A script with declare -A config fails immediately under Dash with a parse error, not just at runtime. The extended test bracket [[ ]] is also missing, which in Bash enables pattern matching, regex with =~ and logical operators without escaping. Under Dash only the POSIX test bracket [ ] is available, which is significantly more restrictive and prone to silent failures when quoting is wrong.
Process substitution such as <(command) and here strings like <<< "text" are also entirely absent from Dash. Anyone using these constructs in a supposedly POSIX script gets the error Syntax error: Bad substitution under Dash. Name references for return values, extended parameter expansion for case conversion and combined redirection in one step are also missing. Anyone moving between Bash and Dash has to consciously replace these convenience features with POSIX compliant alternatives.
#!/bin/sh
# This script LOOKS portable but uses Bash-only syntax
# Running it with dash script.sh instead of bash script.sh fails immediately
declare -A config # syntax error under dash: arrays do not exist
config[env]="prod"
if [[ "$1" == "deploy" ]]; then # syntax error under dash: [[ ]] not supported
echo "Deploying to ${config[env]}"
fi
# POSIX-safe rewrite: no arrays, no [[ ]], works under dash AND bash
env_name="prod"
if [ "$1" = "deploy" ]; then
printf '%s\n' "Deploying to $env_name"
fi
4. echo, printf and escape sequences
A particularly tricky difference between Dash and Bash lies in the behavior of echo. In Bash, echo interprets escape sequences such as \n or \t only with the -e flag, by default they are printed as literal characters. In Dash, the built in echo interprets escape sequences automatically, without any -e flag, because Dash follows the older System V behavior. A script that tests echo "Line1\nLine2" under Bash and expects literal backslashes produces two separate lines under Dash, and vice versa.
This inconsistency is so well known that POSIX recommends using printf instead of echo for portable scripts, since its behavior is defined identically across all shells. A printf format string reliably replaces echo without having to worry about shell specific escape behavior. Anyone with many echo calls involving special characters in existing scripts should systematically replace them with printf before moving between Dash and Bash.
# Bash: -e is required to interpret escape sequences
echo "Line1\nLine2" # bash default: prints literal backslash-n
echo -e "Line1\nLine2" # bash with -e: prints two lines
# dash: escape sequences are interpreted WITHOUT -e
echo "Line1\nLine2" # dash default: already prints two lines
# printf behaves identically in dash AND bash: the safe, portable choice
printf '%s\n' "Line1" "Line2"
printf 'Deploying %s to %s\n' "app" "$env_name"
5. Function definitions and local variables
Function definitions also expose the gap between Dash and Bash. Bash accepts both the POSIX syntax name() { ... } and the keyword form function name { ... }. Dash only supports the first, POSIX compliant form, the function keyword raises a syntax error. Anyone writing library functions meant for both shells should consistently stick to the POSIX form, even though the Bash form feels more familiar.
Local variables get more complicated: local is not part of the POSIX standard but has been supported by Dash as a de facto extension for many years. The difference from Bash lies in the details: in Dash, local cannot be combined with assignment and command substitution on a single line without losing the exit code of the command, a behavior that also causes problems in Bash with set -e but is more often overlooked in Dash. The safe pattern is always to split declaration and assignment into two separate lines.
6. Arithmetic and test operators
Arithmetic expressions work largely the same in Dash and Bash through the POSIX standard mechanism with double parentheses, but the convenient Bash extension as a standalone command for conditions is missing in Dash. An expression like if (( count > 0 )); then works under Bash, under Dash you have to fall back to if [ "$count" -gt 0 ]; then. Anyone writing counters and conditions with double parentheses has to manually adjust these spots when moving to Dash.
For test operators, Dash additionally lacks pattern matching inside the square bracket and regex checking with =~. The POSIX form only knows the equality operator for strings and the numeric comparison operators -eq, -ne, -lt, -gt. A script with a double equals sign often still runs under Dash, because some implementations accept it as an alias, but it is not guaranteed to be portable and should be replaced with the single equals operator in POSIX scripts.
count=5
env_name="prod"
# Bash-only: arithmetic command and pattern matching
if (( count > 0 )); then echo "positive"; fi
if [[ "$env_name" == prod* ]]; then echo "production match"; fi
# POSIX-safe: works under dash, bash and busybox ash
if [ "$count" -gt 0 ]; then echo "positive"; fi
case "$env_name" in
prod*) echo "production match" ;;
esac
7. Finding bashisms with checkbashisms
Instead of manually checking every line for Bash specific syntax, dedicated tools exist. The Debian package checkbashisms scans a script specifically for constructs that do not work under Dash and lists every occurrence with its line number. It is the standard tool Debian maintainers used to secure their package scripts against the 2009 Dash switch, and it is excellent for checking existing scripts before a migration.
ShellCheck with the --shell=sh option adds the same class of warnings directly inside the development environment, including explanations and references to the relevant POSIX rule. Anyone who wants to see how a script actually behaves under Dash can additionally run it temporarily with dash script.sh instead of bash script.sh, since Dash is already preinstalled on most Debian systems. The combination of static analysis and real execution under Dash reliably catches practically every relevant difference between Dash and Bash.
#!/usr/bin/env bash
set -euo pipefail
# Install and run checkbashisms against every script claiming #!/bin/sh
sudo apt-get install -y devscripts
checkbashisms deploy.sh
# possible warning: 'declare' is bash specific, line 4
# ShellCheck with explicit POSIX target
shellcheck --shell=sh deploy.sh
# Real execution test: does it actually run under dash?
dash deploy.sh || echo "[FAIL] script is not dash-compatible"
8. Migration strategy for teams
The central decision is: should a script really be POSIX portable and run under Dash, or is it enough to explicitly require Bash? For simple system scripts, init hooks and package postinst scripts, the answer is usually Dash, because these scripts should run on as many systems as possible without extra dependencies. For more complex automation involving arrays, error handling and parallelization, the detour through Dash compatibility rarely pays off, here an explicit #!/usr/bin/env bash shebang is the more pragmatic choice.
Consistency matters most: a shebang of #!/bin/sh that actually contains Bash syntax is the most dangerous combination, because it runs flawlessly on most developer machines and only breaks on a Dash system, often first in production. Teams should therefore define which script category gets which shebang and enforce that rule automatically with checkbashisms in the CI pipeline instead of relying on manual code review.
9. Dash vs Bash side by side
The following table summarizes the most important differences between Dash and Bash that most often cause failures in practice.
| Feature | Bash | Dash | Consequence |
|---|---|---|---|
| Arrays | indexed and associative | not available | parse error, not just at runtime |
| Test bracket | [[ ]] with regex | only POSIX [ ] | case needed instead of pattern matching |
| echo escapes | only with -e | active by default | use printf instead of echo for portability |
| Process substitution | <(command) | not available | temp file needed as substitute |
| Startup speed | slower | noticeably faster | Debian boot time argument for Dash as /bin/sh |
Overall, the comparison shows that Dash deliberately forgoes convenience features that Bash has accumulated over time. For a script guaranteed to run on any POSIX system, whether Dash, BusyBox ash or another lean shell, giving up these features is the price of true portability. The choice between Dash and Bash should therefore be made deliberately and documented, not left to chance in the shebang.
Mironsoft
Shell automation, DevOps tooling and portable infrastructure scripts
Scripts that run reliably on every system?
We audit existing shell scripts for bashisms, make them portable with checkbashisms and ShellCheck, and decide where Dash and where Bash is the right choice.
Portability audit
Run checkbashisms and ShellCheck across the entire script inventory
Migration
Rebuild critical system scripts to be POSIX compliant without losing functionality
CI integration
Automatically enforce shebang rules before a script gets merged
10. Summary
The difference between Dash and Bash decides, in practice, whether a script with #!/bin/sh actually runs on Debian, Ubuntu and Alpine or breaks with a syntax error. Dash deliberately forgoes arrays, the extended test bracket, process substitution and many other Bash extensions in order to stay fast and lean. Anyone who knows these differences can decide deliberately when a script genuinely needs to be written POSIX portable and when an explicit Bash shebang is the simpler and more honest choice.
Tools like checkbashisms and ShellCheck with the --shell=sh option remove the burden of manual checking from a team and reliably find bashisms before they cause problems in production. The combination of clear team rules, automated checking in the CI pipeline, and a deliberate approach to Dash and Bash as two distinct tools is the most reliable path to genuinely portable shell scripts.
Dash vs Bash: the essentials at a glance
Why it happens
/bin/sh has pointed to Dash, not Bash, on Debian and Ubuntu since 2009. A shebang of #!/bin/sh with Bash syntax breaks there immediately.
Missing features
Arrays, [[ ]], process substitution, here strings and the function keyword do not exist in Dash.
Tools
checkbashisms and shellcheck --shell=sh find bashisms automatically, dash script.sh tests real execution.
Decision
Simple system scripts: keep Dash compatible. Complex automation: explicit #!/usr/bin/env bash shebang.