A shell script is nothing more than a file full of the commands you would otherwise type into the terminal one at a time, saved so you can run them all again with a single command. If you have ever found yourself typing the same handful of commands every day — backing up a folder, renaming a batch of files, cleaning out a cache — a script turns that chore into one short word you type once.
This guide takes you from a blank file to a small, genuinely useful script, building up one idea at a time. The shell we use is bash (the Bourne-Again SHell — free software, GPLv3+), the default on most GNU/Linux systems. Everything here runs with tools that are already on your machine; the one extra program we install at the end, shellcheck, is free and open source too.
You do not need to read it all at once. Work top to bottom the first time, then use the table of contents to jump back to whatever you have forgotten.
Hello, world: your first script
Open a text editor and create a file called hello.sh with these two lines:
#!/usr/bin/env bash
echo "Hello, world!"
The first line is special. That #! at the very top is called the shebang, and it tells the system which program should run the file. Writing #!/usr/bin/env bash means "find bash on this machine and use it" — a touch more portable than hard-coding #!/bin/bash, because env looks bash up wherever it happens to live. The echo command simply prints its argument.
Now make the file executable — give it permission to be run as a program — and run it:
# add the "executable" permission bit to the file
chmod +x hello.sh
# run it
./hello.sh
You should see Hello, world! printed back at you. Congratulations — that is a working program.
Why the ./ in front? For safety, the shell does not look in your current directory when it hunts for a command to run — only in the directories on your PATH. The ./ spells out "the file named hello.sh right here in this directory", so the shell runs your file instead of complaining it cannot find a command by that name.
Tip: you can skip both the
chmodand the./by handing the file to bash yourself:bash hello.sh. Here bash is the command and your script is just data it reads, so no executable bit is needed. Making the script executable and running it with./hello.shis the tidier habit, though — it behaves like a real command.
Tip: the
.shending is only a convention to remind you what the file is; the shebang line is what actually decides how it runs. A script with no extension at all works exactly the same.
Variables and quoting
A variable is a named box you put a value in. You assign one with = and — this trips up everyone at first — there must be no spaces around the =:
#!/usr/bin/env bash
# correct: no spaces around the =
name="Ada"
# with spaces it breaks: "name = Ada" makes bash try to run a command called name
# read the value back by putting a $ in front of the name
echo "Hello, $name"
To read the value back, put a $ in front of the name. Notice the variable sits inside the quotes — bash looks inside double quotes and swaps $name for its value.
Always quote your variables
This is the single most important habit in shell scripting. Wrap every variable in double quotes — "$name", not a bare $name. Without the quotes, if a value contains a space or a wildcard, bash splits it into separate words or expands it, and your script misbehaves in ways that are maddening to debug:
#!/usr/bin/env bash
file="my report.txt"
# WRONG: bash sees TWO arguments, "my" and "report.txt"
rm $file
# RIGHT: bash sees one argument, "my report.txt"
rm "$file"
The rule is simple: when in doubt, quote it. There is almost never a reason not to.
Single quotes versus double quotes
The two kinds of quote are not interchangeable. Double quotes let bash expand variables and command substitutions inside them; single quotes are completely literal — whatever you type comes out untouched, dollar signs and all:
#!/usr/bin/env bash
name="Ada"
# double quotes: expands -> Hello, Ada
echo "Hello, $name"
# single quotes: literal -> Hello, $name
echo 'Hello, $name'
Reach for single quotes when you mean the characters exactly as written (a literal $, a regex, a price tag), and double quotes when you want a variable filled in.
Command substitution
You can capture the output of a command into a variable with $(...). Bash runs whatever is inside the parentheses and hands you back what it printed:
#!/usr/bin/env bash
# put today's date into a variable
today="$(date +%Y-%m-%d)"
echo "Today is $today"
# count the files in the current directory
count="$(ls | wc -l)"
echo "There are $count entries here"
Tip: you may see the older backtick form,
`date`, in old scripts. It does the same thing, but$(...)is clearer and nests cleanly, so prefer it in anything you write.
Arguments: passing values into your script
A script becomes far more useful when you can hand it information as you run it. Anything you type after the script name becomes an argument, and bash gives each one a numbered variable: $1 is the first, $2 the second, and so on. A few related variables come for free:
$1,$2,$3… — the individual arguments.$@— all the arguments, as a list.$#— how many arguments there were (a count).$0— the name the script was called by.
Here is a script — save it as greet.sh — that greets whoever you name:
#!/usr/bin/env bash
# the first argument is the person to greet
echo "Hello, $1! Welcome."
# show a couple of the built-in argument variables
echo "You ran $0 with $# argument(s)."
Run it with an argument after the name:
chmod +x greet.sh
./greet.sh Ada
It prints Hello, Ada! Welcome. followed by the count. Try it with a name that has a space — ./greet.sh "Ada Lovelace" — and notice the quotes keep it as a single argument, exactly as in the quoting section above.
To act on every argument in turn, loop over "$@" (we cover loops in a moment):
#!/usr/bin/env bash
# greet each argument, however many there are
for person in "$@"; do
echo "Hello, $person!"
done
Making decisions: if, then, else
A script that always does the same thing is only half a tool. Conditionals let it react. The shape is if … then … fi (that is if spelled backwards to mark the end):
#!/usr/bin/env bash
# greet, but complain if no name was given
if [[ -z "$1" ]]; then
echo "Please give me a name to greet."
else
echo "Hello, $1!"
fi
The test goes inside [[ ... ]]. Here -z means "is this string empty?". Bash has a whole vocabulary of tests; these are the ones you reach for constantly.
Comparing strings and numbers
Strings and numbers use different operators — a classic beginner trap. For strings use = and !=; for numbers use the lettered operators -eq (equal), -ne (not equal), -lt (less than), -le, -gt (greater than), and -ge:
#!/usr/bin/env bash
answer="yes"
# string comparison
if [[ "$answer" = "yes" ]]; then
echo "You agreed."
fi
# numeric comparison: is the first argument less than 5?
if [[ "$1" -lt 5 ]]; then
echo "$1 is less than 5"
fi
Warning: do not mix them up.
[[ "$a" < "$b" ]]compares alphabetically, so"10" < "9"is true (because "1" sorts before "9"). For numbers you must use-ltand friends:[[ "$a" -lt "$b" ]].
Testing files and directories
You will constantly want to know whether a file exists before you touch it. These tests answer that:
-e path— does it exist at all?-f path— does it exist and is it a regular file?-d path— does it exist and is it a directory?-r/-w/-x— is it readable / writable / executable?
#!/usr/bin/env bash
# only proceed if the config file is actually there
if [[ -f "$HOME/.myapp.conf" ]]; then
echo "Found the config file."
elif [[ -d "$HOME/.myapp" ]]; then
echo "No config file, but the directory exists."
else
echo "Nothing set up yet."
fi
The elif ("else if") lets you chain several conditions; the else at the end is the catch-all when none matched.
Loops: doing something many times
Loops are where scripts earn their keep — they do the boring, repetitive work for you.
The for loop
A for loop walks through a list, running its body once for each item. The item lands in a variable you name:
#!/usr/bin/env bash
# loop over a fixed list of words
for colour in red green blue; do
echo "Colour: $colour"
done
# loop over files: convert every .txt in this folder (the glob expands to a list)
for file in *.txt; do
echo "Found: $file"
done
If you need to count, bash also has the C-style for with a counter:
#!/usr/bin/env bash
# count from 1 to 5
for (( i=1; i<=5; i++ )); do
echo "Step $i"
done
The while loop
A while loop keeps going as long as its condition stays true:
#!/usr/bin/env bash
count=1
# keep looping while count is 3 or less
while [[ "$count" -le 3 ]]; do
echo "count is $count"
# add 1 to count
count=$(( count + 1 ))
done
The $(( ... )) is bash's arithmetic — it does the maths and gives you back the number.
Reading a file line by line
A very common job is to process a file one line at a time. The reliable incantation is while IFS= read -r line:
#!/usr/bin/env bash
# read names.txt one line at a time, into the variable "line"
while IFS= read -r line; do
echo "Name: $line"
done < names.txt
That line looks fussy, so here is what each piece buys you: the < names.txt at the end feeds the file into the loop; IFS= stops bash from trimming leading and trailing spaces; and -r stops it from mangling backslashes. Together they read each line exactly as written — memorise it as one phrase and you will never lose data to a stray space again.
Functions: naming a block of commands
When a chunk of your script does one identifiable job, give it a name and call it a function. It keeps the script readable and saves you repeating yourself:
#!/usr/bin/env bash
# define a function called "greet"
greet() {
# inside a function, $1 is the function's first argument (not the script's)
echo "Hello, $1!"
}
# call it like a command, passing arguments after the name
greet "Ada"
greet "Grace"
Arguments work inside a function just as they do for the whole script: $1, $2, $@, and $# — but now they refer to what you passed the function. A function reports success or failure through its exit status (more on that next); you can set it explicitly with return:
#!/usr/bin/env bash
# returns success (0) if the argument is a file that exists, failure otherwise
is_real_file() {
if [[ -f "$1" ]]; then
return 0
else
return 1
fi
}
# because the function returns a status, you can test it directly with if
if is_real_file "/etc/hostname"; then
echo "Yes, that file exists."
fi
Exit codes and a safety header
Every command leaves behind a number when it finishes: its exit code. By convention 0 means success and anything else means some kind of failure. The most recent one is waiting in the special variable $?:
#!/usr/bin/env bash
# try to enter a directory that may not exist
cd /some/place
# $? holds the exit code of the previous command: 0 = it worked
echo "cd exited with code $?"
Your own script returns an exit code too. End it with exit 0 for success or exit 1 (or any non-zero number) to signal a problem, so whatever ran your script — another script, cron, a Makefile — can tell whether it worked.
The safety header: set -euo pipefail
By default bash plows ahead even after a command fails, which is exactly how a small bug becomes a deleted-the-wrong-folder disaster. Put this line right under the shebang of every serious script and bash becomes far stricter and safer:
#!/usr/bin/env bash
set -euo pipefail
It bundles three separate protections:
-e(errexit) — stop the whole script the moment any command fails, instead of carrying on with broken assumptions.-u(nounset) — treat using an unset variable as an error. This catches typos: a misspelled"$ouptut"would otherwise silently expand to nothing.-o pipefail— in a pipeline likea | b | c, fail if any stage fails, not just the last one. Without it, a brokenais hidden whenevercsucceeds.
Warning:
-ehas a few surprises — a command that "fails" on purpose (agrepthat finds nothing, for instance) will stop your script. The usual fix is to tell bash that case is fine by adding|| trueto that one command. Even with its quirks,set -euo pipefailis the right default; you will catch far more bugs than it causes.
Talking to the user: input and here-docs
To ask the person running the script a question, use read. With -p it prints a prompt first and stores the answer in a variable:
#!/usr/bin/env bash
# prompt, then read the typed answer into the variable "name"
read -p "What is your name? " name
echo "Nice to meet you, $name."
When you need to print or feed in a whole block of text, a here-document (<<EOF) is tidier than a stack of echo lines. Everything up to the closing EOF is sent as-is:
#!/usr/bin/env bash
# print a multi-line block; variables are still expanded inside it
cat <<EOF
Welcome to the setup script.
Your home directory is $HOME
Running as user: $(whoami)
EOF
Tip: if you want the block kept completely literal — no variables expanded — quote the marker as
<<'EOF'. Then a$HOMEinside stays the four characters$HOME.
Putting it together: a timestamped backup script
Time to build something you would actually keep. This script takes a directory you name and packs it into a compressed .tar.gz archive stamped with the date and time, dropped into a backups folder. It uses nearly everything above: the safety header, arguments, a file test, command substitution, and an exit code.
Save it as backup.sh:
#!/usr/bin/env bash
set -euo pipefail
# where finished archives go; override by setting BACKUP_DIR before running
backup_dir="${BACKUP_DIR:-$HOME/backups}"
# the directory to back up comes in as the first argument
source_dir="$1"
# refuse to run without an argument
if [[ -z "$source_dir" ]]; then
echo "Usage: ./backup.sh <directory-to-back-up>"
exit 1
fi
# refuse to run if that argument is not actually a directory
if [[ ! -d "$source_dir" ]]; then
echo "Error: '$source_dir' is not a directory."
exit 1
fi
# make sure the destination folder exists (-p does not complain if it already does)
mkdir -p "$backup_dir"
# build a timestamped archive name, e.g. backup-2026-06-20_14-30-05.tar.gz
timestamp="$(date +%Y-%m-%d_%H-%M-%S)"
archive="$backup_dir/backup-$timestamp.tar.gz"
# create the compressed archive: -c create, -z gzip, -f file, -C change dir first
echo "Backing up '$source_dir' ..."
tar -czf "$archive" -C "$source_dir" .
echo "Done: $archive"
exit 0
Make it executable and try it on any folder:
chmod +x backup.sh
./backup.sh ~/Documents
It writes something like ~/backups/backup-2026-06-20_14-30-05.tar.gz. Because the name carries a timestamp, running it again never overwrites the last one — you build up a little history. A couple of details worth noticing: ${BACKUP_DIR:-$HOME/backups} means "use BACKUP_DIR if it is set, otherwise fall back to $HOME/backups", a handy way to make a setting optional. And tar -C "$source_dir" . changes into the source directory first so the archive holds the contents, not a long chain of parent folders.
Tip: once a script like this is reliable, you can have it run on a schedule with
cron(runcrontab -eand add a line) so your backups happen without you remembering. That is the real payoff of scripting: the chore does itself.
Debugging: when it does not work
Every script misbehaves eventually. Two tools find almost all of it.
Trace it with bash -x
Run any script with bash -x and bash prints each command, with its variables already filled in, just before it runs it. You see exactly what the shell saw — usually the bug jumps right out:
# print every command as it executes
bash -x backup.sh ~/Documents
To trace only a suspicious section rather than the whole file, switch tracing on and off around it from inside the script with set -x and set +x.
Catch bugs before they happen with shellcheck
shellcheck (free software, GPLv3) is a "linter" for shell scripts: it reads your script without running it and warns about the exact mistakes beginners make — the unquoted variable, the = with stray spaces, the string-versus-number mix-up. It is the single best thing you can add to your workflow. Install it from your package manager:
# Debian / Mint / Ubuntu
sudo apt install shellcheck
# Arch / Manjaro
sudo pacman -S shellcheck
# Fedora
sudo dnf install shellcheck
Then point it at your script:
shellcheck backup.sh
For each issue it shows the line, explains what is wrong, and suggests the fix. Run it on everything you write — it catches the quoting bugs that cause most beginner head-scratching, long before they bite you in production.
Tip: many editors can run shellcheck as you type, underlining problems live. If yours supports it, turn it on — it is like having an experienced reviewer reading over your shoulder for free.
Where to go next
You now have the whole core of the language: variables and quoting, arguments, conditionals, loops, functions, exit codes, and the habits that keep a script safe. From here, a few directions pay off quickly:
- Arrays — bash can hold lists in a single variable:
files=(a.txt b.txt)and"${files[@]}". casestatements — a cleaner way to branch on many values than a tower ofelif.trap— run a cleanup function automatically when the script exits, even on error.- Reading
man bash— dense, but the definitive reference once the basics click.
The fastest way to improve, though, is simply to script the next repetitive thing you catch yourself doing by hand. Keep shellcheck running, quote your variables, and you will be writing reliable tools in no time.
Quick reference
#!/usr/bin/env bash— the shebang; first line of every script.chmod +x script.shthen./script.sh— make it runnable and run it (orbash script.sh).set -euo pipefail— the safety header: stop on errors, unset variables, and pipeline failures.name="value"— assign (no spaces around=); read it back as"$name"(always quoted)."$(command)"— capture a command's output into text.$1 $2,$@,$#,$0— arguments, all of them, the count, the script name.if [[ ... ]]; then ... elif ... else ... fi— branch; tests like-z,-f,-d,-eq,-lt,=.for x in list; do ... doneandwhile [[ ... ]]; do ... done— loops.while IFS= read -r line; do ... done < file— read a file line by line.name() { ...; }thenname args— define and call a function.$?— exit code of the last command;exit 0/exit 1— set your script's.read -p "prompt " var— ask the user;cat <<EOF ... EOF— a here-doc.bash -x script.sh— trace execution;shellcheck script.sh— lint before running.