Embercode
✕
The TerminalLesson 7 of 14

45 min read

Loops and conditionals in scripts

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.md

printf 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 Asha

Two 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"
done

Read 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: cherry

The 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.txt

The 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"
done

Here 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))
done

The 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 2

A 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.txt

The 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: cherry

Stopping a loop early

Two words change how a loop runs:

  • break leaves the loop at once. The commands after it in the block do not run, and the commands after done run next.
  • continue skips 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
done

Save 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
fi

elif 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 matched

Run 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:

TestTrue whenExample
-f FILEFILE exists and is a regular file[[ -f notes.txt ]]
-d DIRDIR exists and is a folder[[ -d reports ]]
-e PATHPATH exists, as a file or a folder[[ -e missing.txt ]]

Text tests compare strings. A string is any run of characters:

TestTrue whenExample
==both sides are the same text[[ $name == "Asha" ]]
!=the two sides differ[[ $name != "Ravi" ]]
-z STRSTR 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:

TestMeaning
-eqequal to
-nenot equal to
-ltless than
-leless than or equal to
-gtgreater than
-gegreater 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 kiwi

Save 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 $?
1

Two short operators use exit codes without an if:

  • command1 && command2 runs command2 only when command1 succeeds.
  • command1 || command2 runs command2 only when command1 fails.
#!/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 $?
3

Save 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 0

The 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 $?
1

Run 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 $?
0

A 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.
  • ! -d means "is not a folder". The ! flips the test. This branch exits with code 1.
  • The loop visits every name in the folder. The continue line skips anything that does not end in .txt, so mar.md is ignored.
  • count goes up by one for each .txt file, 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 $?
1

The 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 found

Missing 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 expected

The 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 three

The 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 2

The 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
end

Check 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"
done

Running 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
done

The 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 $?
1

Exercise 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
done

Show 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"
fi

Show 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"
fi

This 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 for loop runs a block once for each word, file or number in its list. A while loop runs as long as its test is true.
  • if with [[ ]] runs a block when a file, string or number test is true. Always close the block with fi.
  • Every command returns an exit code, where 0 means success. You can put a command directly in an if test, and scripts can set their own exit code with exit.
  • Quote variables inside tests, and set variables before you use them, to avoid errors and silent wrong answers.
Check yourself: Script basics (9 questions) →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Module assignment: The Log Detective