Skip to content
C Codeloom
Linux

Shell Scripting Basics: Your First Bash Script

Write your first bash scripts — shebangs, variables, arguments, conditionals, loops, command substitution, exit codes, and the safety line every script should start with.

·9 min read · By Yash Kesharwani
Beginner 14 min read

What you'll learn

  • What a shebang line does and which one to use
  • How to make a script executable and run it
  • Variables, quoting, and reading command-line arguments
  • if/then/fi conditionals and for loops
  • Command substitution with $(...) and exit codes
  • The "set -euo pipefail" line every serious script should start with

Prerequisites

If you have typed the same three commands more than twice in a row, you have already noticed the problem shell scripts solve. A shell script is just a text file of commands that the shell runs top-to-bottom. There is no separate language to learn — it is the same bash you already use, saved to a file.

This post takes you from zero to writing real, safe scripts you can drop into a project.

A first script

Create a file called hello.sh:

#!/usr/bin/env bash
echo "Hello, $(whoami)!"
echo "Today is $(date +%A)."

Save it. Then:

chmod +x hello.sh
./hello.sh
# Hello, yash!
# Today is Monday.

That is a real script. Three lines worth unpacking.

The shebang line

The first line of every script should be a shebang:

#!/usr/bin/env bash

The #! tells the kernel “execute this file with the program at this path.” /usr/bin/env bash looks up bash on your PATH and runs it. This is portable: it works whether bash lives at /bin/bash (Linux) or /opt/homebrew/bin/bash (macOS Homebrew).

You will also see #!/bin/bash. It works on most Linux systems but breaks on macOS if the user has installed a newer bash via Homebrew. Prefer /usr/bin/env bash.

chmod +x — making it executable

A fresh file is not executable. chmod +x flips the executable bits:

chmod +x hello.sh
ls -l hello.sh
# -rwxr-xr-x  1 yash  staff  64 Jun 15 09:11 hello.sh

The three x characters mean owner, group, and other can all execute it. You only need to do this once per file.

If you do not want to mark the file executable, you can also run it explicitly:

bash hello.sh

This works but ./hello.sh is the form you will see everywhere.

Variables

No var, let, or const. Just a name, an =, and a value, with no spaces around the =:

name="Yash"
echo "Hello, $name!"

The space rule trips up everyone once:

name = "Yash"   # WRONG — bash tries to run a command called "name"

Reference variables with $name or, more safely, ${name}:

greeting="Hello"
echo "${greeting}, world!"

Always quote variable expansions with double quotes:

filename="my report.txt"
rm "$filename"      # correct — one file
rm $filename        # WRONG — tries to remove "my" and "report.txt"

This single habit prevents a huge class of bugs. Double-quote every $variable unless you have a specific reason not to.

Command-line arguments

Arguments to your script appear as $1, $2, $3, etc. The whole list is $@. The count is $#.

#!/usr/bin/env bash
echo "Script name: $0"
echo "First argument: $1"
echo "Second argument: $2"
echo "Total arguments: $#"
echo "All arguments: $@"
./args.sh hello world
# Script name: ./args.sh
# First argument: hello
# Second argument: world
# Total arguments: 2
# All arguments: hello world

$0 is the script’s own name. $@ is the entire argument list, and like variables, it should usually be quoted: "$@".

Conditionals: if/then/fi

The syntax looks unusual but is consistent:

#!/usr/bin/env bash
if [ "$1" = "hello" ]; then
  echo "You said hello."
else
  echo "You said something else: $1"
fi

Things to notice:

  • [ ... ] is actually a command (it’s test). The spaces around the brackets are required.
  • = compares strings. For integers use -eq, -lt, -gt.
  • Block ends with fi (if backwards) — same trick for case/esac.

Common file tests:

if [ -f config.json ]; then
  echo "config.json exists"
fi

if [ -d /var/log ]; then
  echo "/var/log is a directory"
fi

if [ -z "$1" ]; then
  echo "No argument given"
fi

-f checks for a regular file, -d for a directory, -z for an empty string, -n for a non-empty string.

The more modern [[ ... ]] form is bash-specific and friendlier with quoting and pattern matching:

if [[ "$name" == "Yash" ]]; then
  echo "Hi Yash"
fi

if [[ "$file" == *.txt ]]; then
  echo "It's a text file"
fi

Prefer [[ ... ]] for new bash scripts. Use [ ... ] only if you need POSIX sh compatibility.

for loops

for name in alice bob carol; do
  echo "Hello, $name"
done

Loop over files:

for file in *.txt; do
  echo "Processing $file"
done

Loop over the script’s own arguments:

for arg in "$@"; do
  echo "Got: $arg"
done

C-style numeric loops also work in bash:

for i in {1..5}; do
  echo "Iteration $i"
done

Command substitution with $(...)

To capture the output of a command into a variable, wrap it in $(...):

today=$(date +%Y-%m-%d)
echo "Today is $today"
# Today is 2026-06-15

files_count=$(ls | wc -l)
echo "There are $files_count files here"

You will see an older backtick form: today=`date +%Y-%m-%d`. It works but does not nest cleanly. Use $(...).

Exit codes

Every command returns an exit code0 for success, anything else for failure. The shell stores the last one in $?:

ls /tmp
echo $?      # 0
ls /not-a-real-place
echo $?      # 2

Scripts can use this to make decisions:

if grep -q "ERROR" server.log; then
  echo "Found an error in the log"
else
  echo "Log looks clean"
fi

if runs the command and branches on its exit code: zero is “true,” non-zero is “false.” This is why if [ ... ] works — [ is a command that returns 0 or 1.

You can also return your own exit code:

if [ -z "$1" ]; then
  echo "Usage: $0 <name>" >&2
  exit 1
fi

>&2 redirects the echo to standard error, which is the correct place for error messages. exit 1 ends the script with a failure exit code so callers can detect it.

Try it yourself. Write a script greet.sh that requires exactly one argument. If $# is not 1, print a usage message to stderr and exit with code 1. Otherwise, print "Hello, $1!". Run it with and without an argument, and check echo $? after each.

The safety line: set -euo pipefail

Bash, by default, plows on after errors. A failed rm, a typo in a variable name, a broken pipe — the script keeps going as if nothing happened. This is how disasters happen (“the script ran fine but the deploy is broken”).

Put this at the top of every serious script, right after the shebang:

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

What each flag does:

  • -e — exit immediately if any command fails. No more silently continuing past errors.
  • -u — treat unset variables as an error. Catches typos: $useranme instead of $username becomes a loud failure, not silent expansion to an empty string.
  • -o pipefail — in a pipeline, fail if any command fails, not just the last one. Without this, false | true is considered a success.

These three settings turn bash from a shrugging optimist into a strict, paranoid co-worker. They are the single biggest quality improvement you can make to a shell script.

Putting it together: a real script

A small backup script that uses everything in this post:

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

# Usage: ./backup.sh <source-dir> <destination-dir>

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

src="$1"
dst="$2"

if [ ! -d "$src" ]; then
  echo "Source directory does not exist: $src" >&2
  exit 1
fi

mkdir -p "$dst"

timestamp=$(date +%Y-%m-%d_%H-%M-%S)
archive="$dst/backup_${timestamp}.tar.gz"

echo "Creating $archive from $src..."
tar -czf "$archive" -C "$src" .

size=$(du -h "$archive" | cut -f1)
echo "Done. Archive size: $size"

Save it as backup.sh, run chmod +x backup.sh, then:

./backup.sh ~/Documents ~/backups
# Creating /Users/yash/backups/backup_2026-06-15_09-11-02.tar.gz from /Users/yash/Documents...
# Done. Archive size: 14M

Notice how every variable is quoted, every error path exits, and the script fails loudly if anything goes wrong. This is the shape of a script you can actually rely on.

Try it yourself. Modify the backup script to also accept a third optional argument — the number of backups to keep — and delete older archives in the destination directory beyond that count. (Hint: ls -t "$dst"/backup_*.tar.gz | tail -n +N lists the ones to remove.)

Debugging scripts

Two flags help enormously:

bash -x script.sh        # print each command as it runs
bash -n script.sh        # syntax-check without running

Or add to the script itself:

set -x      # turn tracing on
# ... code you want to trace ...
set +x      # turn tracing off

There is also a fantastic external tool: ShellCheck. It lints your bash scripts and catches subtle bugs (missing quotes, broken loops, unsafe globs):

sudo apt install shellcheck     # Ubuntu/Debian
brew install shellcheck         # macOS

shellcheck backup.sh

Run it on every script you write. It teaches you bash as you go.

Recap

You now know:

  • Scripts start with a shebang (#!/usr/bin/env bash) and need chmod +x to be directly executable.
  • Variables: name="value", no spaces around =, always double-quote "$name".
  • Arguments live in $1, $2, $@, with $# for the count.
  • if [[ ... ]]; then ... fi for conditionals, for x in ...; do ... done for loops.
  • $(command) captures output into variables; $? is the last exit code.
  • Start every script with set -euo pipefail — exit on error, fail on unset vars, fail on broken pipes.
  • Lint with shellcheck and debug with bash -x.

Next steps

The next posts in the series shift gears to Git. We have already covered branching basics — the next one tackles the parts of Git people get scared of: stashing changes, undoing commits safely, and recovering from mistakes.

→ Next: Git Stash and Undoing Changes Safely

Questions or feedback? Email codeloomdevv@gmail.com.