Shell Scripting Basics
Writing small bash scripts: the shebang, variables and quoting, conditions, loops, exit codes and functions.
A first script
A shell script is a text file that contains shell commands, run from top to bottom by a shell such as bash. Anything you can type at the prompt can go in a script, so a script is the natural next step once you catch yourself typing the same commands again: it saves time, and it does the job the same way every time. This page uses bash, the default shell on most Linux distributions (see The Shell and the Terminal).
cat > hello.sh <<'EOF'
#!/bin/bash
# hello.sh - a first script
echo "Hello from a script"
echo "Arguments given: $#"
EOF
chmod +x hello.sh
./hello.sh
bash hello.sh one two
Hello from a script Arguments given: 0 Hello from a script Arguments given: 2
The examples on this page create their script files with
cat > file <<'EOF' (a here document, see
Pipes, Redirection and Filters) so that each one runs on its own. In practice you write the
script in an editor such as nano or vim. Lines that start with
# are comments.
This page covers the basics of bash scripts. Bash & PowerShell Scripting goes further, with PowerShell side by side, error handling, JSON and APIs, and scripts that are safe to schedule.
The shebang line
The first line, #!/bin/bash, is the
shebang: the characters
#! followed by the path of the interpreter that should
run the file. When you start a script as a program, the kernel reads
this line and runs /bin/bash hello.sh for you. Without
it, the file runs in whatever shell happens to start it, which may
not be bash, and started directly by systemd or another program that
does not go through a shell, it fails with "Exec format error".
-
#!/bin/bashnames the path directly. Bash lives there on practically every Linux distribution. -
#!/usr/bin/env bashasksenvto findbashthroughPATH. It is more portable, for example to macOS or FreeBSD, where a newer bash is installed elsewhere, but it runs whicheverbashcomes first in the user'sPATH. -
#!/bin/shasks for a plain POSIX shell (POSIX, the Portable Operating System Interface, is the standard that defines what Unix-like shells must support). On Debian and Ubuntu that isdash, which does not understand bash features such as[[ ]]and arrays. Use it only for scripts written forsh.
Making it executable and running it
There are two ways to run a script, and they need different permissions:
| Command | Needs | What happens |
|---|---|---|
./hello.sh |
Read and execute permission (chmod +x) |
The kernel reads the shebang and starts that interpreter |
bash hello.sh |
Read permission only | Bash runs the file itself; the shebang is just a comment |
source hello.sh or . hello.sh
|
Read permission only |
Runs the commands in your current shell, so
variables and cd stay in effect afterward
|
The ./ is needed because the shell looks for commands
only in the directories listed in PATH, and the current
directory is deliberately not one of them. Forgetting
chmod +x gives this:
$ ./hello.sh
bash: ./hello.sh: Permission denied
chmod +x adds execute permission for everyone; use
chmod u+x or chmod 750 if only you or your
group should run it (see
Users, Groups and Permissions in Practice). Scripts you want to run from anywhere go in
~/bin or ~/.local/bin (in
PATH on Debian and Ubuntu once the directory exists)
or, for all users, /usr/local/bin.
Variables and quoting
A shell variable is a name
that holds a piece of text. You assign it with
name=value and read it with $name or
${name}. The braces are needed when letters follow
directly, as in ${city}-Centraal below.
city="Utrecht"
stations=4
echo "$city has $stations train stations"
echo "Main one: ${city}-Centraal"
cat > oops.sh <<'EOF'
#!/bin/bash
city = "Utrecht"
EOF
bash oops.sh
echo "exit status $?"
Utrecht has 4 train stations Main one: Utrecht-Centraal oops.sh: line 2: city: command not found exit status 127
Variables are text: stations=4 stores the character
"4", and arithmetic happens only where you ask for it (below). By
convention, your own variables use lowercase names, so they do not
collide with environment variables such as PATH and
HOME. A shell variable exists only in the current
shell; export turns it into an
environment variable
that child processes inherit:
color="blue"
bash -c 'echo "Child process sees: [$color]"'
export color
bash -c 'echo "Child process sees: [$color]"'
Child process sees: [] Child process sees: [blue]
Quoting
Quoting tells the shell which
characters to take literally. It matters because, after replacing an
unquoted $variable with its value, the shell splits
that value at spaces and expands wildcards in it. A file name with a
space becomes two arguments:
file="annual report.txt"
touch "$file"
ls $file
echo "---"
ls "$file"
ls: cannot access 'annual': No such file or directory ls: cannot access 'report.txt': No such file or directory --- annual report.txt
The three kinds of quoting:
price=12
echo "Total: $price euro"
echo 'Total: $price euro'
echo "Literal dollar: \$price"
Total: 12 euro Total: $price euro Literal dollar: $price
-
Double quotes
"…"keep the text together as one argument but still expand$variables,$(commands)and$((arithmetic)). -
Single quotes
'…'take everything literally; nothing inside is expanded. -
A backslash
\takes the next single character literally.
The rule that prevents most script bugs:
always write "$var", unless you know exactly why you want the splitting.
Command substitution and arithmetic
Command substitution,
$(command), runs a command and puts its output in
place, with trailing newlines removed. You will also see the older
backtick form `command`, which is harder to read and to
nest. Arithmetic expansion, $(( … )), calculates with
whole numbers; inside it, variables need no $.
printf '%s\n' Amsterdam Rotterdam Utrecht > cities.txt
count=$(wc -l < cities.txt)
first=$(head -n 1 cities.txt)
echo "cities.txt has $count lines; the first is $first"
echo "7 * 6 = $(( 7 * 6 ))"
echo "7 / 2 = $(( 7 / 2 )), remainder $(( 7 % 2 ))"
total=$(( count * 250 ))
echo "Total: $total"
cities.txt has 3 lines; the first is Amsterdam 7 * 6 = 42 7 / 2 = 3, remainder 1 Total: 750
Bash arithmetic has no decimals: 7 / 2 is 3. For
fractions, use a tool such as awk or bc,
or write the script in Python instead.
Arguments and exit status
Arguments
Words after the script name arrive as numbered positional parameters:
| Parameter | Contains |
|---|---|
$0 |
The name the script was started with |
$1, $2 … ${10} |
The first, second … tenth argument |
$# |
The number of arguments |
"$@" |
All arguments, each as its own word, exactly as given |
"$*" |
All arguments joined into one word |
$? |
The exit status of the last command |
cat > args.sh <<'EOF'
#!/bin/bash
echo "Script: $0"
echo "First: $1"
echo "Second: $2"
echo "Count: $#"
echo 'With "$@":'
for arg in "$@"; do
echo " [$arg]"
done
echo 'With $@ unquoted:'
for arg in $@; do
echo " [$arg]"
done
EOF
bash args.sh "New York" Paris
Script: args.sh First: New York Second: Paris Count: 2 With "$@": [New York] [Paris] With $@ unquoted: [New] [York] [Paris]
Only "$@", in double quotes, passes the arguments on
exactly as the user typed them. A missing argument is simply empty;
${1:-default} supplies a fallback value:
cat > greet.sh <<'EOF'
#!/bin/bash
name="${1:-colleague}"
echo "Good morning, $name"
EOF
bash greet.sh
bash greet.sh Ana
Good morning, colleague Good morning, Ana
Exit status
Every command ends with an
exit status (exit code): a number
from 0 to 255. 0 means success; any other value
means some kind of failure, and the command's manual page says
which. The special parameter $? holds the status of the
command that just finished. A script sets its own status with
exit n; without it, the script returns the status of
its last command.
printf '09:00 INFO start\n09:05 ERROR disk full\n' > app.log
grep -q "ERROR" app.log
echo "grep found ERROR: exit status $?"
grep -q "CRITICAL" app.log
echo "grep found CRITICAL: exit status $?"
ls missing.txt
echo "ls on a missing file: exit status $?"
cat > check.sh <<'EOF'
#!/bin/bash
echo "Checking..."
exit 3
EOF
bash check.sh
echo "check.sh ended with exit status $?"
grep found ERROR: exit status 0 grep found CRITICAL: exit status 1 ls: cannot access 'missing.txt': No such file or directory ls on a missing file: exit status 2 Checking... check.sh ended with exit status 3
grep uses 1 for "nothing found" and 2 for a real error;
the shell itself uses 126 for "found but not executable" and 127 for
"command not found". $? is overwritten by every
command, including echo, so read it immediately or save
it: status=$?.
Conditions
if runs a command and looks at its exit status: 0 takes
the then branch, anything else the elif or
else branch. The command is often [, also
called test, which exists only to compare things and
return 0 or 1. Because [ is a command, it needs spaces
around it and around every part inside.
mkdir reports
printf 'quarterly figures\n' > reports/q3.txt
: > empty.txt
for path in reports reports/q3.txt empty.txt notes.txt; do
if [ -d "$path" ]; then
echo "$path: directory"
elif [ -s "$path" ]; then
echo "$path: file with content"
elif [ -f "$path" ]; then
echo "$path: empty file"
else
echo "$path: does not exist"
fi
done
reports: directory reports/q3.txt: file with content empty.txt: empty file notes.txt: does not exist
Test operators
| Kind | Test | True when |
|---|---|---|
| File | -e path |
The path exists (any type) |
| File | -f path |
It is a regular file |
| File | -d path |
It is a directory |
| File | -s path |
It exists and is not empty |
| File |
-r / -w / -x path
|
You may read / write / execute it |
| File | -L path |
It is a symbolic link |
| String | "$a" = "$b" |
The strings are equal (!= for not equal) |
| String | -z "$a" |
The string is empty |
| String | -n "$a" |
The string is not empty |
| Number | "$a" -eq "$b" |
Equal (-ne not equal) |
| Number | -lt / -le |
Less than / less than or equal |
| Number | -gt / -ge |
Greater than / greater than or equal |
| Logic | ! test |
The test is false |
Strings and numbers have separate operators. < and
> compare text alphabetically, character by
character, which gives surprising answers for numbers:
disk_used=91
name=""
if [ "$disk_used" -ge 90 ]; then
echo "Disk almost full: ${disk_used}%"
fi
if [ -z "$name" ]; then
echo "No name given"
fi
if [ "10" \< "9" ]; then
echo "As strings, 10 sorts before 9"
fi
if [ 10 -gt 9 ]; then
echo "As numbers, 10 is greater than 9"
fi
Disk almost full: 91% No name given As strings, 10 sorts before 9 As numbers, 10 is greater than 9
(Inside [ ], < needs a backslash,
otherwise the shell reads it as input redirection.)
[ ] versus [[ ]]
[ ] is the portable POSIX test. Bash adds
[[ ]], which is part of the shell's own syntax rather
than a command. That makes it safer: variables inside are not split
into words, so a missing quote does not break the test. With
[ ], an empty unquoted variable simply disappears:
cat > unquoted.sh <<'EOF'
#!/bin/bash
answer=""
if [ $answer = "yes" ]; then
echo "confirmed"
fi
echo "done"
EOF
bash unquoted.sh
unquoted.sh: line 3: [: =: unary operator expected done
[ $answer = "yes" ] became [ = "yes" ].
[[ ]] also understands && and
|| inside, wildcard patterns with ==, and
regular expressions with =~ (see
Searching with grep, find and Regular Expressions):
file="backup-2026.tar.gz"
answer="Yes"
if [[ $file == *.tar.gz ]]; then
echo "$file is a gzip-compressed tar archive"
fi
if [[ $file =~ ([0-9]{4}) ]]; then
echo "Year in the name: ${BASH_REMATCH[1]}"
fi
if [[ $answer == [Yy]* && -n $file ]]; then
echo "Going ahead"
fi
backup-2026.tar.gz is a gzip-compressed tar archive Year in the name: 2026 Going ahead
In bash scripts, prefer [[ ]]. Keep the pattern on the
right unquoted when you want it treated as a pattern:
[[ $file == "*.tar.gz" ]] compares with the literal
text *.tar.gz. For arithmetic comparisons,
(( disk_used >= 90 )) is a readable alternative to
-ge.
&& and ||
a && b runs b only if
a succeeded; a || b runs
b only if a failed. They are handy for
short checks:
mkdir -p backup && echo "backup directory ready"
[ -d old-reports ] || echo "no old-reports directory, nothing to archive"
printf 'INFO all good\n' > today.log
grep -q "ERROR" today.log && echo "errors found" || echo "no errors found"
backup directory ready no old-reports directory, nothing to archive no errors found
case
case compares one value with a list of wildcard
patterns and runs the first branch that matches. It is clearer than
a long elif chain. Each branch ends with
;;, | separates alternatives, and
*) catches everything else.
for file in report.pdf photo.JPG notes.txt site.tar.gz; do
case "$file" in
*.pdf) echo "$file: document" ;;
*.jpg | *.JPG | *.png) echo "$file: image" ;;
*.tar.gz | *.tgz) echo "$file: archive" ;;
*) echo "$file: other" ;;
esac
done
report.pdf: document photo.JPG: image notes.txt: other site.tar.gz: archive
Loops
for
for runs its body once for each word in a list. The
list can be written out, come from a wildcard pattern, or be
"$@". Bash also has a counting form with double
parentheses.
for server in web01 web02 db01; do
echo "Checking $server"
done
touch access.log error.log notes.txt
for log in *.log; do
echo "Found log: $log"
done
for (( i = 1; i <= 3; i++ )); do
echo "Attempt $i"
done
Checking web01 Checking web02 Checking db01 Found log: access.log Found log: error.log Attempt 1 Attempt 2 Attempt 3
Loop over files with a wildcard, never over the output of
ls: the pattern handles names with spaces correctly,
$(ls) does not. One catch: when a pattern matches
nothing, bash leaves it as the literal text. Turn on
nullglob to get an empty list instead:
for f in *.csv; do
echo "Processing $f"
done
shopt -s nullglob
for f in *.csv; do
echo "Processing $f"
done
echo "Loop finished"
Processing *.csv Loop finished
while and reading lines
while repeats as long as its command succeeds. The most
useful form reads a file line by line:
cat > stock.csv <<'EOF'
Laptop stand,14
USB-C cable,120
Monitor arm,0
EOF
while IFS=, read -r item quantity; do
if [ "$quantity" -eq 0 ]; then
echo "Reorder: $item"
else
echo "In stock: $item ($quantity)"
fi
done < stock.csv
In stock: Laptop stand (14) In stock: USB-C cable (120) Reorder: Monitor arm
-
< stock.csvafterdonefeeds the file to the whole loop;readtakes one line per round and fails at the end of the file, which ends the loop. -
IFS=,splits each line at commas, just for thisread; the last variable gets the rest of the line. For whole lines, usewhile IFS= read -r line, which also keeps leading spaces. -
-rkeeps backslashes as they are (see Reading input safely).
A counting while loop:
count=0
while [ "$count" -lt 3 ]; do
count=$(( count + 1 ))
echo "Try $count"
done
Try 1 Try 2 Try 3
break leaves a loop early and continue
skips to the next round, as in most languages.
Functions
A function groups commands under a name, so the script reads like a
list of steps. It is called like a command, receives its own
$1, $2 and $#, and ends with
an exit status like a command: the one given to return,
or else that of its last command.
greet() {
local name="$1"
echo "Hello, $name"
}
is_weekend() {
case "$1" in
Sat | Sun) return 0 ;;
*) return 1 ;;
esac
}
name="outer"
greet "Ana"
echo "name is still: $name"
for day in Fri Sat; do
if is_weekend "$day"; then
echo "$day: weekend"
else
echo "$day: workday"
fi
done
message=$(greet "Ben")
echo "Captured: $message"
Hello, Ana name is still: outer Fri: workday Sat: weekend Captured: Hello, Ben
-
Variables in bash are global unless declared
local. Withoutlocal,greetwould have overwrittenname. -
returnonly sets the exit status (0 to 255), which makes functions likeis_weekendusable inif. To hand back text,echoit and capture it with$( ). - A function must be defined before the line that calls it.
Safer scripts
set -euo pipefail
By default bash carries on after a failed command. In a script that
cleans up, deploys or deletes, that is dangerous: a failed
cd leaves you in the wrong directory, and a typo in a
variable name silently becomes an empty string. Many scripts
therefore start with set -euo pipefail:
| Option | Effect |
|---|---|
-e (errexit) |
Exit as soon as a command fails |
-u (nounset) |
Treat the use of an unset variable as an error and exit |
-o pipefail |
A pipeline fails if any command in it fails, not only the last one |
cat > nosafety.sh <<'EOF'
#!/bin/bash
cd missing-dir
echo "Still running, in the wrong directory"
echo "Cleaning up files of: $custmer"
EOF
cat > safe.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd missing-dir
echo "never printed"
EOF
cat > typo.sh <<'EOF'
#!/bin/bash
set -euo pipefail
customer="acme"
echo "Cleaning up files of: $custmer"
EOF
bash nosafety.sh; echo "--- exit status $?"
bash safe.sh; echo "--- exit status $?"
bash typo.sh; echo "--- exit status $?"
nosafety.sh: line 2: cd: missing-dir: No such file or directory Still running, in the wrong directory Cleaning up files of: --- exit status 0 safe.sh: line 3: cd: missing-dir: No such file or directory --- exit status 1 typo.sh: line 4: custmer: unbound variable --- exit status 1
Without the options, the first script reported success although both
of its problems went unnoticed. And pipefail
changes what a pipeline reports:
false | true
echo "Without pipefail: $?"
set -o pipefail
false | true
echo "With pipefail: $?"
Without pipefail: 0 With pipefail: 1
set -e has exceptions you need to know. It does not
react to a failing command that is itself being tested: in an
if or while condition, before
&& or ||, or after !.
And some commands "fail" as part of normal work:
grep with no match returns 1, and so does the
arithmetic command (( )) when its result is 0:
cat > counter.sh <<'EOF'
#!/bin/bash
set -e
count=0
(( count++ ))
echo "count is $count"
EOF
bash counter.sh
echo "exit status $?"
exit status 1
count++ returns the old value, 0, so the command
"fails" and the script stops silently. Write
count=$(( count + 1 )) instead, and add
|| true to commands such as grep whose
"nothing found" is not an error for you. Treat
set -euo pipefail as a safety net, not as a replacement
for checking the things that matter. Its full list of exceptions,
including functions called from if or ||,
is on
Error Handling › Where set -e does not help.
Reading input safely
read reads one line from standard input into variables.
Always give it -r: without it, backslashes are treated
as escape characters and disappear. -p "text" shows a
prompt when someone types at a terminal.
printf 'C:\\new\\temp\n' > paths.txt
read line < paths.txt
echo "Without -r: $line"
read -r line < paths.txt
echo "With -r: $line"
Without -r: C:newtemp With -r: C:\new\temp
Treat everything a user or a file supplies as untrusted: check that
it has the form you expect before using it, and reject anything
else. A regular expression in [[ ]] does that in one
line. Send error messages to standard error with
>&2 and exit with a non-zero status:
cat > order.sh <<'EOF'
#!/bin/bash
read -r -p "Quantity: " qty
if [[ ! $qty =~ ^[0-9]+$ ]]; then
echo "Not a whole number: '$qty'" >&2
exit 1
fi
echo "Ordering $qty items"
EOF
echo "12" | bash order.sh
echo "twelve" | bash order.sh
echo "exit status $?"
Ordering 12 items Not a whole number: 'twelve' exit status 1
Never put passwords or tokens in a script; see Linux Security Basics › Secrets.
A complete script, step by step
Time to combine everything into a tool you could actually use: a script that summarizes a log file, counting the lines per level and listing the error messages. It should behave like a well-mannered command: explain its usage, write errors to standard error, and return an exit status that another script or a scheduled job can act on (0 = no errors, 1 = errors found or unreadable file, 2 = wrong usage).
Step 1: check the arguments
Start with the skeleton: the shebang, the safety options, and a check that exactly one readable file was given. Test the failure cases before writing the real work.
cat > log-summary.sh <<'EOF'
#!/bin/bash
# log-summary.sh - step 1: check the argument
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 LOGFILE" >&2
exit 2
fi
logfile="$1"
if [[ ! -r $logfile ]]; then
echo "Error: cannot read '$logfile'" >&2
exit 1
fi
echo "Summarizing $logfile"
EOF
chmod +x log-summary.sh
printf '2026-10-01 09:00:01 INFO Backup started\n' > app.log
./log-summary.sh; echo "exit status $?"
./log-summary.sh old.log; echo "exit status $?"
./log-summary.sh app.log; echo "exit status $?"
Usage: ./log-summary.sh LOGFILE exit status 2 Error: cannot read 'old.log' exit status 1 Summarizing app.log exit status 0
Step 2: add the work
Now the real work, in two small functions.
usage replaces the inline message.
count_level counts the lines of one level with
grep -c, which prints 0 but exits with status 1 when
nothing matches; || true stops set -e from
treating that as a failure. The error messages are listed with the
pipeline from
Pipes, Redirection and Filters: cut off the date, time and level, then count identical lines.
cat > app.log <<'EOF'
2026-10-01 09:00:01 INFO Backup started
2026-10-01 09:00:04 WARN Slow response from db01
2026-10-01 09:01:12 ERROR Disk quota exceeded
2026-10-01 09:02:30 INFO Retrying upload
2026-10-01 09:02:31 ERROR Disk quota exceeded
2026-10-01 09:03:00 ERROR Connection reset by peer
2026-10-01 09:04:45 INFO Backup finished
EOF
grep -v ERROR app.log > quiet.log
cat > log-summary.sh <<'EOF'
#!/bin/bash
# log-summary.sh - count log levels and list the errors in a log file
# Usage: log-summary.sh LOGFILE
# Exit status: 0 no errors, 1 errors or unreadable file, 2 wrong usage
set -euo pipefail
usage() {
echo "Usage: $0 LOGFILE" >&2
exit 2
}
count_level() {
local level="$1"
grep -c " $level " "$logfile" || true # grep -c exits 1 when it counts 0
}
[[ $# -eq 1 ]] || usage
logfile="$1"
if [[ ! -r $logfile ]]; then
echo "Error: cannot read '$logfile'" >&2
exit 1
fi
echo "Summary of $logfile"
for level in INFO WARN ERROR; do
printf ' %-5s %3d\n' "$level" "$(count_level "$level")"
done
errors=$(count_level ERROR)
if (( errors > 0 )); then
echo "Errors by message:"
grep " ERROR " "$logfile" | cut -d ' ' -f 4- | sort | uniq -c | sort -rn
exit 1
fi
echo "No errors."
EOF
chmod +x log-summary.sh
./log-summary.sh app.log
echo "exit status $?"
./log-summary.sh quiet.log
echo "exit status $?"
Summary of app.log
INFO 3
WARN 1
ERROR 3
Errors by message:
2 Disk quota exceeded
1 Connection reset by peer
exit status 1
Summary of quiet.log
INFO 3
WARN 1
ERROR 0
No errors.
exit status 0
Because it reports through its exit status, the script combines with
other tools:
./log-summary.sh app.log || echo "check the log", or a
nightly job that alerts someone only when the status is not 0 (see
Scheduling Tasks). Before
sharing a script, run it through ShellCheck (shellcheck log-summary.sh, in the package repositories of most distributions): it finds
missing quotes and most of the other traps on this page (see
ShellCheck).