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/fiwith[[ ]] - Repeat actions with
forandwhile - Organise the code with functions
- Handle arguments
$1,$#,$@and exit codes$? - Debug with
bash -xandshellcheck
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:
chmod +x hello.sh./hello.shHello, bob!#!/bin/bash: the shebang, the system reads this path and starts that interpreterchmod +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}"User : aliceValue : 42Command 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.
today=$(date '+%Y-%m-%d')nb_users=$(wc -l < /etc/passwd)
echo "Date : $today"echo "Accounts : $nb_users"Date : 2026-04-10Accounts : 42Read 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.
echo "Enter your name:"read -r firstnameecho "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:
| Variable | Content |
|---|---|
$USER | Current user name |
$HOME | Home directory |
$PWD | Current directory |
$RANDOM | Random 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."fiFile 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.
| Operator | True 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.
| Operator | True 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".
| Operator | Meaning |
|---|---|
-eq | equal |
-ne | not equal |
-lt | strictly less than |
-le | less than or equal |
-gt | strictly greater than |
-ge | greater than or equal |
score=85
if [[ $score -ge 90 ]]; then echo "Excellent"elif [[ $score -ge 70 ]]; then echo "Good"else echo "Needs work"fiGoodLoops
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.
for service in nginx ssh cron; do echo "Service: $service"doneService: nginxService: sshService: cronOver the files of a directory:
for file in /etc/*.conf; do echo "Config: $file"doneOver a numeric range:
for i in {1..5}; do echo "Iteration $i"donewhile, 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.
counter=1
while [[ $counter -le 3 ]]; do counter=$((counter + 1)) echo "Turn $counter"doneTurn 2Turn 3Turn 4Read 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.
while IFS= read -r line; do echo "→ $line"done < /etc/hostnameIFS=: keeps the leading and trailing spaces and tabs, whichreadremoves 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"=== 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 $():
full_name() { local first="$1" local last="$2" echo "${first} ${last}"}
result=$(full_name "alice" "martin")echo "Hello, $result!"Hello, alice martin!Script arguments
When you pass arguments to your script, they are available through special variables, set by Bash at launch:
| Variable | Content |
|---|---|
$0 | Script 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 1fi
echo "Script : $0"echo "Source : $1"echo "Dest : $2"echo "Nb args : $#"./copy.sh /etc/hosts /tmp/hosts.bakScript : ./copy.shSource : /etc/hostsDest : /tmp/hosts.bakNb args : 2Exit codes
Every command ends with an exit code: 0 = success, non-zero value = error. $? holds the code of the last command run.
ls /etc/passwdecho "Code: $?" # 0: success
ls /no-such-file 2>/dev/nullecho "Code: $?" # 2: errorIn a script, exit sets the code returned to the caller, whether it is a shell, cron or a CI pipeline:
if [[ ! -d "$1" ]]; then echo "Error: '$1' is not a directory." >&2 exit 1fi
# … processing …exit 0Complete 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 1fi
directory="$1"
if [[ ! -d "$directory" ]]; then echo "Error: '$directory' is not a directory." >&2 exit 1fi
print_header "Report: $directory"echo "Date : $(date '+%Y-%m-%d %H:%M')"echo "User : $USER"echo ""
nb_files=0for entry in "$directory"/*; do [[ -f "$entry" ]] && nb_files=$((nb_files + 1))done
echo "Files : $nb_files"exit 0./generate-report.sh /etc=== Report: /etc ===Date : 2026-04-10 07:44User : bob
Files : 158Debug 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.
bash -x ./generate-report.sh /etcset -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.
set -x # start of debugmv "$src" "$dst"set +x # end of debugshellcheck, 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.
# Debian/Ubuntusudo apt install shellcheck
# RHEL and derivatives: the package comes from EPELsudo dnf install epel-releasesudo dnf install ShellCheckshellcheck my-script.shOn 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:
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.
| Symptom | Likely cause | Fix |
|---|---|---|
Permission denied when running | Missing +x permission | chmod +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\r | Check #!/bin/bash; convert with dos2unix my-script.sh |
unbound variable | Undefined variable used under set -u | Initialise the variable or use ${VAR:-} |
[[: not found or declare: not found | Script launched with sh script.sh: dash ignores the shebang | Run with ./script.sh or bash script.sh |
syntax error near unexpected token | Missing fi/done/quote | Run shellcheck script.sh to locate it |
Key points
- A script always starts with
#!/bin/bashand is made executable withchmod +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 -xto debug,shellcheckto 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Bash variables and parameters
A new full quiz with random questions
Work again only on the questions you missed