Processing GB-sized log files without ever loading them fully into memory
Processing a 20 GB log file with a Bash loop that collects lines into an array is one of the most reliable ways to push a server into swapping. awk was built from the ground up as a streaming tool, processing each line individually and keeping memory usage nearly constant regardless of file size.
Table of Contents
- 1. The problem: why naive Bash loops fail on large files
- 2. How awk processes lines as a stream instead of as a whole file
- 3. Direct comparison: naive Bash loop vs. awk on the same task
- 4. Combining grep and awk to reduce the data volume early
- 5. Streaming compressed log files directly without unpacking first
- 6. Field-based aggregation without growing Bash arrays
- 7. Processing extremely large files in parallel with split and xargs
- 8. Monitoring and guarding memory usage during processing
- 9. When to use awk, when a Bash loop, and when something else
- 10. Summary
- 11. FAQ
1. The problem: why naive Bash loops fail on large files
An apparently harmless Bash loop like while read -r line; do array+=("$line"); done < logfile works fine on a file with a thousand lines but turns into a problem on a file with a hundred million lines, because every appended line stays permanently in the process's memory inside the Bash array. Memory usage grows linearly with file size until either available RAM runs out or the system starts paging memory to disk, which slows processing dramatically.
The actual problem is not the while read loop itself, which genuinely needs only constant memory per line, it is accumulating results in a growing data structure. Once a script collects all lines, all matches, or all intermediate results in an array or a variable instead of processing and writing them out immediately, it behaves like a program that loads the entire file, even though it never explicitly does so.
2. How awk processes lines as a stream instead of as a whole file
awk was designed from the ground up for exactly this use case: it reads a file line by line, applies the given patterns and actions to each line, and emits results immediately without keeping the line in memory afterward. An awk program's memory usage therefore depends almost entirely on the variables the program itself explicitly creates, not on the size of the input file.
That property makes awk the natural tool for tasks like filtering, per-column aggregation, or reformatting huge log files, as long as the aggregation itself does not build up another structure that grows with every line. A simple example: counting error lines in a 30 GB log file needs only a single counter in awk, regardless of whether the file has a thousand lines or a billion.
#!/usr/bin/env bash
set -euo pipefail
# Count ERROR lines in a huge log file, constant memory usage
awk '/ERROR/ { count++ } END { print count }' access.log
# Sum a numeric column (bytes transferred, column 10) without loading
# the file into memory -- only a single running total is kept
awk '{ total += $10 } END { print total }' access.log
3. Direct comparison: naive Bash loop vs. awk on the same task
To make the difference concrete, a direct comparison on the same task helps: summing transferred bytes from the tenth column of an access log file. The naive Bash loop reads every line, splits it into fields with read, and adds the corresponding field to a running total. That works correctly, but every single loop iteration starts a new internal Bash interpreter cycle for the arithmetic, which adds up to an enormous amount of time across hundreds of millions of lines, time awk avoids through its compiled processing.
In practice, the speed difference between such a Bash loop and the equivalent awk call on very large files is often a factor of twenty to fifty, without the Bash version even accounting for the extra memory it accumulates. Anyone reaching for a Bash loop only because it feels more familiar pays twice on large files: once in compute time, once in the risk of a memory blowup.
#!/usr/bin/env bash
set -euo pipefail
# SLOW and memory-risky: naive Bash loop, one arithmetic op per line
total=0
while read -r -a fields; do
total=$(( total + fields[9] ))
done < access.log
echo "$total"
# FAST and constant memory: same task in awk
awk '{ total += $10 } END { print total }' access.log
4. Combining grep and awk to reduce the data volume early
When only a small fraction of the lines in a huge log file are actually relevant, a preceding grep pays off, since it works with highly optimized pattern matching and reduces the data volume before awk takes over the actual field processing. This pipeline combination plays to the strengths of both tools: grep for fast prefiltering on simple text patterns, awk for structured field logic on the remaining lines.
It matters that both tools keep streaming through the pipeline, never writing an intermediate file to disk but passing data directly through a pipe to the next process. That way the entire processing path, from first to last stage, stays in O(1) memory behavior, regardless of the original file size.
#!/usr/bin/env bash
set -euo pipefail
# grep pre-filters cheaply, awk only processes the relevant subset
grep 'status=5' access.log \
| awk '{ counts[$7]++ } END { for (path in counts) print path, counts[path] }'
5. Streaming compressed log files directly without unpacking first
Rotated log files are almost always compressed in practice, typically as a .gz archive, and the reflex to fully unpack them before processing unnecessarily doubles both required disk space and runtime. Tools like zcat or gzip -dc instead decompress in streaming mode directly into a pipe, so awk can process the data as it is being decompressed, without the unpacked version ever landing fully on disk.
This technique extends easily to several compressed files at once, for example evaluating a full week of rotated logs in a single pass. As long as each file is decompressed individually and passed straight through, memory usage stays constant whether one or a hundred compressed files are processed.
#!/usr/bin/env bash
set -euo pipefail
# Stream-decompress and process without ever writing an unpacked copy
zcat access.log.*.gz | awk '/ERROR/ { count++ } END { print count }'
# Same idea for a whole week of rotated, compressed logs
for f in /var/log/app/access.log.*.gz; do
zcat "$f"
done | awk '{ total += $10 } END { print total }'
6. Field-based aggregation without growing Bash arrays
The same mistake as in Bash can also be repeated inside awk: collecting all values of a column into an awk array in order to sort or print them at the end effectively builds another structure that grows with file size. But as long as the aggregation keeps only a single counter or a single sum per key, as in the previous path example, memory usage stays dependent on the number of distinct keys, not on the number of lines in the file.
For log files this distinction matters a great deal: the number of lines can run into the billions, but the number of distinct HTTP status codes or URL paths usually stays in the low hundreds or low thousands. An awk aggregation keyed on those values therefore stays practically memory-efficient at essentially any input file size.
7. Processing extremely large files in parallel with split and xargs
For extremely large files that take several hours even with awk, splitting the work across multiple CPU cores pays off. The split command breaks a file into several equally sized chunks by line, never cutting a line in half, and xargs -P starts one awk process per chunk in parallel, whose partial results are then merged afterward.
This parallelization works especially well for aggregations where partial results can simply be added together, such as line counts or sums. Aggregations with distinct keys across multiple chunks need an extra merge step that combines the partial results once more per key, but even that merge step only operates on the already heavily reduced intermediate results, not on the original file.
#!/usr/bin/env bash
set -euo pipefail
# Split into 8 chunks without cutting lines in half
split -n l/8 access.log chunk_
# Process chunks in parallel, one awk process per CPU core
ls chunk_* | xargs -P 8 -I{} awk '{ total += $10 } END { print total }' {} > partials.txt
# Merge the partial sums into the final total
awk '{ sum += $1 } END { print sum }' partials.txt
rm -f chunk_* partials.txt
8. Monitoring and guarding memory usage during processing
Even for carefully written awk pipelines, monitoring actual memory usage alongside the run pays off, especially for new, not-yet-tested aggregation logic. The ps command with the right fields, or a quick look at /proc/PID/status during processing, immediately shows whether memory usage genuinely stays constant or unexpectedly grows over time, which would point to an internal structure that is growing after all.
For production deployment and monitoring scripts that run regularly against large log files, an additional hard memory limit through ulimit -v or a systemd service with MemoryMax is worthwhile. A script that aborts in a controlled way on an actual memory leak is always preferable to a server rendered unusable for minutes by uncontrolled swapping.
9. When to use awk, when a Bash loop, and when something else
For structured, column-based processing of large text files, awk is the right choice in most cases, because it combines streaming, field processing, and aggregation in a single compiled tool. A Bash loop still makes sense when complex external commands need to be invoked per line that awk itself cannot express, as long as results are never accumulated unboundedly in memory along the way.
| Tool | Memory behavior | Speed on GB files | Typical use |
|---|---|---|---|
| awk | Constant, independent of file size | Very high | Filtering, aggregating, reformatting |
| Naive Bash loop with array | Grows linearly with line count | Very low | Only suitable for small files |
| Bash loop without accumulation | Constant | Low, but memory-safe | Invoking external commands per line |
| grep + awk pipeline | Constant | Very high | Prefiltering before structured processing |
| split + xargs -P + awk | Constant per subprocess | Very high, multi-core | Extremely large files, multiple CPU cores |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Streaming Huge Files with awk: The Essentials at a Glance
Cause of the memory blowup
Line-by-line processing itself is not the problem, it is accumulating all lines or results in a growing array.
awk streams by nature
Lines are processed and emitted immediately, memory usage depends only on self-created variables, not on file size.
Process compressed data directly
zcat or gzip -dc into a pipe avoids fully unpacking to disk and keeps the whole processing path in streaming mode.
Parallelize at extreme scale
split -n l/N combined with xargs -P spreads the work across multiple CPU cores, a final merge step combines the partial results.