| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; examples create files in the current directory |
| Scope | Streaming line reads with while IFS= read -r, input redirection, final-line handling, colon-delimited fields per line, process substitution for command output, pipeline subshell pitfalls, here strings, and a brief mapfile comparison. Does not cover interactive read options, full IFS semantics, or a complete mapfile / readarray reference. |
| Related guides | Bash while loop Shell scripting course hub |
The robust Bash pattern for reading a text file line by line is while IFS= read -r line; do ...; done < "$file". It streams one line at a time without loading the whole file into memory and without putting the loop in a pipeline subshell.
That form exists because, with the default IFS, read removes leading and trailing IFS whitespace when reading into a single variable, interprets backslashes unless you pass -r, and returns a non-zero status at EOF even when the final record has no trailing newline. The examples below show why each part matters, including whitespace preservation, missing final newlines, and pipeline subshell behavior.
Read a File Line by Line with while IFS= read -r
Create a small sample file with three colon-separated records:
cat > sample.txt <<'EOF'
alpha:first value
beta:second value
gamma:third value
EOFRead that file one line at a time with the canonical loop:
while IFS= read -r line; do
printf 'Line: %s\n' "$line"
done < sample.txtLine: alpha:first value
Line: beta:second value
Line: gamma:third valueEach printf call prints one full input line. Input redirection (< sample.txt) feeds the loop from the file instead of from a pipeline.
The loop has four pieces that work together:
| Piece | Role |
|---|---|
while |
Keeps running the body while the read command succeeds |
IFS= |
Sets IFS to empty for this read so leading and trailing IFS whitespace is preserved |
read -r |
Reads one line and keeps backslashes literal |
< "$file" |
Redirects the file into the loop's stdin without a pipe |
read returns 0 when it reaches its delimiter (a newline by default). If EOF arrives after some characters but before a final newline, read still stores those characters in line but returns non-zero. At EOF with no data left, it returns non-zero and the loop ends.
This article focuses on file streaming. The Bash read command lesson covers interactive prompts, timeouts, and reading from the terminal.
Preserve Whitespace and Backslashes
Create a small config file with ASCII indentation and Windows-style path separators:
printf '%s\n' $' indented line' > ws.confAppend a line that contains literal backslashes:
printf '%s\n' 'path=C:\data\logs' >> ws.confRead it with IFS= and -r:
while IFS= read -r line; do
printf 'RAW|%s|END\n' "$line"
done < ws.confRAW| indented line|END
RAW|path=C:\data\logs|ENDLeading spaces and backslashes survived because IFS= stopped trimming and -r disabled backslash escaping.
Drop IFS= and the leading spaces disappear:
while read -r line; do
printf 'TRIM|%s|END\n' "$line"
done < ws.confTRIM|indented line|END
TRIM|path=C:\data\logs|ENDCreate a line with backslashes using a %s format so printf does not interpret escape sequences in the format string:
printf '%s\n' 'C:\temp\file' > bs.txtRead that line without -r so backslashes act as escapes:
while IFS= read line; do
printf 'NOR|%s|END\n' "$line"
done < bs.txtNOR|C:tempfile|ENDUse printf '%s\n' "$line" to print the line without interpreting backslashes or option-like text. See Bash echo vs printf for why echo is a poor fit for arbitrary line data.
Handle a Final Line Without a Newline
POSIX text files usually end with a newline, but generators and copy operations sometimes omit it on the last record.
Create a file whose final line has no newline character:
printf 'line one\nline two\nfinal without newline' > no-nl.txtA naive loop stops before that last record:
while IFS= read -r line; do
printf 'NAIVE>%s<\n' "$line"
done < no-nl.txtNAIVE>line one<
NAIVE>line two<The robust guard processes the final partial line even when read returns non-zero at EOF:
while IFS= read -r line || [[ -n $line ]]; do
printf 'ROBUST>%s<\n' "$line"
done < no-nl.txtROBUST>line one<
ROBUST>line two<
ROBUST>final without newline<read still returns non-zero after the last chunk, but [[ -n $line ]] is true when that chunk holds data. Use this pattern when your input may lack a trailing newline; plain files that always end with \n work fine without the guard.
Split Each Line into Fields
Set IFS on the same read that consumes the file when each line is already one record with internal delimiters.
Create a passwd-style row in a file:
cat > users.txt <<'EOF'
alice:x:1000:1000::/home/alice:/bin/bash
EOFRead colon-separated fields directly into variables:
while IFS=: read -r user _ uid gid _ home shell; do
printf 'user=%s uid=%s home=%s shell=%s\n' "$user" "$uid" "$home" "$shell"
done < users.txtuser=alice uid=1000 home=/home/alice shell=/bin/bashSetting IFS=: for this read splits each input line at colons and assigns the fields to separate variables. Full IFS rules and word-splitting order belong in a dedicated field-splitting lesson.
Read Command Output Without Losing Variables
Piping into while runs the loop body in a subshell in Bash's default pipeline model. Assignments made inside the loop do not survive.
Demonstrate with a three-line file:
printf 'a\nb\nc\n' > count.txtPipe cat into the loop and increment a counter:
count=0
cat count.txt | while IFS= read -r line; do
((count++))
done
printf 'After pipe: count=%d\n' "$count"After pipe: count=0The loop ran three times in a subshell, but the parent shell's count never changed. Redirect from the file instead:
count=0
while IFS= read -r line; do
((count++))
done < count.txt
printf 'After redirect: count=%d\n' "$count"After redirect: count=3When the source is command output rather than a file, use process substitution so the loop still reads from a redirection:
count=0
while IFS= read -r line; do
((count++))
done < <(printf 'alpha\nbeta\ngamma\n')
printf 'Lines read: %d\n' "$count"Lines read: 3< <(command) feeds the while loop from a temporary file descriptor. The loop runs in the current shell, so count keeps its value after the loop ends. shopt -s lastpipe can run the last pipeline stage in the current shell, but redirection or process substitution is the clearer fix for scripts you hand to beginners.
Read a variable line by line
A here string feeds multiline text from a variable without a temporary file:
text=$'alpha\nbeta\ngamma'
while IFS= read -r line; do
printf '%s\n' "$line"
done <<< "$text"alpha
beta
gammaA here string adds a trailing newline, so use it for string or variable input—not when you need to preserve the source's exact final-newline state.
while read vs mapfile
while IFS= read -r streams the file: only one line sits in memory at a time. That suits log processing, large inputs, and filters where you handle each line immediately.
mapfile (also called readarray) loads every line into an array in one step. That is convenient when you need random access, line counts up front, or repeated passes over the same data, but it uses more memory on large files.
Pick streaming when the file may be large or you only need a single forward pass. Pick mapfile when you deliberately want an array of lines and the size is bounded. This article stays with streaming; see Bash mapfile for -t, -d, and other array-loading options.
Why for line in $(cat file) Is Wrong
A Bash for loop walks words, not lines. Unquoted command substitution is subject to word splitting, so lines containing spaces or tabs become multiple loop iterations.
Create a file with spaces in each record:
printf 'a file\nb file\n' > spaced.txtFeed it through for and cat:
for line in $(cat spaced.txt); do
printf 'FOR>%s<\n' "$line"
doneFOR>a<
FOR>file<
FOR>b<
FOR>file<Four words appeared instead of two lines. Use while IFS= read -r for line-oriented data; use for only when you already have separate words, glob matches, or array elements.
Practical Example: Process a Configuration File
This script prints each non-blank, non-comment line with its line number and preserves the original text:
process_config() {
local file=$1
local line_no=0
[[ -f "$file" ]] || {
printf 'error: file not found: %s\n' "$file" >&2
return 1
}
local blank_re=$'^[[:space:]]*$'
local comment_re=$'^[[:space:]]*#'
while IFS= read -r line || [[ -n $line ]]; do
line_no=$((line_no + 1))
[[ $line =~ $blank_re ]] && continue
[[ $line =~ $comment_re ]] && continue
printf 'L%d: %s\n' "$line_no" "$line"
done < "$file"
}Build a sample config with a comment, a blank line, two ASCII leading spaces, a backslash path, and no newline after the last line:
{
printf '%s\n' '# database settings'
printf '\n'
printf '%s\n' $' key=value'
printf '%s\n' 'path=C:\logs'
printf '%s' 'last line without newline'
} > app.confRun the processor:
process_config app.confL3: key=value
L4: path=C:\logs
L5: last line without newlineThe comment and blank line were skipped. Line 5 was still processed even without a trailing newline.
Common Problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Leading spaces vanish | Default IFS trims whitespace on read |
IFS= before read |
\ disappears from paths |
read without -r |
read -r |
| Last line missing | EOF before newline; naive while read |
read -r line with ` |
Counter stays 0 after loop |
command | while read subshell |
Redirect with < file or < <(command) |
| Too many iterations | for line in $(cat file) word splitting |
while IFS= read -r |
Summary
Reading a file line by line in Bash means streaming with while IFS= read -r line; do ...; done < "$file". IFS= keeps leading and trailing whitespace on the line; -r keeps backslashes literal; input redirection avoids the pipeline subshell that drops variable updates.
When the last record may lack a newline, add || [[ -n $line ]] so the final chunk is still processed after read returns non-zero at EOF. Print lines with printf '%s\n' "$line" when you need faithful text output.
Do not parse files with for line in $(cat file)—that splits on whitespace, not newlines. Use while read for streaming and mapfile when you need the whole file in an array. Bash variables cannot represent embedded NUL bytes, so line-oriented read is for text records, not arbitrary binary data.
References
- GNU Bash Manual — Bourne Shell Builtins —
read - GNU Bash Manual — Shell Expansions
- GNU Bash Manual — Process Substitution
- POSIX
readutility
Frequently Asked Questions
Why use IFS= read -r?
IFS= stops read from stripping leading and trailing whitespace from each line. -r stops backslash escaping so paths like C:\logs stay literal. Together they preserve leading/trailing whitespace and literal backslashes in ordinary text lines.
How do I keep the last line when the file has no final newline?
Use while IFS= read -r line || [[ -n $line ]]; do ...; done < "$file". After EOF, read fails, but [[ -n $line ]] is still true when the last read stored data in line.
Why is a counter lost after a pipe?
command | while read runs the loop in a subshell. Assignments inside the loop do not update variables in the parent shell. Redirect from the file or use process substitution instead of piping into while.
When should I use while read instead of mapfile?
Use while read to stream large files one line at a time with low memory use. Use mapfile when you need all lines in an array at once and the file size is reasonable.
Can Bash safely read binary files line by line?
No. Bash variables cannot represent embedded NUL bytes, so while read is appropriate for text records, not arbitrary binary data.

