A shell script is a text file of shell commands that run in order, as if you typed them one by one. Scripts automate repeated work: setting up a server, deploying an app, rotating logs, running checks in CI. They also document a procedure, because the steps are written down and repeatable. This note covers the Bash you need to read and write everyday scripts.
Your first script#
A script starts with a shebang line that names the interpreter (more on it below). Comments start with #.
#!/bin/bash
# sysinfo.sh: print basic system information
uname -a
uptime
Then make it executable and run it.
- Save the file, for example as
sysinfo.sh. - Make it executable:
chmod +x sysinfo.sh. - Run it with a path:
./sysinfo.sh.
The ./ matters. The shell searches only the directories in $PATH for bare command names, and the current directory usually isn't one of them.
The shebang#
Back to that first line. #! followed by a path tells the kernel which program runs the file. #!/bin/bash uses Bash. #!/usr/bin/env bash finds bash on the PATH, which helps on systems where Bash lives elsewhere, such as macOS with Homebrew.
Without a shebang, the file usually runs in sh or in your current shell, depending on how you start it. Bash features may then fail. On Debian and Ubuntu, /bin/sh is dash, not Bash.
Variables#
A script that only runs fixed commands gets limited fast. Variables let you store values and reuse them. Assign with name=value and read with $name, with no spaces around =.
name="Ada"
count=5
echo "Hello, $name. You have $count messages."
echo "Backup: ${name}_backup.tar" # braces mark where the name ends
today=$(date +%F) # command substitution
$(command) runs a command and inserts its output. Variable names are case sensitive, so $Name and $name differ. By convention, environment variables are uppercase (PATH, HOME) and script variables are lowercase.
Quoting#
The echo lines above used double quotes, and that choice matters. Quotes control which special characters the shell interprets.
| Form | Effect | echo of it prints |
|---|---|---|
'$HOME' |
Single quotes: everything literal | $HOME |
"$HOME" |
Double quotes: variables and $( ) expand |
/home/alice |
\$HOME |
Backslash escapes one character | $HOME |
Characters such as $, *, ?, &, |, >, < and spaces have special meaning. Quote or escape them to use them as plain text.
Quote every variable: "$file", not $file. Without quotes, a value with spaces splits into several words, and a * in it expands to filenames.
Arithmetic#
Math on variables needs its own syntax. Bash does integer arithmetic inside $(( )), and variables inside need no $.
x=10
y=3
echo $(( x + y )) # 13
echo $(( x / y )) # 3: integer division
echo $(( x % y )) # 1
(( count++ )) # increment in place
For decimals, use another tool such as bc or Python.
Conditions#
With variables in hand, you can make decisions. if runs a command and checks its exit status (0 means success; more on that later). [ ... ] (or Bash's [[ ... ]]) is the usual test command.
if [[ $x -gt $y ]]; then
echo "$x is greater than $y"
elif [[ $x -eq $y ]]; then
echo "equal"
else
echo "$x is smaller than $y"
fi
These are the tests you'll use most inside the brackets:
| Test | True when |
|---|---|
-eq -ne -lt -le -gt -ge |
Number comparisons |
"$a" == "$b" / != |
Strings are equal / differ |
-z "$s" / -n "$s" |
String is empty / not empty |
-f path / -d path |
A regular file / a directory exists |
-e path |
Anything exists at the path |
Spaces inside the brackets are required: [[ $x -gt 1 ]], not [[$x -gt 1]]. [[ ]] is safer than [ ] because it doesn't split unquoted variables.
Loops#
Loops let you repeat work, such as processing every log file in a directory. for loops over a list. while repeats while a command succeeds.
for i in 1 2 3; do
echo "Number: $i"
done
for f in *.log; do
gzip "$f"
done
n=3
while (( n > 0 )); do
echo "$n"
(( n-- ))
done
while read -r line; do # read a file line by line
echo "> $line"
done < input.txt
Functions#
As a script grows, you'll want to name and reuse blocks of it. A function groups commands under a name. Inside it, $1, $2 and so on are the function's own arguments. Use local so variables don't leak into the rest of the script.
greet() {
local who="$1"
echo "Hello, $who"
}
greet "Ada"
Arguments and special parameters#
Scripts take arguments the same way functions do. A script reads its command-line arguments through special variables.
| Parameter | Meaning |
|---|---|
$0 |
The script's name |
$1 .. $9, ${10} |
Positional arguments |
$# |
Number of arguments |
"$@" |
All arguments, each as a separate word |
"$*" |
All arguments joined into one string |
$? |
Exit status of the last command |
$$ |
PID of the current shell |
#!/bin/bash
# args.sh
echo "Script: $0, $# arguments"
echo "First: $1, second: $2"
Running ./args.sh Hello World prints First: Hello, second: World.
*#
The table lists two ways to get all arguments, and they're easy to mix up. Unquoted, $@ and $* behave the same: both split on spaces. The difference appears inside double quotes.
for a in "$@"; do echo "[$a]"; done
# ./show.sh "my file" two -> [my file] [two]
for a in "$*"; do echo "[$a]"; done
# ./show.sh "my file" two -> [my file two]
Use "$@" to pass arguments on or loop over them. It keeps arguments that contain spaces intact.
Exit status#
The $? parameter from that table, and the if checks earlier, both rely on exit codes. Every command returns an exit status: 0 means success, and 1 to 255 means failure. $? holds the status of the last command. A script returns the status of its last command, or the value you give exit.
mkdir /tmp/testdir
if [[ $? -eq 0 ]]; then
echo "created"
fi
# Shorter: test the command directly
if mkdir /tmp/testdir; then
echo "created"
fi
Some non-zero codes have a standard meaning, so the number alone often tells you what went wrong:
| Code | Usual meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of a shell builtin (bad options) |
| 126 | Found, but not executable |
| 127 | Command not found |
| 128 + n | Killed by signal n: 130 is Ctrl+C (SIGINT), 137 is SIGKILL |
Chaining with && and ||#
Exit codes also let you chain commands without a full if. && runs the next command only if the previous one succeeded. || runs it only if the previous one failed.
mkdir -p build && cd build
cd /missing || exit 1
a && b || c looks like if/else, but it isn't. c also runs when a succeeds and b fails. Use a real if when that matters.
Strict mode#
Everything so far assumes you check for errors yourself. By default, Bash keeps going after a failed command and treats unset variables as empty, which hides bugs. Most production scripts start with strict settings.
#!/usr/bin/env bash
set -euo pipefail
| Option | Effect |
|---|---|
-e |
Exit as soon as a command fails |
-u |
Treat an unset variable as an error |
-o pipefail |
A pipeline fails if any command in it fails, not only the last |
set -e has exceptions. It doesn't trigger inside if conditions or on the left side of && and ||. Treat it as a safety net, and still check errors that matter. Run ShellCheck on your scripts; it catches most quoting and logic mistakes.
Key takeaways#
- Start with a shebang,
chmod +x, and run with./script.sh. name=valuewith no spaces; read with"$name". Quote every variable.- Single quotes are literal; double quotes expand variables.
$(( ))does integer math,$( )captures command output.- Use
"$@"for arguments,$?for the last exit status; 0 means success. - Begin scripts with
set -euo pipefail, and know thata && b || cis not if/else.