100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Bash

Writing Portable Shell Scripts

How to write shell scripts that behave consistently across bash versions, macOS vs Linux, and POSIX sh, avoiding the common traps that break scripts on a different machine.

Production BashAdvanced10 min readJul 10, 2026
Analogies

Why 'It Works On My Machine' Fails With Shell Scripts

Portability problems in shell scripting come from three independent sources that are easy to conflate: the shell interpreter itself (bash 3.2 on macOS vs bash 5.x on Linux, or dash masquerading as /bin/sh on Debian-based systems), the userland utilities (BSD sed/grep/date on macOS have different flags than GNU coreutils on Linux), and the environment (PATH ordering, locale, and default umask differing between an interactive login shell and a CI runner's non-interactive shell). A script that runs perfectly in a developer's iTerm session can fail in a Docker container's sh or in a cron job precisely because all three of these differ silently between the two contexts.

🏏

Cricket analogy: It's like a bowler who is lethal on a seaming Headingley pitch suddenly looking ordinary on a flat, dry Chepauk surface — the same action produces wildly different results depending on the underlying conditions.

POSIX sh vs bash: Know What You're Targeting

The single biggest portability decision is the shebang line and the feature set it implies: #!/bin/sh on Debian/Ubuntu points to dash, a minimal POSIX-compliant shell that does not support bash-only features like arrays, [[ ]], local, process substitution (<(cmd)), or ${var,,} case conversion, while #!/usr/bin/env bash explicitly requires bash and unlocks all of those. Writing #!/bin/sh and then using bash-only syntax is a common and confusing bug because the script may work on macOS or RHEL (where /bin/sh is often actually bash in POSIX mode) while silently breaking on Debian, Ubuntu, or Alpine (which uses the even more minimal busybox sh).

🏏

Cricket analogy: It's like entering a T20 tournament roster but then trying to bowl the 50-over format's extra bouncer allowance — the ruleset you declared for doesn't support the moves you're attempting.

bash
#!/bin/sh
# POSIX-portable: no arrays, no [[ ]], no local, no ${var,,}

set -eu

find_config() {
  # Use POSIX test [ ] instead of bash's [[ ]]
  if [ -f "$HOME/.myapprc" ]; then
    printf '%s\n' "$HOME/.myapprc"
  else
    printf '%s\n' "/etc/myapp/config"
  fi
}

config_path=$(find_config)

# GNU vs BSD date differ; use POSIX-compatible options only
timestamp=$(date +%Y%m%d)

printf 'Using config: %s (as of %s)\n' "$config_path" "$timestamp"

GNU vs BSD Userland Differences

Even when the shell itself is identical, the utilities a script calls out to often are not: GNU sed -i 's/foo/bar/' file edits in place, but BSD sed on macOS requires an explicit (even if empty) backup extension argument, sed -i '' 's/foo/bar/' file, and omitting it either errors or corrupts the file depending on the version. Similarly, GNU date -d '1 day ago' has no BSD equivalent (date -v-1d is the BSD form), and GNU grep -P (Perl-compatible regex) simply doesn't exist on BSD grep. The safest portable approach is to detect the platform explicitly with uname -s and branch, or better, depend on a known-consistent tool like python3 or a vendored GNU coreutils install for the specific operations that must behave identically everywhere.

🏏

Cricket analogy: It's like a bowling action that's legal under ICC rules but gets called for throwing under a stricter domestic league's biomechanical review — the same physical motion is judged differently by different rule enforcers.

A reliable trick for portable in-place editing across GNU and BSD sed is to avoid -i entirely: write to a temp file and mv it into place, e.g. sed 's/foo/bar/' file > file.tmp && mv file.tmp file, which behaves identically on every sed implementation.

Never assume /bin/sh is bash. On Debian, Ubuntu, and their derivatives it is dash; on Alpine it is busybox sh. Using bash-only syntax ([[ ]], arrays, local, source, brace expansion {1..10}) under a #!/bin/sh shebang will fail unpredictably depending on the distribution, not fail consistently everywhere.

  • Portability issues stem from three independent layers: the shell interpreter, the userland utilities (GNU vs BSD), and the environment (PATH, locale, umask).
  • The shebang line is a contract: #!/bin/sh means POSIX-only features, #!/usr/bin/env bash unlocks bash-specific syntax.
  • /bin/sh is dash on Debian/Ubuntu, busybox sh on Alpine, and sometimes bash-in-POSIX-mode elsewhere — never assume its identity.
  • GNU and BSD versions of sed, date, and grep differ in flags and behavior; the safest fix is avoiding version-specific flags or detecting the platform explicitly.
  • In-place sed editing is a classic portability trap; writing to a temp file and mv-ing it is a version-agnostic alternative.
  • ShellCheck can be configured to lint against POSIX sh compliance specifically, catching bash-isms before they reach a dash-only system.
  • When true cross-platform consistency matters, delegating to a consistent runtime like python3 is often safer than chasing every shell/tool version difference.

Practice what you learned

Was this page helpful?

Topics covered

#Bash#AdvancedBashScriptingStudyNotes#DevOps#WritingPortableShellScripts#Writing#Portable#Shell#Scripts#StudyNotes#SkillVeris