Pipes and text

Introduction to Bash Shell Scripting

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

A Bash script is a file with shell commands in it, 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 at gluing command-line tools together. When a script grows into a real application, another language is easier to test and maintain. Past a couple of screens of Bash, I reach for Node.js or Python.

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 like #!/bin/bash when you want that specific interpreter.

Make the file executable:

chmod u+x hello.sh

Run it:

./hello.sh

You get Hello from Bash back. Alternatively, pass the file to Bash:

bash hello.sh

In that form Bash is already the interpreter and ignores the shebang.

Check the syntax without running anything:

bash -n hello.sh

Silence means no syntax errors.

Add comments

A comment starts with #:

#!/usr/bin/env bash

# Print a greeting
echo "Hello"

The shebang is the one special first-line exception.

Create variables

Assign a value with no spaces around =:

name="Flavio"
count=3

With spaces, Bash treats name as a command.

Read a variable with $:

echo "$name"
echo "$count"

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

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

Without quotes, Bash splits that into My and notes.txt, and expands wildcards like *.

Environment variables reach child processes only when you export them:

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. Anything else 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 string and file tests in Bash:

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. -n tests for a non-empty one.

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:

  • -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 you can use +, -, *, /, %, <, <=, ==, >=, and >.

The test command and [ ... ] also work, including in POSIX shell scripts. Their parsing rules differ, so don’t 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 the script arguments:

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

break leaves a loop. continue skips to the 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"

When a script can hit end-of-file, check whether the read succeeded:

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: the positional arguments
  • $#: how many positional arguments there are
  • "$@": 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))"

Each option letter 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 -- to mark the end of options, so an argument can start with a hyphen:

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

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 write the full ${...} syntax. 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 any other command:

clean_folder "/Users/flavio/Desktop"

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

Variables are global by default. Use the local builtin to keep one inside the function.

Return a status with return:

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

Functions return an integer status, not a string. To hand data back, print it to standard output.

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 turn on stricter behavior at the top:

set -u
set -o pipefail

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

You will also see set -e. It has important exceptions around conditions, pipelines, subshells, and command lists. Don’t add it blindly.

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

Lesson completed