Skip to main content
TellaDev
Learn Terminal Bash Scripting Fundamentals
intermediate Terminal

Bash Scripting Fundamentals

Write your first bash scripts: variables, loops, conditionals, functions, and handling command output.

Biplab Adhikari 845 words
bash scripting shell automation linux
Bash Scripting Fundamentals

Your First Script

A bash script is a text file containing shell commands that runs top to bottom. The first line — the shebang — tells the OS which interpreter to use.

#!/usr/bin/env bash
# The above line makes the script use whatever bash is in PATH

echo "Hello, World!"

Make it executable and run it:

chmod +x hello.sh   # Grant execute permission (one time only)
./hello.sh          # Run it

The #!/usr/bin/env bash shebang is more portable than #!/bin/bash because it finds bash in PATH rather than assuming it’s at /bin/bash (which isn’t always true on macOS).


Variables and String Interpolation

# Assign a variable (no spaces around =)
name="Alice"
count=42

# Access with $
echo "Hello, $name"           # Hello, Alice
echo "Count is: $count"       # Count is: 42

# Curly braces for clarity (required before more text)
echo "Hello, ${name}s team"   # Hello, Alices team

# Default value if variable is unset or empty
echo "${name:-Anonymous}"     # Alice (uses name)
unset name
echo "${name:-Anonymous}"     # Anonymous (uses default)

# String length
echo "${#name}"               # 5 (length of "Alice")

Single quotes vs double quotes:

  • Double quotes "..." — variables are expanded: "Hello, $name"Hello, Alice
  • Single quotes '...' — no expansion: 'Hello, $name'Hello, $name (literal)

Command Substitution

Capture the output of a command into a variable using $(...):

# Get the current directory name
current_dir=$(pwd)
echo "You are in: $current_dir"

# Get today's date
today=$(date +%Y-%m-%d)
echo "Today is: $today"

# Count files in a directory
file_count=$(ls -1 src/ | wc -l)
echo "src/ has $file_count files"

# Use in string interpolation
echo "Branch: $(git branch --show-current)"

Conditionals

# Basic if/elif/else
if [ "$name" = "Alice" ]; then
  echo "Hello, Alice!"
elif [ "$name" = "Bob" ]; then
  echo "Hey, Bob."
else
  echo "Who are you?"
fi

# File tests
if [ -f "config.json" ]; then
  echo "Config file exists"
fi

if [ -d "logs" ]; then
  echo "Logs directory exists"
else
  mkdir logs
fi

# Numeric comparisons (-eq, -ne, -lt, -le, -gt, -ge)
if [ "$count" -gt 10 ]; then
  echo "Count exceeds 10"
fi

# String emptiness (-z = empty, -n = non-empty)
if [ -z "$name" ]; then
  echo "Name is empty"
fi

if [ -n "$API_KEY" ]; then
  echo "API key is set"
fi

Use [[ ... ]] (double brackets) for more features: regex matching, no word splitting issues, and &&/|| inside the test:

if [[ "$url" =~ ^https:// ]]; then
  echo "Secure URL"
fi

Loops

for loop — iterate over files:

for file in logs/*.log; do
  echo "Processing: $file"
  gzip "$file"
done

for loop — iterate over a list:

for env in dev staging prod; do
  echo "Deploying to $env..."
  ./deploy.sh "$env"
done

while loop — read lines from a file:

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

while loop — counter:

count=0
while [ $count -lt 5 ]; do
  echo "Count: $count"
  count=$((count + 1))
done

Functions

# Define a function
greet() {
  local name="$1"    # local = scoped to function, $1 = first argument
  echo "Hello, $name!"
}

# Call it
greet "Alice"    # Hello, Alice!
greet "Bob"      # Hello, Bob!

# Return a value via echo + command substitution
get_timestamp() {
  echo "$(date +%s)"
}

start=$(get_timestamp)
sleep 2
end=$(get_timestamp)
echo "Elapsed: $((end - start)) seconds"

Functions use $1, $2, … for arguments, just like scripts. Always use local for variables inside functions to prevent them from leaking into the global scope.


Exit Codes and set -euo pipefail

Every command returns an exit code: 0 = success, non-zero = failure.

ls /nonexistent 2>/dev/null
echo "Exit code: $?"   # 2 (ls failed)

cp file.txt dest/
echo "Exit code: $?"   # 0 (success)

Add these at the top of scripts to make failures explicit:

#!/usr/bin/env bash
set -euo pipefail

# -e  = exit immediately if any command fails
# -u  = treat unset variables as errors
# -o pipefail = if any command in a pipe fails, the whole pipe fails

Without set -e, a failed command is silently ignored and the script keeps running — often producing confusing results.


Accepting Arguments

Scripts receive arguments as $1, $2, etc.:

#!/usr/bin/env bash
set -euo pipefail

# $0 = script name
# $1, $2, ... = positional arguments
# $@ = all arguments as separate words
# $# = number of arguments

if [ "$#" -lt 2 ]; then
  echo "Usage: $0 <source> <destination>"
  exit 1
fi

source="$1"
destination="$2"

echo "Copying $source to $destination..."
cp "$source" "$destination"
echo "Done."

Run it:

./copy.sh file.txt backup/file.txt

Always quote "$1" and "$@" to handle filenames with spaces correctly.

Make Scripts Boring And Predictable

Good shell scripts are usually boring. They validate input, fail clearly, and do one job. The goal is not to show off clever Bash syntax. The goal is to automate a task so you can trust it when you are tired, rushed, or running it in CI.

Start scripts with a short comment that says what the script does and what arguments it expects. Then validate those arguments before doing work. If a script expects a filename and the filename is missing, print a useful message and exit with a non-zero status.

if [ -z "$1" ]; then
  echo "Usage: ./backup.sh <file>"
  exit 1
fi

Use Strict Mode Carefully

Many scripts benefit from set -euo pipefail. It makes the script exit on errors, undefined variables, and failed pipeline commands. That is useful, but it also means you need to handle expected failures deliberately.

set -euo pipefail

If a command is allowed to fail, make that explicit with an if statement. Avoid hiding failures with broad || true unless you leave a comment explaining why the failure is safe.

Logging And Dry Runs

For scripts that change files, add a dry-run mode. A dry run prints what would happen without doing it. This is especially useful for cleanup, deployment, backup, and migration scripts.

Even simple logging helps. Print the file or directory being processed, the destination being written, and a final success message. When a script fails halfway through, those messages tell you where to resume.

Common Mistakes

The classic mistake is forgetting quotes around variables. Without quotes, filenames with spaces are split into multiple arguments. Another common mistake is assuming the script runs from a specific directory. If relative paths matter, resolve the script directory first or document where the script must be run.

Avoid parsing complex formats with fragile shell pipelines. For JSON, use jq when available. For YAML, use a real parser. Bash is excellent glue, but it is not the best tool for every data transformation.

What I Would Do In Practice

I would use Bash for small automation tasks: cleaning generated files, running a repeatable local workflow, wrapping a few commands, or wiring CI steps together. Once a script grows complex enough to need nested parsing, retries, or data structures, I would move to Python, TypeScript, or another language with stronger error handling.

The best Bash scripts are easy to delete later. They capture a workflow, make it repeatable, and stay clear enough that another developer can debug them without becoming a shell expert.

A Script Review Checklist

Before sharing a Bash script, read it like a teammate will run it on a fresh machine. Does it say what tools it requires? Does it fail clearly when a command is missing? Does it quote variables that may contain spaces? Does it avoid assuming the user is in a specific directory?

For scripts that change files, print the target paths and support a dry-run mode. For scripts that call external services, make credentials explicit through environment variables and never hard-code secrets. For scripts used in CI, keep output concise enough that failures are easy to find in logs.

The best test is simple: run the script twice. A good automation script should either be idempotent or clearly explain why the second run changes something.

When Not To Use Bash

Bash is excellent for connecting commands, moving files, and running repeatable project tasks. It is weaker for complex data structures, large JSON transformations, user interfaces, and long-running services.

If the script needs nested parsing, retries with backoff, structured logging, or extensive tests, consider a language like Python or TypeScript. Choosing a clearer tool is not a failure. It is part of writing automation that someone else can maintain.