Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Write a Bash shell script: the complete guide

By updated

Improve this page

This guide gathers everything you need to write a Bash shell script, from the shebang to functions. Variables, conditions, loops, arguments, exit codes, debugging: each concept comes with a runnable example, then goes deeper in a dedicated page. It is the reference guide of the scripting section, to read end to end when learning, or to consult section by section.

What you will learn

  • Create and run a script with a shebang and chmod +x
  • Use variables to store and reuse values
  • Write conditions if/then/fi with [[ ]]
  • Repeat actions with for and while
  • Organise the code with functions
  • Handle arguments $1, $#, $@ and exit codes $?
  • Debug with bash -x and shellcheck

When is it useful?

A Bash script pays off as soon as you repeat the same sequence of commands more than once or twice:

  • check disk space on ten servers and send an alert when a threshold is exceeded
  • create a user with their home directory, SSH keys and permissions in one command
  • build a daily report from nginx logs and send it by email
  • validate the state of a service before starting a deployment
  • convert or rename a batch of files following a pattern

When preparing for the RHCSA, shell scripts are explicitly on the programme: you must be able to write one from scratch under time pressure.


Create and run your first script

The first line of a script, the shebang, tells which interpreter will run it. Without it, the shell that launches the script decides: Bash runs it with Bash, but dash (the sh of Debian and Ubuntu) runs it with itself, and anything specific to Bash breaks. Measured on Debian 13: the same file without a shebang sees BASH_VERSION defined when launched from Bash, and absent from dash.

#!/bin/bash
echo "Hello, $USER!"

Save this file as hello.sh, then make it executable and run it from the current directory, where the shell does not look for commands by default:

Terminal window
chmod +x hello.sh
./hello.sh
Output
Hello, bob!
  • #!/bin/bash: the shebang, the system reads this path and starts that interpreter
  • chmod +x: adds the execute permission for the owner
  • ./: runs the script from the current directory

Variables

A variable stores a value to reuse it later. In Bash, the rule that traps every beginner is about spacing: the declaration takes no space around the = (name="alice", never name = "alice"), because with spaces Bash thinks it must run a command called name. When reading it, you prefix the name with $ or wrap it in ${}, the latter being mandatory as soon as text follows the name directly.

#!/bin/bash
name="alice"
number=42
echo "User : $name"
echo "Value : ${number}"
Output
User : alice
Value : 42

Command substitution

Command substitution captures the standard output of a command and stores it in a variable. The modern $(...) form replaces the old backtick notation: it nests cleanly and reads without ambiguity. It is the mechanism that lets a script react to the real state of the system, such as today's date or the number of accounts.

Terminal window
today=$(date '+%Y-%m-%d')
nb_users=$(wc -l < /etc/passwd)
echo "Date : $today"
echo "Accounts : $nb_users"
Output
Date : 2026-04-10
Accounts : 42

Read user input

The read command pauses the script and waits for keyboard input, which it stores in the given variable. It is handy for an interactive script, but keep in mind that an automated script (cron, CI) has nobody to answer: keep read for tools run by hand.

Terminal window
echo "Enter your name:"
read -r firstname
echo "Hello, $firstname!"

The -r option turns off the interpretation of backslashes in the input: a pasted Windows path or a line containing \n comes out intact. Always use read -r, shellcheck flags the other form.

Useful automatic variables

Bash sets a few variables on its own, without any declaration. These four come up in almost every administration script, to name a file, build a path or pick a random value:

VariableContent
$USERCurrent user name
$HOMEHome directory
$PWDCurrent directory
$RANDOMRandom number between 0 and 32767

Conditions

The if/then/fi structure runs a block when a condition is true. Use [[ ]] (double brackets), safer and more readable than [ ].

#!/bin/bash
file="$1"
if [[ -f "$file" ]]; then
echo "The file exists."
else
echo "The file cannot be found."
fi

File tests

In system administration, half of the conditions are about files: does the script exist, is the directory there, do I have the right to write here. These one-letter operators return true or false and are used as is inside [[ ]]. The most common is -f, which checks that a regular file exists before reading it.

OperatorTrue when…
-f "$f"$f is a regular file
-d "$f"$f is a directory
-e "$f"$f exists (file or directory)
-r "$f"$f is readable
-w "$f"$f is writable
-x "$f"$f is executable

String tests

String tests mostly validate an argument: is it empty, is it exactly this value. The classic trap is -z versus -n: -z is true when the string is empty, -n when it contains something. Inside [[ ]], an empty unquoted variable remains an empty operand and the test works; inside the older [ ] command, it vanishes and triggers unary operator expected. Keep the quotes out of habit: they protect you in both cases.

OperatorTrue when…
-z "$s"$s is empty
-n "$s"$s is not empty
"$a" == "$b"the two strings are equal
"$a" != "$b"the two strings differ

Numeric tests

Bash clearly separates comparing numbers from comparing strings. For numbers, use these two-letter operators (-eq, -lt...), not the < or > signs, which compare alphabetically. Writing [[ "$a" -gt "$b" ]] compares 9 and 10 as integers, whereas > would place "10" before "9".

OperatorMeaning
-eqequal
-nenot equal
-ltstrictly less than
-leless than or equal
-gtstrictly greater than
-gegreater than or equal
Terminal window
score=85
if [[ $score -ge 90 ]]; then
echo "Excellent"
elif [[ $score -ge 70 ]]; then
echo "Good"
else
echo "Needs work"
fi
Output
Good

Loops

for, iterate over a list

The for loop repeats a block for each item of a list. That list can be written by hand, produced by a file pattern (/etc/*.conf) or a range {1..5}. It is the form to prefer when you know in advance the set of items to process.

Terminal window
for service in nginx ssh cron; do
echo "Service: $service"
done
Output
Service: nginx
Service: ssh
Service: cron

Over the files of a directory:

Terminal window
for file in /etc/*.conf; do
echo "Config: $file"
done

Over a numeric range:

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

while, conditional loop

Where for walks a known set, while repeats as long as a condition stays true, without knowing in advance how many turns there will be. It is the loop to pick to wait for a service to start, or to consume an input until it runs out. Make sure the condition eventually becomes false, otherwise the loop runs forever.

Terminal window
counter=1
while [[ $counter -le 3 ]]; do
counter=$((counter + 1))
echo "Turn $counter"
done
Output
Turn 2
Turn 3
Turn 4

Read a file line by line

Reading a file line by line is a daily need: walk a list of servers, process a log, apply an action per entry. The while IFS= read -r line combination is the correct pattern to do it without surprises. Without IFS=, Bash strips the leading and trailing spaces; without -r, it interprets backslashes. Together, they guarantee that each line arrives intact.

Terminal window
while IFS= read -r line; do
echo "→ $line"
done < /etc/hostname
  • IFS=: keeps the leading and trailing spaces and tabs, which read removes otherwise
  • -r: preserves backslashes

Functions

A function groups reusable instructions under a name, to avoid repeating the same block in several places. In Bash, one constraint is worth knowing straight away: a function must be declared before the line that calls it, because the script is read top to bottom. So place your functions at the top of the file, right after the shebang.

#!/bin/bash
print_header() {
local title="$1"
echo ""
echo "=== $title ==="
}
print_header "System report"
print_header "End"
Output
=== System report ===
=== End ===
  • local: the variable stays local to the function, essential to avoid side effects
  • $1, $2…: arguments passed to the function (independent from the script's arguments)

Return a text value

A function returns an exit code (0–255), not a string. To return text, print it with echo and capture it with $():

Terminal window
full_name() {
local first="$1"
local last="$2"
echo "${first} ${last}"
}
result=$(full_name "alice" "martin")
echo "Hello, $result!"
Output
Hello, alice martin!

Script arguments

When you pass arguments to your script, they are available through special variables, set by Bash at launch:

VariableContent
$0Script name
$1, $2…First, second argument
$#Total number of arguments
$@All the arguments (list)
#!/bin/bash
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <source> <destination>" >&2
exit 1
fi
echo "Script : $0"
echo "Source : $1"
echo "Dest : $2"
echo "Nb args : $#"
Terminal window
./copy.sh /etc/hosts /tmp/hosts.bak
Output
Script : ./copy.sh
Source : /etc/hosts
Dest : /tmp/hosts.bak
Nb args : 2

Exit codes

Every command ends with an exit code: 0 = success, non-zero value = error. $? holds the code of the last command run.

Terminal window
ls /etc/passwd
echo "Code: $?" # 0: success
ls /no-such-file 2>/dev/null
echo "Code: $?" # 2: error

In a script, exit sets the code returned to the caller, whether it is a shell, cron or a CI pipeline:

Terminal window
if [[ ! -d "$1" ]]; then
echo "Error: '$1' is not a directory." >&2
exit 1
fi
# … processing …
exit 0

Complete example

Here is a realistic script that brings together everything above: a display function, the validation of the number of arguments, a directory test, a for counting loop and explicit exit codes. Read it as a template to reuse: the set -euo pipefail line at the top and the argument check at the start are exactly the expected reflexes in a serious administration script.

#!/bin/bash
# generate-report.sh: report on a directory
# Usage: ./generate-report.sh <directory>
set -euo pipefail
print_header() {
local title="$1"
echo ""
echo "=== $title ==="
}
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <directory>" >&2
exit 1
fi
directory="$1"
if [[ ! -d "$directory" ]]; then
echo "Error: '$directory' is not a directory." >&2
exit 1
fi
print_header "Report: $directory"
echo "Date : $(date '+%Y-%m-%d %H:%M')"
echo "User : $USER"
echo ""
nb_files=0
for entry in "$directory"/*; do
[[ -f "$entry" ]] && nb_files=$((nb_files + 1))
done
echo "Files : $nb_files"
exit 0
Terminal window
./generate-report.sh /etc
Output
=== Report: /etc ===
Date : 2026-04-10 07:44
User : bob
Files : 158

Debug a script

bash -x, see each command before it runs

Running the script through bash -x prints each executed line before it runs, prefixed with +, with variables already substituted. It is the fastest way to follow the execution step by step and spot the value that goes wrong.

Terminal window
bash -x ./generate-report.sh /etc

set -x / set +x inside the script

Running the whole script with bash -x soon becomes unreadable on a long script. The set -x and set +x directives surround the only portion you care about: tracing starts at the first, stops at the second. It is the method to prefer to isolate a suspicious command without drowning the output.

Terminal window
set -x # start of debug
mv "$src" "$dst"
set +x # end of debug

shellcheck, static analysis

shellcheck is a static analyser: it reads the script without running it and reports syntax errors and classic traps, starting with unquoted variables. It is the first reflex to adopt before running a script, and the tool exam graders and CI pipelines use to judge the quality of a script. Each warning carries a code (SC2086, etc.) that links to a detailed explanation online.

On Debian and Ubuntu, shellcheck is in the distribution repositories. On RHEL and its derivatives, it is not: it comes from EPEL, under the package name ShellCheck. Measured on AlmaLinux 10: dnf install shellcheck fails without EPEL, and ShellCheck installs once epel-release is in place.

Terminal window
# Debian/Ubuntu
sudo apt install shellcheck
# RHEL and derivatives: the package comes from EPEL
sudo dnf install epel-release
sudo dnf install ShellCheck
Terminal window
shellcheck my-script.sh

On a script that writes rm $f, shellcheck 0.10 answers as follows. The -f gcc option gives the same finding on a single line, handy in an editor or a CI job:

Output of shellcheck my-script.sh
In my-script.sh line 3:
rm $f
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
rm "$f"

Troubleshooting

Beginner scripting errors repeat from one script to the next, and most of them can be read straight from the error message. The messages below are those of Bash 5.2, captured on Debian 13 and AlmaLinux 10. Two reflexes cover almost every case: check the execute permission and the shebang for launch problems, run shellcheck for syntax errors.

SymptomLikely causeFix
Permission denied when runningMissing +x permissionchmod +x my-script.sh
cannot execute: required file not found (Bash 5.2) or bad interpreter (other shells)Wrong shebang or Windows line endings (CRLF): the system looks for bash\rCheck #!/bin/bash; convert with dos2unix my-script.sh
unbound variableUndefined variable used under set -uInitialise the variable or use ${VAR:-}
[[: not found or declare: not foundScript launched with sh script.sh: dash ignores the shebangRun with ./script.sh or bash script.sh
syntax error near unexpected tokenMissing fi/done/quoteRun shellcheck script.sh to locate it

Key points

  • A script always starts with #!/bin/bash and is made executable with chmod +x.
  • Variables are declared without spaces around the = and used in quotes: "$var".
  • Use [[ ]] for tests, safer than [ ].
  • Functions declare their internal variables with local.
  • $1, $2, $#, $@ give access to the arguments passed to the script.
  • exit 0 = success, exit 1 = error, always explicit.
  • bash -x to debug, shellcheck to catch errors beforehand.

Knowledge check

Check that the essentials of this guide stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn