beginner~3h

Bash Scripting Fundamentals

The building blocks of every bash script: the shebang line, variables, basic script structure, reading command-line arguments, and exit codes.

Learning objectives

  • Structure a script correctly starting with a shebang line
  • Declare and use variables, and read command-line arguments via $1, $@, and $#
  • Understand and set exit codes to signal success or failure to callers

Concept Overview The first line of a bash script, #!/bin/bash (or #!/usr/bin/env bash), is called the shebang. It isn't a comment in the usual sense — when you execute a script directly (./script.sh), the kernel reads this exact line to decide which interpreter should run the rest of the file. Without it, the system has no reliable way to know the file is a bash script rather than, say, a Python script or plain text, and running it would fall back to whatever shell is currently interpreting your command, which may not be bash at all and may not support the syntax you wrote.

#!/bin/bash hardcodes the path to bash, which is reliable on systems where bash always lives at /bin/bash (true for most Linux distributions). #!/usr/bin/env bash instead asks the env command to locate bash wherever it happens to be on the current PATH — this is more portable across systems (notably macOS, where bash via Homebrew may not be at /bin/bash) and is generally the safer default for scripts that might run in more than one environment.

A script won't run directly with ./script.sh unless it's also marked executable (chmod +x script.sh) — without that bit set, you'd need to invoke it as bash script.sh instead, which works regardless of the shebang or execute permission, since you're explicitly telling the shell which interpreter to use.

Beyond the shebang, a well-structured script typically follows a predictable shape: the shebang line first, then a brief comment block describing what the script does and how to use it, then set options that change bash's default error-handling behavior (covered in depth in the automation topic later in this course, but set -e to exit on any failing command is worth adopting from your very first script), then variable declarations and constants, then function definitions, and finally the main logic that actually does the work — often calling the functions defined above it. This ordering matters for readability: anyone opening the file can scan top to bottom and understand intent before diving into implementation detail.

Comments use # for the rest of the line, exactly like the shebang's own # — bash doesn't distinguish the shebang syntactically, it's just a comment that happens to be interpreted specially by the kernel when it's the very first line of the file.

💻 Code example

#!/usr/bin/env bash # # backup_check.sh - verifies a backup file exists and reports its age # Usage: ./backup_check.sh /path/to/backup.tar.gz set -e # exit immediately if any command fails BACKUP_DIR="/var/backups" MAX_AGE_HOURS=24 main() { echo "Checking backups in ${BACKUP_DIR}" ls -la "${BACKUP_DIR}" } main # Make it runnable directly: # chmod +x backup_check.sh # ./backup_check.sh # Or run it without the execute bit at all: # bash backup_check.sh

Concept Overview Variables in bash are assigned with name=value — critically, with no spaces around the =. name = value (with spaces) is not an assignment at all; bash parses it as trying to run a command called name with arguments = and value, which fails with a confusing 'command not found' error. This single spacing rule trips up more beginners than almost anything else in bash.

You read a variable's value by prefixing it with $, either as $name or, more safely, ${name} — the braces matter whenever the variable name is immediately followed by other characters that could otherwise be read as part of the name, for example ${name}_backup versus the ambiguous $name_backup (which bash would try to read as a variable literally named name_backup). Using ${} consistently is a good habit even when it isn't strictly required.

Quoting matters enormously. Double quotes ("$name") still allow variable expansion but prevent word splitting and glob expansion — if $name contains spaces, "$name" preserves it as one value, while unquoted $name lets the shell split it into multiple words wherever whitespace appears, often breaking commands that assumed a single argument. Single quotes ('$name') suppress expansion entirely — the text is treated completely literally, dollar signs and all. The practical rule: quote every variable expansion with double quotes unless you specifically need word splitting to happen, which is rare.

Variables assigned without export are local to the current shell/script — a child process (like a script you call from another script) won't see them. export NAME=value makes a variable part of the environment passed to child processes, which is how environment variables like PATH or HOME work; a plain variable, by contrast, exists only in the current shell session.

Command substitution captures a command's output into a variable: result=$(ls /tmp) runs ls /tmp and assigns its output to result. The $(...) syntax is preferred over the older backtick syntax (`ls /tmp`) because it nests cleanly — you can put a $(...) inside another $(...) without the escaping headaches backticks require. Arithmetic uses $(( )): count=$((count + 1)) performs integer arithmetic, which plain string concatenation with + would not.

💻 Code example

#!/usr/bin/env bash # Correct assignment: no spaces around = app_name="myapp" version="1.4.2" # Braces avoid ambiguity when concatenating with adjacent text artifact="${app_name}_${version}.tar.gz" echo "Building ${artifact}" # Quoting matters when a value contains spaces message="deploy failed on host 1" echo "$message" # one argument: the whole sentence # echo $message # would word-split into 5 separate arguments # Command substitution and arithmetic file_count=$(ls /var/log | wc -l) next_count=$((file_count + 1)) echo "Found ${file_count} log files, next build is #${next_count}" # export makes a variable visible to child processes export DEPLOY_ENV="production" bash -c 'echo "child sees: $DEPLOY_ENV"'

Concept Overview When a script is invoked with arguments (./deploy.sh production v2.1), bash makes them available inside the script as positional parameters. $1 is the first argument, $2 the second, and so on; $0 is the script's own name/path, not the first argument — a common off-by-one mistake for people coming from languages where argv[0] is more obviously 'the first real argument.'

$# holds the count of arguments passed, which is what you check before using an argument, to give a helpful usage message instead of a cryptic failure: if [ $# -lt 2 ]; then echo "Usage: $0 <env> <version>"; exit 1; fi. $@ expands to all arguments, and when quoted as "$@" it expands each argument as a separate, correctly-quoted word — this is the form you want when forwarding arguments to another command, because it preserves arguments that themselves contain spaces. $* is similar but joins everything into a single string, which is almost never what you want when forwarding arguments, since it collapses the individual-word boundaries. As a rule: use "$@" to pass arguments through faithfully, and reach for $* only when you deliberately want one combined string (for logging a command line, for instance).

shift removes $1 and renumbers the rest down by one — $2 becomes the new $1, and so on. This is how you process a variable number of arguments in a loop: while [ $# -gt 0 ]; do process "$1"; shift; done walks through every argument one at a time regardless of how many were passed.

Every command in Unix, including your own script, communicates success or failure through a numeric exit code — 0 means success, any nonzero value (1-255) means some kind of failure, and different nonzero values can convey different failure reasons if you choose to use them that way. $? immediately after a command holds that command's exit code, which is how you check whether something succeeded: if [ $? -eq 0 ]; then echo ok; fi — though in practice if command; then ... fi checking the command's exit status directly is more idiomatic than capturing $? into an intermediate check. exit N at the end of your own script sets its exit code for whatever called it; a script with no explicit exit returns the exit code of its last executed command. This matters enormously in automation — a CI pipeline, a cron job, or another script calling yours relies entirely on the exit code to decide whether to proceed, retry, or alert, regardless of what text was printed.

💻 Code example

#!/usr/bin/env bash # deploy.sh - usage: ./deploy.sh <environment> <version> [--dry-run] if [ $# -lt 2 ]; then echo "Usage: $0 <environment> <version> [--dry-run]" >&2 exit 1 fi environment="$1" version="$2" shift 2 # drop the two required args, leaving only optional flags in "$@" echo "Script name: $0" echo "Environment: ${environment}" echo "Version: ${version}" echo "Remaining flags (\$#=$#): $@" if [ "${environment}" != "staging" ] && [ "${environment}" != "production" ]; then echo "Error: environment must be 'staging' or 'production'" >&2 exit 2 fi echo "Deploying version ${version} to ${environment}..." # ... actual deploy commands would go here ... exit 0 # explicit success

Want a visual for this concept?

Generate a diagram tailored to “Bash Scripting Fundamentals” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Bash Control Flow →← Back to all Bash Scripting chapters