A beginner-friendly introduction to Bash scripting

Bash scripts turn command-line commands into small programs. This guide covers the basics: variables, arguments, functions, conditions, loops, and exit codes.

Bash is the Toyota Corolla of programming languages. It is not glamorous, but it is already on most servers and gets everyday jobs done. If the command line is new to you, the beginner's guide to the Unix-style command line covers the surrounding shell basics first.

Your first script

Create a file named hello.sh:

#!/usr/bin/env bash
 
name=${1:-world}
printf 'Hello, %s!\n' "$name"

Make it executable and run it:

$ chmod +x hello.sh
$ ./hello.sh Bob
Hello, Bob!

You can also pass the script directly to Bash. This does not require executable permissions:

$ bash hello.sh Bob
Hello, Bob!

${1:-world} means “use the first argument, or world if it is missing.”

The shebang

The first line is called the shebang:

#!/usr/bin/env bash

It tells the operating system to run the file with Bash. It must be the first line in the file.

You may also see #!/bin/bash. That uses Bash at one exact path. #!/usr/bin/env bash finds Bash through your PATH, which is often more portable.

Script arguments

Bash numbers arguments from left to right:

#!/usr/bin/env bash
 
printf 'Script: %s\n' "$0"
printf 'First:  %s\n' "$1"
printf 'Second: %s\n' "$2"
printf 'Count:  %s\n' "$#"
  • $0 is the script name
  • $1, $2, and so on are arguments
  • $# is the number of arguments
  • "$@" expands to all arguments while keeping each one separate

Use "$@" when passing arguments to another command or looping over them:

for argument in "$@"; do
  printf 'Argument: %s\n' "$argument"
done

Quoting matters. If an argument is Good morning, "$@" keeps it as one argument instead of splitting it into two words.

Variables

Assign variables without spaces around =:

greeting="Hello"
name="Ada Lovelace"
 
printf '%s, %s!\n' "$greeting" "$name"

Write "$name" to read a variable. Double quotes prevent spaces and wildcard characters in its value from being treated as shell syntax.

Use braces when text touches the variable name:

file_name="photo"
printf '%s\n' "images/${file_name}.jpg"

Use readonly for a value that should not change:

readonly backup_dir="/var/backups"

By convention, environment variables and constants often use uppercase names. Regular script variables usually use lowercase names.

Functions

Define a function, then call it by name:

say_hello() {
  printf 'Hello!\n'
}
 
say_hello

Functions receive arguments the same way scripts do:

say_hello() {
  local name=$1
  printf 'Hello, %s!\n' "$name"
}
 
say_hello "Ada Lovelace"

local keeps the variable inside the function. This avoids accidentally changing a variable elsewhere in the script.

Getting a value from a function

In Bash, return sets a function's exit status. It does not return a string. Print the value and capture it with command substitution instead:

make_greeting() {
  local name=$1
  printf 'Hello, %s!' "$name"
}
 
greeting=$(make_greeting "Ada")
printf '%s\n' "$greeting"

Use return 0 for success and a non-zero status for failure:

is_directory() {
  [[ -d $1 ]]
}
 
if is_directory "images"; then
  printf 'The images directory exists\n'
fi

Conditions

For Bash scripts, [[ ... ]] is the clearest way to test strings and files:

if [[ -z $name ]]; then
  printf 'Name is empty\n'
elif [[ $name == "Ada" ]]; then
  printf 'Hello, Ada!\n'
else
  printf 'Hello, %s!\n' "$name"
fi

Some useful tests:

TestMeaning
[[ -z $value ]]The string is empty
[[ -n $value ]]The string is not empty
[[ $a == $b ]]The strings are equal
[[ $a != $b ]]The strings are different
[[ -e $path ]]The path exists
[[ -f $path ]]The path is a regular file
[[ -d $path ]]The path is a directory
[[ -r $path ]]The path is readable
[[ -w $path ]]The path is writable
[[ -x $path ]]The path is executable
(( count > 10 ))The numeric comparison is true

Combine tests with && for “and” and || for “or”:

if [[ -n $name && -d $backup_dir ]]; then
  printf 'Ready to back up %s\n' "$name"
fi

Use ! to negate a test:

if [[ ! -f $config_file ]]; then
  printf 'Config file not found: %s\n' "$config_file" >&2
fi

>&2 sends the message to standard error, which is where errors belong.

Loops

A for loop works well for a known list of values:

for name in Ada Grace Linus; do
  printf 'Hello, %s!\n' "$name"
done

A while loop repeats as long as its condition succeeds:

count=1
 
while (( count <= 3 )); do
  printf 'Count: %d\n' "$count"
  ((count += 1))
done

Arrays

Bash arrays hold ordered lists. When you need text keys instead of numeric positions, Bash associative arrays are the next step:

names=("Ada Lovelace" "Grace Hopper" "Linus Torvalds")
 
printf 'First: %s\n' "${names[0]}"
 
for name in "${names[@]}"; do
  printf 'Hello, %s!\n' "$name"
done

Like "$@", "${names[@]}" keeps every item separate.

Choosing with case

case is easier to read than a long chain of string comparisons:

case ${1:-} in
  start)
    printf 'Starting\n'
    ;;
  stop)
    printf 'Stopping\n'
    ;;
  *)
    printf 'Usage: %s {start|stop}\n' "$0" >&2
    exit 1
    ;;
esac

${1:-} safely expands to an empty string when no first argument exists.

Exit codes

Every command ends with a numeric status. Zero means success; any other value means failure. The script's status is the status of its last command unless you use exit explicitly.

if [[ $# -ne 2 ]]; then
  printf 'Usage: %s SOURCE DESTINATION\n' "$0" >&2
  exit 1
fi

You can inspect the previous command's status with $?, but testing the command directly is usually clearer:

if cp "$1" "$2"; then
  printf 'Copy complete\n'
else
  printf 'Copy failed\n' >&2
  exit 1
fi

A small complete script

This script checks its arguments and creates a backup of one file:

#!/usr/bin/env bash
 
if [[ $# -ne 2 ]]; then
  printf 'Usage: %s FILE BACKUP_DIR\n' "$0" >&2
  exit 1
fi
 
file=$1
backup_dir=$2
 
if [[ ! -f $file ]]; then
  printf 'File not found: %s\n' "$file" >&2
  exit 1
fi
 
if ! mkdir -p "$backup_dir"; then
  printf 'Could not create directory: %s\n' "$backup_dir" >&2
  exit 1
fi
 
if ! cp "$file" "$backup_dir/"; then
  printf 'Copy failed\n' >&2
  exit 1
fi
 
printf 'Backed up %s to %s\n' "$file" "$backup_dir"

That is enough Bash for many everyday scripts. Keep commands simple, quote variable expansions, and treat non-zero exit codes as failures.