Table of Contents
- Understanding Unix System Variations
- Key Principles for Portable Bash Scripts
- Critical Technical Considerations
- Practical Example: A Portable Script
- Testing Across Unix Systems
- Common Pitfalls and How to Avoid Them
- Best Practices for Maintainable Portable Scripts
- Conclusion
- References
1. Understanding Unix System Variations
To write portable scripts, you first need to recognize the key differences between Unix systems. Here are the most impactful:
1.1 GNU vs. BSD Utilities
Many core Unix utilities (e.g., sed, awk, grep, ls) have two dominant implementations:
- GNU Coreutils: Used in Linux distributions (Ubuntu, Fedora, Arch) and provide extended features (e.g.,
sed -iwithout backups,grep --color). - BSD Utilities: Used in macOS, FreeBSD, and OpenBSD. They prioritize POSIX compliance but often omit GNU-specific flags and may require different syntax (e.g.,
sed -i ''for in-place edits, as BSDsedrequires a backup suffix argument).
1.2 File System Layouts
Path conventions vary:
- Linux: Tools like
bashorpythonare often in/binor/usr/bin. - macOS: Some system tools live in
/usr/bin, but user-installed tools (via Homebrew) go to/usr/local/bin. - FreeBSD: Base utilities are in
/binor/usr/bin, while ports/packages use/usr/local/bin.
Hard-coding paths like/usr/local/bin/pythonwill break on systems where Python lives elsewhere.
1.3 Default Shells
The default system shell (/bin/sh) differs:
- Linux (Debian/Ubuntu):
/bin/shlinks todash(a minimal POSIX shell, not bash). - Linux (Fedora/RHEL):
/bin/shlinks tobash(but runs in POSIX mode). - macOS:
/bin/shlinks tobash(version 3.2, due to licensing constraints). - FreeBSD:
/bin/shis the nativesh(a POSIX-compliant shell, not bash).
Scripts relying on bash-specific features (e.g., arrays,[[ ]]conditionals) will fail if run withdashor BSDsh.
2. Key Principles for Portable Bash Scripts
Portability starts with intentional design. Follow these core principles:
2.1 Use POSIX-Compliant Syntax
Stick to the POSIX shell standard, which all Unix shells (including dash, BSD sh, and bash in POSIX mode) support. Avoid bashisms like:
- Arrays (
myarray=(a b c)). - Extended globbing (
shopt -s extglob). - Process substitution (
<(command)). [[ ]]conditionals (use[ ]instead).
2.2 Avoid Hard-Coded Paths
Never assume tools live in specific locations. Instead:
- Use
command -vto check if a tool exists (e.g.,if ! command -v curl >/dev/null; then echo "curl not found"; exit 1; fi). - Rely on
$PATHto resolve executables (e.g., just usecurlinstead of/usr/bin/curl).
2.3 Explicitly Check for Dependencies
Always verify that required tools (e.g., sed, awk, git) are installed before running. A script that fails because jq is missing is better than one that crashes with a cryptic “command not found” error.
2.4 Handle Command Variations Gracefully
When utilities behave differently (e.g., GNU vs. BSD sed), detect the system type and adjust behavior. Use uname -s to identify the OS:
OS=$(uname -s)
if [ "$OS" = "Darwin" ]; then # macOS
SED_CMD="sed -i ''"
elif [ "$OS" = "FreeBSD" ]; then # FreeBSD
SED_CMD="sed -i ''"
else # Linux (GNU sed)
SED_CMD="sed -i"
fi
2.5 Quote Variables and Arguments
Unquoted variables break when they contain spaces or special characters (e.g., file name.txt). Always quote variables:
# Bad: Fails if $filename has spaces
rm $filename
# Good: Safe for spaces/special chars
rm "$filename"
3. Critical Technical Considerations
3.1 The Shebang Line: #!/bin/sh vs #!/bin/bash
The shebang (#!) determines the interpreter. For portability:
- Use
#!/bin/shto enforce POSIX compliance. This ensures the script runs in the system’s native POSIX shell (e.g.,dash, BSDsh), avoiding bashisms. - Avoid
#!/bin/bashunless your script explicitly requires bash-only features (and document this dependency!).
3.2 Portable Command Substitution: $() vs ` `
Both $(command) and `command` capture command output, but $() is:
- More readable (avoids backtick escaping issues).
- POSIX-compliant (supported by all modern shells).
Use$()instead of` `:
# Portable
current_dir=$(pwd)
# Avoid (harder to read, especially with nested commands)
current_dir=`pwd`
3.3 Conditionals: [ ] (POSIX) vs [[ ]] (Bash)
[[ ]] is a bash-specific enhancement with features like pattern matching and logical operators (&&, ||). For portability, use the POSIX-compliant [ ] (aka test command) with explicit syntax:
# Bad (bash-specific)
if [[ "$var" == *pattern* ]]; then ...
# Good (POSIX-compliant)
if [ "$var" = "expected_value" ]; then ...
Note: With [ ], always:
- Quote variables to avoid word-splitting.
- Use
-afor logical AND ([ "$a" -eq 1 -a "$b" -eq 2 ]),-ofor OR. - Avoid
=~(regex matching, bash-specific).
3.4 Handling Utilities with System-Specific Behavior
Example 1: sed -i (In-Place Edits)
GNU sed allows sed -i 's/old/new/' file (no backup), but BSD sed requires a backup suffix (e.g., sed -i '' 's/old/new/' file to disable backups). To handle both:
# Portable sed -i wrapper
sed_inplace() {
if sed --version 2>/dev/null | grep -q GNU; then
# GNU sed: no backup suffix needed
sed -i "$@"
else
# BSD sed: require empty backup suffix
sed -i '' "$@"
fi
}
# Usage: sed_inplace 's/old/new/' file.txt
Example 2: date Formatting
GNU date supports date -d "2 days ago", but BSD date (macOS/FreeBSD) uses date -v -2d. Use a portable alternative like:
# Portable "2 days ago" (works on GNU/BSD date)
two_days_ago=$(date -j -f "%Y-%m-%d" "$(date +%Y-%m-%d) - 2 days" +%Y-%m-%d 2>/dev/null || date -d "2 days ago" +%Y-%m-%d)
3.5 Portable Path Resolution
Use command -v to check if a tool exists in $PATH before using it:
# Check if curl is available
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required but not installed." >&2
exit 1
fi
3.6 Error Handling and Robustness
set -e: Exit on error (POSIX-compliant, but use cautiously—some commands return non-zero for non-fatal issues).set -u: Treat undefined variables as errors (helps catch typos). Note: Older shells (e.g., Solarissh) may not supportset -u, so document this if used.trap: Clean up temporary files on exit:temp_file=$(mktemp) trap 'rm -f "$temp_file"' EXIT # Delete temp_file on script exit
4. Practical Example: A Portable Script
Here’s a script that demonstrates portability best practices: it checks for dependencies, uses POSIX syntax, and handles sed -i across systems.
#!/bin/sh
# Purpose: Replace "old_text" with "new_text" in a file (portable across Unix)
set -euo pipefail # Exit on error, undefined var, or pipeline failure
# --------------------------
# Check dependencies
# --------------------------
check_dependency() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: Required tool '$1' not found. Please install it first." >&2
exit 1
fi
}
check_dependency "sed"
check_dependency "mktemp" # For safe temp file handling
# --------------------------
# Validate input
# --------------------------
if [ $# -ne 1 ]; then
echo "Usage: $0 <file-to-edit>" >&2
exit 1
fi
file="$1"
if [ ! -f "$file" ]; then
echo "Error: File '$file' does not exist." >&2
exit 1
fi
# --------------------------
# Portable sed -i wrapper
# --------------------------
sed_inplace() {
if sed --version 2>/dev/null | grep -q GNU; then
sed -i "$@"
else
sed -i '' "$@"
fi
}
# --------------------------
# Main logic
# --------------------------
echo "Replacing 'old_text' with 'new_text' in $file..."
sed_inplace 's/old_text/new_text/g' "$file"
echo "Done. Modified file: $file"
5. Testing Across Unix Systems
Portability is meaningless without testing on target systems. Use these tools:
5.1 Local Testing with Containers/VMs
- Docker: Test Linux distros with official images:
# Test on Ubuntu (GNU utilities) docker run --rm -v "$(pwd):/scripts" ubuntu:latest sh /scripts/your_script.sh # Test on Alpine (musl libc, busybox utilities) docker run --rm -v "$(pwd):/scripts" alpine:latest sh /scripts/your_script.sh - Vagrant: Test FreeBSD or older Linux versions with Vagrant boxes (e.g.,
freebsd/FreeBSD-13.0-RELEASE). - macOS: Use a physical Mac or Apple’s Xcode Cloud for CI.
5.2 CI/CD for Automated Testing
Leverage CI services to test across systems:
- GitHub Actions: Test on Ubuntu, macOS, and even FreeBSD (via
actions/runner-images). - GitLab CI: Use
ubuntu,fedora, andmacosrunners.
6. Common Pitfalls and How to Avoid Them
| Pitfall | Solution |
|---|---|
Assuming bash is the default shell | Use #!/bin/sh and POSIX syntax. |
Using GNU-specific flags (e.g., sed -i without backup) | Detect OS with uname -s and adjust commands. |
Hard-coding paths (e.g., /usr/local/bin/jq) | Use command -v jq to locate executables dynamically. |
Unquoted variables (e.g., rm $file) | Always quote: rm "$file". |
Using [[ ]] or =~ (bash-specific) | Use [ ] and POSIX string comparisons. |
Arrays (e.g., args=(--option 1)) | Use positional parameters ($@) or split into individual variables. |
7. Best Practices for Maintainable Portable Scripts
- Keep It Simple: Avoid over-engineering. Use built-in tools instead of complex dependencies.
- Lint with ShellCheck: Run ShellCheck to catch non-portable syntax (e.g.,
shellcheck your_script.sh). - Document Dependencies: List required tools (e.g.,
curl,jq) in a comment orREADME. - Test Early, Test Often: Validate on target systems during development, not just at release.
- Use
set -euo pipefail: Enforce strict error checking (document ifset -uis used, as some old shells lack support).
8. Conclusion
Writing portable bash scripts requires mindfulness of Unix variations, adherence to POSIX standards, and rigorous testing. By avoiding bashisms, handling utility differences gracefully, and validating across systems, you can ensure your scripts work reliably on Linux, macOS, FreeBSD, and beyond. The effort pays off in broader usability and fewer “works for me” bugs.