Bash Scripting Basics: Your First Useful Shell Script
On this page
A Bash script is just the commands you would type at the prompt, saved in a file so you can run them again. If you have ever repeated the same five commands to deploy a site or back up a folder, you already have your first script.
This guide covers the essentials you need to write small scripts that work reliably, and ends with a complete example you can adapt.
Your first script
Create a file called hello.sh:
#!/usr/bin/env bash
echo "Hello, $USER. Today is $(date +%A)."
Make it executable and run it:
$ chmod +x hello.sh
$ ./hello.sh
Hello, alice. Today is Sunday.
The first line is the shebang. It tells the system which interpreter to run the file with. #!/usr/bin/env bash finds bash on your PATH, which also works on systems where Bash is not at /bin/bash; #!/bin/bash is fine too on Linux. Do not use #!/bin/sh for a Bash script: on Debian and Ubuntu, sh is a smaller shell called dash, and Bash features like [[ ]] will fail.
The ./ is needed because the current directory is not on your PATH. You can also run a script with bash hello.sh, which does not need the execute bit. See the permissions guide for what chmod +x does.
Variables and quoting
name="Alice"
count=3
echo "Hello, $name. You have $count new messages."
There must be no spaces around =. name = "Alice" tries to run a command called name.
Quoting is the most important habit in Bash:
- Double quotes keep the text together as one word but still expand
$variablesand$(commands). - Single quotes keep everything literal:
'$name'prints$name. - No quotes lets Bash split the value on spaces and expand wildcards, which breaks on file names like
My Documents.
file="My Report.txt"
rm $file # wrong: tries to remove "My" and "Report.txt"
rm "$file" # right
When in doubt, put double quotes around every variable. Use ${name} when the name runs into other text: "${name}_backup". Store the output of a command with $(...), and do arithmetic with $((...)):
today=$(date +%Y-%m-%d)
files=$(ls | wc -l)
next=$((count + 1))
Default values are handy for optional settings: ${2:-/tmp} means "the second argument, or /tmp if it is empty or missing".
Script arguments
Arguments passed to a script are available as special variables:
| Variable | Meaning |
|---|---|
$0 | The script's name as it was called |
$1, $2, ... | The first, second, ... argument (${10} from ten on) |
$# | Number of arguments |
"$@" | All arguments, each kept as a separate word |
"$*" | All arguments joined into one string |
$? | Exit status of the last command |
Always write "$@" with the quotes when passing arguments on. With arguments "one two" and three:
for arg in "$@"; do echo "[$arg]"; done
[one two]
[three]
for arg in $@; do echo "[$arg]"; done
[one]
[two]
[three]
Conditions: if, test and [[ ]]
if runs a command and checks its exit status. Most often that command is a test, written with [[ ]] in Bash:
if [[ -f config.ini ]]; then
echo "Config found"
elif [[ -d config ]]; then
echo "Config directory found"
else
echo "No config" >&2
fi
Common tests:
| Test | True when |
|---|---|
-f file | File exists and is a regular file |
-d dir | Directory exists |
-e path | Anything exists at that path |
-r, -w, -x | Readable, writable, executable |
-z "$s" / -n "$s" | String is empty / not empty |
"$a" == "$b", != | Strings equal / not equal |
$a -eq $b, -ne, -lt, -le, -gt, -ge | Number comparisons |
Use -lt and friends for numbers. Inside [[ ]], < and > compare strings alphabetically, so [[ 10 > 9 ]] is false. For arithmetic, (( count > 9 )) is often clearer.
[[ ]] also matches wildcards and regular expressions:
if [[ $file == *.txt ]]; then echo "text file"; fi
if [[ $input =~ ^[0-9]+$ ]]; then echo "a number"; fi
[ ] (the same as the test command) is the older, portable version. It works in any POSIX shell, but it is stricter about quoting and has no pattern matching. In Bash scripts, prefer [[ ]]. For short checks, && and || work as compact if statements:
[[ -d logs ]] || mkdir logs
command -v rsync >/dev/null || { echo "rsync is not installed" >&2; exit 1; }
Choosing with case
case compares one value against several patterns, which is cleaner than a long if/elif chain:
case "$1" in
start)
echo "Starting..." ;;
stop|halt)
echo "Stopping..." ;;
*.conf)
echo "Using config file $1" ;;
*)
echo "Usage: $0 {start|stop|FILE.conf}" >&2
exit 1 ;;
esac
Exit codes
Every command returns a number when it finishes: 0 means success, and 1 to 255 mean some kind of failure. Your script should do the same. Use exit 0 when everything worked and a non-zero exit on errors, and send error messages to standard error with >&2 so they do not mix with normal output.
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
$ echo $?
2
If a script ends without exit, its exit status is that of the last command it ran.
set -euo pipefail, with caveats
Many scripts start with this line, sometimes called "strict mode":
set -euo pipefail
-e: exit as soon as a command fails.-u: treat using an unset variable as an error, which catches typos like$fiel.-o pipefail: a pipeline fails if any command in it fails, not only the last one.
It catches many mistakes, and it is a good default for small scripts. But it is not magic, and -e in particular has surprising rules:
- It is ignored for commands in an
iforwhilecondition and for all but the last command in a&&or||list. A function called asif myfunc; thenkeeps running after failures inside it. ((count++))returns a failure status whencountwas 0, soset -eexits your script at that line. Usecount=$((count + 1))instead.- Commands that "fail" normally trip it, such as
grepfinding no match. Writegrep pattern file || truewhen no match is acceptable. - With
pipefail,somecommand | head -1can fail whenheadexits early and the first command gets a "broken pipe" signal.
Use strict mode, but still check important steps explicitly, and test your script's failure paths. The set builtin section of the manual describes the exact rules.
Reading input
read takes a line from the user (or from a pipe). Always use -r so backslashes are kept as typed:
read -r -p "Project name: " project
read -r -s -p "Password: " password; echo
read -r -p "Continue? [y/N] " answer
if [[ $answer == [yY] ]]; then
echo "Continuing with $project"
fi
-p shows a prompt and -s hides what is typed. To process a file line by line, see the Bash loops guide.
A complete example
This script archives a directory into a dated .tar.gz file. It checks its arguments, uses a default destination, quotes every variable, and reports errors properly:
#!/usr/bin/env bash
# backup.sh - archive a directory into a dated .tar.gz file
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") SOURCE_DIR [DEST_DIR]" >&2
exit 1
}
[[ $# -ge 1 ]] || usage
src=${1%/} # strip a trailing slash
dest=${2:-"$HOME/backups"} # default destination
if [[ ! -d $src ]]; then
echo "Error: '$src' is not a directory" >&2
exit 2
fi
mkdir -p -- "$dest"
stamp=$(date +%Y-%m-%d_%H%M%S)
name=$(basename -- "$src")
archive="$dest/${name}_${stamp}.tar.gz"
tar -czf "$archive" -C "$(dirname -- "$src")" -- "$name"
echo "Created $archive ($(du -h "$archive" | cut -f1))"
$ ./backup.sh ~/projects/site
Created /home/alice/backups/site_2026-10-25_141502.tar.gz (2.4M)
$ ./backup.sh nope
Error: 'nope' is not a directory
The -- after commands like mkdir and tar marks the end of options, so a directory name starting with - cannot be mistaken for an option. To check your scripts for common mistakes automatically, install shellcheck and run shellcheck backup.sh.
Quick reference
- Start with
#!/usr/bin/env bash, thenchmod +x script.sh. var=valuewith no spaces; use it as"$var".$1,$#,"$@"for arguments.if [[ -f $file ]]; then ... fifor tests.case "$x" in pattern) ... ;; esacfor several choices.exit 0on success, non-zero on failure; errors to>&2.set -euo pipefailas a safety net, not a guarantee.read -r -p "Prompt: " varto ask the user.