Introduction

This guide is trying to teach you just enough bash to get you going. I’m not going to try to cover all of bash. Also I don’t know all of bash.

Why Bash?

Bash is free software. Bash is installed almost everywhere. Bash has proven itself. Bash started in 1989 and is actively maintained.

If you are on Linux, it’s very likely you are using Bash already. Bash is very capable and usable for many use-cases.

However: Bash is a shell, not a programming language.

Bash has its limitations if you compare it to a full-blown programming language. It’s not a full-blown programming language.

Audience

You are interested in bash. You are a software developer, devops guy, IT, …​

For all the examples I used BASH 4.x.x, a very stable and fast shell interpreter made by Brian Fox and Chet Ramey.

Install Bash

On Linux:

You probably are using bash already. Check that you have at least bash 4:

bash --version

Also make sure you are running bash by typing bash. You are in a bash now.

On Windows:

The easiest way to get bash on windows is to install git from https://git-scm.com . It will install git-bash for you which is sufficient.

Start it by pressing winkey, then type git-bash.

Variables

Define Variables

Like (probably) every other programming or scripting language, in BASH you can define variables and use them throughout your program.

#!/bin/bash
foo="bar" 1
echo ${foo} 2
echo $foo 3
1 define a variable
2 print out the variables content
3 this is valid as well

With ${} you can define and use variable names that start with a . (dot), an underline or even digits.

Positional Parameters

#!/bin/bash
echo ${0} 1
1 print the first positional parameter which is the scripts name

Save this as "test.sh", "chmod +x test.sh" and call it like:

./test.sh

It will print "./test.sh" to your console. The positional parameter ${0} contains the script name.

Another example:

#!/bin/bash
echo ${1} 1
1 this will print the second positional parameter

Save this as "test.sh", "chmod +x test.sh" and call it like:

./test.sh foo

It will print "foo" to your console.

Default Values

This is how you provide default values for positional parameters:

#!/bin/bash
foo=${1:-bar} 1
echo ${foo}
1 "bar" will be the default value if no positional parameter has been provided

If you call this script with no positional params, it will print "bar". If you call this script like "./test.sh foo" it will print "foo".

Help Text

This is how you can show an error message to the user if he didn’t provide the positional parameter:

#!/bin/bash
foo=${1:?please provide the foo parameter}
echo $foo

If you call the script without any parameters, it will print the message. It’s not very beautiful but it does its job.

Arrays

Bash supports arrays.

The simplest array

#!/bin/bash
declare countries=("Germany" "Ireland") 1
echo ${countries[0]} 2
echo ${countries[1]} 3
1 define the array
2 print out "Germany"
3 print out "Ireland"

Define Arrays

You can also declare arrays like this:

#!/bin/bash
declare -a cars 1
cars[0]='Audi'
cars[1]='BMW'
cars[2]='Mercedes'
echo ${cars[1]} 2
1 "-a" declares an index based array
2 will print "BMW"

In fact you can omit -a.

Print all items

echo ${cars[*]} 1
echo ${cars[@]} 2
1 prints out all items in the array
2 also prints out all items in the array

Define Associative Arrays

#!/bin/bash
declare -A countrycodes 1
countrycodes=(
        ['de']='Germany'
        ['ie']='Ireland'
        ['il']='Israel'
)
echo ${countrycodes['ie']} 2
1 "declare -A" defines an associative array
2 will print "Ireland"

Print all items

echo ${countrycodes[*]} 1
echo ${!countrycodes[*]} 2
1 will print "Germany Israel Ireland"
2 will print "de ie il", the ! in front gives you the keys

Get the length of an array

echo ${#countrycodes[*]} 1
1 will print "3", the # in front will give you the length

Constants

Bash supports constants.

Define Constants

#!/bin/bash
readonly foo='bar'
foo='somethingelse'

The script will exit with an error message because you defined that variable as read-only. This is the bash version of a constant.

Define a constant array

#!/bin/bash
readonly -A countrycodes=(
    ['de']='Germany'
    ['it']='Italy'
)
countrycodes=( ['at']='Austria' )

The script will exit with an error message because you defined that variable as read-only. Be careful in this case. Whenever you need to define an associative array, you must use the "-A".

Loops

Bash supports loops and they are quite flexible.

A very simple loop

#!/bin/bash
for make in "Audi" "Mercedes"
do
    echo ${make}
done

This will output:

Audi
Mercedes

A simple loop with a range

#!/bin/bash
for i in {1..10}
do
    echo '$i is now: ' ${i}
done

This will output:

$i is now:  1
$i is now:  2
$i is now:  3
$i is now:  4
$i is now:  5
$i is now:  6
$i is now:  7
$i is now:  8
$i is now:  9
$i is now:  10

A simple c-style loop

#!/bin/bash
for (( i=1; i<=10; i++))
do
    echo '$i is now: ' ${i}
done

This loop does the same as the "range" loop before. It’s just a different variant.

It will output:

$i is now:  1
$i is now:  2
$i is now:  3
$i is now:  4
$i is now:  5
$i is now:  6
$i is now:  7
$i is now:  8
$i is now:  9
$i is now:  10

Loop through simple arrays

Let’s loop through a simple array:

#!/bin/bash
declare countries=("Germany" "Ireland")
for country in "${countries[@]}"
do
    echo "${country}"
done

This will print:

Germany
Ireland

The loop construct looks familiar. However, why do you need to write: ${countries[@]}? This basically means "all items of this array". Thus the @-sign. It probably means "all". An alternative syntax is ${countries[*]}. The wildcard says "all items" as well.

But it still doesn’t answer why Bash wants it like that. Let’s dissect it step by step.

You probably thought it is sufficient to write it like this:

#!/bin/bash
declare countries=("Germany" "Ireland")
for country in "${countries}"
do
    echo "${country}"
done

This is looking better, right? But it only prints "Germany" so it’s kind of wrong.

Let’s see another example to build up knowledge:

#!/bin/bash
for country in "Germany" "Ireland"
do
    echo "${country}"
done

This will print:

Germany
Ireland

This is the output we are expecting.

So let’s see how an array behaves when we use it as a normal variable:

#!/bin/bash
declare countries=("Germany" "Ireland")
echo ${countries}

It will output Germany only. This is the reason our previous loop only printed Germany.

So how can we print out all items of this array?

#!/bin/bash
declare countries=("Germany" "Ireland")
echo "${countries[@]}" 1
1 print out all items of the array

This will output:

Germany Ireland

Are you seeing where we are going?

We are now understanding why this is working:

#!/bin/bash
declare countries=("Germany" "Ireland")
for country in "${countries[@]}" 1
do
    echo "${country}"
done
1 ${countries[@]} expands to: Germany Ireland

It’s working because we are expanding the array first.

And this is wrong:

#!/bin/bash
declare countries=("Germany" "Ireland")
for country in "${countries}" 1
do
    echo "${country}"
done
1 ${countries} expands to just: Germany

It’s wrong because we didn’t expand ${countries}.

Loop through files

A more practical example would be to loop through files. This is easier than you think.

Here is a simple example:

#!/bin/bash

declare files=$(ls)

for file in "${files[@]}"
do
    echo "${file}"
done

A more condensed example:

#!/bin/bash

for file in "$(ls)"
do
    echo "${file}"
done

These two examples are very compatible and are showing that you aren’t limited. However, listing files in this example creates a subshell, a second process. This might be a waste of resources.

So let’s write the same functionality without spawning a subshell.

#!/bin/bash

shopt -s nullglob

for file in *
do
    echo "${file}"
done

shopt -u nullglob

You might notice nullglob here. You need to enable it before and disable it after you looped through your files.

But what is nullglob?

If set, Bash allows filename patterns which match no files to expand to a null string, rather than themselves.

— GNU Bash manual

Let’s try an example without nullglob

#!/bin/bash

for file in *.pdf
do
    echo "${file}"
done

Assume that no pdf-files are in your directory. What would be the output? One would assume no output at all.

This is the output:

*.pdf

So for this case we need to enable nullglob. But it’s also important to disable it again when have no use for it anymore. It has side effects on other tools.

See this simple command:

ls *.pdf

It will output:

ls: cannot access '*.pdf': No such file or directory

Just what you would expect.

Now the same with nullglob enabled:

shopt -s nullglob
ls *.pdf

The output in my test directory:

file1  file2  file3  test.sh

It printed all the files in my directory. Not remotely what I have expected. But it makes sense.

With nullglob enabled ls *.pdf expands to a null value. So basically it translates to just ls which then lists all of the current directory.

So whenever possible, disable it after you have used it.

Functions

TODO :).

Error Handling

Things in Bash can go havoc quite quickly.

An example:

#!/bin/bash
directory=/home/web
echo "${director}"

In this case there is no variable director. You made a typo. It’s directory.

Will you notice the problem? In this simple example you will. When you are at work and churn out a script in a hurry you won’t.

Bash will just go on.

I will show you:

#!/bin/bash
directory=/home/web
echo "${director}"
echo "success!"

Bash will print out "success!" but that’s clearly wrong. This is a simplified example. I know real life mistakes where things got deleted that shouldn’t simply because there was a typo in a variable name.

So how are we going to fix this?

#!/bin/bash
set -u
directory=/home/web
echo "${director}"
echo "success!"

Try this example.

You will get the following error message:

$ ./test.sh
./test.sh: line 4: director: unbound variable

That is useful. It clearly says that there is an unbound variable in line 4. Obviously the shebang counts as a line so start counting from there. Or just open the file in your editor and navigate to line 4. You will see your typo.

Also there is no success message so Bash didn’t just went on with your script. This is important. This is good.

I suggest to put set -u in every bash script.

But bash is also a command "interpreter" and executes other executables. These executables return an exit code. The exit code describes if it was successful or not.

In Bash you want to know this. The exit code is very important.

A simple example:

#!/bin/bash
true
echo "success"

It will print success. And it makes sense because the previous command returned true. We typed "true". It means "success".

But what if we type false?

Let’s see:

#!/bin/bash
false
echo "success"

It will print success as well! This is cleary wrong, isn’t it? yes.

So how do we fix this?

It’s not that simple, but let’s try:

#!/bin/bash
set -e
false
echo "success"

It won’t print "success" so this is what we want. But what if we are executing external programs that fail?

#!/bin/bash
set -e
ls /schnueeffelbruenf
echo "success"

-e is sufficient in this case. ls /schnueeffelbruenf will fail. So "success" won’t be printed. The script will exit (as expected).

My suggestion:

Start every script like this:

#!/bin/bash
set -ue

u for noticing unbound variables and e for noticing failed calls.

But there’s always a "but". In my example ls churned out an error message that it can’t access the directory or file. This is good. But many programs don’t do that.

With set -e your script would exit early and you wouldn’t know why. It would quit silently.

An example:

#!/bin/bash
ls /schnueffelbruenf 2> /dev/null
echo "success"

Ok, I faked ls to not output its error message. Your script will print "success".

#!/bin/bash
set -ue
ls /schnueffelbruenf 2> /dev/null
echo "success"

Now your script won’t print "success" but you don’t know where it failed. Well, in this simple example you do know where it failed but imagine a more complicated script.

Let’s fix this:

#!/bin/bash
set -ueo verbose
ls /schnueffelbruenf 2> /dev/null
echo "success"

With o verbose we can see the last command that was issued. However, this will print out every command of your shell script. I think it’s ok.

You might want to omit -o verbose and in case of an error you can execute your bash script with -x like this:

bash -x test.sh

It’s basically the same as set -o verbose but the output is slightly different.

It depends :). Choose what you need. In my opinion it’s useful if the script always prints out its commands.

It depends (again).

Useful Bits

Copy pasting code isn’t always a bad thing.

Script directory

It’s useful to know the directory of your script. It might help you writing robust scripts that work from wherever you execute them.

script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

I put this line at the very top of my shell scripts. This way the user can