Pipes and text

Introduction to Bash Shell Scripting

Learn Bash scripting with executable files, variables, quoting, conditions, loops, functions, arrays, positional arguments, and getopts.

8 minute lesson

~~~

Bash scripts put shell commands into a file so you can repeat a task.

Bash is both a command interpreter and a programming language. It has variables, conditions, loops, arrays, functions, and input/output redirection.

Shell scripts are excellent for connecting command-line tools. When a script grows into a large application, another language may be easier to test and maintain.

Check out my introduction to Bash first if the shell is new to you.

Create a script

Create a file named hello.sh:

#!/usr/bin/env bash

echo "Hello from Bash"

The first line is the shebang. It asks env to find bash in the current PATH.

Use an exact path such as #!/bin/bash when the environment guarantees that location and you want that specific interpreter.

Make the file executable:

chmod u+x hello.sh

Run it:

./hello.sh

Alternatively, pass the file to Bash:

bash hello.sh

In that form, Bash is already the interpreter and does not use the shebang to choose one.

Check syntax without running the script:

bash -n hello.sh

Add comments

A comment starts with #:

#!/usr/bin/env bash

# Print a greeting
echo "Hello"

The shebang is the special first-line exception.

Create variables

Assign a value without spaces around =:

name="Flavio"
count=3

Read a variable with $:

echo "$name"
echo "$count"

Quote variable expansions unless you specifically need word splitting or filename expansion:

file_name="My notes.txt"
cat "$file_name"

Without quotes, Bash can split one value into several arguments and expand wildcard characters.

Environment variables are inherited by child processes only when exported:

export APP_ENV="production"

Capture command output

Use command substitution:

current_date=$(date +%F)
echo "Today is $current_date"

$(...) is easier to nest and read than the older backtick syntax.

Work with exit statuses

Every command returns an exit status.

0 means success. A nonzero value means failure.

Run a second command only when the first succeeds:

mkdir -p backup && cp notes.txt backup/

Run a fallback when the first command fails:

cp notes.txt backup/ || echo "The copy failed" >&2

Negate a command’s status with !:

if ! grep -q "ready" status.txt
then
  echo "Not ready"
fi

Write conditions

Use [[ ... ]] for Bash string and file tests:

dog_name="Roger"

if [[ -z $dog_name ]]
then
  echo "A name is required"
else
  echo "The dog is $dog_name"
fi

-z tests for an empty string. Use -n for a non-empty string.

Compare strings:

if [[ $dog_name == "Roger" ]]
then
  echo "Found Roger"
fi

Test files:

if [[ -f notes.txt ]]
then
  echo "notes.txt is a regular file"
fi

Common file tests include:

  • -e exists
  • -f is a regular file
  • -d is a directory
  • -r is readable
  • -w is writable
  • -x is executable

Use (( ... )) for integer arithmetic:

age=23
minimum=18

if (( age >= minimum ))
then
  echo "Old enough"
fi

Inside arithmetic expressions, use operators such as +, -, *, /, %, <, <=, ==, >=, and >.

The test command and [ ... ] also work, including in POSIX shell scripts. Their parsing rules differ from [[ ... ]], so do not mix the syntaxes blindly.

Use if, elif, and else

The complete shape is:

if command_one
then
  echo "First condition matched"
elif command_two
then
  echo "Second condition matched"
else
  echo "No condition matched"
fi

The commands after if and elif are tested by exit status.

Loop over values

Use for to iterate over a list:

for city in Copenhagen Rome Lisbon
do
  echo "$city"
done

Loop over script arguments without losing their boundaries:

for argument in "$@"
do
  echo "$argument"
done

"$@" expands to one quoted word per argument. Prefer it over $*.

Use while while a command succeeds:

count=1

while (( count <= 3 ))
do
  echo "$count"
  ((count += 1))
done

Use break to leave a loop and continue to skip to its next iteration.

Match values with case

case is clear when one value can match several patterns:

case $1 in
  start)
    echo "Starting"
    ;;
  stop)
    echo "Stopping"
    ;;
  restart|reload)
    echo "Restarting"
    ;;
  *)
    echo "Usage: $0 {start|stop|restart}" >&2
    exit 2
    ;;
esac

The *) branch is the fallback.

Read input

Use read -r so backslashes are not treated as escapes:

read -r -p "Your name: " name
echo "Hello $name"

Check whether input succeeded when a script can receive end-of-file:

if IFS= read -r line
then
  echo "$line"
fi

Setting IFS= preserves leading and trailing whitespace.

Use positional arguments

Bash exposes script arguments as:

  • $0: the script name
  • $1, $2, and so on: positional arguments
  • $#: the number of positional arguments
  • "$@": every positional argument, preserving boundaries

Example:

#!/usr/bin/env bash

if (( $# != 1 ))
then
  echo "Usage: $0 FILE" >&2
  exit 2
fi

file=$1
echo "Processing $file"

Use ${10} for argument ten and higher.

Parse options with getopts

The Bash getopts builtin parses short options:

verbose=false
name=""

while getopts ":vn:" option
do
  case $option in
    v)
      verbose=true
      ;;
    n)
      name=$OPTARG
      ;;
    :)
      echo "Option -$OPTARG needs a value" >&2
      exit 2
      ;;
    \?)
      echo "Unknown option: -$OPTARG" >&2
      exit 2
      ;;
  esac
done

shift "$((OPTIND - 1))"

The option name goes into option. An option’s value goes into OPTARG.

The colon after n in :vn: means -n needs a value. The leading colon lets the script handle errors itself.

After shift, the remaining positional arguments start at $1.

Use -- when calling a command to mark the end of options:

./report.sh -v -- -july.csv

The single hyphen in the old tutorial was incorrect.

Create arrays

Create an indexed array:

breeds=(
  "husky"
  "setter"
  "border collie"
)

Read one element:

echo "${breeds[0]}"

Loop over every element:

for breed in "${breeds[@]}"
do
  echo "$breed"
done

Get the number of elements:

echo "${#breeds[@]}"

Always include the parameter expansion syntax. Writing breeds[0] by itself does not read the value.

Create functions

Define a function:

clean_folder() {
  local folder=$1

  echo "Cleaning $folder"
}

Call it like another command:

clean_folder "/Users/flavio/Desktop"

Function arguments use $1, $2, and "$@".

Variables are not all global. Use the Bash local builtin to keep a function variable inside that function.

Return a status with return:

is_text_file() {
  [[ $1 == *.txt ]]
}

Bash functions return an integer status, not an arbitrary string. Print data to standard output when a caller needs to capture it.

Handle failures

Check failures where you can explain or recover from them:

if ! cp "$source" "$destination"
then
  echo "Could not copy $source" >&2
  exit 1
fi

Many scripts enable stricter behavior with:

set -u
set -o pipefail

set -u reports unset variables. pipefail makes a pipeline fail when any command in it fails.

You will also see set -e. Its behavior has important exceptions around conditions, pipelines, subshells, and command lists. Do not add it without understanding how the script handles expected failures.

For the complete language rules and builtin documentation, see the official GNU Bash Reference Manual.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →