
Shell
- 590 installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
shell is a Claude skill that enforces safe, portable bash and sh scripting practices for developers who write CI/CD pipelines, Dockerfiles, Makefiles, and automation scripts.
About
shell is a Claude skill from dot-skills that encodes comprehensive bash and POSIX shell best practices for AI agents and human reviewers. It organizes 49 rules across 9 categories, prioritized from critical safety and portability concerns down to incremental style guidance, with ShellCheck-aligned guidance on quoting, `set -euo pipefail`, variables, and error handling. Developers reach for shell when writing or refactoring scripts in GitHub Actions, GitLab CI, Dockerfile RUN commands, Makefile targets, cron jobs, or systemd unit ExecStart lines. Each rule includes explanations and real-world examples so agents produce maintainable scripts instead of fragile one-liners. The skill triggers on bash, sh, POSIX, ShellCheck, and pipeline automation keywords.
- 49 rules across 9 priority categories from critical safety to style
- 6 safety & security rules, 5 portability rules, 8 error handling rules
- Real-world before/after code examples with impact metrics for every rule
- Triggers on bash, sh, POSIX, ShellCheck, set -euo pipefail patterns
- Hard-gate review for any script before merging into CI or production
Shell by the numbers
- 590 all-time installs (skills.sh)
- Ranked #385 of 2,725 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill shellAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 590 |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you write safe portable bash for CI pipelines?
Generate safe, portable, and maintainable shell scripts for CI/CD, Dockerfiles, Makefiles, and automation tasks.
Who is it for?
DevOps and backend developers authoring bash for GitHub Actions, Dockerfiles, Makefiles, cron, or systemd who want 49 prioritized shell rules.
Skip if: Python or Node automation tasks where a typed scripting language is the better default.
When should I use this skill?
The user writes, reviews, or refactors bash, sh, Dockerfile RUN commands, Makefile recipes, or CI shell steps.
What you get
ShellCheck-aligned bash scripts, Dockerfile RUN commands, Makefile recipes, and CI pipeline scripts with proper error handling.
- portable bash scripts
- ShellCheck-clean CI steps
By the numbers
- Includes 49 shell scripting rules across 9 categories
Files
Shell Scripts Best Practices (Community)
Comprehensive best practices guide for shell scripting, designed for AI agents and LLMs. Contains 49 rules across 9 categories, prioritized by impact from critical (safety, portability) to incremental (style). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics.
When to Apply
Reference these guidelines when:
- Writing new bash or POSIX shell scripts
- Reviewing shell scripts for security vulnerabilities
- Debugging scripts that fail silently or behave unexpectedly
- Porting scripts between Linux, macOS, and containers
- Optimizing shell script performance
- Setting up CI/CD pipelines with shell scripts
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Safety & Security | CRITICAL | safety- | 6 |
| 2 | Portability | CRITICAL | port- | 5 |
| 3 | Error Handling | HIGH | err- | 8 |
| 4 | Variables & Data | HIGH | var- | 5 |
| 5 | Quoting & Expansion | MEDIUM-HIGH | quote- | 6 |
| 6 | Functions & Structure | MEDIUM | func- | 5 |
| 7 | Testing & Conditionals | MEDIUM | test- | 5 |
| 8 | Performance | LOW-MEDIUM | perf- | 6 |
| 9 | Style & Formatting | LOW | style- | 3 |
Quick Reference
1. Safety & Security (CRITICAL)
- `safety-command-injection` - Prevent command injection from user input
- `safety-eval-avoidance` - Avoid eval for dynamic commands
- `safety-absolute-paths` - Use absolute paths for external commands
- `safety-temp-files` - Create secure temporary files
- `safety-suid-forbidden` - Never use SUID/SGID on shell scripts
- `safety-argument-injection` - Prevent argument injection with double dash
2. Portability (CRITICAL)
- `port-shebang-selection` - Choose shebang based on portability needs
- `port-avoid-bashisms` - Avoid bashisms in POSIX scripts
- `port-printf-over-echo` - Use printf instead of echo for portability
- `port-export-syntax` - Use portable export syntax
- `port-test-portability` - Use portable test constructs
3. Error Handling (HIGH)
- `err-strict-mode` - Use strict mode for error detection
- `err-exit-codes` - Use meaningful exit codes
- `err-trap-cleanup` - Use trap for cleanup on exit
- `err-stderr-messages` - Send error messages to stderr
- `err-pipefail` - Use pipefail to catch pipeline errors
- `err-check-commands` - Check command success explicitly
- `err-shellcheck` - Use ShellCheck for static analysis
- `err-debug-tracing` - Use debug tracing with set -x and PS4
4. Variables & Data (HIGH)
- `var-use-arrays` - Use arrays for lists instead of strings
- `var-local-scope` - Use local for function variables
- `var-naming-conventions` - Follow variable naming conventions
- `var-readonly-constants` - Use readonly for constants
- `var-default-values` - Use parameter expansion for defaults
5. Quoting & Expansion (MEDIUM-HIGH)
- `quote-always-quote-variables` - Always quote variable expansions
- `quote-dollar-at` - Use "$@" for argument passing
- `quote-command-substitution` - Quote command substitutions
- `quote-brace-expansion` - Use braces for variable clarity
- `quote-here-documents` - Use here documents for multi-line strings
- `quote-glob-safety` - Control glob expansion explicitly
6. Functions & Structure (MEDIUM)
- `func-main-pattern` - Use main() function pattern
- `func-single-purpose` - Write single-purpose functions
- `func-return-values` - Use return values correctly
- `func-documentation` - Document functions with header comments
- `func-avoid-aliases` - Prefer functions over aliases
7. Testing & Conditionals (MEDIUM)
- `test-double-brackets` - Use [[ ]] for tests in bash
- `test-arithmetic` - Use (( )) for arithmetic comparisons
- `test-explicit-empty` - Use explicit empty/non-empty string tests
- `test-file-operators` - Use correct file test operators
- `test-case-patterns` - Use case for pattern matching
8. Performance (LOW-MEDIUM)
- `perf-builtins-over-external` - Use builtins over external commands
- `perf-avoid-subshells` - Avoid unnecessary subshells
- `perf-process-substitution` - Use process substitution for temp files
- `perf-read-files` - Read files efficiently
- `perf-parameter-expansion` - Use parameter expansion for string operations
- `perf-batch-operations` - Batch operations instead of loops
9. Style & Formatting (LOW)
- `style-indentation` - Use consistent indentation
- `style-file-structure` - Follow consistent file structure
- `style-comments` - Write useful comments
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| AGENTS.md | Complete compiled guide with all rules |
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Key Sources
Shell Scripts (Bash/POSIX)
Version 1.1.0 Community January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring Shell Scripts (Bash/POSIX) codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive best practices guide for shell scripting (bash and POSIX sh), designed for AI agents and LLMs. Contains 48 rules across 9 categories, prioritized by impact from critical (safety & security, portability) to incremental (style & formatting). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Safety & Security — CRITICAL
- 1.1 Avoid eval for Dynamic Commands — CRITICAL (eliminates code injection vector)
- 1.2 Create Secure Temporary Files — CRITICAL (prevents symlink attacks and race conditions)
- 1.3 Never Use SUID/SGID on Shell Scripts — CRITICAL (prevents privilege escalation vulnerabilities)
- 1.4 Prevent Argument Injection with Double Dash — CRITICAL (prevents options interpreted as filenames)
- 1.5 Prevent Command Injection from User Input — CRITICAL (prevents arbitrary code execution)
- 1.6 Use Absolute Paths for External Commands — CRITICAL (prevents PATH hijacking attacks)
2. Portability — CRITICAL
- 2.1 Avoid Bashisms in POSIX Scripts — CRITICAL (prevents failures on dash/ash/busybox systems)
- 2.2 Choose Shebang Based on Portability Needs — CRITICAL (determines script compatibility across systems)
- 2.3 Use Portable Export Syntax — CRITICAL (prevents failures on strict POSIX shells)
- 2.4 Use Portable Test Constructs — CRITICAL (prevents silent logic failures across shells)
- 2.5 Use printf Instead of echo for Portability — CRITICAL (ensures consistent output across all systems)
3. Error Handling — HIGH
- 3.1 Check Command Success Explicitly — HIGH (prevents cascading failures from silent errors)
- 3.2 Send Error Messages to stderr — HIGH (enables proper output piping and filtering)
- 3.3 Use Meaningful Exit Codes — HIGH (enables proper error handling by callers)
- 3.4 Use pipefail to Catch Pipeline Errors — HIGH (detects failures hidden in pipeline stages)
- 3.5 Use Strict Mode for Error Detection — HIGH (catches 90% of common script failures)
- 3.6 Use trap for Cleanup on Exit — HIGH (prevents resource leaks and orphaned processes)
4. Variables & Data — HIGH
- 4.1 Follow Variable Naming Conventions — HIGH (prevents collisions with environment and builtins)
- 4.2 Use Arrays for Lists Instead of Strings — HIGH (prevents word splitting bugs in argument handling)
- 4.3 Use local for Function Variables — HIGH (prevents namespace pollution and hidden bugs)
- 4.4 Use Parameter Expansion for Defaults — HIGH (handles unset variables safely without conditionals)
- 4.5 Use readonly for Constants — HIGH (prevents accidental modification of configuration values)
5. Quoting & Expansion — MEDIUM-HIGH
- 5.1 Always Quote Variable Expansions — MEDIUM-HIGH (prevents word splitting and glob expansion bugs)
- 5.2 Control Glob Expansion Explicitly — MEDIUM-HIGH (prevents unintended file matching in commands)
- 5.3 Quote Command Substitutions — MEDIUM-HIGH (prevents word splitting of command output)
- 5.4 Use "$@" for Argument Passing — MEDIUM-HIGH (preserves arguments with spaces correctly)
- 5.5 Use Braces for Variable Clarity — MEDIUM-HIGH (prevents ambiguous variable boundaries)
- 5.6 Use Here Documents for Multi-line Strings — MEDIUM-HIGH (avoids quoting complexity in long strings)
6. Functions & Structure — MEDIUM
- 6.1 Document Functions with Header Comments — MEDIUM (enables maintenance and API understanding)
- 6.2 Prefer Functions Over Aliases — MEDIUM (enables arguments and proper scoping)
- 6.3 Use main() Function Pattern — MEDIUM (enables testing and prevents execution on source)
- 6.4 Use Return Values Correctly — MEDIUM (enables proper error propagation and testing)
- 6.5 Write Single-Purpose Functions — MEDIUM (improves testability and reusability)
7. Testing & Conditionals — MEDIUM
- 7.1 Use (( )) for Arithmetic Comparisons — MEDIUM (provides clearer syntax and prevents string comparison bugs)
- 7.2 [Use [[ ]] for Tests in Bash](references/test-double-brackets.md) — MEDIUM (prevents word splitting and enables regex)
- 7.3 Use case for Pattern Matching — MEDIUM (cleaner than chained if/elif for multiple patterns)
- 7.4 Use Correct File Test Operators — MEDIUM (prevents logic errors with symlinks and special files)
- 7.5 Use Explicit Empty/Non-empty String Tests — MEDIUM (prevents misreads and silent failures)
8. Performance — LOW-MEDIUM
- 8.1 Avoid Unnecessary Subshells — LOW-MEDIUM (reduces fork overhead and variable scope issues)
- 8.2 Batch Operations Instead of Loops — LOW-MEDIUM (single command vs N process spawns)
- 8.3 Read Files Efficiently — LOW-MEDIUM (prevents O(n) line reads and subshell overhead)
- 8.4 Use Builtins Over External Commands — LOW-MEDIUM (10-100× faster by avoiding fork/exec overhead)
- 8.5 Use Parameter Expansion for String Operations — LOW-MEDIUM (avoids external commands for common transformations)
- 8.6 Use Process Substitution for Temp Files — LOW-MEDIUM (eliminates file I/O and cleanup overhead)
9. Style & Formatting — LOW
- 9.1 Follow Consistent File Structure — LOW (enables quick navigation and maintenance)
- 9.2 Use Consistent Indentation — LOW (improves readability and maintenance)
- 9.3 Use ShellCheck for Static Analysis — LOW (catches bugs before runtime)
- 9.4 Write Useful Comments — LOW (explains why, not what)
---
References
1. https://google.github.io/styleguide/shellguide.html 2. https://www.shellcheck.net/ 3. https://mywiki.wooledge.org/ 4. https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html 5. http://www.etalabs.net/sh_tricks.html 6. https://developer.apple.com/library/archive/documentation/OpenSource/Conceptual/ShellScripting/ShellScriptSecurity/ShellScriptSecurity.html
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on the performance, security, or reliability implications.
Incorrect (description of what's wrong):
#!/bin/bash
# Bad code example - production-realistic, not strawman
# Comment explaining the specific problem
bad_exampleCorrect (description of what's right):
#!/bin/bash
# Good code example - minimal diff from incorrect
# Comment explaining the benefit
good_exampleAlternative (when applicable):
#!/bin/bash
# Alternative approach for different contexts
alternative_exampleWhen NOT to use this pattern:
- Exception case 1
- Exception case 2
Benefits:
- Specific benefit 1
- Specific benefit 2
Reference: Reference Title
{
"version": "1.0.6",
"organization": "Community",
"technology": "Shell Scripts (Bash/POSIX)",
"category": "scripting",
"date": "January 2026",
"abstract": "Comprehensive best practices guide for shell scripting (bash and POSIX sh), designed for AI agents and LLMs. Contains 48 rules across 9 categories, prioritized by impact from critical (safety & security, portability) to incremental (style & formatting). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://google.github.io/styleguide/shellguide.html",
"https://www.shellcheck.net/",
"https://mywiki.wooledge.org/",
"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html",
"http://www.etalabs.net/sh_tricks.html",
"https://developer.apple.com/library/archive/documentation/OpenSource/Conceptual/ShellScripting/ShellScriptSecurity/ShellScriptSecurity.html"
]
}
Shell Scripts Best Practices Skill
A comprehensive best practices guide for shell scripting (bash and POSIX sh), designed for AI agents and LLMs.
Overview
This skill contains 48 rules across 9 categories, prioritized by impact:
| Impact | Categories |
|---|---|
| CRITICAL | Safety & Security, Portability |
| HIGH | Error Handling, Variables & Data |
| MEDIUM-HIGH | Quoting & Expansion |
| MEDIUM | Functions & Structure, Testing & Conditionals |
| LOW-MEDIUM | Performance |
| LOW | Style & Formatting |
Getting Started
# Install dependencies
pnpm install
# Build AGENTS.md from rule files
pnpm build
# Validate the skill
pnpm validateUsage
For AI Agents
Point your agent to SKILL.md for a quick reference, or AGENTS.md for the complete compiled guide with all rules inline.
For Humans
Browse the references/ directory for individual rules with detailed examples.
Files
| File | Purpose |
|---|---|
SKILL.md | Entry point with quick reference |
AGENTS.md | Complete compiled guide |
metadata.json | Version and references |
references/_sections.md | Category definitions |
references/*.md | Individual rules |
assets/templates/_template.md | Template for new rules |
Creating a New Rule
1. Copy the template file 2. Fill in the frontmatter and content 3. Place in the references/ directory 4. Rebuild AGENTS.md
Rule File Structure
Each rule file follows this structure:
---
title: Rule Title
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified benefit (e.g., "2-10× improvement")
tags: category-prefix, technique, related-concepts
---
## Rule Title
Brief explanation of WHY this matters.
**Incorrect (what's wrong):**
\`\`\`bash
# Bad example
\`\`\`
**Correct (what's right):**
\`\`\`bash
# Good example
\`\`\`
Reference: [Source](url)File Naming Convention
Rule files follow the pattern: {category-prefix}-{descriptive-slug}.md
Examples:
safety-command-injection.mderr-strict-mode.mdperf-builtins-over-external.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Security vulnerabilities or failures across environments |
| HIGH | Cascading errors or data corruption |
| MEDIUM-HIGH | Common bugs affecting multiple operations |
| MEDIUM | Maintenance burden or subtle logic errors |
| LOW-MEDIUM | Performance issues that multiply in loops |
| LOW | Style and readability improvements |
Scripts
| Script | Description |
|---|---|
build-agents-md.js | Compiles rule files into AGENTS.md |
validate-skill.js | Validates skill against quality checklist |
Contributing
To contribute a new rule:
1. Copy assets/templates/_template.md to references/{prefix}-{slug}.md 2. Fill in the frontmatter: title, impact, impactDescription, tags 3. Write the rule with Incorrect and Correct code examples 4. Run pnpm validate to check for issues 5. Run pnpm build to regenerate AGENTS.md 6. Submit a pull request
Key Sources
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Safety & Security (safety)
Impact: CRITICAL Description: Command injection, path security, and privilege issues can compromise entire systems. Security flaws in shell scripts propagate to every process they spawn.
2. Portability (port)
Impact: CRITICAL Description: Non-portable scripts fail silently across different environments. POSIX compliance ensures scripts work on Linux, macOS, BSD, and minimal containers.
3. Error Handling (err)
Impact: HIGH Description: Unhandled errors cascade into data corruption and silent failures. Proper exit codes, strict mode, traps, and static analysis prevent downstream damage.
4. Variables & Data (var)
Impact: HIGH Description: Variable bugs propagate to all downstream commands. Proper arrays, scoping, and naming prevent data corruption and namespace pollution.
5. Quoting & Expansion (quote)
Impact: MEDIUM-HIGH Description: Unquoted variables cause word splitting and glob expansion in every command using them. Quoting errors are the most common source of shell script bugs.
6. Functions & Structure (func)
Impact: MEDIUM Description: Poor structure compounds maintenance cost and makes scripts harder to test. Well-designed functions enable reuse and isolation.
7. Testing & Conditionals (test)
Impact: MEDIUM Description: Wrong test syntax causes subtle logic bugs. Using the correct test constructs prevents unexpected behavior with special characters and edge cases.
8. Performance (perf)
Impact: LOW-MEDIUM Description: Fork overhead from external commands multiplies in loops. Using builtins and avoiding subshells improves script execution speed significantly.
9. Style & Formatting (style)
Impact: LOW Description: Consistent style aids readability and maintenance. Following established conventions like Google Shell Style Guide enables team collaboration.
Check Command Success Explicitly
Even with set -e, some failures don't trigger exits (in conditions, pipes, subshells). Critical operations need explicit checking to prevent cascading failures.
Incorrect (assuming success):
#!/bin/bash
# These don't trigger errexit:
cd "$dir" && process_files # cd failure only skips process_files
result=$(failing_command) # With 'local', exit status is masked
if grep -q "pattern" file; then # grep failure is expected here
# Dangerous assumptions
cd /important/directory
rm -rf * # Runs in WRONG directory if cd failed!Correct (explicit error handling):
#!/bin/bash
set -euo pipefail
# Check cd explicitly
if ! cd "$dir"; then
echo "Error: Cannot change to directory: $dir" >&2
exit 1
fi
# Or use subshell to contain cd
(
cd "$dir" || exit 1
rm -rf ./*
)
# Check critical commands with ||
mv "$src" "$dst" || {
echo "Error: Failed to move $src to $dst" >&2
exit 1
}Handle command substitution properly:
#!/bin/bash
set -euo pipefail
# WRONG: local masks exit status
process() {
local result=$(failing_command) # Always succeeds!
}
# CORRECT: Separate declaration and assignment
process() {
local result
result=$(failing_command) # Exit status preserved
}
# CORRECT: Check explicitly
process() {
local result
if ! result=$(failing_command); then
echo "Command failed" >&2
return 1
fi
echo "$result"
}Pattern for retrying failures:
#!/bin/bash
retry() {
local max_attempts=$1
local delay=$2
shift 2
local attempt=1
until "$@"; do
if ((attempt >= max_attempts)); then
echo "Failed after $attempt attempts" >&2
return 1
fi
echo "Attempt $attempt failed. Retrying in ${delay}s..." >&2
sleep "$delay"
((attempt++))
done
}
# Usage
retry 3 5 curl -f http://example.com/apiReference: ShellCheck SC2155
Use Debug Tracing with set -x and PS4
Without tracing, debugging shell scripts means adding temporary echo statements. set -x prints every command before execution, and a custom PS4 adds file name and line numbers automatically.
Incorrect (echo-based debugging):
#!/bin/bash
deploy_service() {
echo "DEBUG: Starting deploy"
local image_tag
image_tag=$(get_latest_tag "$service_name")
echo "DEBUG: Got tag $image_tag"
docker pull "registry.example.com/${service_name}:${image_tag}"
echo "DEBUG: Pull done, status=$?"
# Must remove all echo lines before committing
}Correct (set -x with custom PS4):
#!/bin/bash
# PS4 shows source file, line number, and function name
export PS4='+ ${BASH_SOURCE[0]}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x
deploy_service() {
local image_tag
image_tag=$(get_latest_tag "$service_name")
docker pull "registry.example.com/${service_name}:${image_tag}"
}
# Output: + deploy.sh:8:deploy_service(): get_latest_tag myapp
# Output: + deploy.sh:9:deploy_service(): docker pull registry.example.com/myapp:v2.3.1Enable tracing selectively:
#!/bin/bash
export PS4='+ ${BASH_SOURCE[0]}:${LINENO}: '
# Trace only the problematic section
deploy_service() {
local image_tag
image_tag=$(get_latest_tag "$service_name")
set -x # Enable tracing
docker pull "registry.example.com/${service_name}:${image_tag}"
docker tag "${service_name}:${image_tag}" "${service_name}:latest"
set +x # Disable tracing
notify_deployment "$image_tag"
}Debug via environment variable:
#!/bin/bash
# Allow callers to enable tracing: DEBUG=1 ./deploy.sh
export PS4='+ ${BASH_SOURCE[0]}:${LINENO}: '
[[ "${DEBUG:-0}" == "1" ]] && set -x
# Script runs normally without DEBUG, traces with DEBUG=1Redirect trace output to file:
#!/bin/bash
# Send trace to file, keep stdout/stderr clean
exec 4>debug_trace.log
BASH_XTRACEFD=4
export PS4='+ $(date +%T) ${BASH_SOURCE[0]}:${LINENO}: '
set -x
# Trace goes to debug_trace.log, not stderr
deploy_service "$@"Reference: Bash Manual - The Set Builtin
Use Meaningful Exit Codes
Exit codes communicate success/failure to calling scripts and tools. Using exit 0 for failures or inconsistent codes breaks automation and makes debugging difficult.
Incorrect (ignoring exit codes):
#!/bin/bash
# Always exits 0, even on failure
process_file "$1"
echo "Done"
# Implicit exit 0
# Generic exit code
if [[ ! -f "$file" ]]; then
echo "File not found"
exit 1 # Same code for all errors
fiCorrect (explicit exit codes):
#!/bin/bash
set -euo pipefail
# Define exit codes at top of script
readonly E_SUCCESS=0
readonly E_ARGS=1
readonly E_FILE_NOT_FOUND=2
readonly E_PERMISSION=3
readonly E_NETWORK=4
main() {
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <file>" >&2
return $E_ARGS
fi
local file="$1"
if [[ ! -f "$file" ]]; then
echo "Error: File not found: $file" >&2
return $E_FILE_NOT_FOUND
fi
if [[ ! -r "$file" ]]; then
echo "Error: Permission denied: $file" >&2
return $E_PERMISSION
fi
process_file "$file"
}
main "$@"
exit $?Standard exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of shell command |
| 126 | Command not executable |
| 127 | Command not found |
| 128+N | Terminated by signal N |
| 130 | Ctrl+C (SIGINT) |
Propagate exit codes:
#!/bin/bash
# Preserve exit code through pipes
command | tee output.log
exit "${PIPESTATUS[0]}"
# Preserve across function calls
result=$(some_function) || exit $?Reference: Advanced Bash Scripting Guide - Exit Codes
Use pipefail to Catch Pipeline Errors
By default, a pipeline's exit status is the exit status of the last command. Failures in earlier commands are silently ignored, leading to corrupt or incomplete data.
Incorrect (pipeline hides errors):
#!/bin/bash
# curl fails but grep succeeds with empty input
# Script reports success!
curl http://invalid-url 2>/dev/null | grep "data" > results.txt
echo "Exit status: $?" # Shows 0 (grep's status)
# Processing corrupt/incomplete data
failing_command | process_data | save_results
# Only save_results status is checkedCorrect (pipefail catches failures):
#!/bin/bash
set -o pipefail
# Now pipeline fails if ANY command fails
curl http://invalid-url 2>/dev/null | grep "data" > results.txt
echo "Exit status: $?" # Shows non-zero (curl's status)Check individual pipeline stages:
#!/bin/bash
# PIPESTATUS array holds exit codes of all pipeline commands
producer | filter | consumer
# Check each stage
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
echo "Producer failed" >&2
fi
if [[ ${PIPESTATUS[1]} -ne 0 ]]; then
echo "Filter failed" >&2
fi
if [[ ${PIPESTATUS[2]} -ne 0 ]]; then
echo "Consumer failed" >&2
fiPIPESTATUS must be read immediately:
#!/bin/bash
producer | consumer
# WRONG: PIPESTATUS is already overwritten
echo "Checking status"
echo "${PIPESTATUS[@]}" # Shows status of echo!
# CORRECT: Capture immediately
producer | consumer
pipe_status=("${PIPESTATUS[@]}") # Save immediately
echo "Producer: ${pipe_status[0]}, Consumer: ${pipe_status[1]}"Alternative without pipefail:
#!/bin/bash
# When you can't use pipefail, use process substitution
# to get the producer's exit status
producer > >(consumer)
producer_status=$?Reference: Greg's Wiki - BashFAQ/105
Use ShellCheck for Static Analysis
ShellCheck catches security vulnerabilities (unquoted variables), error handling bugs (masked exit status), and portability issues (bashisms in POSIX scripts). It enforces rules from nearly every category in this guide. Run it in CI and during development.
Incorrect (no static analysis):
#!/bin/bash
# Script ships with common bugs undetected
echo $unquoted_var # Word splitting bug
local result=$(cmd) # Exit status masked
cd /some/dir # May fail silently
files=`find .` # Deprecated syntaxCorrect (ShellCheck-validated code):
#!/bin/bash
# ShellCheck catches these before they cause problems
echo "$unquoted_var" # SC2086 fixed
local result
result=$(cmd) # SC2155 fixed
cd /some/dir || exit 1 # SC2164 fixed
files=$(find .) # SC2006 fixedRun ShellCheck:
# Basic usage
shellcheck deploy.sh
# Check multiple files
shellcheck scripts/*.sh
# Specify shell dialect
shellcheck --shell=bash deploy.sh
shellcheck --shell=sh install.sh
# Output formats for CI
shellcheck --format=gcc deploy.sh # GCC-style for editors
shellcheck --format=json deploy.sh # Machine-readable
shellcheck --format=checkstyle deploy.sh # CI integration
# Severity filter
shellcheck --severity=warning deploy.sh # Skip style/infoDirective comments:
#!/bin/bash
# Disable specific check for next line
# shellcheck disable=SC2086
echo $intentionally_unquoted # Documented exception
# Disable for entire file (at top)
# shellcheck disable=SC2034,SC2154
# Enable optional checks
# shellcheck enable=require-variable-braces
# Always explain why a check is disabled
# shellcheck disable=SC2029
# Intentional remote expansion: DEPLOY_ENV is expanded on server
ssh "$deploy_host" "echo $DEPLOY_ENV"Common ShellCheck warnings:
#!/bin/bash
# SC2086: Double quote to prevent globbing and word splitting
echo $var # Warning
echo "$var" # OK
# SC2155: Declare and assign separately
local config=$(cmd) # Warning: exit status lost
local config # OK
config=$(cmd) # OK
# SC2164: Use cd ... || exit
cd /deploy/dir # Warning
cd /deploy/dir || exit # OK
# SC2006: Use $() instead of backticks
tag=`git describe` # Warning
tag=$(git describe) # OKCI integration:
# GitHub Actions
name: Lint
on: [push, pull_request]
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
with:
severity: warningPre-commit hook:
#!/bin/bash
# .git/hooks/pre-commit
changed_scripts=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$')
if [[ -n "$changed_scripts" ]]; then
shellcheck $changed_scripts || exit 1
fiReference: ShellCheck Wiki
Send Error Messages to stderr
Error messages sent to stdout mix with program output, breaking pipes and making automation fail. Always separate errors (stderr) from data (stdout).
Incorrect (errors to stdout):
#!/bin/bash
# Error messages go to stdout, breaking pipelines
if [[ ! -f "$file" ]]; then
echo "Error: File not found" # Goes to stdout
fi
# This breaks:
# ./script.sh | process_output
# Error message gets piped to process_output!Correct (errors to stderr):
#!/bin/bash
# Redirect error messages to stderr
if [[ ! -f "$file" ]]; then
echo "Error: File not found" >&2
fi
# Define error function for consistency
err() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
}
warn() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] WARN: $*" >&2
}
# Use throughout script
if [[ ! -d "$output_dir" ]]; then
err "Output directory does not exist: $output_dir"
exit 1
fiComplete logging pattern:
#!/bin/bash
set -euo pipefail
# Log levels
readonly LOG_ERROR=0
readonly LOG_WARN=1
readonly LOG_INFO=2
readonly LOG_DEBUG=3
LOG_LEVEL=${LOG_LEVEL:-$LOG_INFO}
log() {
local level=$1
shift
local msg="$*"
if [[ $level -le $LOG_LEVEL ]]; then
local prefix
case $level in
$LOG_ERROR) prefix="ERROR" ;;
$LOG_WARN) prefix="WARN" ;;
$LOG_INFO) prefix="INFO" ;;
$LOG_DEBUG) prefix="DEBUG" ;;
esac
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $prefix: $msg" >&2
fi
}
# Usage
log $LOG_INFO "Starting process"
log $LOG_ERROR "Failed to connect"Proper output separation:
#!/bin/bash
# stdout: Data output (can be piped)
# stderr: Progress, status, errors (goes to terminal)
process_files() {
for file in "$@"; do
echo "Processing: $file" >&2 # Status to stderr
cat "$file" # Data to stdout
done
}
# Usage: ./script.sh file1 file2 > output.txt
# Status messages appear on terminal, data goes to fileReference: Google Shell Style Guide - STDOUT vs STDERR
Use Strict Mode for Error Detection
Without strict mode, scripts continue after failures, undefined variables expand to empty strings, and pipeline errors are hidden. This causes silent data corruption.
Incorrect (no error checking):
#!/bin/bash
# Script continues after failures silently
cd /nonexistent/directory
rm -rf * # Deletes files in WRONG directory!
# Undefined variable expands to empty
rm -rf "${TEMP_DIR}/"* # If unset: rm -rf /*
# Pipeline failure hidden
curl http://example.com | process_data
# Even if curl fails, process_data runs with empty inputCorrect (strict mode enabled):
#!/bin/bash
set -euo pipefail
# -e (errexit): Exit on any command failure
# -u (nounset): Error on undefined variables
# -o pipefail: Pipeline fails if any command fails
cd /nonexistent/directory # Script exits here
rm -rf * # Never reached
# Undefined variable causes error
rm -rf "${TEMP_DIR}/"* # Error: TEMP_DIR: unbound variable
# Pipeline failure detected
curl http://example.com | process_data # Exits if curl failsHandle intentional failures:
#!/bin/bash
set -euo pipefail
# Method 1: || true for commands that may fail
grep "pattern" file.txt || true
# Method 2: Conditional check
if grep -q "pattern" file.txt; then
echo "Found"
else
echo "Not found"
fi
# Method 3: Temporarily disable errexit
set +e
risky_command
status=$?
set -eAdditional safety options:
#!/bin/bash
set -euo pipefail
shopt -s inherit_errexit # Subshells inherit errexit
shopt -s nullglob # Globs expand to nothing if no matchCaveats — `set -e` does NOT catch everything:
#!/bin/bash
set -euo pipefail
# 1. Disabled inside if/while/until conditions
if some_failing_command; then # No exit — set -e disabled in condition
echo "success"
fi
# 2. Disabled in left side of && / ||
failing_command && echo "ok" # No exit — set -e disabled before &&
# 3. Does NOT propagate into command substitutions (bash < 4.4)
result=$(failing_command) # May not exit without inherit_errexit
# Fix: shopt -s inherit_errexit (bash 4.4+)
# 4. local masks exit status (even with set -e)
my_func() {
local config=$(cat /missing/file) # No exit! local succeeds
}
# Fix: separate declaration and assignment (see port-export-syntax)Always combine set -e with explicit error checks for critical operations. Do not rely on it as the sole safety mechanism.
Reference: Greg's Wiki - BashFAQ/105
Use trap for Cleanup on Exit
Without cleanup traps, scripts leave behind temporary files, running background processes, and held locks when interrupted or on errors.
Incorrect (no cleanup handling):
#!/bin/bash
build_log=$(mktemp)
staging_dir=$(mktemp -d)
# If script is interrupted (Ctrl+C) or fails,
# these files are never cleaned up
process_data > "$build_log"
# ... more operations
rm "$build_log"
rm -rf "$staging_dir" # May never be reachedCorrect (trap-based cleanup):
#!/bin/bash
set -euo pipefail
# Global cleanup variables
BUILD_LOG=""
STAGING_DIR=""
WORKER_PID=""
cleanup() {
local exit_code=$?
# Remove working files
[[ -n "$BUILD_LOG" && -f "$BUILD_LOG" ]] && rm -f "$BUILD_LOG"
[[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]] && rm -rf "$STAGING_DIR"
# Kill background processes
[[ -n "$WORKER_PID" ]] && kill "$WORKER_PID" 2>/dev/null || true
exit "$exit_code"
}
# Register cleanup on EXIT only — EXIT fires on all shell exits
# including signal-induced exits (INT, TERM), avoiding double execution
trap cleanup EXIT
# Now create resources
BUILD_LOG=$(mktemp)
STAGING_DIR=$(mktemp -d)
# Script work here - cleanup runs automatically on exit
process_data > "$BUILD_LOG"Common mistake — trapping EXIT + signals causes double execution:
#!/bin/bash
# WRONG: cleanup runs TWICE on SIGINT/SIGTERM
# (once for signal handler, once for EXIT when shell exits)
trap cleanup EXIT ERR INT TERM
# CORRECT: EXIT alone catches all exit paths including signals
trap cleanup EXITSeparate handlers for signal-specific behavior:
#!/bin/bash
# Use separate handlers only when different behavior is needed per signal
trap 'echo "Interrupted" >&2; exit 130' INT
trap 'echo "Terminated" >&2; exit 143' TERM
trap 'cleanup' EXIT
# ERR trap for debugging (separate concern from cleanup)
trap 'echo "Error on line $LINENO: $BASH_COMMAND" >&2' ERRLock file pattern with trap:
#!/bin/bash
LOCKFILE="/var/run/myapp.lock"
acquire_lock() {
if ! mkdir "$LOCKFILE" 2>/dev/null; then
echo "Another instance is running" >&2
exit 1
fi
trap 'rm -rf "$LOCKFILE"' EXIT
}
acquire_lock
# Script runs exclusivelyReference: Greg's Wiki - BashFAQ/105
Prefer Functions Over Aliases
Aliases are simple text substitution with limitations: no arguments in the middle, no local variables, unpredictable expansion. Functions handle all cases properly.
Incorrect (using aliases):
#!/bin/bash
# Aliases have severe limitations
# Can't use arguments in the middle
alias greet='echo "Hello, $1"'
greet World # Prints "Hello, " then "World" as separate command
# Alias expands unexpectedly
alias rm='rm -i'
# Then: rm -f file.txt
# Becomes: rm -i -f file.txt (not what you expected)
# Can't use local variables or logic
alias random_name='echo "prefix_${RANDOM}"'
# RANDOM evaluated at definition time, not call time!
# Can't have multi-line logic
alias complex='cmd1 && cmd2' # Works but uglyCorrect (use functions):
#!/bin/bash
# Functions solve all alias limitations
# Arguments work naturally
greet() {
echo "Hello, $1"
}
greet World # Prints "Hello, World"
# Logic and local variables
safe_rm() {
local file="$1"
if [[ -f "$file" ]]; then
rm -i "$file"
else
echo "File not found: $file" >&2
return 1
fi
}
# Dynamic values evaluated at call time
random_name() {
echo "prefix_${RANDOM}"
}
random_name # Different each time
# Multi-line logic is clean
backup_and_edit() {
local file="$1"
cp "$file" "${file}.bak"
"${EDITOR:-vim}" "$file"
}When aliases are acceptable:
# Interactive shell shortcuts (in .bashrc, NOT scripts)
alias ll='ls -la'
alias ..='cd ..'
alias grep='grep --color=auto'
# These are OK because:
# - Used interactively, not in scripts
# - Simple substitution is sufficient
# - No arguments needed in middleConverting aliases to functions:
# Alias version (limited)
alias gitlog='git log --oneline -n 10'
# Function version (flexible)
gitlog() {
local count="${1:-10}"
git log --oneline -n "$count"
}
gitlog # Shows 10
gitlog 20 # Shows 20
# Alias with complex options
alias findbig='find . -size +10M -exec ls -lh {} \;'
# Function version (configurable)
findbig() {
local size="${1:-10M}"
local dir="${2:-.}"
find "$dir" -size "+$size" -exec ls -lh {} \;
}
findbig # Default: 10M in current dir
findbig 100M /var # 100M in /varFunctions can call functions:
#!/bin/bash
# Composition that aliases can't do
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}
info() { log "INFO: $*"; }
warn() { log "WARN: $*"; }
error() { log "ERROR: $*"; }
info "Starting process"
warn "Low disk space"
error "Connection failed"Reference: Google Shell Style Guide - Features to Avoid
Document Functions with Header Comments
Complex functions without documentation become unmaintainable. Header comments explain purpose, parameters, and return values for future readers and users.
Incorrect (no documentation):
#!/bin/bash
process_data() {
local f="$1"
local o="$2"
local v="${3:-false}"
# What do these parameters mean?
# What does this return?
# What are the side effects?
}Correct (documented function):
#!/bin/bash
#######################################
# Process input data and generate output.
#
# Reads the input file, applies transformation rules,
# and writes results to output file or stdout.
#
# Globals:
# CONFIG_FILE: Path to configuration (read)
# PROCESSED_COUNT: Incremented for each file (modified)
#
# Arguments:
# $1 - Input file path (required)
# $2 - Output file path (optional, defaults to stdout)
# $3 - Verbose mode: true/false (optional, default: false)
#
# Outputs:
# Writes processed data to stdout or output file.
# Progress messages to stderr if verbose.
#
# Returns:
# 0 - Success
# 1 - Input file not found
# 2 - Permission denied
# 3 - Invalid input format
#######################################
process_data() {
local input_file="$1"
local output_file="${2:-}"
local verbose="${3:-false}"
# Implementation...
}When to document:
#!/bin/bash
# Document these:
# - Library functions (used by other scripts)
# - Complex logic (non-obvious behavior)
# - Public API functions
# - Functions with side effects
# - Functions with multiple parameters
# Skip documentation for:
# - Trivial one-liners that are self-explanatory
# - Private helper functions with obvious names
# Trivial - name is self-documenting
die() {
echo "$*" >&2
exit 1
}
# Non-obvious - needs documentation
#######################################
# Retries a command with exponential backoff.
# Arguments:
# $1 - Max attempts
# $@ - Command to run
# Returns:
# Exit status of command, or 1 if all retries fail
#######################################
retry_with_backoff() {
# ...
}Minimal documentation template:
#!/bin/bash
# Short description of what the function does.
# Arguments: $1 - description, $2 - description
# Returns: 0 on success, 1 on error
function_name() {
# ...
}File header template:
#!/bin/bash
#
# Script name and one-line description.
#
# Longer description of what this script does, when to use it,
# and any important caveats or prerequisites.
#
# Usage: script.sh [options] <required_arg>
#
# Options:
# -h, --help Show help
# -v, --verbose Verbose output
#
# Examples:
# script.sh input.txt
# script.sh -v -o output.txt input.txt
#
# Dependencies:
# - jq (JSON processing)
# - curl (HTTP requests)
#
set -euo pipefailReference: Google Shell Style Guide - Function Comments
Use main() Function Pattern
Code at file level executes when sourced for testing. Wrapping logic in main() with a guard allows both direct execution and sourcing without side effects.
Incorrect (top-level execution):
#!/bin/bash
# All code runs immediately, even when sourced
config_file="/etc/app.conf"
load_config
process_args "$@"
do_work
cleanup
# Cannot source this file for testing without side effects
# source script.sh # Immediately runs everything!Correct (main function with guard):
#!/bin/bash
set -euo pipefail
# Constants and function definitions
readonly CONFIG_FILE="/etc/app.conf"
load_config() {
# ...
}
process() {
local input="$1"
# ...
}
cleanup() {
# ...
}
main() {
local args=("$@")
load_config
process "${args[@]}"
cleanup
}
# Only run main if executed directly (not sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fiTesting sourced functions:
#!/bin/bash
# test_script.sh
# Source the script without running main
source ./my_script.sh
# Now test individual functions
test_process() {
local result
result=$(process "test input")
if [[ "$result" != "expected output" ]]; then
echo "FAIL: process returned '$result'"
return 1
fi
echo "PASS: process"
}
test_processAlternative guards:
#!/bin/bash
# Method 1: BASH_SOURCE comparison (recommended)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
# Method 2: Function existence check
if ! declare -f main > /dev/null; then
main() { :; } # Define no-op for sourcing
fi
# Method 3: Environment variable
if [[ "${SCRIPT_TESTING:-}" != "true" ]]; then
main "$@"
fiScript structure template:
#!/bin/bash
#
# Description of what this script does.
#
set -euo pipefail
# Constants
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
# Global variables with defaults
: "${LOG_LEVEL:=info}"
# Function definitions (alphabetical or logical order)
cleanup() { :; }
parse_args() { :; }
process() { :; }
usage() { :; }
# Main entry point
main() {
trap cleanup EXIT
parse_args "$@"
process
}
# Run if executed directly
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@"Reference: Google Shell Style Guide - main
Use Return Values Correctly
Functions communicate results via exit status (return code) and stdout. Mixing status and output or ignoring return values causes silent failures.
Incorrect (mixed output and status):
#!/bin/bash
# Returns data AND error message on same channel
get_value() {
local file="$1"
if [[ ! -f "$file" ]]; then
echo "Error: File not found" # Goes to stdout!
return 1
fi
cat "$file"
}
# Caller gets error message as data
value=$(get_value missing.txt)
echo "Value: $value" # Prints "Value: Error: File not found"Correct (separate status and output):
#!/bin/bash
# Status via return, errors to stderr, data to stdout
get_value() {
local file="$1"
if [[ ! -f "$file" ]]; then
echo "Error: File not found: $file" >&2 # stderr
return 1 # Non-zero status
fi
cat "$file" # stdout
}
# Caller checks status and captures output
if value=$(get_value missing.txt); then
echo "Value: $value"
else
echo "Failed to get value" >&2
fiReturn values for different scenarios:
#!/bin/bash
# Boolean check - use return directly
is_valid_email() {
local email="$1"
[[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]]
# Return status of [[ ]] implicitly
}
if is_valid_email "$input"; then
echo "Valid"
fi
# Computation - output to stdout
calculate_sum() {
local a="$1"
local b="$2"
echo "$((a + b))"
}
result=$(calculate_sum 5 3)
# Multiple outputs - use arrays or delimiter
get_file_info() {
local file="$1"
local size name
size=$(wc -c < "$file" 2>/dev/null) || return 1
name=$(basename "$file")
echo "$size:$name" # Colon-separated
}
# Read multiple values
IFS=: read -r size name < <(get_file_info "/path/to/file")Preserve exit status:
#!/bin/bash
# Exit status is lost after any command
run_and_log() {
local result
result=$(some_command)
local status=$? # Capture IMMEDIATELY
echo "Result: $result" >> log.txt
return "$status" # Propagate original status
}
# Or use || for error handling
run_safely() {
some_command || {
local status=$?
echo "Failed with status $status" >&2
return "$status"
}
}Avoid return with command output:
#!/bin/bash
# WRONG: return with command output
bad_function() {
return $(some_command) # Word splitting issues!
}
# CORRECT: Store in variable first
good_function() {
local status
some_command
status=$?
return "$status"
}
# CORRECT: Implicit return of last command
simple_function() {
some_command
# Exit status of some_command is returned
}Reference: Google Shell Style Guide - Calling Functions
Write Single-Purpose Functions
Functions that do multiple tasks are hard to test, reuse, and debug. Each function should do one task well and compose with others for complex operations.
Incorrect (multi-purpose function):
#!/bin/bash
# Does too many concerns: parse args, validate, process, output
process_file() {
local file="$1"
local verbose="$2"
local output="$3"
# Validation
if [[ ! -f "$file" ]]; then
echo "Error: File not found" >&2
return 1
fi
# Processing
local result
result=$(grep -c "pattern" "$file")
# Output (mixed concerns)
if [[ "$verbose" == "true" ]]; then
echo "Processing $file..."
fi
if [[ -n "$output" ]]; then
echo "$result" > "$output"
else
echo "$result"
fi
}Correct (focused functions):
#!/bin/bash
# Each function has a single responsibility
validate_file() {
local file="$1"
if [[ ! -f "$file" ]]; then
echo "Error: File not found: $file" >&2
return 1
fi
if [[ ! -r "$file" ]]; then
echo "Error: File not readable: $file" >&2
return 1
fi
}
count_pattern() {
local file="$1"
local pattern="$2"
grep -c "$pattern" "$file"
}
log_verbose() {
local message="$1"
if [[ "${VERBOSE:-false}" == "true" ]]; then
echo "$message" >&2
fi
}
write_output() {
local content="$1"
local output_file="${2:-}"
if [[ -n "$output_file" ]]; then
echo "$content" > "$output_file"
else
echo "$content"
fi
}
# Compose functions
process_file() {
local file="$1"
local pattern="${2:-pattern}"
local output="${3:-}"
validate_file "$file" || return 1
log_verbose "Processing $file..."
local result
result=$(count_pattern "$file" "$pattern")
write_output "$result" "$output"
}Function naming guidelines:
#!/bin/bash
# Use verb_noun format
get_config() # Retrieves configuration
set_option() # Assigns an option value
validate_input() # Checks input constraints
process_file() # Transforms file content
check_status() # Verifies system state
# Boolean functions: use is_, has_, can_, should_
is_valid()
has_permission()
can_write()
should_retry()
# Private/internal functions: prefix with underscore
_parse_internal()
_helper_function()Short functions are better:
#!/bin/bash
# Aim for functions that fit on one screen (~20-30 lines)
# If longer, break into smaller functions
# Too long? Extract helpers:
process_all() {
local -a files
gather_files files
validate_all files
transform_all files
output_results files
}Reference: Google Shell Style Guide - Function Comments
Avoid Unnecessary Subshells
Subshells create new processes with copied state. Pipelines, $(), and () create subshells. Variables modified in subshells are lost when they exit.
Incorrect (unnecessary subshells):
#!/bin/bash
# Pipeline creates subshell - variable lost
cat file.txt | while read -r line; do
((count++))
done
echo "Count: $count" # Still 0! count modified in subshell
# Useless use of cat creates subshell
cat file | grep pattern
# Command substitution for simple output
echo $(pwd)
# Unnecessary ( ) grouping
(cd /var/cache/app && rm -f *.log) # cd affects only subshellCorrect (avoid subshells):
#!/bin/bash
# Process substitution keeps loop in main shell
while read -r line; do
((count++))
done < <(cat file.txt)
echo "Count: $count" # Correct value
# Or redirect directly
while read -r line; do
((count++))
done < file.txt
# Remove useless cat
grep pattern file
# Variable instead of command substitution
echo "$PWD"
# Use { } for grouping without subshell
{
cd /var/cache/app && rm -f *.log
} # cd affects current shell!
# Or be explicit about wanting subshell isolation
(
cd /var/cache/app # Only affects subshell
rm -f *.log
)
# Still in original directory hereWhen subshells are created:
#!/bin/bash
# Pipeline (each command in pipeline)
cmd1 | cmd2 | cmd3 # Three subshells
# Command substitution
var=$(command) # One subshell
# Explicit subshell
( commands ) # One subshell
# Process substitution
<(command) # One subshell
>(command) # One subshell
# Background processes
command & # One subshellPassing data from subshells:
#!/bin/bash
# Problem: Can't use variables from subshell
echo "hello" | read -r var
echo "$var" # Empty!
# Solution 1: Here-string
read -r var <<< "hello"
echo "$var" # "hello"
# Solution 2: Process substitution
read -r var < <(echo "hello")
echo "$var" # "hello"
# Solution 3: File or named pipe
staging_file=$(mktemp)
command > "$staging_file"
read -r var < "$staging_file"
rm "$staging_file"
# Solution 4: Command output to array
mapfile -t lines < <(command)
for line in "${lines[@]}"; do
process "$line"
doneCheck subshell level:
#!/bin/bash
echo "Main: $BASH_SUBSHELL" # 0
(
echo "Subshell: $BASH_SUBSHELL" # 1
(
echo "Nested: $BASH_SUBSHELL" # 2
)
)Intentional subshell uses:
#!/bin/bash
# Isolate cd - don't affect main script
(cd /some/dir && do_work)
# Isolate variable changes
(
export TEMP_VAR="value"
run_with_temp_env
)
# TEMP_VAR doesn't exist here
# Parallel execution
(slow_task1) &
(slow_task2) &
waitReference: Greg's Wiki - SubShell
Batch Operations Instead of Loops
Looping over files and running a command for each spawns N processes. Batch commands like find -exec +, xargs, or glob expansion run one process for many files.
Incorrect (one command per file):
#!/bin/bash
# Spawns grep N times
for file in *.log; do
grep "error" "$file"
done
# Spawns chmod N times
for file in $(find . -name "*.sh"); do
chmod +x "$file"
done
# Spawns rm N times
for file in /var/cache/myapp/*; do
rm "$file"
doneCorrect (batch operations):
#!/bin/bash
# One grep with multiple files
grep "error" *.log
# find -exec with + (batches arguments)
find . -name "*.sh" -exec chmod +x {} +
# rm with glob (one invocation)
rm /var/cache/myapp/*
# xargs for complex batching
find . -name "*.log" -print0 | xargs -0 grep "error"find -exec + vs \;:
#!/bin/bash
# \; runs command once per file (slow)
find . -name "*.txt" -exec grep "pattern" {} \;
# Equivalent to: grep pattern file1; grep pattern file2; ...
# + batches files into single command (fast)
find . -name "*.txt" -exec grep "pattern" {} +
# Equivalent to: grep pattern file1 file2 file3 ...
# Handles ARG_MAX automatically - splits if too manyxargs for complex batching:
#!/bin/bash
# Basic xargs
find . -name "*.log" | xargs rm
# Handle spaces and special chars with -0
find . -name "*.log" -print0 | xargs -0 rm
# Limit batch size
find . -name "*.log" -print0 | xargs -0 -n 100 rm
# Parallel execution
find . -name "*.log" -print0 | xargs -0 -P 4 -n 10 compress
# With placeholder
find . -name "*.txt" -print0 | xargs -0 -I {} cp {} /backup/
# Run if no input (--no-run-if-empty)
find . -name "*.bak" -print0 | xargs -0 --no-run-if-empty rmWhen loops are necessary:
#!/bin/bash
# When you need shell logic per file
for file in *.txt; do
if [[ -s "$file" ]]; then
# Complex shell logic
base="${file%.txt}"
mv "$file" "${base}_$(date +%Y%m%d).txt"
fi
done
# When you need variables from iteration
total=0
for file in *.csv; do
count=$(wc -l < "$file")
((total += count))
done
echo "Total lines: $total"Parallel batch operations:
#!/bin/bash
# GNU parallel for complex parallel batching
find . -name "*.jpg" | parallel convert {} -resize 50% {.}_thumb.jpg
# xargs parallel
find . -name "*.gz" -print0 | xargs -0 -P $(nproc) gunzip
# Background jobs (manual parallelism)
for file in *.dat; do
process "$file" &
done
wait # Wait for all background jobsSafe batch operations:
#!/bin/bash
# Always handle special filenames
# Use -print0 and -0 for null-separated
# Dangerous (spaces, newlines break this)
find . -name "*.txt" | xargs rm
# Safe
find . -name "*.txt" -print0 | xargs -0 rm
# Or use find -exec directly
find . -name "*.txt" -exec rm {} +Reference: GNU Findutils Manual
Use Builtins Over External Commands
Every external command spawns a new process (fork + exec). In loops, this overhead multiplies. Builtins execute in the same process, avoiding this cost.
Incorrect (external commands in loops):
#!/bin/bash
# Each iteration spawns external processes
for i in $(seq 1 1000); do # External: seq
result=$(expr $i + 1) # External: expr
echo "$result" | cat >> output # External: cat
done
# Total: 3000+ process spawns!
# Using external commands for simple operations
if [ "$(echo "$var" | wc -c)" -gt 10 ]; then
echo "long"
fiCorrect (use builtins):
#!/bin/bash
# Builtins - no process spawning
for ((i = 1; i <= 1000; i++)); do # Builtin: (( ))
((result = i + 1)) # Builtin: (( ))
echo "$result" >> output # Builtin: echo, redirection
done
# Total: 0 external processes
# Parameter expansion instead of external commands
if [[ ${#var} -gt 10 ]]; then
echo "long"
fiCommon replacements:
#!/bin/bash
# Arithmetic
# BAD: result=$(expr $a + $b)
# GOOD: ((result = a + b))
# String length
# BAD: len=$(echo "$str" | wc -c)
# GOOD: len=${#str}
# Substring
# BAD: sub=$(echo "$str" | cut -c1-5)
# GOOD: sub=${str:0:5}
# Basename
# BAD: name=$(basename "$path")
# GOOD: name=${path##*/}
# Dirname
# BAD: dir=$(dirname "$path")
# GOOD: dir=${path%/*}
# Search and replace
# BAD: new=$(echo "$str" | sed 's/old/new/g')
# GOOD: new=${str//old/new}
# Upper/lowercase (bash 4+)
# BAD: lower=$(echo "$str" | tr 'A-Z' 'a-z')
# GOOD: lower=${str,,}
# GOOD: upper=${str^^}
# Sequence generation
# BAD: for i in $(seq 1 10); do
# GOOD: for ((i = 1; i <= 10; i++)); do
# Reading files line by line
# BAD: cat file | while read line; do
# GOOD: while read -r line; do ... done < fileCheck if command is builtin:
#!/bin/bash
# type shows if command is builtin
type echo # echo is a shell builtin
type cat # cat is /bin/cat
type [[ # [[ is a shell keyword
# Use help for builtin documentation
help echo
help read
help printfWhen external commands are fine:
#!/bin/bash
# Outside loops - one-time cost is negligible
date=$(date +%Y-%m-%d)
hostname=$(hostname)
# Complex text processing - sed/awk are optimized
# Piping many lines through sed is faster than bash loop
sed 's/old/new/g' large_file.txt
# When builtin doesn't exist or is limited
# sort, uniq, grep for large data setsReference: Greg's Wiki - Builtins
Use Parameter Expansion for String Operations
External commands like basename, dirname, sed, cut spawn processes. Parameter expansion handles most string operations as builtins, eliminating fork overhead.
Incorrect (external commands):
#!/bin/bash
path="/home/user/documents/report.txt"
# External commands for string manipulation
filename=$(basename "$path") # Spawns process
directory=$(dirname "$path") # Spawns process
extension=$(echo "$path" | sed 's/.*\.//') # Two processes
name=$(echo "$filename" | sed 's/\.[^.]*$//') # Two processes
upper=$(echo "$str" | tr 'a-z' 'A-Z') # Two processesCorrect (parameter expansion):
#!/bin/bash
path="/home/user/documents/report.txt"
# Builtins - no external processes
filename=${path##*/} # report.txt
directory=${path%/*} # /home/user/documents
extension=${path##*.} # txt
name=${filename%.*} # report
upper=${str^^} # UPPERCASE (bash 4+)
lower=${str,,} # lowercase (bash 4+)Parameter expansion reference:
#!/bin/bash
var="hello.world.txt"
# Remove prefix (shortest match)
${var#*.} # world.txt
# Remove prefix (longest match)
${var##*.} # txt
# Remove suffix (shortest match)
${var%.*} # hello.world
# Remove suffix (longest match)
${var%%.*} # hello
# Substitution (first occurrence)
${var/world/there} # hello.there.txt
# Substitution (all occurrences)
${var//./-} # hello-world-txt
# Length
${#var} # 15
# Substring
${var:0:5} # hello
${var:6} # world.txt
${var: -3} # txt (note the space before -)
${var:(-3)} # txt (alternative)
# Case conversion (bash 4+)
${var^} # Hello.world.txt (first char upper)
${var^^} # HELLO.WORLD.TXT (all upper)
${var,} # hello.world.txt (first char lower)
${var,,} # hello.world.txt (all lower)Common use cases:
#!/bin/bash
# Extract filename and extension
filepath="/path/to/image.tar.gz"
filename=${filepath##*/} # image.tar.gz
dir=${filepath%/*} # /path/to
base=${filename%%.*} # image
ext=${filename#*.} # tar.gz
# Change extension
newfile=${filepath%.tar.gz}.zip # /path/to/image.zip
# Strip leading/trailing whitespace (bash 4.4+)
trimmed=${string#"${string%%[![:space:]]*}"}
trimmed=${trimmed%"${trimmed##*[![:space:]]}"}
# Or simpler with read
read -r trimmed <<< "$string"
# Add prefix/suffix to all elements
files=(*.txt)
prefixed=("${files[@]/#/backup_}") # backup_file1.txt ...
suffixed=("${files[@]/%/.bak}") # file1.txt.bak ...
# Check prefix/suffix
if [[ "$var" == prefix* ]]; then
echo "Starts with prefix"
fi
if [[ "$var" == *suffix ]]; then
echo "Ends with suffix"
fiDefault values:
#!/bin/bash
# Use default if unset or empty
${var:-default}
# Set default if unset or empty
${var:=default}
# Error if unset or empty
${var:?error message}
# Use alternate if set
${var:+alternate}Reference: Bash Manual - Parameter Expansion
Use Process Substitution for Temp Files
Creating intermediate files for intermediate data requires file I/O and cleanup. Process substitution <() and >() provides files that are actually pipes, avoiding disk overhead.
Incorrect (intermediate files for intermediate data):
#!/bin/bash
# Creating intermediate files is slow and needs cleanup
sorted1=$(mktemp)
sorted2=$(mktemp)
trap 'rm -f "$sorted1" "$sorted2"' EXIT
sort file1.txt > "$sorted1"
sort file2.txt > "$sorted2"
diff "$sorted1" "$sorted2"
# Multiple intermediates with risky predictable names
filtered_first=$(mktemp)
filtered_second=$(mktemp)
trap 'rm -f "$filtered_first" "$filtered_second"' EXIT
grep "pattern1" input > "$filtered_first"
grep "pattern2" "$filtered_first" > "$filtered_second"
wc -l "$filtered_second"Correct (process substitution):
#!/bin/bash
# No intermediate files, no cleanup needed
diff <(sort file1.txt) <(sort file2.txt)
# Chain processing without files
wc -l <(grep "pattern2" <(grep "pattern1" input))
# Or simpler with pipes for linear chains
grep "pattern1" input | grep "pattern2" | wc -lProcess substitution types:
#!/bin/bash
# <(command) - command output as file (reading)
# Compare output of two commands
diff <(ls dir1) <(ls dir2)
# Feed multiple sources to command expecting files
paste <(cut -f1 file1) <(cut -f2 file2)
# >(command) - write to command as file (writing)
# Tee to multiple processors
./generate_data | tee >(grep "error" > errors.log) >(grep "warn" > warns.log)
# Avoid file for single-use data
command > >(process_output)Practical examples:
#!/bin/bash
# Compare sorted versions of files
diff <(sort -u file1.txt) <(sort -u file2.txt)
# Compare remote and local files
diff <(curl -s http://example.com/file) local_file.txt
# Join data from different sources
join <(sort file1.txt) <(sort file2.txt)
# Feed compressed data to commands expecting files
zcat large_file.gz > decompressed_log.txt
wc -l decompressed_log.txt
rm decompressed_log.txt
# Better:
wc -l <(zcat large_file.gz)
# Source from process output
source <(generate_config)
# Compare directory listings with filters
diff <(ls -la dir1 | awk '{print $9, $5}') \
<(ls -la dir2 | awk '{print $9, $5}')Combining with while loops:
#!/bin/bash
# Reading from process - keeps variables in main shell
count=0
while read -r line; do
((count++))
process "$line"
done < <(find . -name "*.txt")
echo "Processed $count files"
# Without process substitution, need intermediate file or lost variablesLimitations:
#!/bin/bash
# Process substitution is bash/ksh/zsh only - not POSIX
# In POSIX sh, use intermediate files or pipes
# Can't seek in process substitution (it's a pipe)
# This won't work:
head -1 <(generate_data)
tail -1 <(generate_data) # Data is gone!
# Instead:
data=$(generate_data)
echo "$data" | head -1
echo "$data" | tail -1Reference: Bash Manual - Process Substitution
Read Files Efficiently
Reading files line-by-line in bash is slow. For large files, use specialized tools (awk, sed, grep). When shell processing is needed, use efficient patterns.
Incorrect (slow patterns):
#!/bin/bash
# Spawns cat + creates subshell
cat file.txt | while read -r line; do
process "$line"
done
# Reading file multiple times
line_count=$(wc -l < file.txt)
first_line=$(head -1 file.txt)
last_line=$(tail -1 file.txt)
# Processing large files in shell loop
while read -r line; do
echo "${line//old/new}"
done < large_file.txt # Extremely slowCorrect (efficient patterns):
#!/bin/bash
# Direct redirection - no cat, no subshell variable loss
while IFS= read -r line; do
process "$line"
done < file.txt
# Read entire file at once for small files
content=$(<file.txt)
# Read into array
mapfile -t lines < file.txt
for line in "${lines[@]}"; do
process "$line"
done
# Use awk/sed for large file transformations
sed 's/old/new/g' large_file.txt > output.txt
awk '{print $1, $3}' large_file.txt > output.txtChoosing the right approach:
#!/bin/bash
# Small files (< 100 lines) - shell is fine
while IFS= read -r line; do
# Shell processing OK
done < small_file.txt
# Medium files (100-10000 lines) - consider awk/sed
# If you need shell variables, mapfile is good
mapfile -t lines < medium_file.txt
# Large files (> 10000 lines) - always use awk/sed/grep
# Shell loops are too slow
grep "pattern" huge_file.txt | head -100
awk '/pattern/ {print $2}' huge_file.txtReading specific parts:
#!/bin/bash
# First N lines
head -n 10 file.txt
# Last N lines
tail -n 10 file.txt
# Lines M to N
sed -n '5,10p' file.txt
# Read first line into variable
IFS= read -r first_line < file.txt
# Read first N lines into array
mapfile -t first_10 -n 10 < file.txtHandling special characters:
#!/bin/bash
# IFS= prevents leading/trailing whitespace trimming
# -r prevents backslash interpretation
while IFS= read -r line; do
echo "[$line]" # Preserves exact content
done < file.txt
# Handle files without final newline
while IFS= read -r line || [[ -n "$line" ]]; do
process "$line"
done < file.txt
# Handle null-separated data (find -print0)
while IFS= read -r -d '' file; do
process "$file"
done < <(find . -name "*.txt" -print0)Processing fields efficiently:
#!/bin/bash
# Parse fields in one read
while IFS=: read -r user _ uid gid _ home shell; do
echo "User $user uses $shell"
done < /etc/passwd
# Process CSV
while IFS=, read -r field1 field2 field3; do
process "$field1" "$field2" "$field3"
done < data.csv
# But for complex CSV, use a real parser
# Bash can't handle quoted fields with embedded commasReference: Greg's Wiki - BashFAQ/001
Avoid Bashisms in POSIX Scripts
Scripts with #!/bin/sh shebang must avoid bash-specific features. On Ubuntu/Debian, /bin/sh is dash; on Alpine/BusyBox, it's ash. Bashisms cause silent failures or syntax errors.
Incorrect (bashisms in /bin/sh script):
#!/bin/sh
# These fail on dash/ash:
# Arrays don't exist
files=(one two three)
# [[ ]] is bash-only
[[ -f "$file" ]]
# source is bash-only
source ./lib.sh
# &> redirection is bash-only
command &> /dev/null
# Process substitution is bash-only
diff <(cmd1) <(cmd2)
# echo -e is not portable
echo -e "line1\nline2"Correct (POSIX-compliant alternatives):
#!/bin/sh
# POSIX equivalents:
# Use positional parameters or newline-separated strings
set -- one two three
for file in "$@"; do echo "$file"; done
# Use [ ] single brackets
[ -f "$file" ]
# Use . (dot) instead of source
. ./lib.sh
# Use explicit redirections
command > /dev/null 2>&1
# Use mktemp instead of process substitution
diff_left=$(mktemp) diff_right=$(mktemp)
trap 'rm -f "$diff_left" "$diff_right"' EXIT
cmd1 > "$diff_left"
cmd2 > "$diff_right"
diff "$diff_left" "$diff_right"
# Use printf instead of echo -e
printf 'line1\nline2\n'Common bashisms to avoid:
| Bashism | POSIX Alternative |
|---|---|
[[ ]] | [ ] with proper quoting |
source | . (dot space) |
&> | > file 2>&1 |
$'...' | printf |
array=() | positional parameters |
${var,,} | tr '[:upper:]' '[:lower:]' |
{1..10} | seq 1 10 |
Reference: ShellCheck SC2039
Separate Local Declaration from Command Substitution
Combining local (or declare) with command substitution masks the exit status. The local builtin always succeeds, overwriting $? from the substituted command. This causes silent failures even with set -e.
Incorrect (combined declaration and assignment):
#!/bin/bash
set -euo pipefail
process_config() {
# local succeeds, masking the failed command's exit status
local config_data=$(cat /nonexistent/config.yaml)
echo "Status: $?" # Always 0 — local succeeded!
echo "$config_data"
}
process_config # No error raised despite missing fileCorrect (separate declaration and assignment):
#!/bin/bash
set -euo pipefail
process_config() {
local config_data
config_data=$(cat /nonexistent/config.yaml) # Exits here with set -e
echo "Status: $?" # Shows actual exit status
echo "$config_data"
}
process_config # Correctly fails if file is missingMultiple variables:
#!/bin/bash
set -euo pipefail
deploy_service() {
# Declare all locals first
local image_tag
local registry_url
local deploy_status
# Then assign — exit status is preserved
image_tag=$(get_latest_tag "$service_name")
registry_url=$(resolve_registry "$environment")
docker pull "${registry_url}/${service_name}:${image_tag}"
}Applies to both bash and POSIX sh:
#!/bin/sh
# POSIX sh has the same issue with local (where supported)
fetch_user() {
local user_json
user_json=$(curl -sf "https://api.example.com/users/$1") || return 1
echo "$user_json"
}Note on export: The export VAR=value combined syntax is valid POSIX (IEEE Std 1003.1-2001). Unlike local, export VAR=$(cmd) does preserve exit status in most shells, but separating them is still clearer and avoids confusion.
Reference: ShellCheck SC2155
Use printf Instead of echo for Portability
echo behavior varies across shells and systems. Some interpret -n, -e flags; others print them literally. printf is standardized and behaves consistently everywhere.
Incorrect (echo with non-portable options):
#!/bin/sh
# Behavior varies by system:
echo -n "Enter name: " # Some shells print "-n "
echo -e "line1\nline2" # Some shells print "-e line1\nline2"
echo "Path: $PATH" # Some echo versions expand backslashes
# Even without flags, echo is problematic:
echo "$var" # If var="-n", output is blankCorrect (printf is consistent):
#!/bin/sh
# printf works the same everywhere:
printf "Enter name: " # No trailing newline
printf "line1\nline2\n" # Escapes always interpreted
printf "Path: %s\n" "$PATH" # Safe with any content
# Safe with arbitrary data:
printf "%s\n" "$var" # Works even if var="-n"
printf "%s" "$var" # No trailing newlineprintf format specifiers:
#!/bin/sh
name="Alice"
count=42
price=19.99
# String interpolation
printf "User: %s\n" "$name"
# Integer formatting
printf "Count: %d items\n" "$count"
# Floating point
printf "Price: %.2f\n" "$price"
# Multiple arguments
printf "%s has %d items\n" "$name" "$count"
# Width and padding
printf "%-10s %5d\n" "$name" "$count"When echo is acceptable:
- Bash-only scripts (
#!/bin/bash) - Simple strings without variables
- No special characters or flags needed
- Performance-critical loops (echo is slightly faster)
Reference: Rich's POSIX sh tricks
Choose Shebang Based on Portability Needs
The shebang line determines which interpreter runs your script. Using #!/bin/sh implies POSIX compliance, while #!/bin/bash enables bash-specific features but reduces portability.
Incorrect (mismatched shebang and features):
#!/bin/sh
# Uses bash-specific features with sh shebang
# Fails on systems where /bin/sh is dash, ash, or busybox
declare -a array=(one two three) # bash-only
[[ $var == pattern ]] # bash-only
echo "value is $((x + 1))" # mostly portable
source ./config.sh # bash-only (use . instead)Correct (match shebang to features used):
#!/bin/bash
# Bash script - use bash-specific features freely
set -euo pipefail
declare -a files=()
while IFS= read -r -d '' file; do
files+=("$file")
done < <(find . -type f -print0)
if [[ "${#files[@]}" -gt 0 ]]; then
process_files "${files[@]}"
fiCorrect (POSIX-compliant script):
#!/bin/sh
# POSIX script - avoid bashisms for maximum portability
set -eu
# Use . instead of source
. ./config.sh
# Use [ ] instead of [[ ]]
if [ -n "$var" ]; then
echo "var is set"
fi
# No arrays - use positional parameters or files
set -- one two three
for item in "$@"; do
echo "$item"
doneDecision guide:
#!/bin/bash- Complex scripts, arrays,[[ ]], process substitution#!/bin/sh- Simple scripts, containers, embedded systems, CI pipelines#!/usr/bin/env bash- When bash location varies (macOS vs Linux)
Reference: Google Shell Style Guide
Use Portable Test Constructs
[[ ]] is bash/ksh/zsh-only. POSIX shells only support [ ] (test). Using [[ ]] in /bin/sh scripts causes syntax errors or silent failures.
Incorrect (bash-only tests in sh script):
#!/bin/sh
# [[ ]] is not POSIX - fails on dash/ash
if [[ -f "$file" ]]; then
echo "exists"
fi
# Regex matching is bash-only
if [[ "$input" =~ ^[0-9]+$ ]]; then
echo "numeric"
fi
# Pattern matching without quotes is bash-only
if [[ $var == *.txt ]]; then
echo "text file"
fiCorrect (POSIX-compliant tests):
#!/bin/sh
# Use [ ] with proper quoting
if [ -f "$file" ]; then
echo "exists"
fi
# Use case for pattern matching (POSIX)
case "$input" in
*[!0-9]*|"")
echo "not numeric"
;;
*)
echo "numeric"
;;
esac
# Use case for glob patterns
case "$var" in
*.txt)
echo "text file"
;;
esacTest operator portability:
#!/bin/sh
# These work in POSIX [ ]:
[ -f "$file" ] # File exists and is regular
[ -d "$dir" ] # Directory exists
[ -n "$var" ] # String is non-empty
[ -z "$var" ] # String is empty
[ "$a" = "$b" ] # String equality (single =)
[ "$a" != "$b" ] # String inequality
[ "$a" -eq "$b" ] # Numeric equality
[ "$a" -lt "$b" ] # Numeric less than
[ -r "$file" ] # File is readable
# Combine with -a (and) and -o (or), or use separate [ ]:
[ -f "$file" ] && [ -r "$file" ] # PreferredReference: ShellCheck SC3010
Always Quote Variable Expansions
Unquoted variables undergo word splitting (on IFS) and pathname expansion (globbing). A filename with spaces becomes multiple arguments; * expands to all files.
Incorrect (unquoted variables):
#!/bin/bash
file="my file.txt"
rm $file # Tries to remove "my" and "file.txt"
pattern="*.log"
echo $pattern # Expands to all .log files!
dir="/path/with spaces"
cd $dir # Fails: /path/with and spaces are separate
# Dangerous with user input
user_input="foo; rm -rf /"
grep $user_input file.txtCorrect (quoted variables):
#!/bin/bash
file="my file.txt"
rm "$file" # Removes "my file.txt"
pattern="*.log"
echo "$pattern" # Prints literal "*.log"
dir="/path/with spaces"
cd "$dir" # Changes to "/path/with spaces"
# Safe with user input
user_input="foo; rm -rf /"
grep "$user_input" file.txt # Searches for literal stringWhen NOT to quote:
#!/bin/bash
# Intentional globbing - document it!
# shellcheck disable=SC2086
for file in $file_glob; do
process "$file"
done
# Integer arithmetic context (already unquoted)
count=5
(( count++ ))
if (( count > 10 )); then
echo "Done"
fi
# Array expansion with [@] already handles it
files=("one" "two" "three")
process "${files[@]}" # Already properly splitVariable expansion in different contexts:
#!/bin/bash
var="value"
# Always quote in these contexts:
echo "$var"
[[ "$var" == "value" ]]
if [ -n "$var" ]; then ...; fi
cmd --option="$var"
cmd --option "$var"
# Brace syntax is preferred for clarity
echo "${var}"
echo "${var}_suffix"
# Inside [[ ]], right side of == can be unquoted for patterns
[[ "$var" == val* ]] # Pattern match (unquoted)
[[ "$var" == "val*" ]] # Literal match (quoted)Reference: ShellCheck SC2086
Use Braces for Variable Clarity
Without braces, variable boundaries are ambiguous. $var_suffix looks for variable var_suffix, not $var + _suffix. Braces make boundaries explicit.
Incorrect (ambiguous boundaries):
#!/bin/bash
prefix="file"
# What variable is this?
echo $prefix_name.txt # Looks for $prefix_name, not $prefix
echo $prefix1 # Looks for $prefix1, not $prefix + "1"
# Array access without braces fails
files=(one two three)
echo $files[0] # Wrong: prints "one[0]"Correct (explicit braces):
#!/bin/bash
prefix="file"
echo "${prefix}_name.txt" # Clear: $prefix + "_name.txt"
echo "${prefix}1" # Clear: $prefix + "1"
# Array access requires braces
files=(one two three)
echo "${files[0]}" # Correct: prints "one"
echo "${files[@]}" # All elements
echo "${#files[@]}" # Array lengthWhen braces are required:
#!/bin/bash
var="value"
# Adjacent to valid identifier characters
echo "${var}_suffix" # Required
echo "${var}123" # Required
echo "${var}text" # Required
# Array operations
echo "${array[0]}" # Required
echo "${array[@]}" # Required
echo "${#array[@]}" # Required
# Parameter expansion operations
echo "${var:-default}" # Required
echo "${var:0:5}" # Required (substring)
echo "${var^^}" # Required (uppercase)
echo "${var//old/new}" # Required (substitution)When braces are optional but recommended:
#!/bin/bash
# Optional but clearer
echo "${var}" # Consistent style
echo "${1}" # Positional parameters
# Can omit for simple cases
echo "$var" # OK if followed by space/newline
echo "$1 $2 $3" # OK with spaces
# Special parameters (braces optional)
echo "$?" # Exit status
echo "$$" # PID
echo "$!" # Background PIDConsistency recommendation:
#!/bin/bash
# Google Style Guide recommends:
# - Always use braces: "${var}"
# - Exception: simple $1, $2, etc. in clear context
# This is acceptable:
echo "Processing $1"
for arg in "$@"; do ...
# But this is clearer and safer:
echo "Processing ${1}"
echo "File: ${filename}"Reference: Google Shell Style Guide - Quoting
Quote Command Substitutions
Command substitution output undergoes word splitting and glob expansion like variables. A command returning "my file.txt" becomes two words when unquoted.
Incorrect (unquoted command substitution):
#!/bin/bash
# Output with spaces is split
file=$(find_first_file) # Returns "my file.txt"
rm $file # Tries to rm "my" and "file.txt"
# Glob patterns in output expand
pattern_cmd=$(get_pattern) # Returns "*.log"
echo $pattern_cmd # Expands to all .log files!
# Nested substitution issues
result=$(process $(get_input)) # Inner output may splitCorrect (quoted command substitution):
#!/bin/bash
# Quote the result
file=$(find_first_file)
rm "$file" # Correctly handles "my file.txt"
# Preserves literal patterns
pattern_cmd=$(get_pattern)
echo "$pattern_cmd" # Prints "*.log" literally
# Nested - quote inner too
result=$(process "$(get_input)")$() vs backticks:
#!/bin/bash
# Prefer $() over backticks for readability and nesting
# INCORRECT (hard to read and nest)
result=`command \`nested\``
# CORRECT (clear nesting)
result=$(command "$(nested)")
# Complex nesting is clear with $()
value=$(echo "$(cat "$(find_config)")")Common patterns:
#!/bin/bash
# Assign to variable (quotes on use, not assignment)
output=$(my_command)
echo "$output"
# Direct use in arguments
grep "pattern" "$(get_filename)"
# In conditionals
if [[ "$(get_status)" == "ready" ]]; then
proceed
fi
# Capturing exit status
output=$(my_command)
status=$? # Get status IMMEDIATELY after
# When you want splitting (rare, document it)
# shellcheck disable=SC2046
set -- $(get_list) # Intentionally split into positional argsHandle empty output:
#!/bin/bash
# Check for empty result
result=$(find_something)
if [[ -z "$result" ]]; then
echo "Nothing found" >&2
exit 1
fi
# Or use default
result=$(find_something)
: "${result:=default_value}"Reference: ShellCheck SC2046
Use "$@" for Argument Passing
$* joins all arguments into a single string. $@ unquoted splits on spaces. Only "$@" preserves argument boundaries, handling spaces and special characters correctly.
*Incorrect (using $ or unquoted $@):**
#!/bin/bash
# $* joins everything into one argument
wrapper() {
my_command $* # "arg with space" becomes three args
}
wrapper "arg with space" second
# Unquoted $@ also splits
wrapper() {
my_command $@ # Same problem
}
# "$*" joins with IFS
wrapper() {
my_command "$*" # All args become ONE argument
}
wrapper one two three # my_command receives "one two three"Correct (use "$@"):
#!/bin/bash
# "$@" preserves each argument exactly
wrapper() {
my_command "$@" # Arguments passed through correctly
}
wrapper "arg with space" second # Two args: "arg with space", "second"
# Iterate over arguments
process_all() {
for arg in "$@"; do
echo "Processing: $arg"
done
}
process_all "file one.txt" "file two.txt" # Two iterationsCommon patterns:
#!/bin/bash
# Pass all arguments to another command
exec_wrapper() {
exec "$@"
}
# Add arguments before/after
run_with_prefix() {
local prefix="$1"
shift
echo "$prefix: $*"
command "$@"
}
# Filter arguments
run_verbose() {
local verbose=false
local -a args=()
for arg in "$@"; do
case "$arg" in
-v|--verbose) verbose=true ;;
*) args+=("$arg") ;;
esac
done
if [[ "$verbose" == true ]]; then
set -x
fi
my_command "${args[@]}"
}Using shift with arguments:
#!/bin/bash
process() {
local first="$1"
shift # Remove first argument
echo "First: $first"
echo "Remaining: $@"
# Pass remaining to another command
sub_command "$@"
}Difference summary:
| Syntax | Result |
|---|---|
$* | All args as separate words (split on spaces) |
$@ | Same as $* |
"$*" | All args as ONE string, joined by first char of IFS |
"$@" | Each arg as separate quoted string (preserves spaces) |
Reference: Google Shell Style Guide - Quoting
Control Glob Expansion Explicitly
Unquoted wildcards expand to matching files. If no files match, the literal pattern is passed (error-prone). If too many match, argument limits may be exceeded.
Incorrect (uncontrolled globbing):
#!/bin/bash
pattern="*.log"
rm $pattern # Expands - might delete wrong files
echo $pattern # Prints filenames, not pattern
# No matches: literal passed
rm *.xyz # If no .xyz files: rm "*.xyz" (error)
# In find, glob should NOT expand
find . -name *.log # Shell expands BEFORE find sees it!Correct (controlled globbing):
#!/bin/bash
# Quote to prevent expansion
pattern="*.log"
echo "$pattern" # Prints "*.log" literally
# Let find handle the pattern
find . -name "*.log" # find sees "*.log" pattern
find . -name '*.log' # Single quotes also work
# Intentional glob with safety
shopt -s nullglob # No matches = empty list
for file in *.log; do
rm "$file" # Only runs if files exist
donenullglob and failglob:
#!/bin/bash
# Default: unmatched glob is passed literally
echo *.nonexistent # Prints "*.nonexistent"
# nullglob: unmatched glob expands to nothing
shopt -s nullglob
echo *.nonexistent # Prints nothing
for f in *.nonexistent; do
echo "$f" # Loop body never runs
done
# failglob: unmatched glob is an error
shopt -s failglob
echo *.nonexistent # Error: no match
# Restore defaults
shopt -u nullglob failglobSafe iteration over files:
#!/bin/bash
shopt -s nullglob # Set once at script start
# Safe - no iteration if no matches
for file in /path/to/logs/*.log; do
process "$file"
done
# Safe array building
files=(/path/to/logs/*.log)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No log files found"
else
process_files "${files[@]}"
fiExtended globs:
#!/bin/bash
shopt -s extglob nullglob
# Extended patterns (bash only)
echo *.@(jpg|png|gif) # Match .jpg, .png, or .gif
echo !(*.log) # Match everything except .log
echo *.+(o|a) # One or more of .o or .a
echo file?.txt # Single character wildcard
echo file[0-9].txt # Character classGlob in case statements:
#!/bin/bash
# Globs work in case patterns without expansion
case "$filename" in
*.tar.gz|*.tgz)
tar -xzf "$filename"
;;
*.zip)
unzip "$filename"
;;
*)
echo "Unknown format"
;;
esacReference: Greg's Wiki - Glob
Use Here Documents for Multi-line Strings
Building multi-line strings with quotes and escapes is error-prone. Here documents provide clean multi-line text with clear variable expansion control.
Incorrect (escaped multi-line strings):
#!/bin/bash
# Messy escaping and concatenation
message="Line 1\n\
Line 2 with \"quotes\"\n\
Line 3 with \$variable"
# Hard to read SQL
query="SELECT * FROM users \
WHERE name = '$name' \
AND status = 'active' \
ORDER BY created_at"Correct (here documents):
#!/bin/bash
# Clean multi-line text (variables expand)
cat << EOF
Line 1
Line 2 with "quotes"
Line 3 with $variable
EOF
# SQL query
read -r -d '' query << EOF
SELECT *
FROM users
WHERE name = '$name'
AND status = 'active'
ORDER BY created_at
EOF
# Assign to variable
message=$(cat << EOF
Hello $user,
Your order #$order_id has shipped.
Tracking: $tracking_number
EOF
)Quoted delimiter prevents expansion:
#!/bin/bash
# 'EOF' or "EOF" prevents variable expansion
cat << 'EOF'
This $variable is literal
Backslashes are literal: \n \t
$(commands) are not executed
EOF
# Useful for generating scripts
cat << 'SCRIPT' > /tmp/generated.sh
#!/bin/bash
echo "Arguments: $@"
echo "PID: $$"
SCRIPTIndented here documents:
#!/bin/bash
# <<- strips leading TABS (not spaces!)
main() {
cat <<- EOF
This text can be indented with tabs
The tabs before each line are stripped
But the delimiter must also be indented with tabs
EOF
}
# Note: Only tabs work, not spaces
# Most editors need configuration to insert tabsHere strings for single lines (bash only):
#!/bin/bash
# <<< for single-line input — NOT available in POSIX sh, dash, or ash
grep "pattern" <<< "$variable"
# Instead of echo | pipe
echo "$variable" | grep "pattern" # Works but spawns subshell
grep "pattern" <<< "$variable" # More efficient (bash/zsh/ksh only)
# Read into variable
read -r first rest <<< "$line"POSIX alternative to here strings:
#!/bin/sh
# Use printf | pipe in POSIX sh (dash, ash, busybox)
printf '%s\n' "$variable" | grep "pattern"
# Or use a here document for single-line input
grep "pattern" << EOF
$variable
EOFCommon patterns:
#!/bin/bash
# Generate config files
cat << EOF > /etc/myapp.conf
[database]
host = $DB_HOST
port = $DB_PORT
name = $DB_NAME
EOF
# Multi-line usage message
usage() {
cat << EOF
Usage: $0 [options] <file>
Options:
-h, --help Show this help
-v, --verbose Verbose output
-o FILE Output file
Examples:
$0 input.txt
$0 -v -o output.txt input.txt
EOF
}Reference: Bash Manual - Here Documents
Use Explicit PATH for External Commands
Relying on inherited $PATH for command resolution allows attackers to place malicious executables earlier in the path. Scripts running with elevated privileges are especially vulnerable.
Incorrect (relies on inherited PATH):
#!/bin/bash
# Attacker could create ~/bin/rm that exfiltrates data first
rm -rf /var/cache/deploy
cp release.tar.gz /opt/releases/
mail -s "Deploy Complete" ops@example.com < deploy.logCorrect (set a known-safe PATH at script start):
#!/bin/bash
# Primary defense: reset PATH to known-safe directories
PATH=/usr/local/bin:/usr/bin:/bin
export PATH
# Commands now resolve from safe locations only
rm -rf /var/cache/deploy
cp release.tar.gz /opt/releases/Alternative (verify command locations dynamically):
#!/bin/bash
# Useful when expected paths vary across platforms
# (macOS vs Linux, UsrMerge systems)
verify_command() {
local cmd_name="$1"
local cmd_path
cmd_path=$(command -v "$cmd_name") || {
echo "Error: $cmd_name not found in PATH" >&2
return 1
}
case "$cmd_path" in
/usr/local/bin/*|/usr/bin/*|/bin/*) ;;
*) echo "Error: $cmd_name at untrusted location: $cmd_path" >&2; return 1 ;;
esac
}
verify_command rm
verify_command cpKey practices:
- Set
PATHexplicitly at script start — this is the primary defense - Avoid hardcoding paths like
/bin/rm— locations differ across platforms (/binvs/usr/binon macOS vs Linux, UsrMerge systems) - Use
command -vto verify locations when platform portability is needed - Never trust inherited
PATHin cron jobs or privileged scripts
Reference: Apple Shell Script Security
Prevent Argument Injection with Double Dash
Filenames starting with - are interpreted as command options. User-controlled filenames can inject flags that change command behavior, enabling attacks without shell metacharacters.
Incorrect (filename interpreted as option):
#!/bin/bash
filename="$1"
# User passes "-rf" as filename
# rm interprets it as options, not a file
rm $filename
# User passes "--help" → leaks command info
cat $filename
# User passes "-e /etc/shadow" → reads sensitive file
grep "pattern" $filenameCorrect (use -- to end option parsing):
#!/bin/bash
filename="$1"
# -- signals end of options; everything after is an operand
rm -- "$filename"
cat -- "$filename"
grep -- "pattern" "$filename"
# Or use ./ prefix for current directory files
rm "./$filename"Correct (for wildcards/globs):
#!/bin/bash
# DANGEROUS: * might expand to files starting with -
rm *
# SAFE: Explicit path prefix
rm ./*
# SAFE: Use -- before glob
rm -- *Commands that need -- protection:
rm,cp,mv,cat,grep,sed,awkgit,docker,kubectl(most CLI tools)- Any command that accepts filenames and options
Reference: ShellCheck SC2035
Prevent Command Injection from User Input
User input passed to shell commands without validation enables arbitrary code execution. Attackers can inject shell metacharacters like ;, |, $(), or backticks to run malicious commands.
Incorrect (direct user input in command):
#!/bin/bash
# User provides filename
filename="$1"
# DANGEROUS: User could pass "; rm -rf /" as filename
cat $filename
grep "pattern" $filenameCorrect (validate and quote input):
#!/bin/bash
filename="$1"
# Validate input against whitelist pattern
if [[ ! "$filename" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Error: Invalid filename" >&2
exit 1
fi
# Always quote variables
cat -- "$filename"
grep "pattern" -- "$filename"Alternative (use printf %q for dynamic commands):
#!/bin/bash
# When you must build commands dynamically
user_arg="$1"
safe_arg=$(printf '%q' "$user_arg")
# Still prefer arrays over eval
declare -a cmd=(grep -r "$user_arg" .)
"${cmd[@]}"Key protections:
- Validate input with whitelist regex
- Always quote variables:
"$var"not$var - Use
--to end option parsing - Prefer arrays over string concatenation for commands
- Never use
evalwith user data
Reference: Apple Shell Script Security
Avoid eval for Dynamic Commands
eval interprets its arguments as shell code, making it extremely dangerous with any variable data. Even carefully escaped data can be exploited through edge cases.
Incorrect (using eval):
#!/bin/bash
# Building command dynamically with eval
cmd="ls"
opts="-la"
dir="$1"
eval "$cmd $opts $dir"
# User passes: "; rm -rf /" → executes destructive commandCorrect (use arrays for command building):
#!/bin/bash
# Build commands safely with arrays
declare -a cmd=(ls -la)
dir="$1"
# Add arguments safely
cmd+=("$dir")
# Execute without shell interpretation
"${cmd[@]}"Alternative (indirect variable expansion):
#!/bin/bash
# When you need variable indirection
var_name="PATH"
# Instead of: eval "echo \$$var_name"
# Use bash indirect expansion:
echo "${!var_name}"
# For associative data, use associative arrays:
declare -A config
config[database]="mydb"
config[host]="localhost"
key="database"
echo "${config[$key]}"When eval seems necessary, alternatives exist:
- Command building → arrays with
"${array[@]}" - Variable indirection →
${!var}ordeclare -n - Dynamic assignment →
declare "$name=$value" - Arithmetic →
$(( expression ))
Reference: Google Shell Style Guide
Never Use SUID/SGID on Shell Scripts
Shell scripts cannot be made secure with SUID/SGID due to race conditions between the kernel reading the shebang and the interpreter opening the file. Many systems ignore SUID on scripts entirely.
Incorrect (SUID shell script):
#!/bin/bash
# File: /usr/local/bin/admin-task
# Permissions: -rwsr-xr-x (SUID set)
# DANGEROUS: Multiple attack vectors exist
# Race condition: attacker can replace script between
# kernel reading shebang and bash opening file
rm -rf /var/cache/app/*Correct (use sudo with specific permissions):
#!/bin/bash
# File: /usr/local/bin/admin-task
# Permissions: -rwxr-xr-x (no SUID)
# Check if running with required privileges
if [[ $EUID -ne 0 ]]; then
echo "This script must be run with sudo" >&2
exit 1
fi
rm -rf /var/cache/app/*sudoers configuration:
# /etc/sudoers.d/admin-task
# Allow specific users to run specific script
appuser ALL=(root) NOPASSWD: /usr/local/bin/admin-taskAlternative (compiled wrapper):
/* For complex cases, use a compiled SUID wrapper */
/* that validates arguments before exec'ing script */
#include <unistd.h>
int main(int argc, char *argv[]) {
/* Validate environment, clear dangerous vars */
clearenv();
setenv("PATH", "/usr/bin:/bin", 1);
execl("/usr/local/lib/admin-task.sh", "admin-task", NULL);
return 1;
}Reference: Google Shell Style Guide - SUID/SGID
Create Secure Temporary Files
Predictable temporary file names enable symlink attacks where attackers create links to sensitive files. Race conditions between checking and creating files can be exploited.
Incorrect (predictable temp file):
#!/bin/bash
# DANGEROUS: Predictable name, race condition
tmpfile="/tmp/myapp.$$"
# Attacker creates: ln -s /etc/passwd /tmp/myapp.1234
# Before this runs, overwriting /etc/passwd
echo "data" > "$tmpfile"Correct (use mktemp):
#!/bin/bash
# mktemp creates file with secure permissions atomically
tmpfile=$(mktemp) || exit 1
tmpdir=$(mktemp -d) || exit 1
# Use trap to clean up on exit
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT
echo "data" > "$tmpfile"Alternative (template with mktemp):
#!/bin/bash
# Use template for readable names (X's are replaced)
tmpfile=$(mktemp /tmp/myapp.XXXXXX) || exit 1
tmpdir=$(mktemp -d /tmp/myapp.XXXXXX) || exit 1
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT
# Secure: mktemp uses O_EXCL for atomic creation
# with mode 0600 (owner read/write only)Never do:
- Use
$$(PID) alone for temp names - Create files in
/tmpwithoutmktemp - Check existence then create (TOCTOU race)
- Forget cleanup on script exit
Reference: CWE-377: Insecure Temporary File
Write Useful Comments
Comments that repeat the code add noise. Good comments explain why something is done, document non-obvious behavior, and mark incomplete work.
Incorrect (useless comments):
#!/bin/bash
# Increment counter
((counter++))
# Check if file exists
if [[ -f "$file" ]]; then
# Read the file
content=$(<"$file")
fi
# Loop through items
for item in "${items[@]}"; do
# Process item
process "$item"
doneCorrect (useful comments):
#!/bin/bash
# Retry count starts at 1 because the initial attempt isn't a "retry"
((counter++))
# Legacy systems create zero-byte marker files; treat as non-existent
if [[ -f "$file" && -s "$file" ]]; then
content=$(<"$file")
fi
# Process in reverse order to handle dependencies correctly
# (items may reference later items in the array)
for ((i = ${#items[@]} - 1; i >= 0; i--)); do
process "${items[i]}"
doneComment types:
#!/bin/bash
# TODO(username): Implement retry logic for network failures (issue #123)
# FIXME: This workaround breaks on filenames with newlines
# HACK: Temporary fix until upstream patches the library
# NOTE: This assumes UTC timezone; local time will break calculations
# Explain non-obvious code
# The seemingly redundant `|| true` prevents errexit from triggering
# on expected "file not found" errors from grep
grep "pattern" file.txt || true
# Document unexplained constants
readonly MAX_CONNECTIONS=100 # Limit from database license
readonly TIMEOUT_MS=30000 # Match nginx upstream timeout
# Explain regex patterns
# Pattern matches: user@domain.tld (basic email validation)
if [[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then
valid=true
fiWhen to comment:
#!/bin/bash
# DO comment:
# - Why a non-obvious approach was chosen
# - Workarounds for bugs or limitations
# - Performance considerations
# - Security implications
# - External dependencies or assumptions
# - Complex regex or parameter expansion
# DON'T comment:
# - What the code literally does (read the code)
# - Every function or variable
# - Obvious operationsInline vs block comments:
#!/bin/bash
# Block comment for multi-line explanation
# This function implements exponential backoff because the API
# rate-limits aggressive callers. The jitter prevents thundering
# herd problems when multiple instances retry simultaneously.
retry_with_backoff() {
# ...
}
# Inline comment for single clarification
readonly BATCH_SIZE=1000 # Matches API page size limitDisabled code:
#!/bin/bash
# Don't leave commented-out code without explanation
# BAD:
# old_function "$arg"
# new_function "$arg"
# GOOD: Remove old code entirely, use version control
new_function "$arg"
# Or if needed, explain why it's kept:
# Disabled pending migration to new API (tracking: PROJECT-456)
# old_function "$arg"Reference: Google Shell Style Guide - Comments
Follow Consistent File Structure
Scripts without consistent structure are hard to navigate. Following a standard layout helps readers find what they need quickly.
Incorrect (unstructured):
#!/bin/bash
process() { ... }
readonly VAR=1
set -e
another_func() { ... }
# Random comment
CONFIG=/etc/app
main() { ... }
source ./lib.sh
main "$@"Correct (structured layout):
#!/bin/bash
# deploy.sh — Deploy application to staging environment
# Usage: deploy.sh [-v] [-e environment] <version>
set -euo pipefail
# 1. Constants
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DEPLOY_CONFIG="/etc/myapp/deploy.conf"
# 2. Configurable defaults (override via environment)
: "${LOG_LEVEL:=info}"
: "${DRY_RUN:=false}"
# 3. Source dependencies
source "${SCRIPT_DIR}/lib/logging.sh"
# 4. Global variables (mutable state)
VERBOSE=false
TARGET_ENV="staging"
# 5. Functions
cleanup() { :; }
parse_args() { :; }
deploy_release() { :; }
# 6. Main entry point
main() {
trap cleanup EXIT
parse_args "$@"
deploy_release
}
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@"Section order:
1. Shebang and file header comment 2. set options (strict mode) 3. Constants (readonly) 4. Configurable defaults 5. Source dependencies 6. Global variables 7. Function definitions 8. Main function 9. Main invocation guard
File naming:
# Executables
my-script # No extension, executable
my-script.sh # With extension (if build system renames)
# Libraries (sourced, not executed)
lib/common.sh # Always .sh extension
lib/logging.sh # Not executableReference: Google Shell Style Guide - File Organization
Use Consistent Indentation
Inconsistent indentation makes control flow hard to follow. Use 2 spaces (Google style) consistently throughout the script.
Incorrect (inconsistent indentation):
#!/bin/bash
if [[ -f "$file" ]]; then
echo "exists"
if [[ -r "$file" ]]; then
echo "readable"
# Mixed tabs and spaces
fi
fi
for item in "${items[@]}"; do
process "$item"
doneCorrect (consistent 2-space indentation):
#!/bin/bash
if [[ -f "$file" ]]; then
echo "exists"
if [[ -r "$file" ]]; then
echo "readable"
fi
fi
for item in "${items[@]}"; do
process "$item"
doneFormatting guidelines:
#!/bin/bash
# Maximum line length: 80 characters
# Indent: 2 spaces (no tabs except in heredocs)
# If/then on same line
if [[ condition ]]; then
commands
fi
# For/do on same line
for item in list; do
commands
done
# While/do on same line
while [[ condition ]]; do
commands
done
# Case indentation
case "$var" in
pattern1)
commands
;;
pattern2)
commands
;;
esacLong lines:
#!/bin/bash
# Break long commands with backslash
command \
--long-option="value" \
--another-option="another value" \
file.txt
# Or use arrays for complex commands
declare -a args=(
--long-option="value"
--another-option="another value"
)
command "${args[@]}" file.txt
# Pipelines - one per line
command1 \
| command2 \
| command3 \
| command4Function formatting:
#!/bin/bash
# Opening brace on same line as function name
my_function() {
local var="$1"
if [[ -n "$var" ]]; then
process "$var"
fi
}
# Not this:
my_function()
{
# ...
}Heredoc indentation:
#!/bin/bash
# Only tabs work with <<- (not spaces)
my_function() {
cat <<- EOF
This text is indented with tabs.
The tabs are stripped from output.
EOF
}
# Or don't indent heredoc content
my_function() {
cat << EOF
This text is not indented.
Output appears at column 0.
EOF
}Reference: Google Shell Style Guide - Formatting
Use (( )) for Arithmetic Comparisons
Using [ ] or [[ ]] with -eq, -lt for numbers is error-prone. (( )) provides natural math syntax and fails clearly on non-numeric input.
Incorrect (string-based numeric comparison):
#!/bin/bash
count="10"
# Confusing operators
if [ "$count" -gt 5 ]; then
echo "greater"
fi
# String comparison mistake
if [[ "$count" > "5" ]]; then # String comparison! "10" < "5"
echo "greater" # Not printed! "10" sorts before "5" alphabetically
fi
# Using let (deprecated)
let "count = count + 1"
# Using expr (external command, slow)
count=$(expr $count + 1)
# Using $[ ] (deprecated)
count=$[ count + 1 ]Correct (arithmetic context):
#!/bin/bash
count=10
# Natural comparison syntax
if (( count > 5 )); then
echo "greater"
fi
# Arithmetic assignment
(( count++ ))
(( count += 5 ))
(( count = count * 2 ))
# Arithmetic in expressions
result=$(( count + 5 ))
result=$(( (count + 5) * 2 ))
# Multiple conditions
if (( count > 5 && count < 20 )); then
echo "in range"
fiArithmetic operators:
#!/bin/bash
a=10
b=3
# Arithmetic expression operators
(( sum = a + b )) # Addition: 13
(( diff = a - b )) # Subtraction: 7
(( prod = a * b )) # Multiplication: 30
(( quot = a / b )) # Division (integer): 3
(( rem = a % b )) # Modulo: 1
(( pow = a ** 2 )) # Exponentiation: 100
# Comparison operators (return 0=true, 1=false)
(( a == b )) # Equal
(( a != b )) # Not equal
(( a > b )) # Greater than
(( a >= b )) # Greater or equal
(( a < b )) # Less than
(( a <= b )) # Less or equal
# Increment/decrement
(( a++ )) # Post-increment
(( ++a )) # Pre-increment
(( a-- )) # Post-decrement
(( a += 5 )) # Add and assign
(( a *= 2 )) # Multiply and assign
# Ternary operator
(( max = a > b ? a : b ))Combining with conditionals:
#!/bin/bash
# (( )) returns exit status 0 if non-zero, 1 if zero
count=0
if (( count )); then
echo "count is non-zero"
else
echo "count is zero"
fi
# Use in while loops
while (( count < 10 )); do
echo "$count"
(( count++ ))
done
# C-style for loop
for (( i = 0; i < 10; i++ )); do
echo "$i"
doneNote: Variables don't need $ inside (( )):
#!/bin/bash
x=5
y=10
# $ is optional inside (( ))
(( z = x + y )) # Works
(( z = $x + $y )) # Also works, but unnecessary
# But needed for special variables
(( z = ${array[0]} )) # Array access needs ${}Reference: Bash Manual - Arithmetic Evaluation
Use case for Pattern Matching
Multiple if/elif chains for pattern matching are verbose and error-prone. case is cleaner, supports glob patterns natively, and is POSIX-compliant.
Incorrect (if/elif chains):
#!/bin/bash
# Verbose and repetitive
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
show_help
elif [[ "$1" == "-v" || "$1" == "--version" ]]; then
show_version
elif [[ "$1" == "-q" || "$1" == "--quiet" ]]; then
quiet=true
elif [[ "$1" == "-"* ]]; then
echo "Unknown option: $1"
exit 1
fi
# File type detection
if [[ "$file" == *.tar.gz || "$file" == *.tgz ]]; then
tar -xzf "$file"
elif [[ "$file" == *.tar.bz2 || "$file" == *.tbz2 ]]; then
tar -xjf "$file"
elif [[ "$file" == *.zip ]]; then
unzip "$file"
fiCorrect (case statement):
#!/bin/bash
# Clean pattern matching
case "$1" in
-h|--help)
show_help
;;
-v|--version)
show_version
;;
-q|--quiet)
quiet=true
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
# File type detection
case "$file" in
*.tar.gz|*.tgz)
tar -xzf "$file"
;;
*.tar.bz2|*.tbz2)
tar -xjf "$file"
;;
*.tar.xz|*.txz)
tar -xJf "$file"
;;
*.zip)
unzip "$file"
;;
*)
echo "Unknown format: $file" >&2
return 1
;;
esacCase pattern features:
#!/bin/bash
input="$1"
case "$input" in
# Exact match
start|stop|restart)
handle_command "$input"
;;
# Glob patterns
*.txt)
echo "Text file"
;;
# Character classes
[0-9]*)
echo "Starts with digit"
;;
[a-zA-Z]*)
echo "Starts with letter"
;;
# Negation (bash extended)
!(*.bak|*.tmp))
echo "Not a backup or temp file"
;;
# Default case (always put last)
*)
echo "No match"
;;
esacFall-through with ;&:
#!/bin/bash
# Bash 4+ feature: fall-through
level="$1"
case "$level" in
debug)
enable_debug=true
;& # Fall through
verbose)
enable_verbose=true
;& # Fall through
normal)
enable_logging=true
;;
esac
# Continue matching with ;;&
case "$option" in
--all)
all=true
;;& # Continue checking
--verbose|--all)
verbose=true
;;& # Continue checking
--debug|--all)
debug=true
;;
esacOption parsing with case:
#!/bin/bash
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-v|--verbose)
VERBOSE=true
shift
;;
-o|--output)
OUTPUT="$2"
shift 2
;;
--)
shift
break
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
break
;;
esac
done
ARGS=("$@")
}Reference: Bash Manual - Case Statement
Use [[ ]] for Tests in Bash
In bash scripts, [[ ]] is safer than [ ]: no word splitting on variables, supports &&/|| inside, regex matching, and better error messages.
Incorrect (single brackets with issues):
#!/bin/bash
file="my file.txt"
# Word splitting breaks this
if [ -f $file ]; then # Error: too many arguments
echo "exists"
fi
# Empty variable causes error
if [ $unset == "value" ]; then # Error: unary operator expected
echo "match"
fi
# Can't use && inside [ ]
if [ -f "$file" && -r "$file" ]; then # Syntax error
echo "readable"
fiCorrect (double brackets):
#!/bin/bash
file="my file.txt"
# No word splitting inside [[ ]]
if [[ -f $file ]]; then # Works! (but still quote for clarity)
echo "exists"
fi
# Empty/unset variables are safe
if [[ $unset == "value" ]]; then # Works, evaluates to false
echo "match"
fi
# Logical operators inside [[ ]]
if [[ -f "$file" && -r "$file" ]]; then # Works!
echo "readable"
fi
# Regex matching
if [[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then
echo "Valid email format"
fi
# Pattern matching (without quotes on right side)
if [[ "$file" == *.txt ]]; then
echo "Text file"
fiSingle bracket use cases:
#!/bin/bash
# Use [ ] only when:
# 1. POSIX compliance required (#!/bin/sh)
#!/bin/sh
if [ -f "$file" ]; then
echo "exists"
fi
# 2. Combining with -a and -o (though avoid these)
# Prefer: [ cond1 ] && [ cond2 ]
# Over: [ cond1 -a cond2 ]Pattern vs literal matching:
#!/bin/bash
var="hello.txt"
# Pattern matching (unquoted right side)
[[ "$var" == *.txt ]] # True - pattern match
[[ "$var" == "*.txt" ]] # False - literal match
# Regex matching
[[ "$var" =~ \.txt$ ]] # True - regex match
# Common patterns
[[ "$str" == *substring* ]] # Contains
[[ "$str" == prefix* ]] # Starts with
[[ "$str" == *suffix ]] # Ends withTest operators reference:
#!/bin/bash
# String tests
[[ -z "$var" ]] # Empty string
[[ -n "$var" ]] # Non-empty string
[[ "$a" == "$b" ]] # String equality
[[ "$a" != "$b" ]] # String inequality
[[ "$a" < "$b" ]] # String comparison (alphabetical)
# File tests
[[ -f "$file" ]] # Regular file exists
[[ -d "$dir" ]] # Directory exists
[[ -e "$path" ]] # Exists (any type)
[[ -r "$file" ]] # Readable
[[ -w "$file" ]] # Writable
[[ -x "$file" ]] # Executable
[[ -s "$file" ]] # Non-empty file
[[ "$f1" -nt "$f2" ]] # f1 newer than f2
# Numeric comparison (use (( )) instead for clarity)
[[ "$a" -eq "$b" ]] # Equal
[[ "$a" -lt "$b" ]] # Less thanReference: Google Shell Style Guide - Test
Use Explicit Empty/Non-empty String Tests
Implicit string tests like [[ "$var" ]] are ambiguous. Explicit -z (empty) and -n (non-empty) make intent clear and prevent bugs with special values.
Incorrect (implicit tests):
#!/bin/bash
# What does this test? Existence? Non-empty? Boolean?
if [[ "$var" ]]; then
echo "true" # When exactly?
fi
# Empty vs unset confusion
if [[ $var ]]; then # False for both unset and empty
echo "has value"
fi
# String "false" is truthy!
flag="false"
if [[ "$flag" ]]; then
echo "truthy" # Prints! "false" is a non-empty string
fiCorrect (explicit tests):
#!/bin/bash
# Explicit empty check
if [[ -z "$var" ]]; then
echo "var is empty or unset"
fi
# Explicit non-empty check
if [[ -n "$var" ]]; then
echo "var has a value"
fi
# Boolean values - compare explicitly
flag="false"
if [[ "$flag" == "true" ]]; then
echo "flag is true"
fi
# Or use true/false commands
is_enabled=true
if "$is_enabled"; then # Runs the command 'true'
echo "enabled"
fiCommon patterns:
#!/bin/bash
# Check before using
input="$1"
if [[ -z "$input" ]]; then
echo "Error: Input required" >&2
exit 1
fi
# Default if empty
config="${CONFIG:-}"
if [[ -z "$config" ]]; then
config="/etc/default.conf"
fi
# Or use parameter expansion
config="${CONFIG:-/etc/default.conf}"Testing for set vs unset:
#!/bin/bash
# -z doesn't distinguish unset from empty
unset var1
var2=""
[[ -z "$var1" ]] # True (unset)
[[ -z "$var2" ]] # True (empty)
# To distinguish, use parameter expansion
if [[ -z "${var+x}" ]]; then
echo "var is unset"
fi
if [[ -z "${var-}" ]]; then
echo "var is unset or empty"
fi
# Or with set -u active:
if [[ "${var:-}" == "" ]]; then
echo "var is unset or empty (safe with set -u)"
fiBoolean patterns:
#!/bin/bash
# Pattern 1: String comparison
verbose="true"
if [[ "$verbose" == "true" ]]; then
set -x
fi
# Pattern 2: Command-based (true/false are builtins)
enabled=true # No quotes - this is the command name
if $enabled; then
echo "Enabled"
fi
# Pattern 3: Integer (0=false, non-zero=true)
debug=1
if (( debug )); then
echo "Debug mode"
fiAvoid double-negative:
#!/bin/bash
# Hard to read
if [[ ! -z "$var" ]]; then
echo "not empty" # Double negative
fi
# Clear
if [[ -n "$var" ]]; then
echo "has value"
fiReference: Google Shell Style Guide - Testing Strings
Related skills
How it compares
Use shell for bash CI and container scripts; use DevOps CI/CD skills when you need pipeline architecture beyond individual shell script quality.
FAQ
How many rules does the shell skill include?
The shell skill includes 49 rules organized into 9 categories, ranked from critical safety and portability issues to incremental style guidance. Rules cover quoting, `set -euo pipefail`, variables, and ShellCheck-aligned patterns for CI and container scripts.
Where does the shell skill apply?
The shell skill applies when writing or reviewing bash and sh for CI pipelines, Dockerfile RUN commands, Makefile recipes, cron jobs, and systemd ExecStart directives. Agents trigger on bash, POSIX, ShellCheck, and pipeline automation keywords.
Is Shell safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.