Getting started
What is POSIX sh?
POSIX sh is the portable shell scripting standard. Works on any UNIX system without bash/zsh dependencies.
#!/bin/sh
# NOT #!/bin/bash
set -eu # Exit on error, undefined vars
Use sh when:
- Writing build scripts
- Maximum portability needed
- Targeting minimal environments (containers, embedded)
- CI/CD pipelines across platforms
Script header template
#!/bin/sh
#
# Script Name - Brief description
#
set -eu # Exit on error, undefined variables
# Optional: Allow pipefail if available
set -o pipefail 2>/dev/null || true
Testing for POSIX compliance
# Validate syntax
sh -n script.sh
# Run with POSIX shell
dash script.sh # Debian Almquist Shell
ash script.sh # Alpine Shell
# Check for bashisms
checkbashisms script.sh
shellcheck -s sh script.sh
Variables & Expansion
Variable assignment
var="value" # No spaces around =
readonly CONST="value" # Constants
export PATH="/bin:$PATH" # Environment vars
Never:
var = "value" # ❌ Syntax error
let var=5 # ❌ Bashism
Variable expansion
"$var" # Always quote
"${var}" # Explicit braces
"${var:-default}" # Default if unset
"${var:=default}" # Assign default
"${var:?error}" # Error if unset
"${var:+replace}" # Replace if set
Quoting rules
"$var" # ✅ Prevent word splitting
'$var' # ✅ Literal (no expansion)
`command` # ✅ Command substitution
$(command) # ✅ Preferred command sub
$var # ❌ Unquoted (unsafe)
Control Flow
if/then/else
if [ "$x" = "value" ]; then
echo "match"
elif [ "$x" = "other" ]; then
echo "other"
else
echo "default"
fi
case statement
case "$var" in
pattern1)
echo "match 1"
;;
pattern2|pattern3)
echo "match 2 or 3"
;;
*)
echo "default"
;;
esac
while loop
while [ "$count" -lt 10 ]; do
echo "$count"
count=$((count + 1))
done
# Read file line-by-line
while IFS= read -r line; do
echo "$line"
done < file.txt
for loop
# Iterate over words
for item in one two three; do
echo "$item"
done
# Iterate over files
for file in *.txt; do
[ -e "$file" ] || continue # Skip if no match
echo "$file"
done
until loop
count=0
until [ "$count" -ge 10 ]; do
echo "$count"
count=$((count + 1))
done
Test Operators
String tests
| Test | Description |
|---|---|
[ -z "$s" ] |
String is empty |
[ -n "$s" ] |
String not empty |
[ "$a" = "$b" ] |
Strings equal |
[ "$a" != "$b" ] |
Strings not equal |
Always use = not ==
Numeric tests
| Test | Description |
|---|---|
[ "$a" -eq "$b" ] |
Equal |
[ "$a" -ne "$b" ] |
Not equal |
[ "$a" -lt "$b" ] |
Less than |
[ "$a" -le "$b" ] |
Less or equal |
[ "$a" -gt "$b" ] |
Greater than |
[ "$a" -ge "$b" ] |
Greater or equal |
File tests
| Test | Description |
|---|---|
[ -e "$f" ] |
Exists |
[ -f "$f" ] |
Regular file |
[ -d "$f" ] |
Directory |
[ -L "$f" ] |
Symlink |
[ -r "$f" ] |
Readable |
[ -w "$f" ] |
Writable |
[ -x "$f" ] |
Executable |
[ -s "$f" ] |
Non-empty |
[ "$a" -nt "$b" ] |
a newer than b |
[ "$a" -ot "$b" ] |
a older than b |
Logical operators
[ "$a" = "x" ] && [ "$b" = "y" ] # AND
[ "$a" = "x" ] || [ "$b" = "y" ] # OR
! [ "$a" = "x" ] # NOT
# Combine tests
[ "$a" = "x" -a "$b" = "y" ] # AND (deprecated)
[ "$a" = "x" -o "$b" = "y" ] # OR (deprecated)
Use [ ] not [[ ]] (bash-specific)
Functions
Basic functions
my_function() {
echo "arg1: $1"
echo "arg2: $2"
echo "all args: $*"
return 0
}
my_function "hello" "world"
Local variables
# ❌ POSIX sh has NO local keyword
my_function() {
local var="value" # ❌ Bashism
}
# ✅ Use naming conventions
my_function() {
_my_function_var="value" # Prefix with function name
}
Return values
get_value() {
echo "result" # Return via stdout
}
result=$(get_value)
echo "$result"
# Or use exit codes
check_status() {
[ -f "$1" ] && return 0 || return 1
}
if check_status "file.txt"; then
echo "exists"
fi
String Operations
Parameter expansion
"${var#pattern}" # Remove shortest match from start
"${var##pattern}" # Remove longest match from start
"${var%pattern}" # Remove shortest match from end
"${var%%pattern}" # Remove longest match from end
# Examples
file="path/to/file.txt"
"${file##*/}" # file.txt (basename)
"${file%/*}" # path/to (dirname)
"${file%.txt}" # path/to/file (strip extension)
"${file##*.}" # txt (extension only)
String length
var="hello"
echo "${#var}" # 5
Substring extraction
# ❌ POSIX sh has NO substring syntax
"${var:0:5}" # ❌ Bashism
# ✅ Use cut/sed instead
echo "$var" | cut -c 1-5
String replacement
# ❌ POSIX sh has NO substitution syntax
"${var/old/new}" # ❌ Bashism
"${var//old/new}" # ❌ Bashism
# ✅ Use sed instead
echo "$var" | sed 's/old/new/'
echo "$var" | sed 's/old/new/g'
File Operations
Reading files
# Read entire file
content=$(cat file.txt)
# Read line-by-line (preferred)
while IFS= read -r line; do
echo "$line"
done < file.txt
# Read with custom delimiter
while IFS=: read -r user pass uid gid; do
echo "User: $user"
done < /etc/passwd
Writing files
# Overwrite
echo "content" > file.txt
printf "content\n" > file.txt
# Append
echo "more" >> file.txt
# Heredoc
cat > file.txt << 'EOF'
Line 1
Line 2
EOF
File manipulation
# Create directory
mkdir -p path/to/dir
# Copy files
cp source dest
cp -r source_dir/ dest_dir/
# Move/rename
mv old new
# Remove files
rm file.txt
rm -rf directory/
# Create temp file
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT
Process Management
Running commands
# Run command
command arg1 arg2
# Run in background
command &
# Wait for background jobs
wait
# Run with different exit code
command || true
# Chain commands
command1 && command2 # Run if success
command1 || command2 # Run if failure
command1; command2 # Run always
Command substitution
result=$(command) # ✅ Preferred
result=`command` # ✅ Alternate syntax
# Nested (prefer $())
result=$(echo "$(date)")
Exit codes
# Check last exit code
echo $?
# Set exit code
exit 0 # Success
exit 1 # Failure
# Use exit codes
if command; then
echo "success"
else
echo "failed"
fi
Signal handling
# Trap signals
trap 'cleanup' EXIT
trap 'echo "interrupted"' INT TERM
cleanup() {
rm -f "$tmpfile"
echo "cleaned up"
}
Arithmetic
POSIX arithmetic
# ✅ POSIX: $(( ))
result=$((1 + 2))
count=$((count + 1))
result=$((x * y / z))
# Operators: + - * / % ( )
# Comparisons: < > <= >= == !=
# ❌ Bashisms to AVOID
let count++ # ❌
((count++)) # ❌
result=$[1 + 2] # ❌ Deprecated
Increment/decrement
# ✅ POSIX way
count=$((count + 1))
count=$((count - 1))
# ❌ Bashisms
let count++ # ❌
((count++)) # ❌
count+=1 # ❌
Common Bashisms to AVOID
Comparison table
| ❌ Bashism | ✅ POSIX sh | Notes |
|---|---|---|
[[ ]] |
[ ] |
Always use single brackets |
== |
= |
String equality |
=~ |
case or grep |
Regex matching |
let x++ |
x=$((x + 1)) |
Arithmetic |
((x++)) |
x=$((x + 1)) |
Arithmetic |
${var:0:5} |
cut/sed |
Substring |
${var//x/y} |
sed |
Substitution |
local |
Naming convention | No local vars |
echo -e |
printf |
Escape sequences |
echo -n |
printf |
No newline |
source |
. |
Source files |
$'string' |
"string" |
ANSI quoting |
function f() |
f() |
Function syntax |
{1..10} |
seq 1 10 |
Brace expansion |
**/*.txt |
find |
Globstar |
read -a |
Multiple reads | Arrays |
${!var} |
eval |
Indirect expansion |
[[]] vs [ ]
# ❌ Bash-specific
if [[ "$x" == "value" ]]; then
echo "match"
fi
# ✅ POSIX
if [ "$x" = "value" ]; then
echo "match"
fi
Arrays
# ❌ Bash arrays
arr=(one two three) # ❌
echo "${arr[0]}" # ❌
# ✅ POSIX: Use positional parameters
set -- one two three
echo "$1" # one
echo "$@" # all items
# Or use a string with delimiters
items="one:two:three"
echo vs printf
# ❌ echo is NOT portable
echo -n "no newline" # ❌ Not POSIX
echo -e "tab\there" # ❌ Not POSIX
# ✅ printf is portable
printf "no newline"
printf "tab\there\n"
printf "%s\n" "$var"
source vs .
# ❌ Bashism
source script.sh # ❌
# ✅ POSIX
. script.sh # ✅
function keyword
# ❌ Bashism
function myfunc() { # ❌
echo "hello"
}
# ✅ POSIX
myfunc() { # ✅
echo "hello"
}
Build Script Patterns
Standard build script
#!/bin/sh
set -eu
# Configuration
BUILD_DIR="build"
SRC_DIR="src"
# Clean
clean() {
rm -rf "$BUILD_DIR"
}
# Build
build() {
mkdir -p "$BUILD_DIR"
# Build commands here
}
# Test
test() {
build
# Test commands here
}
# Main
case "${1:-build}" in
clean)
clean
;;
build)
build
;;
test)
test
;;
*)
echo "Usage: $0 {clean|build|test}"
exit 1
;;
esac
Error handling
#!/bin/sh
set -eu
# Fail function
fail() {
printf "Error: %s\n" "$1" >&2
exit 1
}
# Check prerequisites
command -v make >/dev/null 2>&1 || fail "make not found"
# Validate input
[ $# -eq 1 ] || fail "Usage: $0 <arg>"
[ -f "$1" ] || fail "File not found: $1"
Detecting features
# Check if command exists
if command -v git >/dev/null 2>&1; then
echo "git available"
fi
# Check shell features
if (set -o pipefail 2>/dev/null); then
set -o pipefail
fi
# Platform detection
case "$(uname -s)" in
Linux*) OS=linux ;;
Darwin*) OS=macos ;;
*) OS=unknown ;;
esac
Parallel execution
# Run jobs in background
job1 &
job2 &
job3 &
# Wait for all
wait
# Or wait individually
job1 &
pid1=$!
job2 &
pid2=$!
wait "$pid1" || fail "job1 failed"
wait "$pid2" || fail "job2 failed"
Configuration files
# Load config if exists
if [ -f .buildrc ]; then
. .buildrc
fi
# Set defaults
BUILD_TYPE="${BUILD_TYPE:-release}"
VERBOSE="${VERBOSE:-0}"
# Use configuration
if [ "$VERBOSE" -eq 1 ]; then
set -x # Enable debug output
fi
Portability Tips
Always quote
# ✅ Always quote variables
[ -f "$file" ]
echo "$var"
for item in "$@"; do
# ❌ Unquoted (word splitting)
[ -f $file ] # ❌
echo $var # ❌
Use command not which
# ✅ POSIX
command -v git >/dev/null 2>&1
# ❌ Not portable
which git # ❌
Prefer printf
# ✅ Portable
printf "%s\n" "$var"
printf "Line 1\nLine 2\n"
# ❌ Not portable
echo -e "Line 1\nLine 2" # ❌
echo -n "no newline" # ❌
Avoid external commands
# ✅ Built-in parameter expansion
basename="${file##*/}"
dirname="${file%/*}"
# ❌ External commands (slower)
basename=$(basename "$file") # Slower
dirname=$(dirname "$file") # Slower
Use || true carefully
# Allow command to fail
set -e
command || true # Won't exit
# Better: Check explicitly
if command; then
echo "success"
else
echo "failed (expected)"
fi
Common Gotchas
Word splitting
# ❌ Unquoted expands
var="a b c"
[ $var = "a b c" ] # ❌ Error: too many args
# ✅ Quoted preserves
[ "$var" = "a b c" ] # ✅ Works
Empty variables
# ❌ Fails if var unset (with set -u)
echo $var # ❌
# ✅ Use defaults
echo "${var:-}" # Empty string if unset
echo "${var:-default}" # Default if unset
Glob expansion
# ❌ Expands to files
for item in *.txt; do
# Runs once with "*.txt" if no match!
done
# ✅ Check if file exists
for item in *.txt; do
[ -e "$item" ] || continue
echo "$item"
done
Command substitution
# ❌ Loses exit code
result=$(command)
echo $? # ❌ Always 0
# ✅ Check before assignment
command > tmpfile || fail "command failed"
result=$(cat tmpfile)
Test operators
# ❌ Wrong operator
[ "$x" == "y" ] # ❌ Use = not ==
[ "$x" -eq "y" ] # ❌ -eq is numeric only
# ✅ Correct
[ "$x" = "y" ] # ✅ String equality
[ "$x" -eq "$y" ] # ✅ Numeric (no quotes needed)
Debugging
Debug mode
# Enable debug output
set -x
# Disable debug output
set +x
# Debug from start
#!/bin/sh -x
Verbose mode
# Show commands as executed
set -v
# Disable verbose
set +v
Check syntax
# Validate without running
sh -n script.sh
# With shellcheck
shellcheck -s sh script.sh
# With checkbashisms
checkbashisms script.sh
Trace execution
# Show all commands
PS4='+ ${LINENO}: '
set -x
# Example output:
# + 42: echo "hello"
Also see
- POSIX Shell Specification (pubs.opengroup.org)
- Dash Shell (gondor.apana.org.au) - POSIX-compliant shell for testing
- ShellCheck (shellcheck.net) - Shell script linter
- checkbashisms (debian.org) - Detect bashisms in scripts
- Rich's sh Tricks (etalabs.net) - POSIX shell idioms