Loops: for, while & reading files
Repeat work and read input line by line.
Doing the same job on a pile of things
You have forty servers to check, two hundred log files to scan, or a list of user accounts to audit. Typing the same command forty times is slow and easy to fumble. A loop is the fix. It is the assembly-line worker of Bash (the command language you type into a Linux terminal): you describe the steps once, hand it a pile of items, and it runs those steps on each item in turn. This lesson covers the loops you will actually reach for in operations work, plus the one file-reading pattern that quietly breaks security scripts when people get it wrong.
for: one run per item in a list
A for loop is you reading names off a guest list. For each name you do the same thing: check them in. You write the list of items after the word in, and Bash runs the body once with the loop variable set to each item.
Bash split that list on spaces and ran the body three times. $env held dev, then staging, then prod. The words after in are the whole list, and they can be anything: hostnames, ports, filenames, usernames.
for over a glob: let the shell find the files
Most of the time you do not want to type the filenames by hand. You want to hand the shell a rule instead of a list: "every file whose name ends in .log". That shorthand is a glob (short for global, the old name for filename wildcards). The * matches any run of characters, so *.log means every name ending in .log. The shell expands the glob into the real filenames before the loop starts.
The shell replaced *.log with the three matching files (in alphabetical order) and the loop ran once per file. Notice the double quotes around "$logfile". A filename can contain spaces, and the quotes keep such a name as one item instead of two. Leave them off and a file called access log.txt would split and break your script. One sharp edge: if no file matches, plain Bash leaves the text *.log unexpanded and the loop runs once with that literal string. Running shopt -s nullglob first makes a no-match glob expand to nothing, so the loop runs zero times, which is usually what you want.
Counting and waiting: C-style for and while
Two loops cover the cases where you are not walking a list. When you want to run something a fixed number of times, use the C-style for, named after the C programming language it copies. It works like a lap counter at a track: it starts at a number, keeps going while a condition holds, and ticks the number up each time around. Inside the double parentheses those three parts sit in order: where the counter starts, how long to keep going, and how the counter changes each pass.
When you do not know the count ahead of time and instead want to keep going until some condition flips, use while. It works like a bucket under a tap: keep filling while the bucket is not full. Bash checks the condition, runs the body, then checks again, stopping the moment the condition is false.
(( count-- )) subtracts one from count each pass. Forget that line and the condition never turns false, so you get an infinite loop. Press Ctrl-C to kill one if it happens.
Reading a file line by line, the right way
This is the pattern people get wrong most often, and the wrong version fails in ways that matter for security. Say you have an allowlist of addresses, where the exact content on each line matters:
10.0.0.510.0.0.6C:\logs\path*
Here is the correct way to read it. Memorize this line as a single unit:
Every line arrived exactly as written: the leading spaces on the second address, the backslashes in the path, and the lone *. Read the machinery from right to left. < allowlist.txt feeds the file into the loop as its input. read pulls one line into the variable line and returns success until the file runs out, which is what stops the loop at the end. Two small pieces carry the weight: IFS= and -r.
IFS is the Internal Field Separator, the set of characters Bash treats as gaps between words. Normally read uses it to trim leading and trailing spaces and tabs off each line. Setting IFS= (empty) for this one command turns that trimming off, so 10.0.0.6 keeps its indentation. -r means raw. Without it, read treats a backslash as an escape character and swallows it. The difference is not cosmetic:
Without -r, C:\logs\audit came back as C:logsaudit. The backslashes vanished. On Windows paths, Active Directory names, or regular expression (regex) patterns stored in a rules file, that silent damage changes what your script matches. Keep -r on every read loop.
Now the wrong way, the one you will find in old blog posts and copy-pasted scripts:
$(cat file) gets split on every space and newline, not only on newlines, so 10.0.0.6 lost its leading spaces, and any line containing a space would shatter into separate items. Second, the shell runs filename expansion on the result, so the lone * on the last line turned into the filename allowlist.txt. Feed such a loop a file an attacker can write to, drop a * on a line, and you are suddenly looping over your directory contents instead of the data you meant to process. Always read files with while IFS= read -r line; do ... done < file, never with for over cat.break and continue: skip one, or stop early
Inside any loop, two commands change the flow. continue says "skip the rest of this pass and move to the next item", like passing on a name on your checklist. break says "stop the whole loop now and walk away". A common shape reads a host list, skips comment lines, and halts at a marker:
web-01# web-02 is decommissionedweb-03STOPweb-04
The comment line was skipped by continue, web-03 was scanned, and STOP ended the loop with break before web-04 was ever read. [[ "$host" == \#* ]] is a pattern test: it is true when host starts with a #. The backslash before # keeps the shell from reading it as the start of a comment.
Putting it together: scan a folder of logs
Real work stacks these loops. Here is a script that walks every log file in a folder and, for each one, reads it line by line to count failed logins. The outer loop is a glob over files, and the inner loop is a while read over the lines of each file.
#!/usr/bin/env bashfor logfile in logs/*.log; dofails=0while IFS= read -r line; do[[ "$line" == FAIL* ]] && (( fails++ ))done < "$logfile"echo "$logfile: $fails failed login(s)"done
The fails counter is reset to zero at the top of each file, counts lines starting with FAIL, and gets printed once per file. Because the file is fed in with < "$logfile" rather than piped, the whole loop runs in your current shell, so the counter survives to the echo after it.
read returns failure when it hits the end of the file without a trailing newline, even though it did place that last chunk into the variable. So a plain while IFS= read -r line loop silently skips the final line of any file that does not end in a newline (truncated logs and some exported data do this). Guard against it: while IFS= read -r line || [[ -n "$line" ]]; do. The extra test is true whenever read pulled in leftover text but reported end-of-file, so that last line still gets processed.for f in *.csv; do echo "$f"; done in a directory that contains no files ending in .csv. What happens?shopt -s nullglob; plain Bash does not silently drop an unmatched pattern.shopt -s nullglob exists to make it expand to nothing instead.while IFS= read -r line; do ((n++)); done < export.csv. It reports one fewer line than the file contains, but only for files from one tool that omits the trailing newline. What is going on, and what fixes it?read reports end-of-file on an unterminated final line, so while IFS= read -r line || [[ -n "$line" ]] is needed to catch that leftover text.IFS= only disables leading/trailing whitespace trimming; it never drops a whole line.-r only stops backslash from being treated as an escape; it has nothing to do with the missing line.((n++))'s exit status is ignored; the dropped line is an end-of-file behaviour of read.Try this
Work through “Putting it together: scan a folder of logs” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: for line in $(cat file) is broken and unsafe. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.