cv.cm

Coding · Programming languages

Bash scripting tutorial for beginners

7 min read

Short answer

Bash is the Unix command-line shell and scripting language used on Linux servers, macOS terminals and CI pipelines. A Bash script is a text file of shell commands, combined with variables, conditions, loops, functions and pipes. This page shows how to write and run scripts, quote variables safely, control flow, use arrays, redirect output and handle errors with set -euo pipefail.

Open Chmod calculator

What Bash is and what it is used for

Bash (the Bourne Again SHell) is a command-line shell and scripting language from the GNU Project. It reads commands you type, runs programs, and connects them with pipes and redirections. A Bash script is a text file of those same commands, so anything you do by hand in a terminal can be automated.

Bash is the default interactive shell on most Linux distributions and is available on macOS (which now defaults to zsh but still ships Bash) and on Windows through WSL or Git Bash. Developers, system administrators and DevOps engineers use it for build scripts, deployment, CI jobs, server maintenance and quick data processing with tools like grep, sed, awk and sort.

Write and run Hello, world

A script starts with a shebang line that tells the system which interpreter to use. Make the file executable with chmod, then run it.

#!/usr/bin/env bash
# hello.sh
echo "Hello, world!"
chmod +x hello.sh
./hello.sh
# Hello, world!
bash hello.sh       # also works without chmod
bash --version      # check which version you have

#!/usr/bin/env bash finds Bash on the PATH, which is more portable than hard-coding /bin/bash. If a script uses only POSIX features, it can use #!/bin/sh instead, but then Bash-only syntax such as [[ ]] and arrays is not allowed.

Variables, quoting and expansion

Assign with name=value and read with $name or ${name}. Every variable is a string; Bash does integer arithmetic only inside $(( )). Use double quotes around expansions so that spaces and special characters stay intact.

name="Ada"                 # no spaces around =
count=3
echo "Hello, $name"        # Hello, Ada
echo "Next: $((count + 1))"  # Next: 4  (integer arithmetic)
today=$(date +%Y-%m-%d)    # command substitution
echo "Today is $today"
file="my report.txt"
echo "${file%.txt}.pdf"    # my report.pdf  (strip suffix)
echo "${#name}"            # 3  (string length)
echo "${missing:-default}" # default  (fallback value)
SyntaxMeaning
"$var"value of var, kept as one word
'text'literal text, no expansion at all
$(command)output of a command
$((a + b))integer arithmetic
$1, $2, $@, $#script or function arguments, all arguments, argument count
$?exit status of the last command (0 means success)

Conditions and case

if runs a command and checks its exit status. The [[ ]] test command compares strings and checks files; (( )) compares numbers. case matches a value against glob patterns.

#!/usr/bin/env bash
file="${1:-notes.txt}"

if [[ -f "$file" ]]; then
  echo "$file exists"
elif [[ -d "$file" ]]; then
  echo "$file is a directory"
else
  echo "$file not found"
fi

n=7
if (( n % 2 == 1 )); then echo "$n is odd"; fi

case "$file" in
  *.txt) echo "text file" ;;
  *.sh)  echo "shell script" ;;
  *)     echo "something else" ;;
esac
TestTrue when
[[ -f path ]]path is a regular file
[[ -d path ]]path is a directory
[[ -z "$s" ]]string is empty
[[ "$a" == "$b" ]]strings are equal
(( a > b ))number a is greater than b
[[ $a -lt $b ]]number a is less than b (older style)

Loops

Bash has for over a list of words, a C-style for (( )), while and until. Reading a file line by line uses while read.

for fruit in apple banana cherry; do
  echo "fruit: $fruit"
done

for (( i = 1; i <= 3; i++ )); do
  echo "i = $i"
done

for f in *.log; do
  [[ -e "$f" ]] || continue   # skip if no .log files match
  echo "log file: $f"
done

while IFS= read -r line; do
  echo "line: $line"
done < input.txt

IFS= read -r reads each line exactly as written: IFS= keeps leading spaces and -r keeps backslashes.

Functions

Define a function with name() { ... } and call it like a command. Arguments arrive as $1, $2 and so on. A function returns an exit status (0 to 255) with return; to return data, print it and capture the output with $( ).

greet() {
  local name="${1:-world}"   # local variable, default value
  echo "Hello, $name!"
}

is_even() {
  (( $1 % 2 == 0 ))          # exit status 0 means true
}

greet                         # Hello, world!
greet "Ann"                   # Hello, Ann!
msg=$(greet "Bo")             # capture output
echo "Got: $msg"              # Got: Hello, Bo!

if is_even 4; then echo "4 is even"; fi

Arrays and associative arrays

Indexed arrays hold a list of strings. Associative arrays map string keys to values and need Bash 4 or later, so they are not available in the older Bash 3.2 that ships with macOS.

langs=("bash" "python" "go")
langs+=("rust")
echo "${langs[0]}"           # bash
echo "${#langs[@]}"          # 4
for l in "${langs[@]}"; do echo "- $l"; done

# associative array (Bash 4 or later)
declare -A port
port[http]=80
port[https]=443
echo "${port[https]}"        # 443
for key in "${!port[@]}"; do
  echo "$key -> ${port[$key]}"
done

Always write "${array[@]}" with quotes to loop over elements; it keeps each element as one word even if it contains spaces.

Pipes and redirection

Bash's signature feature is composing small programs. A pipe | sends one command's output to the next command's input. Redirections send output to files or read input from them. File descriptor 1 is standard output and 2 is standard error.

ls -l > files.txt            # write stdout to a file (overwrite)
echo "more" >> files.txt     # append
grep error app.log 2> /dev/null   # discard error messages
make > build.log 2>&1        # stdout and stderr to the same file

# count the 3 most common words in a file
tr -cs '[:alpha:]' '\n' < book.txt | tr '[:upper:]' '[:lower:]' |
  sort | uniq -c | sort -rn | head -n 3

Error handling and safe scripts

By default Bash keeps going after a command fails. Many scripts start with set -euo pipefail: -e exits on an unhandled error, -u treats unset variables as errors, and -o pipefail makes a pipeline fail if any command in it fails. trap runs cleanup code when the script exits.

#!/usr/bin/env bash
set -euo pipefail            # stop on errors, unset vars, pipe failures

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT     # clean up when the script exits

if ! grep -q "ready" status.txt; then
  echo "status is not ready" >&2   # errors go to stderr
  exit 1
fi

cp config.txt "$tmp" || { echo "copy failed" >&2; exit 2; }
echo "ok"

Use || to handle a failure on one line and && to run a command only if the previous one succeeded. set -e has exceptions (for example, commands inside if conditions do not trigger it), so check important commands explicitly.

A small complete script: dated backups

This script archives a directory into a timestamped .tar.gz file and keeps only the five newest archives. It uses arguments, defaults, tests, command substitution and pipes.

#!/usr/bin/env bash
# backup.sh: copy a directory into a dated .tar.gz archive
set -euo pipefail

usage() { echo "usage: $0 SOURCE_DIR [DEST_DIR]" >&2; exit 1; }

[[ $# -ge 1 ]] || usage
src="$1"
dest="${2:-$HOME/backups}"
[[ -d "$src" ]] || { echo "not a directory: $src" >&2; exit 1; }

mkdir -p "$dest"
stamp=$(date +%Y%m%d-%H%M%S)
archive="$dest/$(basename "$src")-$stamp.tar.gz"

tar -czf "$archive" -C "$(dirname "$src")" "$(basename "$src")"
size=$(du -h "$archive" | cut -f1)
echo "Created $archive ($size)"

# keep only the 5 newest archives for this source
ls -1t "$dest"/"$(basename "$src")"-*.tar.gz | tail -n +6 | while read -r old; do
  rm -f -- "$old"
  echo "Removed $old"
done
./backup.sh ~/projects/site
# Created /home/ann/backups/site-20261031-093000.tar.gz (2.1M)

Where to go next

  • The GNU Bash Reference Manual, the official documentation.
  • man bash and help (for example help read) in your terminal.
  • ShellCheck, a static analysis tool that explains common script bugs.
  • The POSIX shell specification if you need scripts that also run in sh, dash or BusyBox.

FAQ

What is the difference between Bash and sh?

sh is the POSIX shell standard, implemented by programs such as dash. Bash is compatible with POSIX sh but adds features like [[ ]], arrays, (( )) and brace expansion.

What is the difference between Bash and zsh?

Both are compatible for most everyday commands. zsh adds interactive features and is the default shell on macOS; scripts written with a bash shebang still run with Bash.

Why does my script say permission denied?

The file is not executable. Run chmod +x script.sh, or start it with bash script.sh.

How do I pass arguments to a Bash script?

Write them after the script name, as in ./script.sh one two. Inside the script they are $1 and $2, $@ holds all of them and $# is their count.

Should I write large programs in Bash?

Bash is best for gluing commands together. When a script grows complex, needs data structures or careful error handling, a language such as Python is usually easier to maintain.

All coding tutorials