Sample output
2403263 3 /api/reports
1768420 2 /assets/app.js
20144 1 /assets/logo.png
14539 3 /api/users
312 1 /api/login
72 4 /api/health
Three requests to /api/reports moved 2.4 MB, which is 800 KB per response and the reason the egress bill looks the way it does. Compare it against /api/health, which was hit more often than anything else and cost 72 bytes in total. Request counts alone would have ranked these two backwards, and that inversion is the whole argument for totalling the bytes instead of counting the lines.
When you would reach for it
You have a log, a CSV, or any other column of text, and the question is not “which lines match” but “how much, grouped by what”. Bandwidth per endpoint, errors per service, build minutes per job, revenue per region: the shape is always the same, and the usual answer is to load the file somewhere that understands GROUP BY. This does it in one pass over the file, in memory that scales with the number of distinct keys rather than the number of lines, using a tool that is already on the machine.
Gotchas
- mawk, which is the default
awkon Debian and Ubuntu, prints any%dvalue above 2147483647 as exactly 2147483647. It does not warn and it does not wrap, it clamps, so a plausible-looking number is what you get back. Byte totals cross that line at two gigabytes. Use%.0ffor anything you are summing, and reserve%dfor counts you know are small. gawk prints the full value, so the same pipeline can be correct on the workstation where you wrote it and quietly wrong on the Debian box where it runs nightly. - Field numbers are a promise about the log format, not about the log. The combined format puts the path at
$7only because the timestamp in brackets counts as two fields, and one proxy that logs an extra header or one request line containing a space shifts everything to the right for that line alone. Runawk '{print NF}' access.log | sort | uniq -cfirst: more than one answer means your field numbers are wrong somewhere in the file. - The array holds one entry per distinct key, so grouping by a path with query strings in it can turn a million-line log into a million-element array and an out-of-memory kill. Normalize the key before you count it:
sub(/\?.*/, "", $7)strips everything from the first question mark onward. Be aware that assigning to a field rebuilds$0from the fields usingOFS, so if you print the whole line later it comes back with single-space separators. for (u in n)returns keys in hash order, and the hash differs between gawk, mawk and the BSD awk on macOS. Never present that order as meaningful, and never diff it between two machines. gawk can sort internally withPROCINFO["sorted_in"] = "@val_num_desc"set inside theENDblock, but that is a GNU extension and the externalsortis portable.
Variants
$ awk -F, 'NR>1 {u[$2]+=$4; r[$2]+=$5} END {for (k in u) printf "%-10s %6d %10.2f\n", k, u[k], r[k]}' sales.csv | sort -k3 -nr
The same program against a CSV. -F, changes the field separator and NR>1 is a pattern that skips the header row
$ awk '{n[$9,$7]++} END {for (k in n) {split(k, p, SUBSEP); printf "%6d %4s %s\n", n[k], p[1], p[2]}}' access.log | sort -rn
Group by two columns at once. n[$9,$7] joins status and path with SUBSEP, an unprintable character awk reserves for this, and split takes the key back apart
$ zcat -f access.log access.log.*.gz | awk '{n[$7]++; bytes[$7]+=$10} END {for (u in n) printf "%12.0f %6d %s\n", bytes[u], n[u], u}' | sort -rn | head
A month of rotated logs as one table. -f tells zcat to pass uncompressed files through untouched, so the current log and its gzipped ancestors go into the same arrays