linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Simulator
  • Knowledge base
  • Interview prep
Index
Categories
All entries
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
home/linux/kb/Commands/bash-scripting

kb/commands ── Commands ── beginner

bash scripts: basics and idioms

A bash script is a text file with shebang `#!/usr/bin/env bash` and `chmod +x`. Start every script with `set -euo pipefail` and run `shellcheck` to catch errors early.

view as markdownaka: bash-script, shell-script, bash-strict-mode, shellcheck

When to use bash, and when not to

bash works well for:

  • Chaining CLI utilities: pipes, filtering, redirects, exit-code checks.
  • Install, deploy, and CI scripts: run a sequence of commands and stop on failure.
  • System administration automation.

Rewrite in Python or Go when:

  • You need arrays of objects, maps, or JSON parsing.
  • The script exceeds roughly 150 lines, or has functions with multiple parameters.
  • You need concurrency, tests, or types.

Once you reach the point where you want a bash array containing a dictionary, stop writing bash.

Minimal safe skeleton

bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# code here

What each line does:

  • #!/usr/bin/env bash: portable shebang. /bin/bash may not exist on macOS, BSD, or containers using busybox.
  • set -e: exit on the first error (any command with a non-zero exit code).
  • set -u: exit when an undeclared variable is used.
  • set -o pipefail: the pipeline's exit code equals the exit code of the last failed command, not the last command. Without this, false | true returns success.
  • IFS=$'\n\t': split only on newline and tab, not spaces. Protects against filenames with spaces in for f in $(ls)-style loops.

Without set -euo pipefail, errors are silently ignored and the script keeps doing the wrong thing.

Variables

bash
name="serge"             # no spaces around =
echo "$name"             # ALWAYS quote, or it breaks on spaces
echo "${name}_user"      # ${} to delimit the variable name
port=8080
echo "URL: http://localhost:${port}/api"

Quoting:

  • "...": interpolates $var, $(...), and escapes. The default. Use it always.
  • '...': literal, no substitution.
  • Unquoted: triggers word splitting and glob expansion. Dangerous with spaces and *.

Rule: always wrap variables in "$var", even when it seems unnecessary.

Command substitution: $(cmd)

bash
today=$(date +%F)
files=$(find /var/log -name '*.log' | wc -l)
echo "Today: ${today}, log files: ${files}"

The old backtick form `cmd` works too, but $() is better:

  • Nesting is straightforward: $(echo $(date)).
  • Fewer escaping issues.

Conditionals: [[ ]] and (( ))

bash has three syntaxes for tests. Use the modern ones.

Strings and files: [[ ]]

bash
if [[ -f /etc/passwd ]]; then echo "exists"; fi
if [[ -z "$var" ]]; then echo "empty"; fi
if [[ "$user" == "root" ]]; then echo "root"; fi
if [[ "$msg" =~ ^ERROR ]]; then echo "regex match"; fi
TestWhat it checks
-f pathregular file exists
-d pathdirectory exists
-e pathpath exists (any type)
-r pathreadable
-w pathwritable
-x pathexecutable
-z "$s"string is empty
-n "$s"string is non-empty
"$a" == "$b"strings are equal
"$a" != "$b"strings are not equal
"$s" =~ regexregex match

The old [ ... ] (POSIX) form works too, but [[ ]] is richer and does not require quoting variables. Use [[ ]] in all bash scripts, except when targeting /bin/sh.

Numbers: (( ))

bash
if (( count > 100 )); then echo "many"; fi
if (( $# < 2 )); then echo "need 2+ args"; exit 1; fi
total=$(( a + b ))            # arithmetic

Inside (( )), $ on variables is optional and quoting is not needed. This is the arithmetic context.

Loops

bash
# for-in over a list
for host in web1 web2 web3; do
    ssh "$host" "uptime"
done
# iterate over files with a glob, not $(ls)
for f in /var/log/*.log; do
    echo "Processing $f"
done
# while-read line by line (the standard way to process files)
while IFS= read -r line; do
    echo "Got: $line"
done < input.txt
# C-style
for ((i=0; i<10; i++)); do
    echo "$i"
done

while IFS= read -r is the only correct way to read a file line by line. IFS= prevents trimming of leading and trailing whitespace; -r disables backslash escape processing. Without both, lines containing tabs or \n will be corrupted.

Never use for f in $(ls *.txt). It breaks on filenames with spaces and causes double glob expansion. Use for f in *.txt directly.

Functions

bash
greet() {
    local name="${1:-world}"     # default if $1 is not passed
    echo "Hello, $name!"
}
greet              # -> Hello, world!
greet "serge"      # -> Hello, serge!

Arguments: $1, $2, ..., $@ (all), $# (count), $0 (script name).

local is required for every variable inside a function. Without local, variables are global and corrupt state outside the function. This is the most common bash mistake.

Redirect and pipe

bash
cmd > file              # stdout -> file (overwrite)
cmd >> file             # stdout -> file (append)
cmd 2> file             # stderr -> file
cmd > file 2>&1         # stdout and stderr -> file (order matters!)
cmd &> file             # bash shortcut for the same
cmd < file              # stdin from file
cmd1 | cmd2             # stdout of cmd1 -> stdin of cmd2
cmd1 |& cmd2            # stdout and stderr -> stdin of cmd2
cmd > /dev/null 2>&1    # discard all output

Heredoc for multi-line input:

bash
cat <<EOF > /etc/myapp.conf
port=8080
user=$USER       # $USER is expanded (no quotes around EOF)
EOF
cat <<'EOF' > /etc/myapp.conf
port=$PORT       # $PORT is NOT expanded (quotes around EOF)
EOF

Exit codes: the primary error mechanism

Every command returns an integer 0-255. 0 = success; anything else is an error.

bash
cmd && echo "ok"            # runs only if cmd succeeded
cmd || echo "failed"        # runs only if cmd failed
cmd || exit 1               # exit if cmd failed
cmd1; cmd2                  # cmd2 runs regardless
echo $?                     # exit code of the last command

Checking multiple commands:

bash
if cmd1 && cmd2; then echo "both ok"; fi

With set -e, use cmd || true to say "this may fail, keep going."

Argument parsing with getopts

bash
while getopts ":vh:f:" opt; do
    case $opt in
        v) verbose=1 ;;
        h) host="$OPTARG" ;;
        f) file="$OPTARG" ;;
        \?) echo "Unknown: -$OPTARG"; exit 1 ;;
        :) echo "-$OPTARG needs argument"; exit 1 ;;
    esac
done
shift $((OPTIND-1))         # shift positional parameters past the flags

bash does not natively support long options (--verbose). Use getopt (an external utility) or switch to Python or Go.

Debugging

bash
bash -n script.sh           # check syntax only, do not run
bash -x script.sh           # trace each command as it executes
set -x                      # enable tracing mid-script
set +x                      # disable tracing

In trace mode, each command is printed to stderr with a + prefix before it runs. This is the fastest way to find where a script fails.

shellcheck: the required linter

Run any non-trivial script through shellcheck. It catches:

  • Unquoted $var references.
  • $(ls) in a for loop.
  • [ ... -a ... ] instead of [[ ... && ... ]].
  • Mismatched quotes.
  • Global variables inside functions.
bash
sudo apt install shellcheck         # Debian/Ubuntu
sudo dnf install ShellCheck         # Fedora
shellcheck script.sh

Integrate shellcheck into CI and pre-commit hooks. Without it, bash projects accumulate hidden bugs.

Common traps

  • Unquoted $var: rm $file when file="my doc.txt" executes rm my doc.txt (two separate arguments, two victims). Always write rm "$file".
  • set -e does not catch everything: not inside if, not after ||, not in a pipeline without pipefail, not in a subshell. Add explicit || after critical commands.
  • cd without a failure check: write cd /opt/app && do_stuff, not cd /opt/app; do_stuff. If cd fails, the second form runs do_stuff in the current directory.
  • trap for cleanup: trap 'rm -f "$tmp"' EXIT removes temporary files even on kill -2.
  • bash -c vs sh -c: on systems where sh = dash (Debian/Ubuntu), bash syntax does not work in sh -c.

§ команды

bash
shellcheck script.sh

Linter for bash. Catches 90% of common mistakes. Run before every commit.

bash
bash -x script.sh

Run with tracing. Each command is printed to stderr before it executes.

bash
bash -n script.sh

Syntax check only, does not run the script. Useful in CI or for a quick sanity check.

bash
set -euo pipefail

Safe strict mode. Put this at the top of every new script.

bash
trap 'rm -f "$tmpfile"' EXIT

Cleanup on exit for any reason, including kill -TERM and Ctrl+C.

§ см. также

  • cmd-sedsed: stream editorsed is a stream editor: it applies commands (`s/a/b/`, `d`, `p`, ...) to each line. `-i` edits a file in place; `-E` enables ERE; the address range `/start/,/end/` filters a block. Hold space is a second buffer.
  • cmd-awkawk: field-oriented processing of structured textawk splits a line into fields by FS (default is whitespace) and applies pattern { action }. `$1..$NF`, `NR` (a counter), BEGIN/END for a prologue and totals. It covers 80% of "process the columns" tasks without Python.
  • cmd-jqjq: JSON queries and transformationjq is a query language for JSON in the shell. Use .field, .array[], select(...), map(...), and in-expression pipes via |. -r strips quotes, -c packs output into a single line. Works well in curl + jq + grep pipelines.
  • cmd-cron-crontabcron and crontab: scheduling taskscron is a daemon that reads crontab files and runs jobs on a schedule. Format: `min hour day month weekday command`. anacron handles machines that are powered off. On systemd systems, timers are often used instead.
  • cmd-rsyncrsync: incremental file synchronizationrsync copies only the changed blocks of files, locally or over SSH. `-avz` is the baseline combination (archive + verbose + compress). `--delete` mirrors. `--dry-run` is required before the first run.
  • shebangShebang: the first line of a scriptA script's first line like `#!/usr/bin/env bash` tells the kernel which interpreter to start. Without a shebang the script runs under the current shell, and a bash-only script breaks on /bin/sh in production.
  • process-substitutionProcess substitution: <(cmd) and >(cmd)Bash syntax `<(cmd)` substitutes a command as a read-only pseudo-file. `>(cmd)` does it for writing. You get a temporary file you never have to clean up.
  • xargs-and-find-execxargs and find -exec: bulk operationsTwo ways to apply a command to a set of files: `find ... -exec cmd {} +` (inside find) and `... | xargs cmd` (via pipe). For safety with spaces and special characters, use `find -print0 | xargs -0`.

§ упоминается в уроках

  • ›beginner-12-shell-scripting
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies