awk vs sed: when to reach for which

Both read text a line at a time and are built for use in a pipeline, but they solve different shapes of problem.
  • sed (stream editor) thinks in terms of lines: find lines matching something, then substitute, delete, insert, or print them. Reach for it when the unit of work is "this line".
  • awk thinks in terms of fields: it automatically splits every line into columns and gives you variables, arithmetic, and associative arrays to work with them. Reach for it when the unit of work is "this column" — sums, counts, reformatting CSV-ish data, log analysis.
They're frequently chained together: grep to find the lines, awk to pull out and compute over a column, sed to reshape the text of what's left.

awk: basic syntax & fields

An awk program is a series of pattern { action } pairs. For every input line, awk checks each pattern in order and runs the matching action; a bare pattern with no action defaults to { print }, and a bare action with no pattern runs on every line.
echo "alice 30 engineer" | awk '{ print $1, $3 }'
# alice engineer
Before running any action, awk splits the current line ($0) into fields on whitespace by default: $1 is the first field, $2 the second, and so on up to $NF, the last one.
awk 1 file | | the classic minimal awk idiom: the number 1 is an always-true pattern with the default action ({ print }), so this just prints every line unchanged — a common trick for reformatting output through OFS |'aw1'

awk: fields & built-in variables

$0 | | the entire current line, unsplit |'aw2'
$1, $2, ... $NF | | individual fields; $NF is always the last field, $(NF-1) the second-to-last |'aw3'
NF | | the number of fields in the current line |'aw4'
NR | | the current line's record number, counted across every input file given to this invocation |'aw5'
FNR | | the current line's record number within just the current file, resetting to 1 for each new file (differs from NR when awk is given more than one file) |'aw6'
FILENAME | | the name of the file currently being read |'aw7'
FS | | the field separator awk splits each line on. The default value, a single space, is special-cased to mean "any run of whitespace, ignoring leading/trailing" — setting FS to any other single character splits on exactly that character literally |'aw8'
OFS | | the separator awk joins fields with when you print multiple comma-separated fields (print $1, $2) — defaults to a single space |'aw9'
awk -F',' '{ print $2 }' file.csv | | -F sets FS from the command line — the standard way to point awk at comma- or tab-separated data |'aw10'
awk 'BEGIN{ OFS="," } { $1=$1; print }' file | | reassigning any field (even to itself) forces awk to rebuild $0 from the fields using OFS — a common trick for converting a file's separator |'aw11'

awk: patterns

awk '/error/' file | | a bare regex pattern: prints every line containing a match (case-sensitive) |'aw12'
awk '$3 > 100' file | | an expression pattern: prints lines where the third field, compared numerically, exceeds 100 |'aw13'
awk '$1 == "error"' file | | prints lines where the first field is exactly the string "error" |'aw14'
awk '$1 ~ /^ERR/' file | | ~ tests whether a field matches a regex (!~ for does-not-match) — different from == which tests for an exact literal string |'aw15'
awk 'NR==1' file | | prints only the first line — handy for peeling off a CSV header |'aw16'
awk 'NR>1' file | | prints every line except the first — the complementary case, skipping a header |'aw17'
awk 'BEGIN{ ... }' | | a block that runs once, before the first line is read — used for setup like setting FS or initializing variables |'aw18'
awk 'END{ ... }' | | a block that runs once, after the last line has been processed — used to print totals/summaries accumulated along the way |'aw19'

awk: computing & printing

awk '{ sum += $2 } END { print sum }' sales.txt
awk '{ if ($2 > max) max = $2 } END { print max }' sales.txt
awk '{ count[$1]++ } END { for (k in count) print k, count[k] }' log.txt
Variables in awk don't need to be declared; an unseen variable starts as an empty string/zero. This makes running totals, counts, and maximums trivial one-liners — the pattern above (accumulate in the main block, report in END) covers most of what people reach for awk to do.
printf "%-10s%5d\n", $1, $2 | | prints with a C-style format string, for aligned columns instead of print's single-space-separated output |'aw20'
length($1) | | the length of a field (or of $0, if called with no argument) |'aw21'
substr($1, 2, 3) | | a substring: 3 characters of $1, starting at position 2 (awk positions are 1-indexed) |'aw22'
toupper($1) / tolower($1) | | uppercases / lowercases a string |'aw23'
split($0, parts, ":") | | splits a string into the array parts on a given separator, returning the count of pieces |'aw24'
gsub(/old/, "new") | | replaces every match of a regex in $0 (or in a given target argument) in place, and returns the number of replacements made |'aw25'
sub(/old/, "new") | | same as gsub, but only the first match |'aw26'

awk: arrays, control flow & functions

awk's arrays are always associative (string-keyed), even when you use them like a numeric-indexed array — the keys are just stringified numbers under the hood.
function average(arr,    sum, n, k) {   # extra params after the real one are local vars, by convention
  for (k in arr) { sum += arr[k]; n++ }
  return n ? sum / n : 0
}

BEGIN {
  scores["alice"] = 90
  scores["bob"] = 75
  print average(scores)
}
for (key in arr) { ... } | | iterates over every key in an associative array (order is not guaranteed) |'aw27'
(key in arr) | | tests whether a key exists in an array, without the side effect of creating it (unlike checking arr[key] directly, which auto-vivifies the key) |'aw28'
delete arr[key] | | removes one key from an array |'aw29'
if (cond) { ... } else { ... } | | ordinary conditional, usable inside any action block |'aw30'
for (i=1; i<=NF; i++) { ... } | | a C-style loop, commonly used to walk every field on a line |'aw31'
next | | skips immediately to the next input line, without running any remaining patterns/actions for the current one |'aw32'
awk -v threshold=100 '$2 > threshold' file | | -v passes a value from the shell into an awk variable, before the program runs |'aw33'
awk -f script.awk file | | runs a program from a separate .awk file instead of inline on the command line — worth doing once a one-liner grows past a few lines |'aw34'

sed: basic syntax & substitution

sed reads input one line at a time into its pattern space, runs your commands against it, then (unless told otherwise) prints the result and moves to the next line. The single most common command is substitution.
echo "hello world" | sed 's/world/bash/'
# hello bash
s/pattern/replacement/ replaces only the first match on each line by default.
s/old/new/g | | the g flag: replaces every match on the line, not just the first |'sd1'
s/old/new/2 | | replaces only the 2nd match on the line (a number selects which occurrence; combine with g, e.g. 2g, to replace the 2nd occurrence onward) |'sd2'
s/old/new/i | | case-insensitive matching |'sd3'
sed -n 's/old/new/p' file | | -n suppresses sed's normal auto-print of every line; the p flag then explicitly prints only the lines where a substitution actually happened — a grep-and-transform in one pass |'sd4'
sed 's/foo/[&]/' file | | & in the replacement stands for the whole matched text — here, wrapping every match in brackets |'sd5'

sed: addressing

Any sed command can be limited to specific lines by prefixing it with an address.
sed '3d' file | | applies a command (d, delete) to line 3 only |'sd6'
sed '2,4d' file | | applies to a range of lines, 2 through 4 inclusive |'sd7'
sed '3,$d' file | | $ means the last line — here, from line 3 to the end of the file |'sd8'
sed '/start/,/end/d' file | | a range between two regex matches (inclusive of both boundary lines) instead of line numbers |'sd9'
sed '/pattern/d' file | | applies to every line matching a regex, wherever it occurs |'sd10'
sed '2!d' file | | ! negates the address: applies the command to every line EXCEPT line 2. Combined with d, this keeps only line 2 |'sd11'
sed -n '5p' file | | prints only line 5 (requires -n, or every other line would also print once via the default auto-print) |'sd12'

sed: in place, multiple commands & extended regex

sed -i 's/old/new/g' file | | edits the file in place instead of printing to stdout. GNU sed (the default on Ubuntu) accepts -i with no argument; BSD/macOS sed requires an explicit suffix argument, even if empty (-i '') |'sd13'
sed -i.bak 's/old/new/g' file | | edits in place, but first saves the original as file.bak — a cheap safety net before an in-place edit |'sd14'
sed -e 's/a/b/' -e 's/c/d/' file | | runs multiple, separate -e command scripts in sequence on the same input |'sd15'
sed 's/a/b/; s/c/d/' file | | equivalent to the above, with commands separated by semicolons within a single script instead |'sd16'
sed -f script.sed file | | runs commands from a separate .sed script file |'sd17'
sed -E 's/(foo|bar)baz/\1/' file -E (or the older -r) enables extended regular expressions: unescaped ( ), +, ?, and | for alternation, instead of needing backslash-escaped \( \) \+ \? in sed's default basic regex mode
sed 's/\(...\)-\(...\)/\2-\1/' file | | in default (basic) regex mode, capture groups need escaped parentheses, and are referenced as \1, \2, ... in the replacement — here, swapping two hyphen-separated parts |'sd19'

sed: insert, append, change & transliterate

sed '2i some text' file | | inserts a new line BEFORE line 2 |'sd20'
sed '2a some text' file | | appends a new line AFTER line 2 |'sd21'
sed '2c some text' file | | changes (replaces) line 2 entirely with new text |'sd22'
y/abc/xyz/ | | transliterates characters one-for-one, like tr — every a becomes x, b becomes y, c becomes z. Not regex-based, unlike s/// |'sd23'

advanced: sed's hold space

Beyond the pattern space (the current line), sed keeps a second, separate buffer called the hold space that persists across lines. Five commands move data between them: h copies pattern space into hold space, H appends it instead of overwriting, g copies hold space into pattern space, G appends it, and x swaps the two outright. This is what makes sed capable of operating across lines, not just within one.
sed -n '1!G;h;$p' file
This is the classic sed one-liner for reversing a file's line order (equivalent to tac): on every line except the first, it prepends whatever has accumulated in the hold space so far (1!G), saves the growing, reversed result back to the hold space (h), and only prints once it reaches the last line ($p), by which point the whole file has been built up in reverse.

related topics

Ubuntu Terminal Commands — grep, pipes, and the other tools awk/sed are usually chained with.
Bash Scripting Cheat Sheet — wrapping an awk/sed one-liner into a reusable script.
Git Hooks & Automation — a common place sed/awk one-liners end up living.
Debugging: gdb, pdb & a General Method — grepping and awk-ing through logs while tracking down a bug.

reference

GNU awk (gawk) User's Guide
GNU sed manual
sed one-liners, explained