Variables and Arguments
A shell variable is a name bound to a string. The shell has no integers, floats, or arrays-of-numbers in the way a programming language does — everything is text until a command or an arithmetic context interprets it otherwise. You assign with name=value and absolutely no spaces around the =, because name = value is parsed as the command name with arguments = and value. You read the value back by prefixing the name with $, and the shell performs the substitution before the command ever runs.
Arguments are how the outside world reaches the script: the positional parameters $1, $2, and onward hold what the caller typed after the script name, $# holds the count, and "$@" holds the whole list. The single operational fact that governs nearly everything on this page is that the shell splits unquoted variable expansions on whitespace and then expands glob characters. Forget to quote, and a filename with a space becomes two arguments, an empty variable vanishes entirely, and a value containing * silently turns into a directory listing.
Assignment and Scope
An assignment with no export creates a shell variable that lives only inside the current shell and is invisible to any command the shell launches. export name (or export name=value) promotes it to an environment variable, which is copied into the environment of every child process. This is why VAR=hello; bash -c 'echo $VAR' prints a blank line, while export VAR=hello; bash -c 'echo $VAR' prints hello — the child only sees what was exported.
A variable assignment placed in front of a command, with no semicolon, sets that variable for that one command only and does not touch the current shell. This is the idiom behind LC_ALL=C sort file or DEBIAN_FRONTEND=noninteractive apt-get install -y nginx: the override applies to the single invocation and is gone afterward. Names are conventionally uppercase for exported and environment variables, lowercase for script-local ones, which keeps your own variables from colliding with the dozens the system already defines.
greeting="hello world" # no spaces around = echo "$greeting" # hello world export PATH="$PATH:/opt/bin" # now visible to child processes # per-command override, does not persist: LC_ALL=C sort names.txt
Quoting and Expansion
Double quotes preserve whitespace and stop word-splitting and globbing while still allowing variable and command substitution — "$var" expands but stays a single argument. Single quotes are literal: '$var' is the four characters dollar-v-a-r, with no substitution at all. The default discipline for any value that came from outside the script is to wrap every expansion in double quotes, including "$@", which is the only form that preserves each argument exactly as the caller passed it.
Curly braces, as in ${name}, disambiguate where the variable name ends — ${file}_backup is the value of file followed by _backup, whereas $file_backup looks up a variable literally named file_backup that probably does not exist and expands to nothing. Brace syntax also unlocks parameter expansion: defaults, length, substring, and pattern removal, all without spawning an external process.
| Expansion | Result |
|---|---|
${var:-default} | value of var, or default if unset or empty |
${var:=default} | same, but also assigns default to var |
${var:?message} | expand var, or exit with message on stderr if unset or empty |
${#var} | length of the value in characters |
${var#prefix} / ${var%suffix} | remove shortest matching prefix / suffix |
${var/old/new} | replace first occurrence of old with new |
Positional Parameters
When a script runs, $0 is the path it was invoked as, $1 through $9 are the first nine arguments, and beyond nine you must brace them — ${10}, not $10, which the shell reads as $1 followed by a literal 0. $# is the argument count, useful for a guard like [ "$#" -lt 2 ] && { echo "need 2 args" >&2; exit 1; }. The pair $@ and $* both list all arguments, but they differ exactly once: quoted. "$@" expands to one word per argument, preserving spaces inside each; "$*" joins them into a single string separated by the first character of IFS. For passing arguments through to another command, "$@" is the only correct choice.
shift drops $1 and renumbers the rest down by one, which is how you walk a variable-length argument list or consume flags one at a time. For anything more than a couple of flags, getopts parses single-letter options in the POSIX style and is built into the shell, so it costs nothing and behaves identically on Debian, Ubuntu, and Red Hat. The external getopt from util-linux handles long options like --verbose but is a separate program with its own quoting rules.
#!/usr/bin/env bash set -euo pipefail verbose=0 while getopts ":vo:" opt; do case "$opt" in v) verbose=1 ;; o) outfile="$OPTARG" ;; *) echo "usage: $0 [-v] [-o file] target" >&2; exit 2 ;; esac done shift "$((OPTIND - 1))" # drop parsed options, leaving "$@" as operands echo "verbose=$verbose target=${1:?target required}"
Command Substitution and Arithmetic
Command substitution runs a command and replaces the expression with its output, trailing newlines stripped. Use $(command), not the backtick form — $() nests cleanly and does not require escaping inner quotes. A common assignment is count=$(wc -l < access.log), and because the result is captured before word-splitting decisions are made, you still quote it on use: echo "$count". The pattern now=$(date +%s) captures a Unix timestamp; files=$(find /var/log -name '*.gz') captures a newline-separated list that you should iterate with care, not by leaving it unquoted.
Arithmetic lives in $(( )), which treats its contents as integers and supports the usual C operators. Inside it you do not prefix variables with $ — $((count + 1)) works. The shell has no floating point; $((7 / 2)) is 3, and for decimals you reach for bc or awk. Unset variables evaluate to 0 in arithmetic, which is convenient for counters but masks typos, so under set -u an unset name in $(( )) still triggers the error and saves you.
- Putting spaces around the
=in an assignment —name = valuerunsnameas a command with two arguments and fails with "command not found", not an assignment error, so the cause is rarely obvious. - Leaving expansions unquoted —
rm $filewhenfilecontains a space deletes two wrong paths, and whenfileis emptyrmsees no operand or, worse, a stray glob expands to every file in the directory. - Passing arguments through with
$*or unquoted$@instead of"$@"— every argument that contained a space is silently re-split, so a single filename arrives at the inner command as several broken pieces. - Setting a variable without
exportand expecting a child process to see it — the value exists in the current shell only, and the child reads a blank, which surfaces as a config-not-found failure far from the assignment. - Writing
$10for the tenth positional parameter — the shell reads it as${1}0, so you get the first argument with a0appended instead of the tenth; you must write${10}. - Relying on the implicit
0that an unset variable yields in$(( ))— a typo'd variable name evaluates to zero rather than erroring, so the arithmetic quietly produces wrong totals unlessset -uis on. - Capturing multi-line command output and looping over it unquoted — the newline-separated list from
$(find ...)gets word-split on spaces inside filenames; usefind ... -print0withread -d ''or awhile readloop instead.
- Quote every variable expansion by default — write
"$var"and"$@"everywhere, and only drop the quotes deliberately when you actually want word-splitting or globbing. - Put
set -euo pipefailat the top of every non-trivial script so that an unset variable, a failed command, or a broken pipe stops execution instead of running on with empty values. - Run
shellcheckon every script — it ships in theshellcheckpackage on Debian and Ubuntu and flags unquoted expansions, the$10trap, and missing"$@"before they reach production. - Validate required arguments explicitly with
${1:?usage message}or an$#check at the top of the script, so a missing argument fails loudly with a usage line rather than running with a blank. - Use
getoptsfor flag parsing instead of hand-rolledcasechains on$1; it handles bundling and option arguments correctly and is built in, so it adds no dependency. - Prefer
$(command)over backticks for command substitution — it nests without escaping and reads clearly, and assign the result to a named variable so the intent is visible. - Brace your expansions when a name is followed by text —
${file}_backup— and reach for parameter expansion like${var:-default}instead of spawningsedorcutfor simple string work.
/bin/sh; the same $1, "$@", and ${var:-default} rules, minus bashisms like arrays and [[ ]]Zsh — does not word-split unquoted expansions by default, which changes the quoting calculus on macOS scriptsPowerShell — typed objects and $args / param() blocks rather than untyped strings and positional $1Knowledge Check
Why does VAR=hello; bash -c 'echo $VAR' print a blank line while the same command with export VAR=hello prints hello?
- Without
exportthe variable lives only in the current shell; only exported variables are copied into a child process's environment - The single quotes around
echo $VARin the child command suppress the expansion entirely unless the variable happens to have been exported beforehand - Child shells reset every inherited variable back to empty unless it was explicitly declared with
local bash -cruns in a restricted mode that ignores non-exported assignments by design
You are passing a script's arguments through to another command and some arguments contain spaces. Which form preserves each argument exactly?
"$@"— it expands to one quoted word per argument, keeping spaces inside each intact$*— left unquoted, it preserves the original argument boundaries including any internal spaces"$*"— quoting it keeps each argument as a separate word$@unquoted — the shell re-joins arguments that belong together
What is the practical reason to quote an expansion as rm "$file" rather than rm $file?
- Unquoted, the value is word-split on whitespace and any glob characters are expanded, so a spaced or empty value targets the wrong files or none
- Quoting forces
rmto ask for confirmation before deleting, which is safer - Without the quotes the variable reference is treated as a literal string, so
rmends up trying to delete a single file that is actually named$fileon disk - Quoting is required only for exported variables; locally scoped ones are perfectly safe to leave unquoted
In arithmetic with set -u not enabled, why can total=$((total + n)) silently produce a wrong result?
- An unset or misspelled variable name evaluates to
0inside$(( )), so a typo contributes zero instead of erroring - Arithmetic context silently truncates every multi-digit value down to a single leading digit unless you wrap each name in braces first
- The shell performs floating-point rounding inside
$(( ))that loses precision $(( ))reads variables as strings, so addition concatenates them instead of summing
You got correct