Bash Scripting Basics: Your First Useful Shell Script

Scripting · 8 min read · Updated 25 Oct 2026

On this page
  1. Your first script
  2. Variables and quoting
  3. Script arguments
  4. Conditions: if, test and [[ ]]
  5. Choosing with case
  6. Exit codes
  7. set -euo pipefail, with caveats
  8. Reading input
  9. A complete example
  10. Quick reference

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 $variables and $(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:

VariableMeaning
$0The 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:

TestTrue when
-f fileFile exists and is a regular file
-d dirDirectory exists
-e pathAnything exists at that path
-r, -w, -xReadable, writable, executable
-z "$s" / -n "$s"String is empty / not empty
"$a" == "$b", !=Strings equal / not equal
$a -eq $b, -ne, -lt, -le, -gt, -geNumber 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 if or while condition and for all but the last command in a && or || list. A function called as if myfunc; then keeps running after failures inside it.
  • ((count++)) returns a failure status when count was 0, so set -e exits your script at that line. Use count=$((count + 1)) instead.
  • Commands that "fail" normally trip it, such as grep finding no match. Write grep pattern file || true when no match is acceptable.
  • With pipefail, somecommand | head -1 can fail when head exits 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, then chmod +x script.sh.
  • var=value with no spaces; use it as "$var".
  • $1, $#, "$@" for arguments.
  • if [[ -f $file ]]; then ... fi for tests.
  • case "$x" in pattern) ... ;; esac for several choices.
  • exit 0 on success, non-zero on failure; errors to >&2.
  • set -euo pipefail as a safety net, not a guarantee.
  • read -r -p "Prompt: " var to ask the user.