Skip to content
elephantoo

Bash scripting basics

Lesson 23 of 31 17 min read

Shebang, variables, quoting, arguments, reading input, arithmetic and exit codes.


A bash script is just a text file of commands you'd otherwise type by hand: the same commands, saved, repeatable and shareable. Scripts are how sysadmins and developers automate backups, deployments, setup steps and reports. In this lesson you'll write your first scripts and learn the building blocks: the shebang, variables, quoting, arguments, input, arithmetic and exit codes.

Your first script#

Terminal
cat > hello.sh <<'EOF'
#!/usr/bin/env bash
# My first script
echo "Hello, $USER!"
echo "Today is $(date +%A) and you are in $PWD"
EOF
chmod +x hello.sh
./hello.sh
Output
Hello, ada!
Today is Thursday and you are in /home/ada

Three things make this work:

  1. The shebang #!/usr/bin/env bash on the first line tells the kernel which interpreter runs the file. #!/bin/bash is also common; env finds bash on your PATH, which is more portable (macOS, BSD, NixOS).
  2. The execute permission from chmod +x.
  3. A path to run it: ./hello.sh. The current directory isn't on your PATH (for security), so you must say ./. Without +x you can still run bash hello.sh.

Lines starting with # are comments. Put personal scripts in ~/.local/bin (on PATH by default on Ubuntu once it exists) or ~/bin to run them from anywhere by name.

💡 Don't name scripts after existing commands (test, install, time), or you'll get confusing results depending on which one PATH finds first.

Variables#

Terminal
name="Ada"
count=3
greeting="Hello, $name"
echo "$greeting, you have $count new messages"
echo "${name}_backup.tar"       # braces separate the name from following text
echo "$name_backup.tar"         # looks for a variable called name_backup (empty!)
Output
Hello, Ada, you have 3 new messages
Ada_backup.tar
.tar

Rules to remember:

  • No spaces around =. name = "Ada" runs a command called name with arguments = and Ada.
  • By convention, lowercase names are for your own variables; UPPERCASE is for environment variables (PATH, HOME).
  • Undefined variables expand to an empty string, silently. (In the robust-scripts lesson, set -u will turn that into an error.)
  • Store command output with command substitution: today=$(date +%F).

Quoting: the most important rule#

SyntaxWhat happens
"double quotes"variables and $(...) expand; spaces and wildcards are kept literally
'single quotes'completely literal: nothing expands
no quotesexpands, then splits on spaces and expands wildcards
Terminal
file="my notes.txt"
touch "$file"
ls -1 $file 2>&1      # unquoted: becomes two arguments, 'my' and 'notes.txt'
ls -1 "$file"         # quoted: one argument
echo 'Single: $HOME'
echo "Double: $HOME"
Output
ls: cannot access 'my': No such file or directory
ls: cannot access 'notes.txt': No such file or directory
my notes.txt
Single: $HOME
Double: /home/ada

Quote every variable expansion ("$var", "$(cmd)", "$@") unless you have a specific reason not to. This one habit prevents most bash bugs, including some catastrophic ones like rm -rf $dir/ with an empty $dir.

Script arguments#

When you run ./script.sh one two three, bash fills in special variables:

VariableMeaning
$0the script's name/path
$1, $2, ... ${10}positional arguments
$#the number of arguments
"$@"all arguments, each one a separate word (use this!)
"$*"all arguments joined into one string
$?the exit status of the last command
$$the script's PID
Terminal
cat > args.sh <<'EOF'
#!/usr/bin/env bash
echo "Script: $0"
echo "Got $# argument(s)"
echo "First: $1, second: $2"
for arg in "$@"; do
  echo "  - $arg"
done
EOF
chmod +x args.sh
./args.sh apple "banana split" cherry
Output
Script: ./args.sh
Got 3 argument(s)
First: apple, second: banana split
  - apple
  - banana split
  - cherry

Note that "banana split" stays one argument because it was quoted on the command line and because the script uses "$@".

Default values

Parameter expansion gives defaults without any if:

Terminal
cat > greet.sh <<'EOF'
#!/usr/bin/env bash
name="${1:-World}"          # use $1, or "World" if it's empty or missing
greeting="${GREETING:-Hello}"
echo "$greeting, $name!"
EOF
chmod +x greet.sh
./greet.sh
./greet.sh Linus
GREETING=Namaste ./greet.sh Aditi
Output
Hello, World!
Hello, Linus!
Namaste, Aditi!

${1:?Usage: greet.sh NAME} instead would stop the script with that error message if $1 were missing.

Reading input#

read reads a line from stdin into variables:

Terminal
cat > ask.sh <<'EOF'
#!/usr/bin/env bash
read -r -p "Project name: " project
read -r -s -p "API token: " token; echo     # -s: don't echo (passwords)
echo "Creating $project (token has ${#token} characters)"
EOF
chmod +x ask.sh
printf 'webshop\nsecret123\n' | ./ask.sh
Output
Creating webshop (token has 9 characters)

(Prompts aren't shown when input comes from a pipe instead of a terminal.) Always use -r so backslashes are kept literally. ${#var} gives a string's length.

Arithmetic#

Bash variables are strings, but $(( )) does integer maths:

Terminal
a=17; b=5
echo "sum=$((a + b)) diff=$((a - b)) product=$((a * b))"
echo "quotient=$((a / b)) remainder=$((a % b)) power=$((2 ** 10))"
count=0
((count++))
((count += 5))
echo "count=$count"
echo "scale=2; 17 / 5" | bc      # decimals need bc (sudo apt install bc) or awk
awk 'BEGIN { printf "%.2f\n", 17 / 5 }'
Output
sum=22 diff=12 product=85
quotient=3 remainder=2 power=1024
count=6
3.40
3.40

Integer division truncates (17 / 5 = 3). Inside $(( )) you don't need $ before variable names.

Useful string tricks#

Terminal
path="/var/log/nginx/access.log"
echo "${path##*/}"        # strip longest */ prefix → basename
echo "${path%/*}"         # strip shortest /* suffix → dirname
echo "${path%.log}.gz"    # swap the extension
echo "${path/nginx/apache}"   # replace first match
s="Hello World"
echo "${s^^} ${s,,} ${s:0:5}"  # upper, lower, substring
Output
access.log
/var/log/nginx
/var/log/nginx/access.gz
/var/log/apache/access.log
HELLO WORLD hello world Hello

basename and dirname commands do the same as the first two, but parameter expansion is faster and built in.

Exit codes#

Every command returns an exit status: 0 for success, 1–255 for failure. Your script returns the status of its last command, or whatever you pass to exit:

Terminal
cat > check-file.sh <<'EOF'
#!/usr/bin/env bash
if [ $# -ne 1 ]; then
  echo "Usage: $0 FILE" >&2      # errors go to stderr
  exit 2
fi
if [ -f "$1" ]; then
  echo "$1 exists ($(wc -c < "$1") bytes)"
else
  echo "$1 not found" >&2
  exit 1
fi
EOF
chmod +x check-file.sh
./check-file.sh hello.sh; echo "status: $?"
./check-file.sh nope.txt; echo "status: $?"
./check-file.sh;          echo "status: $?"
Output
hello.sh exists (107 bytes)
status: 0
nope.txt not found
status: 1
Usage: ./check-file.sh FILE
status: 2

(We'll cover if and [ ] properly in the next lesson.) Good scripts:

  • print errors to stderr with >&2, so they aren't mixed into output that might be piped elsewhere;
  • exit non-zero on failure, so callers (&&, cron, CI pipelines, systemd) know something went wrong;
  • print a usage message when called incorrectly.

A small but real script#

Putting it together, here's a backup script that archives a directory with a timestamp:

Terminal
cat > backup.sh <<'EOF'
#!/usr/bin/env bash
# Usage: backup.sh SOURCE_DIR [DEST_DIR]
src="${1:?Usage: backup.sh SOURCE_DIR [DEST_DIR]}"
dest="${2:-$HOME/backups}"
stamp="$(date +%Y%m%d-%H%M%S)"
archive="$dest/$(basename "$src")-$stamp.tar.gz"

mkdir -p "$dest"
tar -czf "$archive" -C "$(dirname "$src")" "$(basename "$src")"
echo "Backed up $src -> $archive ($(du -h "$archive" | cut -f1))"
EOF
chmod +x backup.sh
mkdir -p "my project" && echo "data" > "my project/file.txt"
./backup.sh "my project" | sed -E 's/[0-9]{8}-[0-9]{6}/YYYYMMDD-HHMMSS/'
Output
Backed up my project -> /home/ada/backups/my project-YYYYMMDD-HHMMSS.tar.gz (4.0K)

(The sed at the end just hides the changing timestamp for this example.) Notice that the directory name contains a space, and the script still works because every expansion is quoted.

Common mistakes#

  • Spaces around = in assignments: x = 5 is a command, not an assignment.
  • Unquoted variables. Values with spaces or * break your script.
  • Windows line endings. A script edited on Windows fails with /usr/bin/env: 'bash\r': No such file or directory. Fix it with sed -i 's/\r$//' script.sh or dos2unix.
  • Forgetting chmod +x or the ./ prefix.
  • Writing sh script.sh for a bash script. On Debian/Ubuntu sh is dash, which lacks bash features like [[ ]], arrays and {1..5}.
  • Using $* instead of "$@" when passing arguments on.

What's next#

Scripts get really useful when they make decisions and repeat work. Next: conditionals and loops, with if, [[ ]], case, for and while.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Why should you write "$file" (with quotes) instead of $file?

  2. 2.In a script run as ./deploy.sh prod 3, what is $2?

  3. 3.What does exit 1 at the end of a script signal to the caller?

Finished reading?

Mark this lesson complete to track your progress.