Why this matters
Up to now you have run one command at a time. A script is a text file that holds many commands and runs them in order. Scripts become useful when they can do two things a single command cannot: repeat the same work for every file or line, and choose between actions based on a test.
A loop repeats commands. A conditional chooses which commands to run. Together they let you write a few lines that handle 3 files or 3,000 files in the same way.
Imagine a folder called reports with a month's worth of files. You want to check each .txt file and print the ones that contain the letter a. By hand, you would open each file. With a loop, you write the rule once and the computer applies it to every file.
The commands in this lesson run in bash, the shell most Linux systems use. macOS ships it too, although its default shell is zsh. Check that it is installed with bash --version. The first line of output names the version.
This lesson uses the terminal basics from the earlier lessons: moving between folders, creating files, and reading output. The text search tool grep appears here as a test inside a script. The next lesson covers its patterns in depth.
Setting up the practice folder
Create this folder and these files so every example works on your machine. The contents are small so you can check each result by eye.
$ mkdir -p ~/forge-lab/loops && cd ~/forge-lab/loops
$ printf 'apple\nbanana\ncherry\n' > fruit.txt
$ printf 'hello\n' > notes.txt
$ mkdir reports
$ printf 'a\n' > reports/jan.txt
$ printf 'b\n' > reports/feb.txt
$ printf 'ok\n' > reports/mar.mdprintf writes text, and \n means "new line". The > symbol sends the text into a file, replacing what was there before.
Your first script
A script file has three parts you need to know now.
The first line is called the shebang. It is the two characters #! followed by the program that should run the file. For bash it is #!/usr/bin/env bash.
Lines that start with # (after the shebang) are comments. Bash ignores them. Use comments to explain what the script is for.
Every other line is a command. Bash runs them from top to bottom.
Make a file called hello.sh with this content. You can write it in any text editor, or create it from the terminal with cat, as you did in the earlier lessons:
#!/usr/bin/env bash
# My first script.
name="Asha"
echo "hello $name"Run it by giving its name to bash:
$ bash hello.sh
hello AshaTwo details matter here. The line name="Asha" stores the text Asha in a variable, which is a named box that holds a value. There must be no spaces around the =. Then $name reads the value back. The double quotes around "hello $name" let the value be inserted while keeping the text together as one argument.
Loops
A loop runs a block of commands again and again. The block sits between do and done. Bash accepts several loop forms. This lesson covers the three you will use most often.
A for loop over a list of words
The simplest loop goes through a list. Put this in loops1.sh:
#!/usr/bin/env bash
# Loop over words, then over files.
for fruit in apple banana cherry; do
echo "fruit: $fruit"
doneRead it as: "for each word in the list, set fruit to that word, then run the block." The variable fruit changes on each pass. On the first pass it is apple, then banana, then cherry. The block runs once for each word, and then the loop ends.
$ bash loops1.sh
fruit: apple
fruit: banana
fruit: cherryThe indentation of two spaces makes the block easy to see. Bash does not require it, but people who read scripts expect it.
A for loop over files
A loop can also go through files. The pattern reports/*.txt is a wildcard. The * stands for any run of characters, so *.txt means "every name that ends in .txt". Bash replaces the pattern with the matching names before the loop starts.
#!/usr/bin/env bash
for file in reports/*.txt; do
echo "file: $file"
done$ bash loops1.sh
file: reports/feb.txt
file: reports/jan.txtThe file reports/mar.md does not appear, because its name does not end in .txt. The names are listed in alphabetical order on this machine, and that is how bash feeds them to the loop.
A counting loop
Sometimes you need a number, not a list. The bash form with double parentheses uses three parts separated by semicolons: a starting value, a test, and a step that runs after each pass.
#!/usr/bin/env bash
for ((i = 1; i <= 3; i++)); do
echo "count $i"
doneHere i = 1 sets the start. The loop keeps going while i <= 3 is true. The i++ adds 1 to i after each pass. The output is:
$ bash count.sh
count 1
count 2
count 3<= means "less than or equal to". Section "Conditionals" below lists the other number comparisons.
A while loop
A while loop repeats as long as its test is true. This is useful when the number of passes is not known in advance. The test uses [[ ]], which is explained in the conditionals section. For now, read [[ $n -lt 3 ]] as "while n is less than 3".
Put this in loops2.sh:
#!/usr/bin/env bash
n=0
while [[ $n -lt 3 ]]; do
echo "n is $n"
n=$((n + 1))
doneThe line n=$((n + 1)) is arithmetic. Bash reads the value of n, adds 1, and stores the result back in n. Without that line, n would stay 0 forever, the test would always be true, and the loop would never stop. Press Ctrl+C in the terminal to stop a loop that runs away.
$ bash loops2.sh
n is 0
n is 1
n is 2A while loop is also the usual way to read a file one line at a time. The < symbol feeds a file into the loop:
#!/usr/bin/env bash
while IFS= read -r line; do
echo "line: $line"
done < fruit.txtThe read command takes one line from the file and stores it in the variable line. When there are no lines left, read fails, and the loop ends. The words IFS= and -r keep leading spaces and backslashes exactly as they are in the file. Without them, read can quietly change some lines, so use this exact form when you read files this way.
Save this loop as read-lines.sh and run it:
$ bash read-lines.sh
line: apple
line: banana
line: cherryStopping a loop early
Two words change how a loop runs:
breakleaves the loop at once. The commands after it in the block do not run, and the commands afterdonerun next.continueskips the rest of the current pass and starts the next one.
#!/usr/bin/env bash
for i in 1 2 3 4; do
if [[ $i -eq 2 ]]; then
continue
fi
if [[ $i -eq 4 ]]; then
break
fi
echo "step $i"
done
echo "done"$ bash loop-control.sh
step 1
step 3
doneSave it as loop-control.sh. Pass 2 is skipped by continue, so nothing prints for it. On pass 4, break ends the loop before echo runs.
Conditionals
A conditional runs a block only when a test succeeds. The test might check a number, a piece of text, or whether a file exists.
The shape of an if statement
if [[ test one ]]; then
commands for test one
elif [[ test two ]]; then
commands for test two
else
commands when nothing above was true
fielif means "else if", and it is optional. else is optional too. The block ends with fi, which is if spelled backwards. Forgetting fi is one of the most common mistakes, and it is covered in the mistakes section.
Here is a small example. Put it in cond1.sh:
#!/usr/bin/env bash
name="Asha"
age=31
if [[ $name == "Asha" ]]; then
echo "hello Asha"
elif [[ $name == "Ravi" ]]; then
echo "hello Ravi"
else
echo "unknown"
fi
if [[ $age -ge 18 && $age -lt 65 ]]; then
echo "working age"
fi
if [[ -f notes.txt ]]; then echo "notes.txt is a file"; fi
if [[ -d reports ]]; then echo "reports is a folder"; fi
if [[ -e missing.txt ]]; then echo "exists"; else echo "missing.txt does not exist"; fi
if [[ -z "" ]]; then echo "empty string"; fi
if [[ $name == A* ]]; then echo "starts with A"; fi
if [[ "abc123" =~ ^[a-z]+[0-9]+$ ]]; then echo "regex matched"; fi$ bash cond1.sh
hello Asha
working age
notes.txt is a file
reports is a folder
missing.txt does not exist
empty string
starts with A
regex matchedRun each line in your head before you look at the output. The first if matches Asha, so the elif lines are skipped. The age check is true because 31 is between 18 and 65. The rest are explained in the tables below.
Tests inside [[ ]]
Bash uses [[ and ]] to wrap a test. Put a space after [[ and a space before ]]. Inside, each test uses an operator, which is a short word or symbol that says what to compare.
File tests ask about the disk:
| Test | True when | Example |
|---|---|---|
-f FILE | FILE exists and is a regular file | [[ -f notes.txt ]] |
-d DIR | DIR exists and is a folder | [[ -d reports ]] |
-e PATH | PATH exists, as a file or a folder | [[ -e missing.txt ]] |
Text tests compare strings. A string is any run of characters:
| Test | True when | Example |
|---|---|---|
== | both sides are the same text | [[ $name == "Asha" ]] |
!= | the two sides differ | [[ $name != "Ravi" ]] |
-z STR | STR is empty | [[ -z "$name" ]] |
=~ | the left side matches a regex | [[ "abc123" =~ ^[a-z]+[0-9]+$ ]] |
On the right of ==, a * acts as a wildcard, so [[ $name == A* ]] is true for any name that starts with A. The regex form uses the same kind of patterns as grep -E, which the next lesson explains.
Number tests compare integers, which are whole numbers such as 3 or 27:
| Test | Meaning |
|---|---|
-eq | equal to |
-ne | not equal to |
-lt | less than |
-le | less than or equal to |
-gt | greater than |
-ge | greater than or equal to |
You can join tests with && (and) and || (or). The line [[ $age -ge 18 && $age -lt 65 ]] is true only when both parts are true.
Exit codes: using a command as the test
Every command that finishes gives back a small number called an exit code. Zero means success. Any other number means something went wrong or the answer was "no".
grep gives 0 when it finds a match and 1 when it finds none. The -q flag makes it quiet, so it prints nothing and only returns the code. Because of that, you can put grep straight into an if:
#!/usr/bin/env bash
if grep -q banana fruit.txt; then
echo "found banana"
else
echo "no banana"
fi
if grep -q kiwi fruit.txt; then
echo "found kiwi"
else
echo "no kiwi"
fi$ bash exit1.sh
found banana
no kiwiSave the script as exit1.sh. You can also read the code yourself. Run grep by hand and then print $?, which holds the exit code of the last command:
$ grep -q banana fruit.txt
$ echo $?
0
$ grep -q kiwi fruit.txt
$ echo $?
1Two short operators use exit codes without an if:
command1 && command2runscommand2only whencommand1succeeds.command1 || command2runscommand2only whencommand1fails.
#!/usr/bin/env bash
mkdir -p out && echo "made out" && echo "second step runs"
ls nothere 2>/dev/null || echo "ls failed with $?"
exit 3$ bash exit2.sh
made out
second step runs
ls failed with 2
$ echo $?
3Save this as exit2.sh. 2>/dev/null throws away the error message that ls would print, so only your own message shows. The exit 3 line at the end sets the exit code of the whole script. Anything that runs your script, including another script, can check that number.
Worked example: count errors in a log
This script reads a log file line by line. It counts the lines that contain ERROR and exits with code 1 when it finds any. Call it triage.sh:
#!/usr/bin/env bash
# Count ERROR lines in a log. Exit 1 if there is at least one.
logfile="$1"
errors=0
while IFS= read -r line; do
if [[ "$line" == *" ERROR "* ]]; then
errors=$((errors + 1))
echo "error on line: $line"
fi
done < "$logfile"
echo "total errors: $errors"
if [[ $errors -gt 0 ]]; then
exit 1
fi
exit 0The first line of the body stores the first argument, which is the file name you type after the script name. $1 means "the first argument". The test *" ERROR "* is a pattern: the * on each side matches any text, and the quoted part must match exactly, spaces included. The spaces stop a line from matching a word such as ERRORS by accident.
Create a log file with two errors in it, then run the script:
$ cat > app.log <<'EOF'
2026-11-04 09:00:01 INFO server started
2026-11-04 09:01:10 WARN slow query 1200ms
2026-11-04 09:02:44 ERROR payment failed user=ravi
2026-11-04 09:03:30 ERROR timeout calling db
2026-11-04 09:04:12 INFO user=asha logout
EOF
$ bash triage.sh app.log
error on line: 2026-11-04 09:02:44 ERROR payment failed user=ravi
error on line: 2026-11-04 09:03:30 ERROR timeout calling db
total errors: 2
$ echo $?
1Run it on a log with no errors and the exit code changes:
$ printf 'x INFO fine\n' > clean.log
$ bash triage.sh clean.log
total errors: 0
$ echo $?
0A script that exits with 1 can be used as a test by other scripts or by a scheduler, for example: if ! bash triage.sh app.log; then echo "check the log"; fi. The ! reverses the result, so the message prints when the script fails.
Worked example: check a folder with a loop, a test and exit codes
The last example combines most of the ideas. It takes a folder name, checks that the folder exists, then loops over the .txt files inside and reports each one. Call it check-reports.sh:
#!/usr/bin/env bash
# Usage: check-reports.sh FOLDER
folder="$1"
if [[ -z "$folder" ]]; then
echo "usage: check-reports.sh FOLDER" >&2
exit 2
fi
if [[ ! -d "$folder" ]]; then
echo "not a folder: $folder" >&2
exit 1
fi
count=0
for file in "$folder"/*; do
if [[ "$file" != *.txt ]]; then
continue
fi
count=$((count + 1))
if grep -q "a" "$file"; then
echo "$file has an a"
else
echo "$file has no a"
fi
done
echo "checked $count .txt files"Read each part:
-z "$folder"is true when no folder name was given. The script prints a usage message to the error output (>&2) and exits with code 2.! -dmeans "is not a folder". The!flips the test. This branch exits with code 1.- The loop visits every name in the folder. The
continueline skips anything that does not end in.txt, somar.mdis ignored. countgoes up by one for each.txtfile, so the final message shows the total.
Run it three ways:
$ bash check-reports.sh reports
reports/feb.txt has no a
reports/jan.txt has an a
checked 2 .txt files
$ echo $?
0
$ bash check-reports.sh
usage: check-reports.sh FOLDER
$ echo $?
2
$ bash check-reports.sh nowhere
not a folder: nowhere
$ echo $?
1The three exit codes tell a caller what happened: 0 for success, 1 for a folder that does not exist, and 2 for a missing argument. This is the usual convention for scripts. Pick distinct codes when a caller might need to tell the cases apart.
Common mistakes
Spaces around the equals sign. Bash reads x = 5 as a command named x with two arguments. Write x=5 with no spaces.
err1.sh: line 3: x: command not foundMissing spaces inside the test brackets. The brackets are words, so they need spaces. A missing space before ]] produces a syntax error:
err8.sh: line 2: syntax error in conditional expression: unexpected token `;'
err8.sh: line 2: syntax error near `;'
err8.sh: line 2: `if [[ 3 -eq 3 ]] && [[ 2 -eq 2]]; then echo ok; fi'An unquoted variable that is empty. The old single-bracket test [ splits a variable into separate words. When the variable is empty, the test has too few parts and fails with an error:
err2.sh: line 3: [: ==: unary operator expectedThe fix is to use [[ ]] and to put the variable in double quotes, as in [[ "$name" == "Asha" ]]. Quoting also protects file names with spaces. With f="my notes.txt", the test [ -f $f ] fails with [: my: binary operator expected, while [ -f "$f" ] works.
A variable that was never set. Inside [[ ]], an unset variable used with a number test counts as 0, and no error appears:
#!/usr/bin/env bash
if [[ $count -eq 3 ]]; then echo "three"; else echo "not three"; fi$ bash err5.sh
not threeThe script runs without an error and prints not three, which hides the bug: count was never set. Set your variables before you test them.
A missing done or fi. Each loop needs done and each if needs fi. Bash reads to the end of the file looking for them:
err3.sh: line 4: syntax error: unexpected end of file from `for' command on line 2
err4.sh: line 4: syntax error: unexpected end of file from `if' command on line 2The number after "line" in the first part of the message is the line where bash ran out of file. The number after "command on line" tells you where the unfinished block started. Look at the block that starts on that line.
A wildcard that matches nothing. If no file matches *.pdf, bash does not skip the loop. It uses the pattern text as the value:
#!/usr/bin/env bash
for f in *.pdf; do echo "pdf: $f"; done
echo end$ bash glob.sh
pdf: *.pdf
endCheck that a loop value is a real file with a test such as [[ -f "$f" ]], or run ls first to see what the pattern matches.
A while loop that never changes its test. Forgetting to update n in the counter example above makes the loop run forever. Press Ctrl+C to stop it, then fix the line that changes the variable.
Practice
Create a folder for these exercises, and make the files you need with printf or cat. Each answer is below its exercise.
Exercise 1. Write a for loop that prints each of the words red, green and blue on its own line, in the form color: red.
Show the answer
#!/usr/bin/env bash
for color in red green blue; do
echo "color: $color"
doneRunning it prints color: red, color: green and color: blue, one per line.
Exercise 2. Using the reports folder from this lesson, print the name of each .txt file that contains the letter a. Use a loop, grep -q and an if.
Show the answer
#!/usr/bin/env bash
for file in reports/*.txt; do
if grep -q "a" "$file"; then
echo "$file"
fi
doneThe output is reports/jan.txt. The file reports/feb.txt contains only b, so it is skipped.
Exercise 3. Write a script called need.sh that takes one argument, a file name. If the file does not exist, it prints missing: NAME to the error output and exits with code 1. If it exists, it prints found and exits with code 0. Test it with notes.txt and with nothere.txt.
Show the answer
#!/usr/bin/env bash
if [[ ! -f "$1" ]]; then
echo "missing: $1" >&2
exit 1
fi
echo "found"$ bash need.sh notes.txt
found
$ bash need.sh nothere.txt
missing: nothere.txt
$ echo $?
1Exercise 4. Predict the output of this loop, then run it to check. It has a continue and a break.
for i in 1 2 3 4 5; do
if [[ $i -eq 2 ]]; then continue; fi
if [[ $i -eq 4 ]]; then break; fi
echo $i
doneShow the answer
The output is 1 and 3. Pass 2 is skipped by continue. On pass 4, break ends the loop, so 5 is never reached.
Exercise 5. This script should print hi Asha when the name is Asha, and no name otherwise. It prints no name for an empty name, but the test is written in a risky way. Rewrite the test so an empty variable does not cause an error.
#!/usr/bin/env bash
name=""
if [ $name == Asha ]; then
echo "hi Asha"
else
echo "no name"
fiShow the answer
The test [ $name == Asha ] becomes [ == Asha ] when name is empty, which is an error. The safe form uses [[ ]] with the variable in quotes:
#!/usr/bin/env bash
name=""
if [[ "$name" == "Asha" ]]; then
echo "hi Asha"
else
echo "no name"
fiThis prints no name, with exit code 0 and no error.
Summary
- A script is a text file that starts with
#!/usr/bin/env bash, and bash runs its lines from top to bottom. - A
forloop runs a block once for each word, file or number in its list. Awhileloop runs as long as its test is true. ifwith[[ ]]runs a block when a file, string or number test is true. Always close the block withfi.- Every command returns an exit code, where 0 means success. You can put a command directly in an
iftest, and scripts can set their own exit code withexit. - Quote variables inside tests, and set variables before you use them, to avoid errors and silent wrong answers.