{"post":{"slug":"how-to-write-a-bash-script-2026","title":"How to write a Bash script in 2026: the safe and practical guide","excerpt":"Bash scripting is still the fastest way to automate repetitive shell work, but most scripts are written unsafely. Here is the 2026-correct approach.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">B</span>ash is the lingua franca of automation on Linux and macOS. In 2026, it ships on every server, every CI runner, and every developer machine. The problem is not the language — it is that most Bash scripts are written without safety defaults, making them fragile, error-prone, and hard to debug. This guide fixes that.\n\n## What changed in 2026\n\n- **ShellCheck is table-stakes** — every CI pipeline should run `shellcheck` on `.sh` files; most linters include it.\n- **POSIX compliance matters more** — with Alpine Linux and minimal containers dominant, `/bin/sh` often means `ash` or `dash`, not Bash. Know the difference.\n- **`bash` v5.2 features** — associative arrays, `${var@Q}` (proper quoting), and `${var@E}` (expand escapes) are widely available.\n- **AI-assisted shell scripting** — models like Claude are good at generating correct Bash; the skill is knowing how to verify what they output.\n- **GitHub Actions shells** — most CI runs on Bash 5.x; scripts that work locally but fail in CI usually have quoting or `pipefail` bugs.\n\n## The safe script template\n\nEvery non-trivial Bash script should start with this:\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\nIFS=$'\\n\\t'\n\n# --- constants ---\nreadonly SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nreadonly LOG_FILE=\"${SCRIPT_DIR}/run.log\"\n\n# --- logging ---\nlog()  { echo \"[$(date +%T)] $*\" | tee -a \"$LOG_FILE\"; }\nerr()  { echo \"[$(date +%T)] ERROR: $*\" >&2; }\ndie()  { err \"$@\"; exit 1; }\n```\n\n`set -e` exits on any command that returns non-zero. `set -u` errors on unset variables. `set -o pipefail` makes pipe failures propagate. Together they catch ~80% of common script bugs.\n\n## Quoting rules\n\n```bash\nname=\"Alice Smith\"\n\n# WRONG — breaks on spaces\necho Hello $name           # echoes: Hello Alice Smith (ok here by luck)\ncp $name /tmp/             # breaks: tries to copy \"Alice\" and \"Smith\" separately\n\n# CORRECT — always quote\necho \"Hello $name\"\ncp \"$name\" /tmp/\n\n# Arrays for multiple files\nfiles=(\"file one.txt\" \"file two.txt\")\ncp \"${files[@]}\" /tmp/     # correct — each element is a separate word\n```\n\n## Conditionals with `[[ ]]`\n\n```bash\n# File tests\nif [[ -f \"$config_file\" ]]; then\n  log \"Found config: $config_file\"\nfi\n\n# String comparison\nif [[ \"$environment\" == \"production\" ]]; then\n  die \"Do not run this script in production\"\nfi\n\n# Regex match (no external grep needed)\nif [[ \"$version\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n  log \"Valid semver: $version\"\nfi\n\n# Arithmetic\nif (( count > 10 )); then\n  err \"Too many items: $count\"\nfi\n```\n\n## Functions with local variables\n\n```bash\n# Bad — global variables leak and collide\nbackup_files() {\n  timestamp=$(date +%Y%m%d)    # global! pollutes outer scope\n  tar czf \"backup-$timestamp.tar.gz\" \"$@\"\n}\n\n# Good — local scope\nbackup_files() {\n  local timestamp\n  timestamp=$(date +%Y%m%d)\n  tar czf \"backup-${timestamp}.tar.gz\" \"$@\"\n}\n```\n\n## Argument parsing\n\n```bash\nusage() {\n  cat <<EOF\nUsage: $(basename \"$0\") [OPTIONS] <input>\n\nOptions:\n  -o, --output DIR   Output directory (default: ./out)\n  -v, --verbose      Verbose output\n  -h, --help         Show this help\nEOF\n}\n\noutput=\"./out\"\nverbose=false\n\nwhile [[ $# -gt 0 ]]; do\n  case \"$1\" in\n    -o|--output)   output=\"$2\"; shift 2 ;;\n    -v|--verbose)  verbose=true; shift ;;\n    -h|--help)     usage; exit 0 ;;\n    --)            shift; break ;;\n    -*)            die \"Unknown option: $1\" ;;\n    *)             break ;;\n  esac\ndone\n\ninput=\"${1:-}\"\n[[ -z \"$input\" ]] && die \"input argument required\"\n```\n\n## Common patterns\n\n### Running a command and capturing output\n\n```bash\n# Capture stdout; fail if command fails\noutput=$(some_command 2>/dev/null) || die \"some_command failed\"\n\n# Check exit code without set -e interference\nif ! result=$(git status --porcelain 2>&1); then\n  die \"git status failed: $result\"\nfi\n```\n\n### Temporary files with cleanup\n\n```bash\ntmpfile=$(mktemp)\ntrap 'rm -f \"$tmpfile\"' EXIT   # always cleans up, even on error\n\ncurl -sS \"$url\" > \"$tmpfile\"\nprocess \"$tmpfile\"\n```\n\n### Comparing Bash vs alternatives\n\n| Task | Best tool |\n| --- | --- |\n| Glue commands together | Bash |\n| Text manipulation (lines) | Bash + `awk`/`sed` |\n| JSON processing | `jq` or Python |\n| HTTP requests | `curl` + `jq` or Python `httpx` |\n| CSV parsing | Python `csv` module |\n| Complex logic / data structures | Python |\n| Cross-platform automation | Python or Node.js |\n\n## How to debug a Bash script\n\n```bash\n# Trace every command as it executes\nset -x\n\n# Or trace just a section\nset -x\nsome_complex_function\nset +x\n\n# Dry-run mode (skip destructive commands)\nDRY_RUN=${DRY_RUN:-false}\nrun() {\n  if [[ \"$DRY_RUN\" == \"true\" ]]; then\n    echo \"[DRY RUN] $*\"\n  else\n    \"$@\"\n  fi\n}\n\nrun rm -rf /tmp/old-data\n```\n\n## How to lint your scripts\n\n```bash\n# Install ShellCheck\nbrew install shellcheck          # macOS\napt-get install shellcheck       # Debian/Ubuntu\n\n# Run on a script\nshellcheck my-script.sh\n\n# Run in CI (GitHub Actions)\n- name: ShellCheck\n  uses: ludeeus/action-shellcheck@master\n```\n\nShellCheck catches quoting errors, deprecated syntax, and subtle logic bugs. Run it on every script before committing.\n\n## Common mistakes\n\n**Not quoting command substitutions.** `result=$(cmd)` is safe; using `$result` later unquoted is not. Quote both.\n\n**Using `ls` to iterate files.** `for f in $(ls *.txt)` breaks on filenames with spaces. Use `for f in *.txt` (glob expansion is safe).\n\n**Ignoring `set -e` nuances.** `set -e` does not trigger inside `if` conditions or `&&` chains. This is by design, but surprises newcomers. Use explicit `|| die` for critical commands.\n\n**Writing platform-specific Bash.** `date -v-7d` is macOS BSD syntax. `date -d '7 days ago'` is GNU/Linux. For portable scripts, use Python's `datetime` instead.\n\n## What to skip\n\n- **Bash for JSON parsing** — `grep`/`sed` hacks on JSON break on whitespace, nesting, and Unicode. Use `jq`.\n- **Long Bash scripts over ~200 lines** — maintainability collapses. Move to Python.\n- **Bash in Docker `CMD`** — prefer `exec` form (`[\"node\", \"server.js\"]`) over shell form to get proper signal handling.\n\n## FAQ\n\n**What is the difference between `sh` and `bash`?**\n`sh` is the POSIX shell — a minimal spec. `bash` is a superset. `#!/bin/sh` on macOS runs `zsh` in POSIX mode; on Alpine it runs `ash`. Use `#!/usr/bin/env bash` when you need Bash features.\n\n**How do I source environment variables from a `.env` file?**\n`export $(grep -v '^#' .env | xargs)` works for simple cases. For files with spaces or special characters in values, use Python or the `dotenv` binary.\n\n**How do I run multiple commands in parallel in Bash?**\nUse `&` to background each command and `wait` to collect: `cmd1 & cmd2 & wait`. For more control (timeouts, exit codes), use GNU Parallel.\n\n**Should I use `#!/bin/bash` or `#!/usr/bin/env bash`?**\nUse `#!/usr/bin/env bash` — it finds bash in `$PATH`, which matters on macOS (system bash is v3; Homebrew installs v5 elsewhere).\n\n## Where to go next\n\n- [How to automate with cron in 2026](/blog/how-to-automate-with-cron-2026)\n- [How to use the command line in 2026](/blog/how-to-use-the-command-line-2026)\n- [How to write a GitHub Action in 2026](/blog/how-to-write-a-github-action-2026)","hue":306,"metaTitle":"How to Write a Bash Script in 2026: Safe Scripting Guide","metaDescription":"Practical 2026 guide to Bash scripting. Covers safe defaults, quoting rules, functions, error handling, and when to use Python instead of Bash.","keywords":["how to write a bash script 2026","bash scripting tutorial 2026","bash script best practices","bash set -euo pipefail","shell scripting guide 2026","bash functions tutorial","bash error handling","bash vs python scripting","bash script examples 2026","linux bash scripting 2026"],"tldr":["**Always start with `set -euo pipefail`** — this makes the script exit on errors, undefined variables, and pipe failures instead of silently continuing.","**Quote every variable** — `\"$var\"` not `$var`; unquoted variables break on spaces and glob characters.","**Use `[[ ]]` for conditionals**, not `[ ]` — `[[` is Bash-specific but safer, supporting regex and avoiding word-splitting bugs.","**Prefer functions over copy-pasted blocks** — scripts over ~50 lines should use named functions with local variables.","**Skip:** writing complex data processing in Bash — anything involving JSON, CSV parsing, or nested loops is clearer and safer in Python."],"readTime":"5 min read","wordCount":1118},"related":[{"slug":"how-to-learn-bash-scripting-2026","title":"How to learn Bash scripting in 2026: automate everything you type twice","excerpt":"Bash is the glue holding every Linux server and CI pipeline together. This 2026 guide teaches the patterns that actually survive contact with production.","category":"code","date":"March 31, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":319},{"slug":"how-to-write-a-python-script-2026","title":"How to write a Python script in 2026: structure, tooling, and best practices","excerpt":"Writing a Python script that works once is easy. Writing one that other people can run, debug, and maintain in 2026 requires knowing the right structure, tooling, and modern idioms.","category":"code","date":"March 18, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":309},{"slug":"how-to-learn-powershell-2026","title":"How to learn PowerShell in 2026: automation for the modern Windows engineer","excerpt":"PowerShell 7 runs on Windows, Linux, and macOS and is the primary automation language for Azure and Microsoft 365. This 2026 guide shows what actually matters.","category":"code","date":"March 31, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":320}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"how-to-wire-ethernet-in-your-house-2026","title":"How to Wire Ethernet in House: The 2026 Playbook","excerpt":"A grounded 2026 walkthrough for wiring your home with Ethernet: planning drops, picking cable, and knowing which upgrades quietly waste your money.","category":"tech","date":"April 6, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":205},"older":{"slug":"how-to-write-a-database-migration-2026","title":"How to write a database migration in 2026: safe, reversible, and zero-downtime","excerpt":"Database migrations fail when they lock tables in production or cannot be rolled back. This guide covers the 2026 patterns for safe, zero-downtime schema changes in Postgres.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":305}}