Shell Scripting: Control Structures, Loops, Arrays and String Handling
11.1 Assignment Context and Recap of Previous Shell Concepts
Hook — why this recap matters: The next assignment is not a pen-and-paper quiz. You will chain half a dozen shell commands with arguments, patterns, and branches inside a single script and make the output match a spec. If wildcards, \$?, or #!/bin/bash are still fuzzy, every later case and loop in this lecture will feel shaky. This section re-anchors those foundations so the new control structures have something solid to sit on.
11.1.1 Assignment Overview and Group Work
The coming assignment builds directly on material covered across the last two weeks and carries a heavy weight on shell scripting. The task asks you to select the right command for each requirement, supply the right arguments and parameters, and combine them cleanly inside scripts to produce the required outputs. You will need commands already practised plus a few you have not met in class — the marking looks at whether you can read a manual page and integrate a new tool, not just recall what was demoed.
It is a group assignment. With a class strength around 26 to 27, groups are of two members each. You find a partner yourself and submit one joint solution per group. The solution will lean on the constructs taught in this lecture (case, for, while, until, break/continue, arrays, string and file tests) and on the commands discussed before. The expectation is fluency: choosing the right tool, chaining it with pipes or tests, and keeping the script readable.
Exam note: The assignment tests command-plus-script fluency, not isolated command recall. Keep notes from the previous class and this one together; the assignment draws on both. Slides for both sessions will be uploaded and a separate announcement will collect the group list, assignment statement, and presentation schedule.
Scope — what this assignment is and is not: It rewards neat, tested scripts that handle arguments, quote variables, and exit with meaningful status. A single clever one-liner that happens to work on one directory but breaks when * expands differently will lose marks. Plan to test with empty inputs, spaces in names, and missing files.
11.1.2 Recap: Wildcards, Substitution and Special Variables
The previous session showed three building blocks you will reuse in every loop and test this week.
Wild or meta characters and pathname expansion. A pattern such as * is not a literal star. The shell expands it before the command runs — called globbing or pathname expansion. In for f in * the * becomes the list of file names in the current directory. The same idea applies to ?, [abc], and brace forms.
Command substitution — inserting a command's output into another line. Two spellings do the same job: the modern \$(command) and the older ` command with backticks. For example, today=\$(date) stores the output of date in today. Inside arithmetic you will see the related form \$(( expression ))`, which evaluates the expression as integers.
Special variables — a script inspecting its own run. These names are always available:
\$?— exit status of the last command:0means success, any other number means error or interruption. Right afterecho \$?you learn whether the previous run finished cleanly.\$#— count of positional parameters (./script a b cgives3).\$*and\$@— all positional parameters as words;"\$@"keeps each argument as a separate word,"\$*"joins them.\$0— the script name as invoked.\$\$— the process identifier (PID) of the running shell, a number such as319or5401.
A small demo program from last week printed all of these in their true form so you could see \$?, \$#, \$0, and \$\$ expand with a leading \$.
Everyday analogy — the backstage pass: Think of special variables as the backstage badges at a concert. \$0 is the badge that says which artist (script) is on stage, \$# tells how many guests (arguments) were invited, \$@ lists the guests by name, \$\$ is the unique wristband number for tonight's show, and \$? is the thumbs-up or thumbs-down the stage manager gives the moment an act finishes. The analogy breaks where badges are physical — these variables are just text the shell replaces before running the next command.
Visual intuition: picture a pipeline diagram with a horizontal timeline. Each command is a box with an arrow out. The arrow carries an integer back to the shell — 0 drawn in green, non-zero in red. \$? is a little window that always shows the colour of the last arrow. \$# and \$@ sit beside the input hopper showing how many parcels arrived and what was written on each.
Pitfalls: Forgetting to quote "\$@" collapses separate arguments into one when they contain spaces. Writing \$? after an echo checks the echo, not the command you meant — capture the status immediately. And * in an assignment like list=* without quotes expands at assignment time, which surprises newcomers inside loops.
11.1.3 Recap: Variables, Shebang and if Constructs
Variables — how to declare and how not to. In shell you write name=value with no spaces around =. name = value tries to run name as a command. You expand with \$name or, more safely, "\$name". You never declare a type first — a shell variable holds text until you use it in arithmetic, when the evaluator treats it as an integer if it looks numeric.
Shebang — the first line that picks the interpreter. A script often starts with #!/bin/bash. The two characters #! are the shebang or hash-bang; the loader reads the path after it and runs that interpreter on the rest of the file. #!/bin/bash says "run this with Bash"; #!/bin/sh says "run this with the system shell". Without a shebang the caller decides (bash script.sh vs sh script.sh), which changes which features are available.
Running and editing. Edit with any text editor such as vi. Run in three ways: bash script.sh (explicit Bash), sh script.sh (system shell — on many Linux systems this is Bash in compatibility mode), or make executable with chmod +x script.sh and then ./script.sh (kernel uses the shebang).
Decision making with if. The session began branching with if and if … else using the test brackets [ condition ]. For example, if [ \$x -eq 3 ]; then echo yes; fi runs the then block only when the test returns true (exit 0). More tests — string (=, -z, -n) and file (-f, -d, -x) — are exactly what this lecture extends with case and loop guards.
Pitfalls: x= 5 with a space after = creates an empty x and tries to run 5 as a command. Forgetting fi to close an if, or forgetting quotes around "\$var" inside [ ] when the variable may be empty or contain spaces, produces too many arguments or silent wrong branches.
Recap + bridge: The recap gives you three guarantees for today — how names expand (\$var, \$?, \$\$), how a script declares its interpreter (#!), and how a single branch works (if). From here the lecture trades a chain of ifs for the multi-way case, then adds repetition (for/while/until), fine control (break/continue), grouping (arrays), and the tests that make real admin scripts reliable (string and file checks). Every later example assumes you quote "\$var" and read \$? right away.
Real-world & domain connection: In systems programming and DevOps, the same three recap ideas appear in every nightly job — a deployment script starts with #!/bin/bash, reads \$# to verify it received a host list and a version tag, loops with for h in "\$@", logs \$? after each ssh or rsync, and branches with if or case to decide whether to roll back. Mastering them now saves hours of debugging when a production pipeline mis-expands * or silently ignores a non-zero exit status.
11.2 The case Statement — Many-Way Branching
Hook: What if a script must answer ten different words — a greeting, a farewell, a help request, and seven more — and do something different for each? Nesting if [ "\$x" = "value1" ] … elif … elif … quickly becomes a tall, hard-to-scan ladder. Is there a single structure that lays every choice side-by-side and jumps straight to the matching one?
11.2.1 Why a Many-Way Branch Helps
When a script must choose among many alternatives, a chain of if … then … else gets hard to follow and easy to break with a missing fi or ;. A single many-way branch is easier to read and to grade. In C this is switch. In shell it is case. Think of it this way: based on whatever input you have given, the flow switches to the block whose pattern matches, and that block runs — everything else is skipped. This gives clarity when you would otherwise need ten or fifteen layered if-else tests. Instead of a staircase that is hard to visualise, you write one case with several labelled shelves, and the reader can spot at once which value maps to which action.
Intuition + analogy — the railway switchyard: Picture a switchyard where one incoming track (the value in \$variable) reaches a set of levers. Each lever is labelled with a pattern (value1, bye, *). The operator flips the first lever whose label matches the incoming wagon and sends that wagon down one siding only. Once the wagon has rolled into its siding, it leaves the yard — it does not continue through other sidings. The ;; is the buffer stop at the end of each siding that prevents roll-through. The analogy breaks where real yards allow shunting between sidings — case never falls through unless you deliberately omit ;; or use the special ;&/;;& extensions.
11.2.2 Syntax: case, in, Patterns, ;; and esac
The keywords are case, in, ;;, and esac. esac is case spelled backward — it marks the end of the whole structure, just as fi marks the end of an if. A minimal skeleton looks like:
case \$variable_name in
pattern1)
commands for pattern1
;;
pattern2)
commands for pattern2
;;
*)
commands for anything else
;;
esac
Formal structure — piece by piece:
case \$variable_name in— evaluates\$variable_nameand starts matching. The word aftercasecan be any expansion ("\$input_string","\$remainder","\$1"). Always quote it as"\$variable_name"if it may contain spaces or be empty.pattern)— the pattern sits on its own line and must be closed with a right parenthesis). That)is compulsory — missing it is asyntax error near unexpected token. A pattern is a shell pattern (glob), not a regular expression:hellomatches exactlyhello;*matches anything;hello|hi)with a|matches either word (see 11.2.4).commands— one or more shell commands. Shell is built around commands, so a branch normally runs commands likeecho,printf, or assignments.;;— two semicolons end that branch. Those two semicolons are the break. Once the matching block runs, control leaves thecaseand does not fall through to later patterns. Forgetting;;tries to run the next pattern as a command.esac— closes the wholecase. Misspelling it ascaseagain or omitting it is a parse error.
Execution is first-match-wins: patterns are tested in order from top to bottom; the first match runs and the rest are skipped. If no pattern matches and there is no *, nothing inside the case runs and execution continues after esac. The pattern * is the catch-all; it matches anything and everything. It is placed last so that if none of the earlier patterns matched, its block runs as the default.
Visual intuition: draw a vertical flowchart. A diamond at the top labelled \$variable. Three horizontal branches to the right, each with a label hello), bye), *). Each branch leads into a rectangle of commands and ends with a thick bar labelled ;; that routes back to a single exit point below esac. Only the branch whose label matches lights up; the others stay dim. The final echo "that's all folks" sits below esac and always lights up.
Assumptions & scope — when case is the right tool and when it is not:
- Works wherever patterns are shell globs — good for words, integers-as-strings, file-name styles (
*.txt)), or alternations (yes|y|Y)). - Not a numeric range tester —
[ "\$n" -le 5 ]style range checks still belong inifor loop guards. For glob ranges use[0-9]inside a pattern. - Order matters: put specific patterns before general ones;
*)must be last, otherwise it swallows everything above. - Portability:
caseis POSIX and works insh,bash,ksh,zshalike — safer than[[ … ]]when you need strict portability.
11.2.3 Pattern Details and the Catch-All Star
A pattern can be an integer or a string — you define it to fit what you plan to match. Examples: 0) matches the string 0 (used for remainder in 11.3), hello) matches the word hello, *.log) matches any word ending in .log. Several patterns can be written, for example pattern1, pattern2, pattern3, pattern4, and the value is matched in order until a match is found. If no match is found and there is no *, nothing inside the case runs — a silent no-op that can be confusing without a default. If there is a *, its block runs as the fallback.
Shell patterns inside case support a few useful shorthands:
|for alternation:hello|hi|hey)matches any of the three.- Character classes:
[Yy]es)matchesYesoryes. - Wildcards:
file*matchesfile1,filename, etc.;*.txtmatches text files.
Pitfalls:
- Forgetting
)after the pattern or;;at the block end — either givessyntax error near unexpected token. Thecase_odd_num.shbuild in 11.3 hit exactly this error before;;was added. - Expecting fall-through like C
switchwithoutbreak. In shell;;is the break — without it the shell falls through to the next pattern's commands, which is rarely intended. - Writing an explicit
breakinside acasebranch — unnecessary and, outside a loop, a runtime error (break: only meaningful in a 'for', 'while', or 'until' loop). The live demo first kept abreakword inside acaseblock then removed it to prove;;alone was enough.
11.2.4 Worked Example: Hello-Bye Conversation
A small interactive script shows the whole mechanism. The file is often called case.sh.
#!/bin/bash
echo "Please talk to me"
read input_string
case \$input_string in
hello)
echo "hello yourself"
;;
bye)
echo "see you again"
;;
*)
echo "sorry, I don't understand"
;;
esac
echo "that's all folks"
Trace — three real runs, every step:
Setup: read input_string declares the variable at the moment of use. You use \$input_string because you want the value. Each block ends with ;;.
Run 1 — exact match hello:
\$ bash case.sh
Please talk to me
hello
hello yourself
that's all folks
Path: \$input_string expands to hello → hello) matches on line 1 → prints hello yourself → ;; jumps to esac → prints that's all folks. The bye) and *) blocks are not even tested after the match.
Run 2 — exact match bye:
\$ bash case.sh
Please talk to me
bye
see you again
that's all folks
Path: bye fails hello) → succeeds bye) → same exit via ;;.
Run 3 — no literal match (hello me with a space):
\$ bash case.sh
Please talk to me
hello me
sorry, I don't understand
that's all folks
Path: hello me is one string with a space. It fails hello) (needs exact hello) and fails bye) → falls to *) which always matches → prints the apology → exits via ;; → final echo runs. The final echo "that's all folks" always runs because it is after esac, outside the case.
Sense-check: The three runs confirm first-match-wins, exact-string matching, and that the default * truly catches everything else. Try HELLO — it also hits *, because patterns are case-sensitive unless you write [Hh]ello).
Q & A — deduplicated:
Q: Can we match several different strings with a single case branch? A: Yes — the language allows alternation. The usual spelling is pattern1|pattern2) on one line, for example hello|hi) or yes|y|Y). The bar | means "or". You can also write [Yy]es) to match Yes or yes. The demo kept separate branches for clarity, but the combined form is valid and preferred when branches would do the same action. Frequency note: several students asked this; one canonical form covers all of them.
Q: If we want to match a literal star * instead of the catch-all, how do we write the pattern? A: Write the star as a quoted or escaped pattern so the shell does not treat it as a glob. All three work: "*") or '*') or \*). Without quoting, a bare * is the catch-all. With quoting or escaping, it becomes the single character *. The same rule applies to ? and [ when you want them literally.
Pitfalls — the three most common slips in the lab:
hello )with a space before)still parses, buthellowithout)does not —)is mandatory.;;on its own line vs;— a single;does not terminate a branch; you need;;.*vs"*"— forgetting quotes turns a literal-star test into a universal default that swallows every later pattern.
Recap + bridge: case is the many-way switch — case value in pattern) commands ;; … *) default ;; esac, first match wins, ;; is the break, *) is the default. You just saw it route exact strings (hello/bye) with a safe fallback. Next, the same skeleton routes a computed number — the remainder on division by two — to tell even from odd, and you will learn why feeding it a word still prints "even".
Real-world & domain connection: System scripts use case exactly like this to dispatch on the first argument: case "\$1" in start) start_service ;; stop) stop_service ;; restart) stop_service; start_service ;; *) echo "Usage: \$0 {start|stop|restart}" ;; esac. Package managers, init scripts, and CLI tools all follow the pattern — one case at the top selects the mode, each branch runs a pipeline, and *) prints usage. The shape you practised with hello/bye is the production shape.
11.3 Arithmetic Forms and the Odd-Even Program with case
Hook: You can already route words with case hello|bye. Can you route numbers the shell computes — for example, the remainder when a user-supplied value is divided by two — and use that single digit to decide "even" versus "odd"? The twist in class: when the user types a letter instead of a number, the same script confidently prints "even". Why does a word become 0?
11.3.1 Evaluating Arithmetic: Double Parentheses, Backticks and expr
Shell arithmetic needs an evaluator — the shell does not do n % 2 by itself unless you invoke one.
Three spellings that do the same integer arithmetic:
- Modern preferred — arithmetic expansion with double parentheses:
In shell:
remainder=\$(( input_number % 2 ))
# or equivalently
remainder=\$(( \$input_number % 2 ))
Inside \$(( … )) you write the expression directly. The inner % is the remainder (modulus) operator. Evaluation is integer arithmetic — the same as expr but without forking an external command. The outer \$ does the expansion; omitting it as (( input_number % 2 )) runs the test without producing a value (useful in if (( … ))).
- Traditional — backticks with
expr:
remainder=`expr \$input_number % 2`
An older form surrounds expr with the backtick character that sits with the tilde key, often called backtick or backquote. Inside the backticks, expr evaluates the integer expression. The advice given was: if the backtick quoting feels confusing, just use \$(( … )) with two round brackets — it is clearer and faster.
- Modern
\$( … )form of the sameexpridea:
remainder=\$(expr \$input_number % 2)
This is the \$(command) spelling of command substitution — same semantics as backticks, but easier to nest.
When to pick which: Prefer \$(( … )) for arithmetic; keep expr only when you need its string or pattern features on older systems. Inside \$(( … )) you rarely need the \$ before variable names (\$(( input_number % 2 )) is idiomatic), though \$((\$input_number % 2)) also works.
Symbol guide on first use:
- — the value read from the user, treated as an integer for the remainder test. In shell this lives in
input_numberafterread input_number. - — defined as
value 0 for even, 1 for odd. An integer in when the input is an integer. In shell this is remainder.
- — the divisor for the even-odd test. Changing it to
10would give the last decimal digit instead.
Detailed intuition with numbers: because . because . The remainder is what is left after taking out as many 2s as possible.
Scope — integer only: Both \$(( … )) and expr do integer arithmetic. 5/2 gives 2, not 2.5. For floating point you need bc, awk, or another tool. Division by zero is an error. And any non-numeric string is treated as 0 in arithmetic context (see 11.3.4) — no warning is printed, which is the silent-pitfall this lecture highlights.
11.3.2 Building the Odd-Even Script Step by Step
The aim is to tell whether a given number is odd or even, but now using case instead of if. The file is often named case_odd_num.sh.
#!/bin/bash
printf "Enter the number to be checked\n"
read input_number
remainder=\$(( input_number % 2 ))
case \$remainder in
0)
printf "This is an even number\n"
;;
1)
printf "This is an odd number\n"
;;
*)
printf "I do not understand\n"
;;
esac
Line-by-line intent:
#!/bin/bash— shebang line (see 11.1). Tells the loader to run this file with Bash.printf "Enter the number …\n"— prints a prompt with a newline.echowould also work;printfgives finer control.read input_number— reads one line from the user and stores it ininput_number. No prior declaration needed.remainder=\$(( input_number % 2 ))— computes
In words: take the input, divide by two, keep the remainder. Examples: input_number=2 → 2 % 2 = 0; input_number=3 → 3 % 2 = 1.
case \$remainder in— branches on that single digit. Value0means even → first branch;1means odd → second branch;*catches anything else (for example, ifinput_numberwere-1, the remainder in Bash is-1, which would hit*— a useful guard).
The alternative with expr would be remainder=expr \$input_number % 2`. Both forms were discussed, with \$(( … )) recommended for clarity. The debug aid printf "\$remainder\n" before the case` lets you see what value is actually being tested while building the script; remove it later.
Comparison — two ways to get the same remainder:
| Form | Spelling | Pros | Cons |
|---|---|---|---|
| Arithmetic expansion | remainder=\$(( input_number % 2 )) |
Clear, no subshell, fast | Bash/POSIX sh only for integers |
expr with backticks |
` remainder=expr \$input_number % 2 ` |
Works on very old shells, also handles pattern matching | Requires backticks, fork, easy to misquote |
When to pick: inside a Bash script prefer \$(( … )); when reading a legacy script that already uses expr, recognise the backtick form as equivalent.
11.3.3 Running the Script and Fixing the Missing Semicolons
Worked example — the missing ;; and its fix:
First run — ;; omitted on each branch:
syntax error near unexpected token `1)'
The shell parser treated the next pattern 1) as a command continuation because the previous branch had not been closed with ;;. No branch ran.
After adding ;; at the end of each branch:
\$ bash case_odd_num.sh
Enter the number to be checked
2
This is an even number
\$ bash case_odd_num.sh
Enter the number to be checked
3
This is an odd number
Step-by-step for input 3: read input_number stores 3; remainder=\$(( 3 % 2 )) evaluates to 1 because ; case \$remainder in tests 1 against 0) (no match), then 1) (match) → runs printf "This is an odd number" → ;; jumps to esac. For input 2, the remainder 0 hits the first branch.
Sense-check: 0 → even, 1 → odd, exactly what parity means. Try 0 itself — remainder 0, even, which matches the mathematical convention that zero is even.
The live edit also added printf "\$remainder\n" before the case to make the branching value visible — a standard debugging move: print the discriminator before you dispatch on it.
Q & A:
Q: What is the other way to evaluate the remainder if we do not want to use \$(( … ))? A: Use the expr command: ` remainder=expr \$input_number % 2 . Inside the backticks, expr evaluates the integer expression. Both forms give the same integer remainder. Modern scripts also write it as remainder=\$(expr \$input_number % 2)` — same semantics, clearer quoting.
Pitfalls:
- Forgetting
;;oresac— thesyntax error near unexpected tokenyou saw is almost always a missing;;or)in acase. - Writing
remainder = \$(( … ))with spaces around=— creates a commandremainderinstead of an assignment. - Treating
=like-eq— insidecasepatterns you match strings; inside[ ]tests you must write[ \$remainder -eq 0 ], not[ \$remainder = 0 ]for integers, thoughcase \$remainder in 0)is a string match on the decimal representation and works because0and1have canonical forms.
11.3.4 What Happens with Letters Instead of Numbers
Striking run — letters fed to n % 2:
Enter the number to be checked
a
This is an even number
b
This is an even number
c
This is an even number
wow
This is an even number
he
This is an even number
A debug print showed remainder was 0 in every case. That is not ASCII: ASCII for a is 97 (odd, because ), for b is 98 (even, because ), so if ASCII were used the results would alternate odd, even, odd. They did not — every alphabetic input fell into the 0 branch.
Why 0? The shell arithmetic evaluator treats any non-numeric string as value 0 for this operation, and so every alphabetic input hits the even branch. Similarly: Try the same inside for1.sh with \$(( i + 1 )) when i=hello — the shell prints 1, because it interpreted hello as 0 then added one.
Test with real numbers to confirm the contrast: with input_number=4, remainder → even; with input_number=7, → odd. Both behave; words collapse to 0.
Sense-check: If the input were 2.5, the shell would also mis-handle it (integer context truncates or errors). The lesson is that \$(( … )) does not validate — it coerces silently.
Teaching moment — letters treated as zero, not ASCII:
Shell has no strict types. When you feed a to \$(( a % 2 )), the evaluator does not look up the character code; it coerces the whole word to the integer 0. The session explicitly called this out because students often guess "maybe the shell uses ASCII and alternates odd/even" — the trace proved it does not. Different words could in principle be compared by length, but the test run showed no length effect: a, hello, I am fine all gave 0. What matters is not the shape of the word but that it is non-numeric.
Q & A — deduplicated:
Q: Can we define a variable with a fixed data type and enforce it so words are rejected? A: No — shell does not have strict type declarations as in C or Java. You can assign a number or a string to any variable; the type is decided at run time when the value is used. If the value looks numeric at the moment of arithmetic, it is treated as a number; otherwise it is treated as text. This dynamic feel is even looser than some other dynamic languages that still have wrappers for integers and floats — here there is no such wrapper at declaration.
Q: So what does the modulus see when the input is a word? A: It sees 0. A non-numeric word is taken as 0, so the remainder is 0, which is why words are labelled even. The fix is to guard before arithmetic (see Exam note).
Visual intuition: imagine a funnel labelled \$(( … )) that has a sieve at the top. Numbers fall through unchanged; words are caught, replaced by a 0 chip, and that chip drops into the mod 2 machine. Both the a chip and the wow chip are swapped for 0, so the machine always outputs 0.
Recap + bridge: Remainder parity is with \$(( input_number % 2 )) (or ` expr … ), then case \$remainder in 0) … ;; 1) … ;; *) … ;; esac. Integers behave; words silently become 0 and mis-route to "even". Next, you will see the same coercion inside a for loop when hello gives 0+1=1`, and how to guard loops that must handle arbitrary words.
Exam note: A question that asks you to restrict input to numbers only is a natural follow-up. You would add a test before the modulus — for example with a pattern case "\$input_number" in ''|*[!0-9]*) echo "not a number";; *) remainder=\$(( input_number % 2));; esac or a string test — and prompt again if it is not numeric. The session flagged this as potential assignment material.
Real-world & domain connection: Production scripts that read user input or CSV fields always guard before arithmetic. A payroll script that computes pay = days * rate will first test case "\$days" in ''|*[!0-9]*) echo "bad days: \$days" >&2; continue;; esac before (( pay = days * rate )). Without that guard, a missing field becomes 0 and an employee silently gets zero pay — exactly the silent-wrong-answer class this odd-even demo was designed to expose.
11.4 The for Loop — List and C-Style Forms
Hook: You need to say hello to five people, then to every file in a directory, then count from 1 to 100 by twos. Must you copy the same echo fifty times? How does the shell let you write the action once and feed it a new name on each pass?
11.4.1 List Form: for Variable in List; do ... done
The for loop is one of the most used constructs. Shell offers a form that iterates over a list of words — the list is written explicitly, not as a range object.
Syntax — the classic list form:
for variable in list
do
commands using \$variable
done
forstarts the loop,inintroduces the list,domarks what to do inside the block,donemarks the end. Forgettingdoneissyntax error: unexpected end of file.- On each entry to the loop, the named variable is assigned the next word from the list. If the list is
1 2 3 4 5, then on the first iteration the variable is1, on the second it is2, and so on until the list is exhausted. - You do not need braces around the list for this form, though braces appear in other languages and in brace expansion (11.4.4). The words in the list can be numbers, characters, strings, or a mix. Order does not matter to the interpreter; it just walks the list left to right.
- Inside the block you refer to
\$variableto get the value.echo iprints the letteri;echo \$iprints the value held ini. Forgetting the\$is a frequent early slip. doanddonedelimit the body. Some texts writefor i in 1 2 3; do echo \$i; doneon one line — the semicolon beforedois then required.
A tiny example that prints numbers one to five:
for i in 1 2 3 4 5
do
echo \$i
done
Equivalent one-liner: for i in 1 2 3 4 5; do echo \$i; done. Both print 1 through 5, one per line.
Intuition — the conveyor belt: picture a conveyor belt carrying parcels labelled 1, 2, 3, 4, 5. The worker (i) picks one parcel each cycle, carries it into the do … done room, runs the commands while holding that parcel, puts it down, and goes back for the next. When the belt is empty, the worker stops.
Scope — where ; do matters: In the multi-line form for i in 1 2 3 newline replaces the semicolon. In the single-line form you must write ; do. Mixing them — for i in 1 2 3 do without ; or newline — is a parse error.
11.4.2 Mixed Lists and Type Behaviour
A list can be mixed without any change in syntax:
for i in 1 hello mine you 0
do
echo \$i
done
The shell treats list items as words. When you later use a word in arithmetic, the shell decides how to handle it. The session built a richer loop often called for1.sh to see this:
for i in 1 2 3 4 5 hello I am fine
do
printf "Looping number %s incremented value is %s\n" "\$i" "\$(( i + 1 ))"
done
Word splitting in the list: I am fine without quotes is three separate words I, am, fine. The loop therefore sees eight items (1 2 3 4 5 hello I am fine → 1,2,3,4,5,hello,I,am,fine), not six. Quoting as "I am fine" would keep it as one item — a distinction that explains many "extra iteration" bugs.
Arithmetic coercion: When arithmetic i + 1 is tried with i=hello, the shell takes hello as 0, so the result is For numeric items, 1 becomes , 2 becomes 3, and so on. This matches the idea that everything in shell is a string until used as a number — the same coercion seen with letters fed to n % 2 in 11.3.
Formally, for each iteration the printed second column is with i coerced to an integer (hello → 0, I → 0, 2 → 2). So hello gives 1, I gives 1, am gives 1, fine gives 1.
A variant tried to compute \$(( \$i + 1 )) when i itself held the letter i. Writing i=i and then \$(( \$i + 1 )) with \$i inside caused a recursion-like expansion error because the evaluator kept expanding the variable name as if it referred to itself. Changing the variable name to something other than i for the outer loop removed the confusion — a concrete lesson to keep the loop variable distinct from any value that looks like the same letter when expanded inside \$(( … )). Modern shells handle \$(( i + 1 )) without the extra \$ more robustly, which is another reason to prefer the form without \$.
Worked trace — for1.sh with numbers then mixed:
Numbers only (1 2 3 4 5):
Looping number 1 incremented value is 2 # 1+1=2
Looping number 2 incremented value is 3 # 2+1=3
Looping number 3 incremented value is 4 # 3+1=4 (recorded as "4→5" in class notes)
Looping number 4 incremented value is 5 # 4+1=5
Looping number 5 incremented value is 6 # 5+1=6
(The class session notes collapsed the middle lines; the arithmetic is the same for each.)
Mixed (1 2 3 4 5 hello I am fine): the first five lines repeat as above, then:
Looping number hello incremented value is 1 # hello→0, 0+1=1
Looping number I incremented value is 1 # I→0, 0+1=1
Looping number am incremented value is 1 # am→0, 0+1=1
Looping number fine incremented value is 1 # fine→0, 0+1=1
Sense-check: The 1s for words are not random — they are exactly the 0→1 coercion, the same class of bug as letters becoming "even" in 11.3.
Failed variant — i=i: with for i in …; do echo \$(( \$i + 1 )); done when the list itself contains i, the inner \$i expands to i, so \$(( i + 1 )) becomes \$(( i + 1 )) recursively. Renaming to for val in …; do echo \$(( val + 1 )); done fixes it.
11.4.3 Glob Expansion: When Star Becomes a File List
Another run used the list hello 1 * 2 goodbye. The * did not stay as a star character. It expanded to the list of files in the current directory, as if ls had been run.
Looping number hello ...
Looping number 1 ...
Looping number file1 ...
Looping number file2 ...
Looping number 2 ...
Looping number goodbye ...
where file1, file2 were the actual file names present where the script ran. The expansion happened before the loop saw the list. This is globbing or pathname expansion: the pattern * matches every file and directory name in the current directory (hidden dot-files excluded without dotglob).
Teaching moment — star expands to directory files, and that is useful, not a bug:
The session explicitly flagged this as both a pitfall and a feature. As a pitfall, the accidental 1 * 2 without an evaluator does not multiply — it lists files, because * is not an operator inside a plain word list. As a feature, for f in * is the idiomatic way to visit each file and then test its type inside the loop. Because * expands to names, you can add file tests ([ -f "\$f" ], [ -d "\$f" ]) to decide whether a name is a regular file, a directory, or a symbolic link. The moment starred * in a list becomes a file-name generator is exactly what makes for file in * the backbone of admin scripts.
Visual intuition: imagine the * as a wildcard-shaped stencil placed over a tray of name cards. When laid down, the stencil lights up every card at once and hands the whole lit set to the for belt. If you wrap the stencil in plastic (quotes or \), the light cannot get through — only the stencil shape itself is handed over.
The shell order that matters here is: brace expansion → tilde expansion → parameter expansion → globbing → word splitting. The * glob happens after \$i expands but before for iterates, which is why the loop sees file names and not a star.
11.4.4 Keeping Star Literal: Escape and Quotes
To keep * as a plain character, three ways work:
for i in hello 1 \* 2 goodbye # with backslash escape
for i in hello 1 '*' 2 goodbye # with single quotes
for i in hello 1 "*" 2 goodbye # with double quotes
All three give:
hello
1
*
2
goodbye
instead of a file list. The backslash escapes the special meaning, and quoting hides it from expansion. The same idea applies to other wild characters (?, […]) when you want the literal shape rather than the pattern. The demo contrasted the two runs back-to-back so the difference was unmistakable: unquoted * → file list; quoted/escaped * → one item *.
Q & A — deduplicated:
Q: Where did that list of files come from in the mixed loop? A: From the * in the list. The shell expanded * to all file names in the current directory before the loop started, so the loop saw those names as its items. If you want the star itself, quote or escape it as '*', "*", or \*.
Q: Does range notation work? A: Yes. Brace expansion such as {1..5} can generate a sequence, and that sequence can be used as the list for for. For example, for i in {1..5}; do echo \$i; done prints 1 through 5 without writing each number. With a step, Bash also supports {1..10..2} for 1 3 5 7 9. The classic portable alternative is \$(seq 1 5): for i in \$(seq 1 5); do …; done.
11.4.5 C-Style for and Shell Compatibility
Shell also supports a C-like three-expression form:
for (( c=1; c<=5; c++ ))
do
echo "welcome \$c"
done
Anatomy of the C-style for (( … )):
for (( init; test; step ))— three expressions inside double parentheses, separated by semicolons.- Expression one
c=1runs once at start. - Expression two
c<=5is checked before each iteration; the loop enters only when
is true.
- Expression three
c++(post-increment) runs at the end of each iteration. Variants likec+=2for a step of two are valid, making "count by twos" plain:for (( c=1; c<=10; c+=2 )). - The body between
doanddoneruns while the test stays true. For the example, iterations givec=1,2,3,4,5and thenc=6fails6 <= 5, so the loop stops.
Worked run — for2.sh and portability:
# for2.sh
for (( c=1; c<=5; c++ ))
do
echo "welcome \$c"
done
\$ bash for2.sh
welcome 1
welcome 2
welcome 3
welcome 4
welcome 5
\$ sh for2.sh
welcome 1
welcome 2
welcome 3
welcome 4
welcome 5
The same script did not run under csh or tcsh on the machine used, and some ksh/zsh installations needed their own syntax variant, because the arithmetic for (( )) form is specific to Bash and a few compatible shells. The advice is to use bash or sh on a system where sh points to Bash when you rely on this form. For the list form for i in … the compatibility is wider — it is POSIX and runs everywhere.
Choosing between the two forms:
| Need | Natural form | Example |
|---|---|---|
| Visit arbitrary words or files | List form | for f in * or for h in web1 web2 db1 |
| Count with numeric bounds and steps | C-style | for (( c=1; c<=10; c+=2 )) |
| Sequence without manual list | Either for i in {1..5} or for (( i=1; i<=5; i++ )) |
Both work; C-style makes step and test explicit |
When to pick which: If a question says "write a for loop that needs a step of 2," the C-like form makes the step plain (c+=2); if it says "visit each file," the list form with * is the natural pick.
Pitfalls:
- Omitting one of the semicolons inside
(( … ; … ; … ))is a parse error — the double parentheses do not forgive missing;. - Using
c++vs++cmatters only when the step expression's value is used; for the increment itself either works. - Assuming C-style
foris POSIX — it is not. A strictly POSIXshonly guarantees the list form.
Recap + bridge: for is your fixed-set repeater — for var in list; do … done walks words left to right (globbing * into file names unless quoted), and for (( c=1; c<=5; c++ )) counts with explicit init/test/step. Words coerced to 0 in arithmetic (hello→1 after +1) echo the same lesson as 11.3. Next, when the number of repetitions is not known ahead of time and depends on a test that changes inside the loop, you trade for for while and until.
Real-world & domain connection: DevOps tooling uses both forms daily. A deploy script uses for h in "\${hosts[@]}" to ssh into each host and rsync a build; a benchmark script uses for (( i=0; i<1000; i++ )) to time an operation and average the result. The list form dominates file and host tasks; the C-style dominates arithmetic sweeps. Knowing which to reach for — and how to keep * literal when you need the character, not the file list — is exactly what the session's contrasting runs drilled.
11.5 The while Loop — Test-Driven Repetition
Hook: A for loop is perfect when you know the list upfront — 1 2 3 4 5 or * as files. What about when you do not know how many steps you need — reading a chat until the user says bye, or reading a CSV until end of file? How do you say "keep going while this is still true"?
11.5.1 Syntax and Test Operators
A while loop repeats as long as a given condition is true:
while [ condition ]
do
commands
done
Structure — what each piece does:
while [ condition ]— evaluates the test in brackets. In shell,[ … ]is thetestcommand; it exits with0(true) or non-zero (false). The loop enters the body when the test is true and re-tests before every iteration. When the test becomes false, control jumps to afterdone.do … done— delimits the body. Everywhilebody must advance the state so the test can eventually become false — otherwise you have an infinite loop.- Integer relational operators inside
[ ](all start with-, all need spaces around them):
| Operator | Meaning | Formal test |
|---|---|---|
-le |
less than or equal | |
-lt |
less than | |
-ge |
greater or equal | |
-gt |
greater than | |
-eq |
equal | |
-ne |
not equal |
Example with all pieces:
i=1
while [ \$i -le 5 ]
do
echo \$i
i=\$(( i + 1 ))
done
In words: set ; while is true, print and then set to . Walk i=1→2→3→4→5 prints 1 2 3 4 5; when i becomes 6, [ 6 -le 5 ] is false, so the loop stops. The test [ \$i -le 5 ] returns true exactly when the integer in i is less than or equal to 5.
The increment inside uses the same arithmetic forms as before: \$(( i + 1 )) or ` expr \$i + 1 . The backtick form is the character that shares the key with tilde; prefer \$(( … )) to avoid confusion with plain single quotes '`.
Visual intuition: picture a turnstile with a sign that reads the current value of i. The gate lifts only while the sign shows in green. Each trip through the loop room increments the sign by one. After 5→6, the sign flips red — gate stays down, and the crowd routes to done. Forgetting the increment is like a turnstile that never updates the sign — the green light never turns red.
Comparison — for vs while for the same count 1…5:
| Aspect | for i in 1 2 3 4 5 |
while [ \$i -le 5 ] |
|---|---|---|
| Knows bounds upfront? | Yes — list given | No — tests each time |
| Stopping logic | List exhausted | Test becomes false |
| Needs manual increment? | No | Yes — i=\$(( i+1 )) inside body |
| Best when | Fixed set of words/files | Open-ended, data-driven repetition |
Assumptions & scope:
[ \$i -le 5 ]is an integer test. With words likehello, it givesinteger expression expected— use string tests (=,!=,-z,-n) for words.- Spaces are mandatory:
[\$i -le 5]without spaces is not a command invocation. Write[ \$i -le 5 ]with spaces after[and before]. - Quoting: for integer tests,
[ "\$i" -le 5 ]is safer whenimay be empty; for string tests, quoting is mandatory (see 11.5.3).
11.5.2 Infinite Loops, Colon and Interrupt Exit Status
The : command and while ::
: is a shell builtin that does nothing and always succeeds — it exits with status 0. It is not punctuation. So while : means "while the always-true command succeeds, repeat". That loop never becomes false on its own.
The file case.sh contained an infinite while:
while :
do
echo "Please talk to me"
read input_string
# ... case dispatch ...
done
Each iteration prints the prompt, reads a line, dispatches on it with case, and then loops. The only way to stop it is from outside — the interrupt key Ctrl-C, which sends SIGINT to the foreground job.
Trace — killing the infinite loop and reading \$?:
- Run
bash case.sh. The prompt reappears forever:
Please talk to me
hello
hello yourself
Please talk to me
hi
sorry, I don't understand
Please talk to me
^C
- Immediately inspect the exit status:
echo \$?
130
Why 130? The usual rule is that a program that finishes without error exits with 0; a non-zero signals error or interruption. The value 130 reflects how the interrupt was handled: on most Unix shells, exit status after Ctrl-C is 128 + signal_number, and SIGINT is signal 2, so . The exact number depends on internal signal handling, but the point is stable: \$? right after a command tells you if the last run finished cleanly (0) or was killed/signalled (non-zero, here 130). A normal run that ends on its own — for example, the guarded loop in 11.5.3 when the user finally types bye — would leave \$? as 0.
Multiple concrete cases:
- Normal exit after
donewith no error:echo \$?→0. - Killed by Ctrl-C after
while ::echo \$?→130(on this shell;131for SIGQUIT,143for SIGTERM are analogous). - Failed test inside loop that exits via
breakon error: leaves the status of the last command beforebreak.
Sense-check: \$? is ephemeral. Run another command and the previous status is lost. Capture it immediately: status=\$? then test status.
Teaching moment — colon always succeeds, interrupt is not zero:
Students often think killing a loop "succeeded" because they asked for it. The shell disagrees — 130 is a non-zero status that parent scripts can test. The session used this to introduce the habit: after an important command or loop, check if [ \$? -ne 0 ]; then echo "interrupted or failed"; fi. This is also why while : is written with a colon and not with while true — : is a builtin and avoids forking an external true command.
Real-world illustration: administrators write guarded while loops to process log files or payroll CSV files. The sketch is:
while IFS=, read name days rate; do
# validate \$name, compute pay, write slip
done < payroll.csv
Here read returning false at end-of-file ends the loop naturally, not a colon. The while : form is reserved for interactive daemons that should run forever until an operator stops them.
11.5.3 Guarded Reading Loop
A second while example avoids the infinite case by adding a real string test:
input_string="hello"
while [ "\$input_string" != "bye" ]
do
printf "Please type something in (bye to quit)\n"
read input_string
printf "%s\n" "\$input_string"
done
Guarded loop — step by step:
input_string="hello"seeds the guard so the loop enters (any value!= "bye"works). Without seeding, an emptyinput_stringwould still enter because"" != "bye"is true, but explicit seeding makes intent clear.while [ "\$input_string" != "bye" ]— string not-equal test!=. The loop runs while the condition is true (not equal tobye) and stops when it becomes false (equal tobye). This is the direct dual ofuntil.read input_stringupdates the guard inside the body — every iteration asks for a fresh line. Forgetting to update the guard is how a guarded loop becomes accidentally infinite.printf "%s\n" "\$input_string"echoes back what was read. The"%s"format is safe when the line may contain%or leading dashes.
The trace showed multiple lines like I am fine, how are you, then bye to quit:
Please type something in (bye to quit)
I am fine
I am fine
Please type something in (bye to quit)
how are you
how are you
Please type something in (bye to quit)
bye
bye
# loop stops — "bye" != "bye" is false
Crucially, the test handles a whole line including spaces — quoting "\$input_string" keeps it as one string for the test. Without quotes, I am fine would become three words and [ \$input_string != "bye" ] would be too many arguments.
Pitfalls:
- Forgetting quotes around
"\$input_string"when it may contain spaces or be empty — givestoo many argumentsor treats empty as missing argument. - Using
-ne(integer) instead of!=(string) to compare words — givesinteger expression expectedor silent wrong answer. Integer operators are for numbers;=and!=are for strings. - Writing
while [ \$input_string != bye ]without quotingbyeis usually harmless, but quoting both sides is the safe habit. - Priming bug: reading before the test vs after changes whether the sentinel
byeis processed. In this sketchbyeis read, printed, then the test fails next time — sobyedoes get echoed. Some designs prefer to test immediately afterreadwithbreak.
Q & A:
Q: What does -le mean inside [ \$i -le 5 ]? A: It means less than or equal. So the test is true when . Similarly: -lt is , -ge is , -gt is , -eq is , -ne is . For strings use =, !=, -z, -n instead.
Exam note: Expect to write the operator from its English name — "while i is less than or equal to five" → while [ "\$i" -le 5 ].
Recap + bridge: while is the test-true repeater: while [ condition ]; do commands; done, re-testing before each pass. while : never ends on its own (exit 130 when you Ctrl-C it); a guarded while [ "\$input_string" != "bye" ] that updates the guard each iteration ends cleanly when the sentence becomes false. This dual leads directly to the opposite form — until, which runs while the test is false and stops when it becomes true.
Exam note: Know the bracket spellings and that \$? after a normal run is 0. If you kill an infinite loop, \$? will not be 0. A typical exam task: fix a while that uses [ \$i = 5 ] when it meant [ \$i -eq 5 ], or add the missing i=\$(( i+1 )) that makes a while [ \$i -le 5 ] finite.
Real-world & domain connection: In systems programming, while with a string guard is exactly how interactive tools stay alive — a network daemon does while [ "\$cmd" != "quit" ]; do read cmd; case "\$cmd" in …; esac; done — and while IFS= read -r line is the standard way to process a log file line-by-line without loading it all into memory. The integer guard while [ \$attempts -le 3 ] implements retry logic for flaky curl or ssh calls, incrementing attempts and sleeping between tries. The colon form while :; do …; sleep 60; done appears in monitoring watchdogs that poll forever until an operator interrupts them.
11.6 The until Loop — Running Until a Condition Becomes True
Hook: You already read "while i is less than or equal to five, keep printing". How would you phrase the same intent as "keep printing until i equals ten"? Is until just a synonym for while, or does flipping the sentence flip the behaviour?
11.6.1 How until Differs from while
The until loop looks exactly like while but runs with the opposite sense:
until [ condition ]
do
commands
done
Dual definition — the one line to memorise:
while→ run while the test is true; stop when it becomes false.until→ run while the test is false; stop when it becomes true — in other words, keep running until the condition becomes true.
So the block inside until runs when the test is false and stops when the test becomes true. Formally, until [ condition ] is equivalent to while [ ! condition ] (the ! negates the test). The shell still re-tests before each iteration, just with inverted polarity.
This is close to the do-while idea in other languages, but written with a leading test, not a trailing one. In C you might see do { … } while (!done) — in shell you write until [ done ] with the test at the top.
Comparison table — while vs until for the same job:
| Aspect | while [ condition ] |
until [ condition ] |
|---|---|---|
| Enters when | condition true | condition false |
| Stops when | condition false | condition true |
Equivalent with ! |
— | while [ ! condition ] |
| Reads as | "while not at end, keep reading" | "until at end, keep reading" |
| Common wording cue | "while not at end of file" | "until equal to …" |
Scope — phrasing trick: Write the final state you want to reach in an until test, not the state you want to keep. until [ \$i -eq 10 ] says "stop when i hits 10" (so it prints 0…9). while [ \$i -le 9 ] says the same fact from the opposite side. Mixing the two phrasings — until [ \$i -le 9 ] when you meant until [ \$i -eq 10 ] — is the most common until bug and was demoed live (see 11.6.3).
Visual intuition: draw the same turnstile as for while, but now the sign is a stop sign that is red while and flips green at . For while [ \$i -le 5 ] the gate lifted on green; for until [ \$i -eq 10 ] the gate lifts on red and drops on green. Same machine, opposite colour rule.
11.6.2 Countdown Example and the Equality Trap
A file often called until1.sh held:
i=3
until [ \$i -eq 0 ]
do
echo \$i
i=\$(( i - 1 ))
done
Reading the test: until [ \$i -eq 0 ] means "keep looping until is true". In words: set ; while (test false), print and decrease by one. The loop prints 3, then 2, then 1; when i becomes 0, the test becomes true and the loop stops before printing 0. If you wanted 3 2 1 0, you would need the body to print after the decrement or use until [ \$i -lt 0 ].
Worked trace — until [ \$i -eq 0 ] with i=3:
| Iteration | i at test |
[ \$i -eq 0 ]? |
until action |
Prints | i after i=\$(( i-1 )) |
|---|---|---|---|---|---|
| 1 | 3 | false | enters body | 3 | 2 |
| 2 | 2 | false | enters body | 2 | 1 |
| 3 | 1 | false | enters body | 1 | 0 |
| 4 | 0 | true | stops, skips body | — | — |
Output:
3
2
1
The equality trap — until [ \$i -ne 0 ] or until [ \$i -ne 3 ]:
If the test is written as until [ \$i -ne 0 ], the logic flips. With i=3, 3 -ne 0 is true at once, so until (which runs while false) does not enter at all and prints nothing — the opposite of what while [ \$i -ne 0 ] would do. A run first tried until [ \$i -ne 3 ] with i=3. Since is false, until did enter once and printed 3 only, which matched the opposite sense but surprised students who expected a countdown. Changing the test to -eq 0 made the intent plain: run until zero is reached.
Sense-check: For any until test, plug in the starting value before running: if the starting value already makes the test true, the loop will run zero times. That quick mental substitution catches most until bugs.
If the test was until [ \$i -ne 3 ] with i=3, the trace is:
i=3:[ 3 -ne 3 ]→ false →untilenters → prints3→idecrements to2.i=2:[ 2 -ne 3 ]→ true →untilstops. Only3printed.
The lesson: phrase the final state you want to reach, not the state you want to keep, and test with the actual starting value.
11.6.3 Counting from Zero to Nine
A classroom exercise asked to count from 0 to 9 using until. This is the exercise that exposed three successive bugs.
Attempt 1 — until [ \$i -le 9 ] with i=0: prints nothing.
i=0
until [ \$i -le 9 ]
do
echo \$i
i=\$(( i + 1 ))
done
With i=0, the test 0 -le 9 i.e. is true at once. Since until runs only while the test is false, it does not enter at all and prints nothing. Mistake: until with a -le keeps the loop out when you start inside the range; you wanted the loop to run inside the range.
If the test was until [ \$i -eq 9 ] with the same start and an increment, the loop printed 0 many times but missed the increment in one version, giving nine zeros — a second bug where the state never advanced.
Working version — until [ \$i -eq 10 ] that prints 0 … 9:
i=0
until [ \$i -eq 10 ]
do
echo \$i
i=\$(( i + 1 ))
done
In words: start at , until print and add one.
| Iteration | i at test |
[ \$i -eq 10 ]? |
Action | Output | i after |
|---|---|---|---|---|---|
| 1 | 0 | false | enter | 0 | 1 |
| 2 | 1 | false | enter | 1 | 2 |
| … | … | false | … | … | … |
| 10 | 9 | false | enter | 9 | 10 |
| 11 | 10 | true | stop | — | — |
Full output: 0 1 2 3 4 5 6 7 8 9.
Formally the loop computes: and stops when which is tested before the body, so 10 itself is never printed.
Sense-check: while [ \$i -le 9 ] and until [ \$i -eq 10 ] are equivalent — same ten numbers, opposite phrasing. Prefer whichever matches the English prompt ("while less-than" vs "until equal-to").
Q & A — deduplicated:
Q: Can we break an infinite for or while after a number of steps? A: Yes. Put a counter and use break or continue with a test inside the loop. break leaves the loop at that point; continue skips to the next iteration. Any of for, while, until can be controlled this way. For example, for i in 1 2 3 4 5; do if [ "\$i" = "3" ]; then break; fi; echo "\$i"; done stops at 3.
Q: Should we use a for here instead of until? A: For this exercise the ask was to practise until, so keep the until form. The same count 0…9 can be done with for i in 0 1 2 3 4 5 6 7 8 9 or while [ \$i -le 9 ], but the point is to see how the stopping test is phrased for until — as the terminal value (-eq 10), not the running range (-le 9).
Teaching moment — wording "until equal ten reaches nine":
Students often say "until i <= 9" meaning "keep going while i <= 9". That is a while sentence, not an until sentence. The professor stressed: say "until i equals ten" when you want 0…9. The phrase "le true trap" is the memory hook — if you write until [ \$i -le 9 ] and start at 0, the le is true immediately, so until does nothing. Trap avoided by writing the equality of the one-past-the-last value.
Recap + bridge: until [ condition ] keeps running while the condition is false and stops when it becomes true — the mirror image of while. until [ \$i -eq 0 ] counts 3 2 1; until [ \$i -eq 10 ] starting from 0 gives 0…9; the classic bug until [ \$i -le 9 ] with i=0 gives nothing because the test is already true.
Exam note: Expect to choose the right loop for the wording — "while not at end of file" suggests while; "until equal to …" suggests until. A frequent slip to grade is until [ \$i -le 9 ] when you meant until [ \$i -eq 10 ].
Pitfalls:
- Off-by-one:
until [ \$i -eq 9 ]withi=0stops before printing9if the increment is placed after the echo; placing the test as-eq 10is the safe one-past-end style. - Infinite
untilwith a test that never becomes true — e.g.,i=0; until [ \$i -eq 5 ]; do echo \$i; donewithouti=\$(( i+1 ))prints0forever (the "nine zeros" bug). - Mixing
=(string) with-eq(integer) —[ \$i = 10 ]works by coincidence for canonical decimals but fails on010vs10; use-eqfor numbers.
Real-world & domain connection: until appears wherever a script waits for a condition produced outside the loop — until ping -c1 dbhost >/dev/null 2>&1; do echo "waiting for DB"; sleep 2; done keeps polling until the database answers, and until [ -f /tmp/ready ]; do sleep 1; done waits until another job creates a sentinel file. The countdown shape you just debugged — until [ \$i -eq 10 ] — is the same shape as until [ "\$status" = "ready" ] used to wait for a service to become ready in deployment scripts.
11.7 Loop Control with break and continue
Hook: A loop reading a payroll CSV hits a record where the essential column — days present — is empty. Should it quietly skip that employee and keep paying everyone else, or must it stop everything right now? Two sibling keywords give the two answers. Which one abandons the whole job, and which one abandons just one pass?
11.7.1 Definitions in the Payroll Scenario
Two keywords fine-tune loops: break leaves the current loop right away and does not come back; continue leaves the current iteration and resumes with the next iteration of the same loop. Both work inside for, while, or until.
Payroll story — the analogy that carries both definitions:
Suppose a CSV holds payroll records with columns including number of days present (essential to compute monthly pay) and other optional fields that are not essential (a free-text remark, a non-critical code).
While reading records line by line:
- If the essential column is empty for a record, you may want to
break— stop the whole run and take corrective action, because you cannot compute pay without it. Continuing would produce a wrong pay slip or silently pay zero. - If a non-essential column is empty or garbled, you may want to
continue— skip the problem field but still compute pay for that employee and keep going to the next record. The current iteration ends early; the next iteration starts normally.
So break aborts the loop; continue skips one pass. You place either inside an if that checks the field, for example checking whether a name string is empty before using it.
while IFS=, read name days rate; do
if [ -z "\$name" ]; then
echo "name missing, stopping" >&2
break # essential field missing — halt the whole payroll
fi
if [ -z "\$remark" ]; then
continue # optional field missing — skip this line's remark, next record
fi
# compute pay using \$days and \$rate
done < payroll.csv
A bare break or continue affects the innermost loop that encloses it. With break n or continue n (e.g., break 2) you can leave n levels of nested loops — a detail useful in nested for scans but rarely needed at this stage.
Visual intuition: picture the loop as a circular track with a do gate and a done wall. break is a trapdoor that drops you outside the circle at done. continue is a shortcut chute that jumps from mid-lap back to the do gate for the next lap. break ends the race; continue skips the rest of this lap but stays in the race.
Scope — when each is safe:
- Use
breakwhen continuing would produce silently wrong output (missing essential data, a sentinel that says "end of file was already reached"). - Use
continuewhen the current item is polluted but the job can proceed (optional column garbled, a file name you want to skip, a header line). - A common anti-pattern is
breakwherecontinuewas intended — one missing optional field then halts a thousand-record payroll. Read the column's role before choosing.
11.7.2 Demonstrating break Inside for
A test inside for1.sh inserted a guarded if before the body action:
for i in hello 1 "*" 2 goodbye 1 2 3 4 5
do
if [ "\$i" = "1" ]
then
break
fi
echo "\$i"
done
Trace — break at the first 1, all steps:
Before the fix, the test used -eq (integer) which is for integers, giving integer expression expected when i held words like hello, and it also gave too many arguments when i expanded to * and then to many file names without quoting "\$i". Changing to the string test = and quoting "\$i" fixed the parsing — the guard must be:
true only when the string i is exactly 1.
Iteration-by-iteration with the corrected guard and quoted "*" (so * stays as one item, not a file list):
| Iteration | \$i |
[ "\$i" = "1" ]? |
Action |
|---|---|---|---|
| 1 | hello |
false | echo hello → prints hello |
| 2 | 1 |
true | break → leave loop immediately |
| remaining | *, 2, goodbye, 1…5 |
— | never reached |
Output:
hello
It printed hello then met 1 and left the loop, so nothing after 1 appeared.
A second version with numbers only made the effect clearer:
for i in 1 2 3 4 5
do
if [ "\$i" = "3" ]
then
break
fi
echo "\$i"
done
Result:
1
2
The loop stopped before printing 3. Everything from 3 onward was skipped, and the loop did not resume — control went to after done.
Sense-check: break is not "skip 3 and continue with 4". That is continue. The trapdoor vs chute distinction maps exactly to 1 2 vs 1 2 4 5.
Pitfalls — the two errors that appeared live before the fix:
[ \$i -eq "1" ]withi=hello→integer expression expected, because-eqexpects integers. For words use[ "\$i" = "1" ].[ \$i = "1" ]withi=*that globbed to file names →too many arguments, because unquoted\$iexpanded to many words and[saw more than three arguments. Fix: always quote the left side —[ "\$i" = "1" ].
A third frequent slip is placing break after the echo instead of before it when you intend to skip the triggering value. Order decides whether 3 is printed.
11.7.3 Demonstrating continue Inside for
Replacing break with continue at the same spot:
for i in 1 2 3 4 5
do
if [ "\$i" = "3" ]
then
continue
fi
echo "\$i"
done
Trace — continue skips 3 but keeps 4 and 5:
| Iteration | \$i |
[ "\$i" = "3" ]? |
Action |
|---|---|---|---|
| 1 | 1 |
false | echo 1 |
| 2 | 2 |
false | echo 2 |
| 3 | 3 |
true | continue → jump to next iteration, skip echo |
| 4 | 4 |
false | echo 4 |
| 5 | 5 |
false | echo 5 |
Result:
1
2
4
5
Here 3 was skipped but the loop carried on with 4 and 5.
In the mixed list version with if [ "\$i" = "1" ] and continue, the run printed hello, skipped 1, and then printed the rest (*, 2, goodbye, …). The earlier mixed run that still had an unquoted * also produced the file-list expansion and extra diagnostics like too many arguments until quoting was added — the same glob pitfall as 11.4.3.
Contrast side-by-side for i=1…5, trigger 3:
| Keyword | Output | What happened to 3…5? |
|---|---|---|
break |
1 2 |
3 and everything after abandoned |
continue |
1 2 4 5 |
only 3 skipped, loop resumed |
Sense-check: Choose break when the triggering record means "this whole job is invalid"; choose continue when it means "this record is noisy, next one may be fine".
A useful admin idiom combines continue with the glob loop:
for f in *; do
if [ "\$f" = "tmp" ]; then continue; fi
if [ ! -f "\$f" ]; then continue; fi # skip non-regular files
echo "\$f is a regular file"
done
Recap + bridge: break exits the loop; continue exits only the iteration. The payroll analogy — essential field missing → break, optional field garbled → continue — maps directly to real CSV handling. The demos drilled the mechanics: break at 1 left only hello; break at 3 gave 1 2; continue at 3 gave 1 2 4 5. Next, you will see where those records live — inside arrays — and how gaps and slices interact with the loops that walk them.
Exam note: Know the spelling break and continue and where they go. A question may ask to fix a loop that wrongly uses -eq for string comparison; the fix is = and quoting — the same fix that cured the too many arguments in this section's * trace.
Real-world & domain connection: In production, break guards against corrupt essential state — if [ -z "\$days" ]; then echo "missing days for \$name, aborting" >&2; break; fi stops a payroll before it emits wrong slips. continue guards against optional noise — if [ "\$log" = "*.tmp" ]; then continue; fi skips temporary files while archiving logs, and if [ ! -r "\$file" ]; then echo "cannot read \$file, skipping" >&2; continue; fi keeps a backup loop going past one unreadable file instead of failing the whole nightly job. The same two-keyword choice appears in every file-processing pipeline you touched with for f in *.
11.8 Arrays — Grouping Mixed Data
Hook: A script that handles one name stores it in one variable. What about ten host names, or a whole row of CSV fields you want to keep together and revisit by position? How does the shell group many values into one named box and let you pull out the second, the third, or "all of them" on demand?
11.8.1 What a Shell Array Is and How Indexing Works
An array (ordered collection) groups values in a systematic way so you can refer to each by its position. Unlike C where an array holds one declared type such as integers or characters, a shell array can hold a mix: a number, then a string, then a single character, then another number. By default every element inside is treated as a string — that is, text. That means 10 is the two-character string "10" until you use it in arithmetic, when the shell coerces it to the integer . This is the same "string until used as number" rule you saw with remainder=\$(( input_number % 2 )) in 11.3.
Indexes start at 0 — the first slot is index 0, the second is 1, and so on. So array[0] is the first element, array[2] the third. The index helps you refer to a particular place in the array later. Special forms let you ask for the whole array (\${array[@]}) or its length (\${#array[@]}).
Array mental model — pigeonholes with numbered labels:
Picture a row of pigeonholes numbered 0, 1, 2, 3 …. Each hole can hold any word — "hello", "10", "X", "mixed". The array name (AR, array, hosts) is the label on the whole bank. \${array[2]} means "open hole 2 and read its card". \${array[@]} means "tip the whole bank out left to right". \${#array[@]} counts how many holes are actually occupied, not how many labels exist.
Two facts that surprise C programmers: (1) shell arrays are sparse — you can fill AR[0] and AR[4] and leave AR[3] empty, and the length will be 2, not 5 (see 11.8.4). (2) Every hole holds text; the shell only interprets it as a number when you write \$(( AR[0] + 1 )).
Visual intuition: draw a horizontal strip divided into boxes 0|1|2|3|4. Fill 0:"error" 1:"zero" 2:"two" 4:"four", leave 3 blank with a dashed outline. An arrow labelled \${array[@]} sweeps left to right showing error zero two four (skipping the blank). An arrow labelled \${array[2]} points only at "two". A badge \${#array[@]} shows 4 — four occupied boxes, not five.
11.8.2 Three Ways to Assign Arrays
Three assignment styles were shown, all valid and interchangeable. All create indexed arrays (numeric keys). A fourth associative style (declare -A) exists in newer Bash but was not used in this lecture.
1. Direct indexed assignment, in any order:
array[0]=10
array[1]="hello"
array[2]=X
array[3]=42
array[4]="mixed"
Here array[0] holds 10, array[1] holds hello, and so on. The order of assignment does not need to be sorted — you can set array[4] before array[0] and the array still has the same contents.
2. Compound indexed assignment in one line — index given explicitly:
array=([0]=10 [1]=20 [2]=30 [3]="program")
Each bracket names the index for the following value. This form lets you create sparse sets in one line: array=([0]="a" [3]="d") leaves 1 and 2 empty.
3. Short form without naming indexes — position decides:
array=(0 10 20 program)
Here placement decides the index: 0 goes to index 0, 10 to index 1, 20 to index 2, program to index 3. A fourth example mixes both: first value goes to 0, second explicitly to 3, leaving a gap, then further values follow. Gaps are allowed: array=(a b [5]=c d) puts a at 0, b at 1, c at 5, d at 6.
Choosing a form: Use direct array[2]=… when building gradually in a loop; use array=( … ) when the whole list is known upfront; use array=([2]=… [5]=…) when you need explicit sparse indexes. All three produce the same internal sparse indexed array — the reading operations in 11.8.3 behave identically regardless of how the array was created.
11.8.3 Printing: All Elements, Single Element and Slices
To print the whole array in one line you can use either * or @ inside the expansion:
echo \${array[@]}
echo \${array[*]}
Whole-array expansions — subtle difference:
\${array[@]}— each element is a separate word. When quoted as"\${array[@]}", it expands to"\$array[0]" "\$array[1]" …— preserves spaces inside elements.\${array[*]}— when quoted as"\${array[*]}", it joins all elements into one word with the first character ofIFS(normally a space) between them.
In the unquoted demonstration echo \${array[@]} vs echo \${array[*]} both printed all elements separated by spaces, so the session noted both forms for that purpose. The important habit for loops is:
for item in "\${array[@]}"; do echo "\$item"; done # safe, each element one turn
Using "\${array[*]}" in a for would give one iteration with the whole joined string — rarely what you want. With @ you visit each hole individually; with * you visit the concatenated banner.
Single element:
echo \${array[0]} # hole 0
Or you can use the array name alone, because the bare name points to the first element:
echo \$array
# same as \${array[0]} in this shell
That bare-name shortcut is easy to misread — always prefer \${array[0]} in scripts you will share.
Particular element and slices: To print a particular element name its index, e.g. \${array[2]}. To print a range (slice) you give a start index and a count:
echo \${array[@]:1:2} # starting at index 1, take 2 elements → holes 1 and 2
echo \${array[@]:2:2} # starting at 2, take 2 → holes 2 and 3 (if 3 exists)
echo \${array[@]:2} # from index 2 to the end
The form shown was \${array[@]:2:2} or similar, described as "print elements between indexes 2 and 4". The key point is that 2 and 4 are indexes, not values — you slice by position. Newer Bash also supports \${array[@]: -1} for the last element.
Concrete walkthrough — AR with a gap:
AR[0]=error
AR[1]=zero
AR[2]=two
# AR[3] left empty
AR[4]=four
Printing all:
echo \${AR[@]}
# → error zero two four
echo \${AR[*]}
# → error zero two four (unquoted, looks the same)
The empty slot at index 3 does not appear as a word; it is simply absent — no extra word is produced for the missing index.
Single and slices:
echo \${AR[0]} # → error
echo \$AR # → error (same as \${AR[0]})
echo \${AR[2]} # → two
echo \${AR[@]:1:2} # → zero two (holes 1 and 2)
echo \${AR[@]:2:2} # → two four (hole 2, then skip empty 3, then 4 — but count is by position, so with sparse arrays the result depends on Bash version's handling of gaps; the dense demo used contiguous data)
Quoted vs unquoted for elements with spaces:
arr=("hello world" "I am" fine)
echo "\${arr[@]}" # three words: "hello world" "I am" "fine"
echo "\${arr[*]}" # one word: "hello world I am fine"
Sense-check: If you iterate with for x in \${AR[@]} (unquoted) and an element contains a space, it splits into two iterations. The safe loop is always for x in "\${AR[@]}".
The session printed a slice from index 1 and got the middle items, and printed from 2 and got a different window — both demonstrated that slicing is by index, the same index used in assignment.
11.8.4 Length and Gaps
Length is asked with #:
echo \${#array[@]}
echo \${#array[*]}
Both return the number of elements that are actually set. In the example with AR[0], AR[1], AR[2], AR[4] and nothing at AR[3], the length was 4, not 5.
Teaching moment — array gap: missing index not counted.
A missing index does not count as a null element; it is no element. The array AR[0]=error, AR[1]=zero, AR[2]=two, AR[4]=four has length 4, not 5, because AR[3] was never assigned. Bash does not create an empty string at the gap; it simply does not count the hole. This matches the rule that gaps do not create counted slots. Verifying live:
AR[0]=error; AR[1]=zero; AR[2]=two; AR[4]=four
echo \${#AR[@]} # → 4
echo \${#AR[*]} # → 4
echo \${AR[@]} # → error zero two four (no blank where AR[3] would be)
If you later set AR[3]="" (explicit empty string), then the length becomes 5 but element 3 prints as empty. The distinction between "never set" and "set to empty string" matters for \${#array[@]} vs iterating and testing [ -z "\${AR[3]}" ].
When gaps appear: Mixing forms like array=(a b [5]=c d) or assigning array[10]=x far ahead creates a sparse array intentionally — useful for mapping numeric IDs. The loop for i in "\${!array[@]}" (exclamation mark) iterates over the indexes that exist (here 0 1 5 6), not 0…6.
Real-world illustration: administrators store lists of hosts, file names, or payroll fields in arrays and then loop safely:
hosts=(web1 web2 db1)
# or read from a file:
# mapfile -t hosts < hostlist.txt
for h in "\${hosts[@]}"; do
echo "Checking \$h"
ssh "\$h" uptime
done
To act on each element by index and show the position:
for idx in "\${!AR[@]}"; do
printf "AR[%s]=%s\n" "\$idx" "\${AR[idx]}"
done
Recap + bridge: A shell array is a numbered set of string slots (0 …), assignable as array[0]=…, array=([0]=…) or array=( … ), read as \${array[0]}, \${array[@]} (all holes) or slices \${array[@]:start:count}, and measured with \${#array[@]} counting only occupied holes — a gap is not a counted null. You have now seen every grouping tool: for walks a list, while/until guard it, break/continue trim it, and arrays store it.
Exam note: Practice the expansions exactly, including braces, #, @, *, and the slice : . A common error is to write \$array[0] without braces — the shell parses that as \$array followed by the literal [0]. The correct form is \${array[0]}. Also know when to quote: for item in "\${array[@]}" (correct) vs for item in \${array[*]} (collapses spaces).
Real-world & domain connection: In DevOps and data pipelines, arrays turn a repeated scalar variable into a vector you can loop, filter, and slice. A build matrix stores versions=(14 16 18) and iterates for v in "\${versions[@]}"; do nvm use \$v && npm test; done. A log-rotation array logs=(/var/log/app.log /var/log/nginx/access.log) is filtered with for log in "\${logs[@]}"; do [ -f "\$log" ] && [ -s "\$log" ] && gzip "\$log"; done — the combination of array, for, and file tests (-f, -s) from 11.10 is exactly what this lecture was building toward.
11.9 String Tests and Operators
Hook: You just saw words silently become 0 in arithmetic. How do you stop a script from even trying the arithmetic when the field that should hold a name comes back empty — before you divide, before you echo, before you compute pay?
11.9.1 Equality, Inequality and Emptiness Tests
When comparing strings you use single-bracket tests [ … ] with string operators — integer operators like -eq are for numbers and will mis-handle words.
String operators — the four you must be able to write from memory:
| Operator | Written as | True when | Example | Formal |
|---|---|---|---|---|
= |
= |
the two strings are byte-identical | [ "\$a" = "\$b" ] |
as strings |
!= |
! = with a space |
the strings differ | [ "\$a" != "\$b" ] |
|
-z |
dash z (zero length) |
string has zero length (empty) | [ -z "\$str" ] |
|
-n |
dash n (non-zero) |
string has non-zero length (not empty) | [ -n "\$str" ] |
|
| bare | [ "\$str" ] |
string is not empty | [ "\$str" ] |
same as -n |
Return values follow the test sense: when the described condition holds, the test exits 0 (true); otherwise it exits non-zero (false). These are string comparisons — 10 and 010 are different strings even though 10 -eq 010 is true as integers.
The session stressed not to use -eq for string comparison: -eq expects integers and gives integer expression expected when given words. Similarly, do not write = inside \$(( … )) — = there is assignment, not comparison.
Payroll motivation — guard before use:
A name field should not be assumed to be present. Before using it you pull the word, for example the third column of a CSV line, into a variable such as name, and then test:
if [ -z "\$name" ]; then
echo "name missing, stopping" >&2
break
fi
If the name is empty you stop (essential field → break from 11.7); otherwise you carry on. An optional-field variant uses continue instead of break. The test "\$name" alone would also work ([ "\$name" ]), but -z makes the emptiness intent explicit and is preferred in production for readability.
Quoting rule: always write "\$var" inside [ ]. Without quotes, an empty \$name disappears and [ -z \$name ] becomes [ -z ], which tests whether the string "-z" is non-empty — always true, the opposite of what you intended. With spaces, [ \$str = we ] where str="hello world" becomes [ hello world = we ] — four words, too many arguments.
Visual intuition: picture a ruler that measures the length of the string card "\$str". -z asks "is the ruler at zero?" (green if empty). -n asks "is the ruler past zero?" (green if any characters). = puts two cards side by side and asks "are they identical, character for character?".
Teaching moment — string = vs integer -eq:
The session used the payroll example to trigger the diagnostic integer expression expected live: [ "\$i" -eq "hello" ] where i=hello is a word comparison attempted with an integer operator — integer test on a word is an error. The fix is [ "\$i" = "hello" ] — string operator for string data, and quoting "\$i" so an empty value or a * that could glob does not break the test. This is the same fix that cured the break demo's too many arguments in 11.7 — = plus quotes is the safe pair for words.
Scope — bare string test [ "\$str" ] vs [ -n "\$str" ] vs [ -z "\$str" ]:
[ -z "\$str" ]— explicit emptiness check; best when the next line is an error message about missing data.[ -n "\$str" ]— explicit non-emptiness; best when the next line processes the string.[ "\$str" ]— shorthand for-n; compact but less self-documenting; avoid when teaching intent.!=needs a space between!and=:!=(correct) vs! =is not the same tokenisation — write!=adjacent.
All three emptiness forms require "\$str" quoted, otherwise empty and multi-word strings mis-parse.
11.9.2 Worked Walkthrough: string1.sh
A file named string1.sh built the checks step by step with STR="we":
STR="we"
printf "check -z\n"
if [ -z "\$STR" ]; then echo "empty"; else echo "not empty"; fi
printf "check -n\n"
if [ -n "\$STR" ]; then echo "not empty"; else echo "empty"; fi
printf "bare\n"
if [ "\$STR" ]; then echo "not empty"; else echo "empty"; fi
printf "equality\n"
if [ "\$STR" = "we" ]; then echo "strings are same"; else echo "strings are not same"; fi
Full trace — STR="we" then STR="hello":
With STR="we" (length 2, content we):
| Test | Evaluates as | Mathematical reading | Result | Prints |
|---|---|---|---|---|
[ -z "\$STR" ] |
[ -z "we" ] |
false (exit 1) | not empty via else |
|
[ -n "\$STR" ] |
[ -n "we" ] |
true (0) | not empty |
|
[ "\$STR" ] |
[ "we" ] |
true | not empty |
|
[ "\$STR" = "we" ] |
[ "we" = "we" ] |
true | strings are same |
The run recorded was:
Using operator -z
not empty
Using operator -n
not empty shown as non-zero length
bare test
not empty
we = we
strings are same
Changing only one line to STR="hello" and re-running the equality check:
STR="hello"
if [ "\$STR" = "we" ]; then echo "strings are same"; else echo "strings are not same"; fi
| Test | Evaluates as | Result | Prints |
|---|---|---|---|
[ "hello" = "we" ] |
false | strings are not same |
Now the output is strings are not same, because the strings differ. The -z/-n/bare tests would still say not empty (length 5), only the = test changed.
Run-it-yourself check: set STR="" (empty). Then [ -z "\$STR" ] becomes true → empty; [ -n "\$STR" ] and [ "\$STR" ] become false → empty; [ "\$STR" = "we" ] becomes false. This is exactly the payroll guard shape.
Sense-check: Every branch above follows one rule — string operators compare text, not numeric value. "10" = "010" is false as strings even though 10 -eq 010 is true as integers.
Q & A:
Q: The last check seemed to print we everywhere — why did the session notes look uniform? A: Because the printf before each test printed the literal prompt (e.g., printf "equality\n"), while the test itself expanded "\$STR". With STR="we" the expanded value is we in each comparison ([ "we" = "we" ]), so every echo inside the then looked like it used we until the value was changed to hello, when the equality correctly became not same. Reading the script line-by-line — printf text vs "\$STR" expansion — clears the confusion.
Pitfalls:
- Using
-eqor-neto compare words — givesinteger expression expected; use=/!=for words. - Forgetting quotes:
[ \$STR = we ]withSTR=""becomes[ = we ]— syntax error; always[ "\$STR" = "we" ]. - Writing
[ \$a !=\$b ]without a space before!=or after — the shell sees a single word, not an operator; spaces around=and!=are mandatory. - Confusing
-zand-n:-zis true when zero length (empty, "missing");-nis true when non-zero (present, "has content").
Recap + bridge: String tests are [ "\$a" = "\$b" ], [ "\$a" != "\$b" ], [ -z "\$str" ] (empty), [ -n "\$str" ] / [ "\$str" ] (not empty). They compare text; -eq and friends are for integers. The demo with STR="we" vs STR="hello" proved = is literal, and changing STR to "" drills the guard you need before any payroll arithmetic.
Exam note: Know the exact spelling -z, -n, =, != and the bare form. Expect a short script that decides empty vs not empty and a second branch same vs not same — and a fixing task where -eq was wrongly used for words.
Real-world & domain connection: Every robust shell pipeline starts with string guards. A deploy script does if [ -z "\$VERSION" ]; then echo "VERSION not set" >&2; exit 1; fi before touching production. An ETL job checks if [ "\$status" = "success" ]; then … else …; fi rather than [ \$status -eq … ]. The payroll CSV pattern this lecture used — [ -z "\$name" ] → break — is literally how HR batch jobs prevent emitting pay slips with blank names while letting optional fields through with continue.
11.10 File Tests and System Administration Checks
Hook: A nightly job loops for f in * and must decide, for each name, "are you a directory I should descend into, a regular file I can compress, a device I must never truncate, or a link I should follow?" One-letter switches answer that — but -s does not mean "symbolic link" everywhere. How do you ask the file system the right question?
11.10.1 File Type Tests for Administrators
File tests are very useful for system and network administrators who must inspect many paths on a machine. The form is [ -letter "\$path" ] where the letter selects the test. The test runs the stat idea underneath and returns true (exit 0) if the path exists and has that file type.
Type tests — the set mentioned plus the portable clarification:
These tests are all single-bracket [ ] operators, always with a quoted "\$path":
| Letter | Portable meaning | True when | Typical paths |
|---|---|---|---|
-f |
regular file | a plain file (not directory, not device) | /etc/passwd, ./script.sh |
-d |
directory | a directory | /tmp, /home/user |
-b |
block device | a block special file (buffered, e.g., disk partition) | /dev/sda, /dev/nvme0n1 |
-c |
character device | a character special file (unbuffered, e.g., terminal) | /dev/tty, /dev/null, /dev/sda is not -c |
-e |
exists | any form exists (any type) | any existing path |
-L / -h |
symbolic link (portable) | a symlink, regardless of target | /usr/bin/python → python3 |
-p |
named pipe (FIFO) | a pipe special file | /tmp/myfifo |
-S |
socket | a socket | /var/run/docker.sock |
Clarification on -s vs -L / -h: The session used -s in its local shorthand to mean "symbolic", but in standard test/[ ] the portable spellings for symlink are -L and -h (both mean the same), while *-s means size greater than zero (non-empty file)* — see 11.10.2. Mixing them silently asks the wrong question: [ -s "\$link" ] tests "is this file non-empty?" not "is this a symlink?". Always write [ -L "\$link" ] or [ -h "\$link" ] for symlinks and [ -s "\$file" ] for non-empty size. The table above records the portable contract so an exam answer using -L/-h is marked correct.
The idea in each case: [ -d "\$path" ] is true only when path is a directory; [ -b "\$path" ] is true only for a block device such as a disk partition entry; [ -c "\$path" ] is true only for a character device such as a terminal. If the path does not exist, all type tests are false (except -e for existence itself).
The earlier for f in * loop gains power when combined with these tests. You can visit every name with * and then sort them by type inside the loop:
for f in *; do
if [ -f "\$f" ]; then echo "\$f is a regular file"
elif [ -d "\$f" ]; then echo "\$f is a directory"
elif [ -L "\$f" ]; then echo "\$f is a symlink"
elif [ -b "\$f" ]; then echo "\$f is a block device"
elif [ -c "\$f" ]; then echo "\$f is a character device"
fi
done
Scope — quoting "\$path" is mandatory when testing names: A file named my report.txt with a space becomes two words without quotes, giving too many arguments. A variable that is empty makes [ -f \$file ] become [ -f ], which tests the string "-f" for non-emptiness — always true. Always write "\$file" or "\$path".
11.10.2 Permission and Special File Tests
Beyond type, tests check permissions and other properties — the questions an administrator asks during audits or before a destructive write.
Permission and property tests:
| Letter | True when | Meaning for an admin |
|---|---|---|
-r |
readable | you can read the file (cat will work) |
-w |
writable | you can write to the file (may still fail due to ACL or read-only FS) |
-x |
executable (or searchable for directories) | you can run it as a program, or cd into it |
-s |
size greater than zero (non-empty) | file has content — distinguishes empty logs from active ones |
-e |
exists | path exists in any form — weakest test, use when type does not matter |
-u / -g |
setuid / setgid bit set | privilege-escalation check |
-k |
sticky bit set | common on /tmp |
Distinction drill — -s vs -L / -h:
[ -s "\$file" ]→ tests size — true for a non-empty regular file such asapp.logwith 10 bytes, false for a newly created emptytouch empty.txt.[ -L "\$file" ]→ tests symlink — true forlink → targeteven if the target is empty.
Testing [ -s "\$link" ] to mean "is symlink" confuses the two; testing [ -L "\$emptyfile" ] to mean "has content" reverses them.
In class the pattern was to start from a working file test such as -x for executable, then swap the letter to ask a different question — the same if [ -letter "\$file" ] skeleton works for many checks:
if [ -x "\$file" ]; then echo "executable"; fi
if [ -d "\$file" ]; then echo "directory"; fi
if [ -b "\$file" ]; then echo "block file"; fi
if [ -c "\$file" ]; then echo "character file"; fi
if [ -w "\$file" ]; then echo "writable"; fi
if [ -s "\$file" ]; then echo "has content"; fi
Each if prints only when its letter matches the real file. The session cycled through -d, -b, -c, -x, -w, and the symlink case to show that the same skeleton works for audits or setup checks.
Visual intuition: imagine each file as a card with five labelled holes (f, d, b, c, L) and three coloured stamps (r, w, x). Holding the card up to a light, the -letter test shines a beam through that hole — light comes through (true, 0) only when the hole is punched for that card. -s is a scale that tips only when the card has weight (size > 0).
11.10.3 Example Checks on Directories and Devices
A file named with a test for existence, often if_there.sh or similar, was used to ask about a path:
path="/dev/sda"
if [ -b "\$path" ]; then echo "block device"; else echo "not a block device"; fi
path="/dev/tty"
if [ -c "\$path" ]; then echo "character device"; else echo "not a character device"; fi
path="/tmp"
if [ -d "\$path" ]; then echo "directory"; else echo "not a directory"; fi
Trace — what each test sees on a typical Linux machine:
[ -b "/dev/sda" ] — block device? /dev/sda is the first SCSI/SATA disk; its entries are block-buffered (reads go through the buffer cache), so [ -b "/dev/sda" ] is true on a system with that disk → prints block device. On a VM without /dev/sda (e.g., nvme0n1 instead), the same test is false → not a block device — the path simply does not exist or is not block type.
[ -c "/dev/tty" ] — character device? /dev/tty is the controlling terminal — a character device (unbuffered, char-by-char), so [ -c "/dev/tty" ] is true → character device. /dev/null is also a character device; contrasting with /dev/sda highlights buffered vs unbuffered.
[ -d "/tmp" ] — directory? /tmp is a directory (often 1777 with the sticky bit), so [ -d "/tmp" ] is true → directory. If you tested [ -f "/tmp" ] it would be false — /tmp is not a regular file. That is exactly why for f in * combined with [ -f "\$f" ] skips /tmp when you iterate over a directory listing.
Negative cases (guarding before a destructive action):
| Intended write | Guard | If omitted |
|---|---|---|
cat "\$log" |
[ -f "\$log" ] — is it a regular file? |
You might cat a directory and get Is a directory |
> "\$log" truncate |
[ -w "\$log" ] and [ -f "\$log" ] |
You might truncate a device file or a read-only mount |
rm "\$f" |
[ -L "\$f" ] before rm vs unlink |
You might follow a symlink and remove the target unexpectedly |
| Skip empty logs | [ -s "\$f" ] — non-empty? |
You archive thousands of empty log skeletons |
Sense-check: The teaching point is not the specific names /dev/sda vs /dev/nvme0n1 but the method: pick the right letter, quote "\$path", branch on the result, and test the negated form if [ ! -f "\$path" ]; then … when you mean "if this is not a regular file, skip it".
Pitfalls:
- Confusing
-s(size > 0) with symlink — use-L/-hfor symlinks,-sfor non-empty. This was the session's warned shorthand and is a prime exam distractor. - Using
[ -e "\$path" ]when you meant[ -f "\$path" ]—-eis true for directories too, so a directory passes an "exists" test you intended to restrict to files. - Testing devices that do not exist on the lab machine — give the answer as "depends on the machine; the method is
if [ -b "\$path" ]" rather than guessing the output for/dev/sdawhen the VM has no such entry. - Forgetting
-rvs-x: a file can be-r(readable) without being-x(executable), and a directory that is-xis searchable, not "executable" — a frequent terminology slip.
Recap + bridge: File tests are [ -letter "\$path" ] — -f file, -d directory, -b block, -c character, -L/-h symlink, -r/-w/-x permissions, -s size > 0, -e exists. They combine with for f in * to visit names and sort them by type — the capstone shape of this whole lecture: case dispatches, for/while/until repeat, break/continue trim, arrays store, string tests guard content, file tests guard the file system.
Exam note: Know how to spell the tests and to combine them with loops and string checks. A serviceable pattern you must be able to write from memory is:
for f in *; do
if [ -f "\$f" ]; then echo "\$f is a regular file"; fi
done
Expect variants that ask for -d, -L, -x, or -s in the same skeleton, and expect a question that tests the -s vs -L distinction directly.
Real-world & domain connection: System and network administrators run these tests in nightly audit jobs that would be tedious by hand. A single loop for f in /var/log/*; do if [ -f "\$f" ] && [ -s "\$f" ] && [ ! -L "\$f" ]; then gzip "\$f"; fi; done archives only regular non-empty non-symlink logs. Before rotating a log you test [ -f "\$log" ] to be sure the log is a regular file; before writing you test [ -w "\$log" ]; before following a link you test [ -L "\$link" ] so you do not follow a symlink out of the intended directory. The permissions checks -r, -w, -x appear in hardening scripts that flag world-writable files ([ -w "\$f" ] plus mode checks) or non-executable scripts that should be executable.
Exam Guidance Summary
- Assignment is the major assessment for this block. Expect shell-scripting problems that require combining commands with arguments inside loops and branches — a single clever one-liner will not suffice. Group size is two; form your own group and submit one joint solution. Marks weight scripting and command fluency. Keep notes from this and the previous class together; the assignment draws on both. Slides for both sessions will be uploaded; a separate announcement will collect the group list, statement, and presentation schedule.
- Focus stays on shell control structures. No outside topics were introduced as examinable — keep effort on
case(with),;;,esac,*default,|alternation),for(list formfor i in …; do … doneand C-stylefor (( c=1; c<=5; c++ ))with Bash/shportability),while(while [ \$i -le 5 ],while :infinite with130on Ctrl-C),until(until [ \$i -eq 10 ]for0…9, the-letrue-trap),break/continue(payroll essential→break, optional→continue, fix-eq→=and quoting),arrays(\${array[0]},\${array[@]},\${#array[@]}, slices\${array[@]:2:2}, gaps not counted), string tests (-zempty,-n/bare not-empty,=/!=with quotes), and file tests (-f,-d,-b,-c,-L/-hsymlink vs-snon-empty,-r/-w/-x,-e).
- Question patterns practised and likely to recur:
- Fix a missing
;;oresacor)in acase; predict output ofcase "\$input" in hello|hi)vshello)vs*)vs"*")literal star. - Compute a remainder with
\$(( n % 2 ))versus `expr \$n % 2and branch on it withcase \$remainder in 0)— and explain why a word input gives0(non-numeric →0`, not ASCII) and therefore "even". - Predict output of a
forover a mixed list with*expansion (expands to file list when unquoted, stays*when'*'/"*"/\*), and offor i in 1 2 hellowith\$(( i+1 ))(words →1). - Count from
0…9withfor,while, anduntil; theuntilbuguntil [ \$i -le 9 ]withi=0gives nothing; the fix isuntil [ \$i -eq 10 ]. - Add a string emptiness test
[ -z "\$name" ]before using a CSV field, choosingbreakvscontinueby whether the column is essential. - Write a file-type loop:
for f in *; do if [ -f "\$f" ]; then echo "\$f is a regular file"; fi; doneand variants with-d,-L,-x,-w,-s— with the-svs-Ldistinction tested directly. - Run
echo \$?after a normal exit (0) vs after killing an infinitewhile :loop (130).
- Study advice — run, break, fix, re-run: Revisit every script from this and the previous class —
case.sh,case_odd_num.sh,for1.sh,for2.sh,whileguarded and infinite variants,until1.sh, the array demo,string1.sh, and the file-test example. Run them yourself, change one thing at a time, and read the error messages. The diagnostics that already appeared are your revision checklist:syntax error near unexpected token(missing;;/)),integer expression expected(-eqon a word → use=),too many arguments(unquoted"\$var"with spaces or*→ many words), andrecursion level exceeded(i=iwith\$(( \$i+1 ))→ rename variable).
- Presentation advice — what graders reward: Start every script with a shebang
#!/bin/bashon its own first line. Quote variables that hold strings ("\$var","\$f","\$name"). Show steps in comments. Putdoanddone(orthen/fi,case/esac) on distinct lines rather than dense one-liners — clear flow is easier to grade. Make scripts executable (chmod +x) and test with bothbash script.shandsh script.shwhen you rely onfor (( )).
- Logistics and next quiz: Slides for today and the previous class will be uploaded shortly. Expect a separate announcement for group-list collection, assignment statement, and presentation schedule. A second quiz will come a little later after a few more topics are covered, so keep notes on loops, arithmetic, and tests fresh rather than cramming at the last minute.
Key Industry Applications
- Payroll and HR pipelines —
while+ CSV +case+break/continue: A production payroll job doeswhile IFS=, read name days rate remark; do if [ -z "\$name" ]; then break; fi; case "\$days" in ''|*[!0-9]*) continue;; esac; pay=\$(( days * rate )); printf "%s,%s\n" "\$name" "\$pay"; done < payroll.csv. Essential fields (name,days) triggerbreakor validation withcasepatterns before arithmetic — because a word silently becomes0(hello→0→1after+1as seen in 11.3/11.4), so guarding before\$(( ))prevents a silent zero-pay error. Optional fields triggercontinue. Thecase-dispatched odd-even logic and the string guards (-z,=) are the same shapes you debugged in class.
- File-system and permission auditing —
for f in *+ file tests: System and network admins loop withfor f in *(glob expands to names before the loop, Section 11.4.3) and sort inside with[ -f "\$f" ],[ -d "\$f" ],[ -b "\$f" ],[ -c "\$f" ],[ -L "\$f" ],[ -x "\$f" ],[ -w "\$f" ],[ -s "\$f" ],[ -e "\$f" ]— the exact set from 11.10. Examples:for f in /var/log/*; do if [ -f "\$f" ] && [ -s "\$f" ] && [ ! -L "\$f" ]; then gzip "\$f"; fi; donearchives only regular non-empty non-symlink logs;if [ -w "\$log" ]guards a truncate before rotate;if [ -L "\$link" ]avoids following a symlink out of the intended directory;while :; do check_disk; sleep 60; donemonitors forever until Ctrl-C (exit130).
- Host-list and config vectors — arrays + quoted
for: Data-prep and deployment scripts storehosts=(web1 web2 db1)orversions=(14 16 18)and iterate safely withfor h in "\${hosts[@]}"; do ssh "\$h" uptime; done(Section 11.8). The\${#hosts[@]}count drives progress bars,\${hosts[@]:1:2}slices a rolling window, and gaps (hosts[0]=a hosts[4]=b) are handled by\${!hosts[@]}— the sparse-array lesson fromAR[0]=error … AR[4]=fourwith length4. Quoting"\${array[@]}"preserves spaces inside elements, the safe habit from 11.8.3.
- Input validation as a gate before arithmetic — string tests +
casepatterns: Treating a word as0when testingn % 2(Section 11.3.4) is a known shell pitfall class. Production scripts precederemainder=\$(( n % 2 ))withcase "\$n" in ''|*[!0-9]*) echo "not a number: \$n" >&2; continue;; esacor[ -z "\$n" ]guards — the same-z/-n/=operators from 11.9. Without that gate, empty CSV fields and stray words produce silently wrong branches incase \$remainder in 0).
- Literal vs pattern handling at scale — quoting
*: Log processing and file handling often need the literal character*or?in searches; quoting or escaping (\*,"*",'*') keeps the character literal instead of expanding it to a file list (Section 11.4.4 and Q&A on"*")literal-star). The reverse — unquoted*as glob — is intentional when scanning a directory. Knowing which spelling to use is the difference between a regex that searches for*.logand a shell that expands*.logbeforegrepeven starts.
- Choosing the right loop form — list vs C-style vs test-driven: Both the classic list form
for i in 1 2 3 4 5and the C-likefor (( i=1; i<=5; i++ ))appear in DevOps tooling (Section 11.4.5); the C-like form (c+=2for steps) is common in Bash-heavy benchmark loops while the list form is the portable way to process arbitrary word lists and*glob results. Test-drivenwhile [ "\$input" != "bye" ]anduntil [ \$i -eq 10 ](Sections 11.5/11.6) implement interactive REPLs,until ping -c1 dbhostwait loops, and end-of-file readers — the triad you now choose from by wording: "while not …" →while, "until …" →until, "for each …" →for.
SP Lecture 11 notes · Shell Scripting: Control Structures, Loops, Arrays and String Handling
Sections Breakdown
Recap of wildcards, command substitution, special variables ($?, $#, $@, $0, $$), shebang, variables, and if — the foundations for case and loops.
Many-way branch case ... in pattern) ;; ... esac; first-match-wins, ;; is the break, * is catch-all, patterns are globs with | alternation and literal "*" via quotes.
Integer arithmetic via $(( )) and expr; remainder = n mod 2 routes via case 0) even 1) odd; words coerce to 0 and falsely appear even.
List form for var in list; do done walks words left-to-right (mixed types, glob * to files); C-style for ((c=1;c<=5;c++)) counts with init/test/step; brace expansion {1..5}.
while [ condition ]; do done repeats while test true; integer tests -le -lt -ge -gt -eq -ne; while : is forever; guarded while [ "$str" != "bye" ] reads until sentinel.
until [ condition ]; do done runs while test false until true; dual to while [ ! condition ]; pitfalls with -le true-trap and off-by-one for 0..9 via until eq 10.
break aborts the loop, continue skips one iteration; payroll analogy essential->break optional->continue; demos at 3 give 1 2 vs 1 2 4 5; need string = and quotes.
Sparse indexed arrays: array[0]=, array=( ) and array=([0]= ); read via ${array[0]}, ${array[@]}, slices ${array[@]:s:n}, length ${#array[@]} counts occupied holes only.
String tests: = same, != different, -z zero length empty, -n non-zero not empty, bare [ "$str" ] true if not empty; string1.sh demo with STR we vs hello.
File tests [ -letter "$path" ]: -f regular, -d dir, -b block, -c char, -L/-h symlink, -r/-w/-x perms, -s non-empty, -e exists; loop for f in * sorts by type.
Assignment heavy on scripting; focus areas case/for/while/until/break/arrays/string/file tests; study by running and fixing scripts and reading diagnostics.
Payroll CSV, file auditing, host arrays, input validation, literal vs glob, loop choice — production mappings of every construct.
Exam Revision Notes
Below is the distilled, exam-ready core. Every entry comes from the full explanation above. Use this section for rapid review; return to the main notes when a point needs more context.
Assignment Context and Recap of Previous Shell Concepts
Must-know: Wildcards glob before commands; $? is 0 on success; variables are name=value without spaces; shebang #!/bin/bash selects interpreter.
⚠️ Top pitfall: Forgetting quotes around "var"; writing name = value with spaces; reading $? after wrong command.
Self-check: What does echo $? show after a successful command vs after Ctrl-C on while : ?
Connects to: 11.2, 11.4
The case Statement — Many-Way Branching
Must-know: case $var in pattern) commands ;; ... *) default ;; esac; ;; is the break; * last is default; first match wins.
⚠️ Top pitfall: Forgetting ) after pattern or ;; at block end; using bare * for literal star; expecting C fall-through.
Self-check: What happens when case input is hello me with patterns hello) and * )?
Connects to: 11.3
Arithmetic Forms and the Odd-Even Program with case
Must-know: remainder = input_number mod 2 via remainder in 0) even ;; 1) odd ;; esac; non-numeric → 0 → even.
⚠️ Top pitfall: No guard before $(( )) so words become 0 and test even; missing ;; gives syntax error.
Self-check: What remainder does a give under $(( a % 2 )) and which case branch runs?
Connects to: 11.4, 11.9
The for Loop — List and C-Style Forms
Must-know: for i in 1 2 3 do done walks list; * globs to files unless quoted; for ((c=1;c<=5;c++)) is Bash C-style.
⚠️ Top pitfall: Unquoted * expands to file list; "I am fine" is three words; i+1 )) with i=i causes recursion.
Self-check: For i in hello 1 * 2 what does * become and how to keep it literal?
Connects to: 11.5, 11.10
The while Loop — Test-Driven Repetition
Must-know: while [ ((i+1)); done; : always true so while : is infinite; Ctrl-C gives exit 130; quote "$str" in string tests.
⚠️ Top pitfall: Missing i=str" with spaces gives too many arguments.
Self-check: Why does echo $? show 130 after Ctrl-C on while : ?
Connects to: 11.6
The until Loop — Running Until a Condition Becomes True
Must-know: until [ i -le 9 ] with i=0 prints nothing.
⚠️ Top pitfall: Writing until [ i -eq 10 ]; off-by-one missing increment gives repeated zeros.
Self-check: Why does i=0; until [ i; done print nothing?
Connects to: 11.5, 11.7
Loop Control with break and continue
Must-know: break leaves the loop; continue skips iteration; for i in 1 2 3 4 5 break at 3 gives 1 2, continue at 3 gives 1 2 4 5.
⚠️ Top pitfall: Using -eq for words gives integer expected; unquoted $i with * gives too many arguments.
Self-check: For i in 1 2 3 4 5 with if [ "$i" = "3" ]; then continue; fi what prints?
Connects to: 11.8, 11.9
Arrays — Grouping Mixed Data
Must-know: array=(a b) or array[0]=a; echo {array[0]} first; ${#array[@]} length counts only set indexes; gap not counted.
⚠️ Top pitfall: Writing {array[@]}" splits spaces; * vs @ quoting.
Self-check: AR[0]=a AR[1]=b AR[4]=c length is 3 or 5 and why?
Connects to: 11.4, 11.10
String Tests and Operators
Must-know: [ "b" ] same strings; [ -z "str" ] not empty; never -eq for words.
⚠️ Top pitfall: Using -eq for words gives integer expected; forgetting quotes around "$str"; missing space around =.
Self-check: What does [ -z "we" ] return and what does it print in if then else?
Connects to: 11.7, 11.10
File Tests and System Administration Checks
Must-know: [ -f "$path" ] file, -d dir, -b block, -c char, -L symlink, -s non-empty, -r/-w/-x perms; use -L not -s for symlink.
⚠️ Top pitfall: Confusing -s size>0 with -L symlink; using -e when -f needed; forgetting quotes around "$path".
Self-check: Write loop for f in * that prints only regular files.
Connects to: 11.4, 11.8
Exam Guidance Summary
Must-know: Be ready to fix ;; esac, predict glob, explain word->0, count 0..9 with until eq 10.
⚠️ Top pitfall: Missing ;; or quotes; using -eq for strings.
Self-check: Which loop form matches wording until equal to vs while not at end?
Connects to: 11.2, 11.6
Key Industry Applications
Must-know: while read CSV + break/continue, for f in * + file tests, arrays + for, guard before $(( )).
⚠️ Top pitfall: No validation before arithmetic gives silent 0; unquoted * expands unexpectedly.
Self-check: How to guard n%2 when input may be a word?
Connects to: 11.3, 11.10
Was this lecture useful?
BitsNotes AI Assistant
Subject Notes AssistantConfigure AI Chat
Choose how to access the chatbotSigned in as
Powered by BitsNotes — 20 messages per day. No API key needed. Want unlimited access? Use "Bring Your Own Key" mode.
Sign in to use AI Chat
Get 20 free AI messages per day to ask questions about your lecture notes. Sign in with Google or GitHub — it takes 5 seconds.
Sign In to BitsNotesSwitch to "Bring Your Own Key" tab above for unlimited access with any OpenAI-compatible provider.