🧮 awk Guide

A how-to manual: how awk reads fields, patterns and actions, numbers and strings, printf, arrays, functions, several files, separators and records, mawk against gawk, safe use in scripts and speed, with every example run for real.

Video: awk (Command Line Tips video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
awk: reads text a line at a time, splits each line into fields, and runs your code on the lines you choose.
awk '{ print $1 }' file.txt
awk -F: '$3 >= 1000 { print $1 }' /etc/passwd
awk '{ n[$1]++ } END { for (k in n) print k, n[k] }' access.log

awk is for text that is a table: log lines, the output of ps and df, /etc/passwd, a report with columns. It can select lines, pick and rearrange columns, add numbers, count things and print a formatted report, all in a line you type once. It is also a complete small language (variables, arrays, functions, loops), so a script of twenty lines can do what would otherwise be a throwaway program.

This guide goes from the first print $1 to the parts that cause trouble: a number that prints as 1.23457e+07, a field separator set one line too late, a join that silently prints nothing when the first file is empty, a shell variable that becomes code, and the fact that the awk on your Ubuntu server (mawk) is not the awk in most tutorials (gawk). Where they differ, the lab ran both.

Every example below ran for real on Ubuntu 24.04 (mawk 1.3.4 20240123, gawk 5.2.1, the original BWK awk 20231127 and BusyBox awk 1.36.1), against the sample files shown in the next section. Where an example uses a gawk-only feature it says so. Things the lab could not run are marked as not run (macOS awk, for one).

What awk is, which one you have, and the sample data

awk reads its input one line (a record) at a time, splits it into fields, and for each record runs the rules you wrote. A rule is a pattern (which lines) and an action (what to do, in braces). Without -i or a redirection, awk never changes a file; it prints, so it is safe to try.

There are several awks, and the name awk is only a link to one of them:

mawk        the awk on a fresh Ubuntu server (24.04: mawk 1.3.4 20240123). Small and the fastest of the four in the lab, but it has fewer features than gawk and it counts bytes, not characters.
gawk        GNU awk, 5.2.1 here. What most tutorials assume: more functions, \s and \w in regexes, sorting, time functions, in-place editing, CSV helpers. Install it with: sudo apt install gawk
original-awk  the "one true awk" by Brian Kernighan (20231127). It is also what macOS ships (an older build there; not run in the lab).
BusyBox awk 1.36.1  the awk in Alpine and many containers. Small, with some GNU features and no math functions in this build.

$ readlink -f /usr/bin/awk              (in a fresh vid-base:24.04 container, before gawk was installed)
/usr/bin/mawk
$ awk -W version | head -1
mawk 1.3.4 20240123

Installing gawk changes the link (Debian alternatives), so afterwards plain awk means gawk:

$ ls -l /etc/alternatives/awk
lrwxrwxrwx 1 root root 13 Oct 18 00:54 /etc/alternatives/awk -> /usr/bin/gawk

The lab had all four installed side by side and the link pointed at gawk, so a command written as awk below ran on gawk 5.2.1. Where the result depends on the version, the guide runs mawk, gawk, original-awk and busybox awk by name.

How to ask each one for its version (they do not agree):

gawk            gawk --version              GNU Awk 5.2.1, API 3.2, ...
mawk            mawk -W version             mawk 1.3.4 20240123      (--version is an error in mawk)
original-awk    original-awk --version      awk version 20231127
BusyBox         busybox | head -1           BusyBox v1.36.1 ...      (awk --version is an error)

The examples use these files, which were made in ~/lab. sales.txt has a header line and six rows:

$ cat sales.txt
region product qty price
north apples 10 1.50
south pears 4 2.25
north pears 7 2.25
east apples 3 1.50
south apples 12 1.40
east plums 0 3.10

app.log (the same log as the sed guide) and access.log (a web server log):

$ cat app.log
2026-10-17 02:00:01 INFO  api  request ok user=ann ip=10.0.1.11
2026-10-17 02:00:02 INFO  api  request ok user=ed ip=10.0.1.12
2026-10-17 02:00:03 WARN  api  slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay  card declined user=kim ip=10.0.2.31
2026-10-17 02:00:05 INFO  web  GET /index.html 200 ip=10.0.3.5
2026-10-17 02:00:06 ERROR pay  upstream timeout user=ed ip=10.0.1.12
2026-10-17 02:00:07 INFO  web  GET /missing 404 ip=10.0.3.6
$ cat access.log
10.0.1.11 - - [17/Oct/2026:02:00:01 +0000] "GET /index.html HTTP/1.1" 200 5120
10.0.1.12 - - [17/Oct/2026:02:00:02 +0000] "GET /api/items HTTP/1.1" 200 842
10.0.3.5 - - [17/Oct/2026:02:00:03 +0000] "GET /missing HTTP/1.1" 404 196
10.0.1.11 - - [17/Oct/2026:02:00:04 +0000] "POST /api/login HTTP/1.1" 302 0
10.0.3.5 - - [17/Oct/2026:02:00:05 +0000] "GET /missing HTTP/1.1" 404 196
10.0.2.31 - - [17/Oct/2026:02:00:06 +0000] "GET /api/items HTTP/1.1" 500 1024
10.0.1.11 - - [17/Oct/2026:02:00:07 +0000] "GET /index.html HTTP/1.1" 200 5120

passwd.txt is five lines in /etc/passwd format, and data.csv is a small CSV with quoted fields:

$ cat passwd.txt
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
ann:x:1000:1000:Ann Lee:/home/ann:/bin/bash
ed:x:1001:1001:Ed Kay:/home/ed:/bin/zsh
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
$ cat data.csv
id,name,note,amount
1,ann,"likes tea, hot",10
2,ed,plain,20
3,kim,"says ""hi""",30

The examples that use only POSIX features were run on mawk as well as on gawk, and gave the same output, except for the order of for (k in a) and the space before a bracket in a function call (both described below). Two small files, ids.txt and names.txt, appear in the section on several files. A one-million-line log (63 MB) was made for the speed section.

How awk works: records, fields, patterns and actions

A program is a list of rules. For every input line awk does this:

1. read the line into $0 (the record),
2. split it into fields $1, $2, ... $NF (NF is how many there are),
3. for each rule in order: if the pattern is true for this line, run the action.

pattern { action }

The pieces are optional, and the defaults are what make awk short:

a rule with no pattern runs on every line:       awk '{ print $1 }' file
a rule with no action prints the whole line:      awk '$3 > 5' file      (same as  '$3 > 5 { print $0 }')
BEGIN { ... } runs once, before the first line    (set variables, print a header)
END { ... } runs once, after the last line        (print totals)

$ awk '{ print $1 }' sales.txt | head -3
region
north
south
$ awk '{ print $2, $3 }' sales.txt | head -3
product qty
apples 10
pears 4
$ awk 'NR > 1 && $3 > 5' sales.txt
north apples 10 1.50
north pears 7 2.25
south apples 12 1.40
$ awk '$2 == "apples"' sales.txt
north apples 10 1.50
east apples 3 1.50
south apples 12 1.40
$ awk 'BEGIN { print "start" } END { print NR, "lines" }' sales.txt
start
7 lines

Three things to remember from the start:

1. The program goes in single quotes, so the shell does not touch $1 or the braces. Fields are $1, $2 (with a dollar sign) inside awk, and the shell never sees them.
2. Fields count from 1. $0 is the whole line, $NF is the last field, $(NF-1) the one before it.
3. Comparing a field with a number compares numbers, and with a string compares strings. The section on numbers and strings has the rules.

The order of the rules matters, and all matching rules run for each line (unless one says next):

$ awk '1; NR == 2 { print "-- inserted --" }' sales.txt | head -4
region product qty price
north apples 10 1.50
-- inserted --
south pears 4 2.25
$ awk 'NR == 2 { print "-- before 2 --" } 1' sales.txt | head -3
region product qty price
-- before 2 --
north apples 10 1.50

The pattern 1 is always true, so it prints every line. It is an awk habit to put 1 last so that the line is printed after the edits made by the other rules.

The options you will use

awk [options] 'program' [file ...]
awk [options] -f program.awk [file ...]

-F fs          set the field separator (FS). -F: -F, -F'\t' -F'[,;]'. A single character other than a space is taken literally; a longer one is a regex.
-v var=value   set a variable before BEGIN runs. Escape sequences in the value are processed (\t becomes a tab).
-f file        read the program from a file. Several -f are allowed, so a library file can be combined with a program.
--             end of options (the program text follows)
var=value      between file names on the command line: set a variable when awk reaches that point
-              read standard input as a file

gawk adds many long options. The ones worth knowing: --lint (warn about doubtful code), --posix, --traditional, --sandbox, --profile, --pretty-print, --dump-variables, -i inplace (edit files), -e 'program' (give the program as text and mix it with -f), -b (count bytes, not characters). mawk has -W options: -W version, -W interactive (unbuffered output), -W posix, -W usage, -W exec file.

$ awk -F: '{ print $1, $NF }' passwd.txt | head -3
root /bin/bash
daemon /usr/sbin/nologin
ann /bin/bash
$ awk -v min=5 'NR>1 && $3 >= min { print $1, $2 }' sales.txt
north apples
north pears
south apples

The -e option is a gawk (and BusyBox) option; mawk refuses it and the original awk ignores it:

$ mawk -e 'BEGIN { print "e-works" }'
mawk: not an option: -e
$ gawk -e 'BEGIN { print "e-works" }'
e-works
$ original-awk -e 'BEGIN { print "e-works" }'
original-awk: unknown option -e ignored

Assignments between file names take effect when awk reaches them, so a variable can differ per file:

$ awk '{ print v, $1 }' v=A passwd.txt v=B sales.txt | sed -n '1p;6p'
A root:x:0:0:root:/root:/bin/bash
B region

A program from a file can start with a shebang, and then it runs like a command (see the section on awk programs).

Using - as a file name reads standard input in the middle of a list of files:

$ echo 'ann' | awk '{ print $1 "|" }' -
ann|

Fields and records

Awk splits each line into fields on runs of spaces and tabs, ignoring leading and trailing blanks. The fields are $1 to $NF, and NF is a variable you can use anywhere:

$ echo '  a   b  c ' | awk '{ print NF; print $2 }'
3
b
$ awk '{ print NF, $NF, $(NF-1) }' sales.txt | head -2
4 price qty
4 1.50 10

A field can be an expression: $(NF-1), $NF, $(i+1). The $ binds tightly, so $NF-1 means ($NF)-1, not $(NF-1):

$ echo '5 b' | awk '{ print $NF-1; print $(NF-1) }'
-1
5

Print the fields you want. A comma puts the output separator (OFS, a space) between them; no comma joins them with nothing:

$ awk '{ print $1, $3 }' sales.txt | head -2
region qty
north 10
$ awk '{ print $1 $3 }' sales.txt | head -2
regionqty
north10
$ awk '{ print NR ": " $0 }' sales.txt | head -2
1: region product qty price
2: north apples 10 1.50

Fields can be assigned to. When you change a field (or NF), awk rebuilds $0 with OFS between the fields, which is how you squeeze spaces, drop a column or change the separator:

$ echo '  a    b   c ' | awk '{ print; $1=$1; print "[" $0 "]" }'
  a    b   c
[a b c]
$ echo 'a b c' | awk '{ $2 = ""; print NF; print }'
3
a  c
$ echo 'a b c' | awk '{ $1 = ""; sub(/^ /, ""); print }'
b c
$ echo 'a b c d' | awk '{ NF = 2; print; print NF }'
a b
2
$ echo 'a b c d' | awk '{ NF--; print }'
a b c
$ echo 'a b' | awk '{ $5 = "e"; print; print NF }' | od -c | head -3
0000000   a       b               e  \n   5  \n
0000012

Assigning $2 = "" leaves the separator behind (a double space), so deleting a column needs the sub() above or a loop. Assigning to $5 when there are two fields creates $3 and $4 as empty fields, and NF becomes 5. NF = 2 and NF-- worked in gawk and in mawk.

Assigning to $0 splits the new text again:

$ echo 'a b c' | awk '{ $0 = "x y"; print NF, $2 }'
2 y

OFS is only used when awk joins fields. print $1, $2 uses it always, plain print uses it only if a field was changed:

$ echo 'a b c' | awk 'BEGIN { OFS="-" } { print $1, $2; print $1 $2; print; $3 = $3; print }'
a-b
ab
a b c
a-b-c

Print the fields in reverse order:

$ echo 'a b c' | awk '{ for (i = NF; i > 0; i--) printf "%s%s", $i, (i > 1 ? OFS : ORS) }'
c b a

In the END rule, $0 and the fields keep the last line that was read (in gawk and mawk):

$ awk 'END { print $0 }' sales.txt
east plums 0 3.10
$ printf 'a\nb\nc\n' | awk 'END { print $0, NR, NF }'
c 3 1

A negative field number is an error:

$ awk '{ print $-1 }' app.log
awk: cmd. line:1: (FILENAME=app.log FNR=1) fatal: attempt to access field -1

Field separators: FS, -F and OFS

The default separator is any run of blanks. Everything else is set with FS (the input) and OFS (the output). The -F option sets FS before the first line is read:

$ echo 'a:b:c' | awk -F: '{ print $2 }'
b
$ awk -F: '$3 >= 1000 { print $1 }' passwd.txt
ann
ed

A single character is taken literally, even if it is special in a regex, so -F. and -F'|' work:

$ printf 'a|b|c\n' | awk -F'|' '{ print $2 }'
b
$ printf 'a.b.c\n' | awk -F. '{ print $2 }'
b

Longer strings are regexes. This takes any run of digits, or a comma with spaces round it:

$ printf 'a1b22c\n' | awk -F'[0-9]+' '{ print $1 $2 $3, NF }'
abc 3
$ printf 'a, b,c ,d\n' | awk -F' *, *' '{ print $2 "|" $3 "|" $4 }'
b|c|d

A tab needs the escape, and the shortcut -Ft is not reliable:

$ printf 'a b\tc\n' | awk -F'\t' '{ print $1 "|" $2 }'
a b|c
$ printf 'atb\n' | gawk -Ft '{ print $2 }'
b
$ printf 'a\tb\n' | original-awk -Ft '{ print $2 }'
b

In gawk and mawk -Ft means the letter t (the first test split on it); the original awk treats -Ft as a tab. Write -F'\t' and the result is the same everywhere.

A single space is special: -F' ' (or the default) means any run of blanks. A single space in brackets means exactly one space, which makes empty fields:

$ printf 'a b\n' | awk -F' ' '{ print NF }'
2
$ printf 'a  b\n' | awk -F'[ ]' '{ print NF }'
3

An empty FS splits into single characters. The -F'' form is not accepted (gawk prints its usage, mawk produces nothing), so set it in BEGIN:

$ printf 'abc\n' | awk 'BEGIN{FS=""} { print NF, $2 }'
3 b

(all four awks gave 3 b for the BEGIN form.)

The classic mistake is to set FS in the main rule. It takes effect on the next line, so the first line is split with the old separator:

$ printf 'a:b\nc:d\n' | awk '{ FS=":" } { print $1 }'
a:b
c
$ printf 'a:b\nc:d\n' | awk 'BEGIN { FS=":" } { print $1 }'
a
c
$ printf 'a:b\nc:d\n' | awk -F: '{ print $1 }'
a
c

To write with the same separator as you read, set OFS too, with -v or in BEGIN. Without OFS, an edited line is joined with spaces:

$ awk -F: -v OFS=: '{ $2 = "*" } 1' <<< 'a:b:c'
a:*:c
$ awk 'BEGIN { FS = OFS = ":" } { $2 = "*" } 1' <<< 'a:b:c'
a:*:c
$ awk -F: '{ $2 = "*" } 1' <<< 'a:b:c'
a * c
$ awk -F'\t' -v OFS='\t' '{ $2 = toupper($2) } 1' <<< $'a\tb\tc'
a	B	c

When fields are defined by what they look like (CSV with quoted commas), or by position, gawk has FPAT and FIELDWIDTHS (see the gawk section). Plain -F, breaks on a comma inside quotes, in every awk:

$ mawk -F, 'NR>1 { print $3 }' data.csv
"likes tea
plain
"says ""hi"""

The first row is cut in two at the comma inside the quotes. That is the point where to stop using FS and use gawk FPAT or a real CSV tool.

Patterns: choosing the lines

A pattern in front of an action decides which lines it runs on. The kinds:

/regex/              the line matches the regex
expression           any expression that is true (non-zero, or a non-empty string)
$2 ~ /re/  !~        a field (or any string) matches or does not match a regex
pattern1, pattern2   a range: from the first line that matches pattern1 to the next that matches pattern2
BEGIN  END           before the first line, after the last
BEGINFILE ENDFILE    before and after each file (gawk only)

$ awk '/ERROR/' app.log
2026-10-17 02:00:04 ERROR pay  card declined user=kim ip=10.0.2.31
2026-10-17 02:00:06 ERROR pay  upstream timeout user=ed ip=10.0.1.12
$ awk '!/INFO/' app.log
2026-10-17 02:00:03 WARN  api  slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay  card declined user=kim ip=10.0.2.31
2026-10-17 02:00:06 ERROR pay  upstream timeout user=ed ip=10.0.1.12
$ awk '$2 ~ /^p/' sales.txt
region product qty price
south pears 4 2.25
north pears 7 2.25
east plums 0 3.10
$ awk '/north/ && /pears/' sales.txt
north pears 7 2.25
$ awk '!/north/ && !/east/' sales.txt
region product qty price
south pears 4 2.25
south apples 12 1.40

Line numbers and arithmetic work as patterns:

$ awk 'NR % 2' sales.txt | head -3
region product qty price
south pears 4 2.25
east apples 3 1.50
$ awk 'length > 22' sales.txt
region product qty price
$ awk 'NF > 3 && $4 + 0 > 2' sales.txt
south pears 4 2.25
north pears 7 2.25
east plums 0 3.10

Ranges print from the line that starts it to the line that ends it, both included. They can use line numbers or regexes, and a range may start again later in the file:

$ awk 'NR==2,NR==4' sales.txt
north apples 10 1.50
south pears 4 2.25
north pears 7 2.25
$ awk '/WARN/,/web/' app.log | cut -c1-60
2026-10-17 02:00:03 WARN  api  slow request 910ms user=ann i
2026-10-17 02:00:04 ERROR pay  card declined user=kim ip=10.
2026-10-17 02:00:05 INFO  web  GET /index.html 200 ip=10.0.3.

A range whose end never matches runs to the end of the file. If one line matches both the start and the end, the range is only that line (awk checks the end on the same line).

The header trap. A rule that works on numbers also reads the header, which is text. Here the minimum and maximum come out wrong because line 1 is the header:

$ awk 'NR==1 { min=max=$3 } $3 > max { max=$3 } $3 < min { min=$3 } END { print min, max }' sales.txt
0 qty

Skip the header with NR > 1 (or FNR > 1 with several files):

$ awk 'NR>1 && (min == "" || $3 < min) { min = $3 } NR>1 && $3 > max { max = $3 } END { print min, max }' sales.txt
0 12

The always-true trap. A bare string is true when it is not empty, so the second half of a || on a string is always true:

$ echo 'a' | awk '{ print ($1 == "z" || "b") }'
1
$ echo 'a' | awk '$1 == "z" || "b" { print "always true" }'
always true

Write each comparison in full: $1 == "z" || $1 == "b".

A string used as a regex is a regex, so the dot means any character, and a backslash has to be doubled in a string:

$ echo 'a.b' | awk '$0 ~ "." { print "dot matches anything" }  $0 ~ "\\." { print "escaped" }'
dot matches anything
escaped
$ echo 'a.b' | awk -v re='.' '$0 ~ re { n++ } END { print n }'
1

The last command counted one match, but only because "." matches any character.

Variables and the built-in ones

A variable is created by using it. It is a number or a string depending on how it is used, it starts empty (which is 0 as a number and "" as a string), and it needs no declaration:

$ awk 'BEGIN { print x + 0, "[" x "]", length(x) }'
0 [] 0
$ awk '/nomatch/ { c++ } END { print c }' sales.txt

$ awk '/nomatch/ { c++ } END { print c + 0 }' sales.txt
0
$ awk '/apples/ { c++ } END { print c + 0 }' sales.txt
3

A counter that never ran prints an empty line, so add + 0 when the answer must be a number.

The built-in variables:

NR        number of the current record, counted across all input
FNR       record number within the current file
NF        number of fields in the current record
FS  OFS   field separator for input / output (default: blanks / a space)
RS  ORS   record separator for input / output (default: newline)
FILENAME  the current file name (empty in BEGIN)
ENVIRON   array of the environment: ENVIRON["HOME"]
ARGC ARGV the command line arguments, as an array: ARGV[0] is awk
SUBSEP    joins the parts of a multi-dimensional array index
RSTART RLENGTH  set by match()
CONVFMT OFMT   number to string formats (default %.6g)

(gawk adds many more: IGNORECASE, PROCINFO, ERRNO, RT, FPAT, FIELDWIDTHS, ARGIND and others.)

Setting variables from outside. -v sets one before BEGIN, and escape sequences in the value are processed. A name=value argument sets it later:

$ awk -v n=5 'BEGIN { print n + 1, n "" 1 }'
6 51
$ awk -v 's=a\tb' 'BEGIN { print s }'
a	b
$ awk 'BEGIN { for (i = 0; i < ARGC; i++) print i, ARGV[i] }' a b=1 c
0 awk
1 a
2 b=1
3 c
$ X=hi awk 'BEGIN { print ENVIRON["X"] }'
hi

ENVIRON does not process escapes, which is why it is the safer way to pass text (see the section on shell scripts):

$ U='a\nb' awk 'BEGIN { print ENVIRON["U"] }'
a\nb
$ awk -v u='a\nb' 'BEGIN { print u }'
a
b

Numbers and strings

Awk has numbers (doubles) and strings, and it converts between them as needed. This is where most surprises come from, so the rules matter.

Fields, -v values, ENVIRON values, getline input and split() pieces that look like numbers are "numeric strings". A comparison between two numeric strings, or a numeric string and a number, is numeric. Otherwise it is a string comparison:

$ echo '10 9' | awk '{ print ($1 > $2), ("10" > "9"), ($1 > "9") }'
1 0 0
$ echo '10 abc' | awk '{ print ($1 > $2) }'
0
$ echo '10.0 ' | awk '{ print ($1 == 10), ($1 == "10"), ($1 "" == 10) }'
1 0 0
$ echo '5' | awk '{ print ($1 < 10), ($1 "" < 10) }'
1 0
$ awk 'BEGIN { x = "5"; y = "10"; print (x < y) }'
0
$ awk 'BEGIN { "echo 5" | getline x; print (x < 10) }'
1

Constants in the program, like "5", are always strings. Adding "" to a field turns it into a string, and adding + 0 turns it back into a number.

Conversions. A string becomes the number at its start, or 0:

$ echo '3 apples' | awk '{ print $0 + 0, $1 + 0 }'
3 3
$ awk 'BEGIN { a = "3x"; b = 4; print a + b, a b, a * 2 }'
7 3x4 6

Hexadecimal is not the same everywhere. mawk reads 0x1A as 26, gawk reads it as 0 (gawk's strtonum() does it, or the --non-decimal-data option):

$ echo '0x1A' | gawk '{ print $1 + 0 }'
0
$ echo '0x1A' | mawk '{ print $1 + 0 }'
26
$ gawk 'BEGIN { print strtonum("0x1A"), strtonum("017"), strtonum("12abc") }'
26 15 12

Number output. print uses OFMT (%.6g) for numbers that are not whole, so a total with decimals can come out in scientific notation. printf does not:

$ awk 'BEGIN { print 12345678.9, 1234567.891, 100000*100000, 0.1*3, 3.0 }'
1.23457e+07 1.23457e+06 10000000000 0.3 3
$ echo '1234567.891 2' | awk '{ s += $1 } END { print s; printf "%.2f\n", s }'
1.23457e+06
1234567.89

Whole numbers print in full, up to 2^53. Past that, doubles lose precision:

$ awk 'BEGIN { print 2^53, 2^53 + 1, 2^31, 123456789012 }'
9007199254740992 9007199254740992 2147483648 123456789012
$ awk 'BEGIN { print 0.1 + 0.2, (0.1+0.2 == 0.3), 1/3 }'
0.3 0 0.333333

OFMT and CONVFMT change the format of printed numbers and of number to string conversions:

$ awk 'BEGIN { OFMT="%.2f"; print 3.14159, 3.14159 ""; CONVFMT="%.2f"; print 3.14159 "" }'
3.14 3.14159
3.14

printf with %d truncates, and large values differ between awks:

$ awk 'BEGIN { printf "%.2f %d %.0f %.0f %.0f\n", 12345678.9, 3.99, 2.5, 3.5, 0.5 }'
12345678.90 3 2 4 0
$ awk 'BEGIN { printf "%d\n", 2^64 }'
18446744073709551616
$ mawk 'BEGIN { printf "%d\n", 2^64 }'
18446744073709551615
$ original-awk 'BEGIN { printf "%d\n", 2^64 }'
-9223372036854775808

(gawk, mawk and the original awk gave three different answers for the same line.) %.0f rounds halves to even: 2.5 prints 2 and 3.5 prints 4.

Arithmetic: + - * / % ^ (and ** in gawk, the original awk and not mawk). % keeps the sign of the left side, and int() cuts towards zero:

$ awk 'BEGIN { print 7%3, -7%3, int(-3.9), int(3.9), 7/2 }'
1 -1 -3 3 3.5
$ awk 'BEGIN { print -3 % 2, 2 ^ 3 ^ 2, 2 ** 3, -2 ^ 2, !0, !1, !"a", !"" }'
-1 512 8 -4 1 0 0 1

^ is right-associative (2^3^2 is 2^9), and binds tighter than a unary minus, so -2^2 is -4. The empty string and 0 are false, everything else is true. A field that holds a number is false when the number is 0 (even 0.0), but the constant string "0" in a program is a string, and a non-empty string is true:

$ echo '0.0' | awk '{ print ($1 == 0), ($1 == "0"), ($1 ? "true" : "false") }'
1 0 false
$ awk 'BEGIN { if ("0") print "const-true"; else print "const-false" }'
const-true

Division by zero is fatal in gawk and in the original awk. mawk prints inf:

$ awk 'BEGIN { x = 1/0 }'
awk: cmd. line:1: error: division by zero attempted
$ mawk 'BEGIN { x = 1/0; print x }'
inf

Concatenation has no operator, which sets a trap with a minus sign. These three lines show it:

$ awk 'BEGIN { print 1 " " -1; print 1 -1; print 1 " " (-1) }'
1-1
0
1 -1

The first line printed 1-1 (the minus bound as a subtraction on the string), the second is plain subtraction, and parentheses give what was meant.

BusyBox awk (this build) has no math support: the ^ operator and sin() fail with "Math support is not compiled in", while int() and length() work.

printf and output: print, files, pipes and system()

print adds the output separators (OFS between items, ORS at the end). printf prints exactly what the format says, with no newline added:

printf format, value, value ...
%s string   %d integer   %i integer   %f decimal   %e scientific   %x hex   %o octal   %c character   %% a percent sign
%-15s left-align in 15 columns   %8.2f width 8, 2 decimals   %05.1f zero-pad   %*d width taken from an argument

$ awk 'NR>1 { printf "%-6s %-7s %3d %6.2f\n", $1, $2, $3, $3*$4 }' sales.txt
north  apples   10  15.00
south  pears     4   9.00
north  pears     7  15.75
east   apples    3   4.50
south  apples   12  16.80
east   plums     0   0.00
$ awk 'BEGIN { s = sprintf("%05.1f|%-5s|%5s|%x|%o|%c|%e", 3.14159, "ab", "cd", 255, 8, 65, 12345.678); print s }'
003.1|ab   |   cd|ff|10|A|1.234568e+04
$ awk 'BEGIN { printf "%5.1f%%\n", 45.678 }'
 45.7%
$ awk 'BEGIN { printf "%*d|%-*d|\n", 5, 42, 5, 42 }'
   42|42   |

%c prints the character with that number, or the first character of a string:

$ awk 'BEGIN { printf "%c%c|\n", "hello", 66 }'
hB|

Never use the data as the format. A % in the line is taken as a format, which breaks or prints the wrong thing:

$ echo '50% done' | awk '{ printf $0 "\n" }'
awk: cmd. line:1: (FILENAME=- FNR=1) fatal: not enough arguments to satisfy format string
$ echo '50% done' | awk '{ printf "%s\n", $0 }'
50% done

Too few arguments is fatal in gawk, and a run-time error in mawk:

$ mawk 'BEGIN { printf "%s %s\n", "only-one" }'
mawk: run time error: not enough arguments passed to printf("%s %s
")

Redirection. print > "file" truncates the file once and then keeps it open, appending; >> appends to an existing file; | sends the output to a command. The target is an expression, so a file per group is easy:

$ awk 'NR>1 { print > ("/tmp/by-" $1 ".txt") }' sales.txt; cat /tmp/by-east.txt
east apples 3 1.50
east plums 0 3.10
$ awk 'NR>1 { print $2 | "sort -u" }' sales.txt
apples
pears
plums
$ awk 'BEGIN { print "to stderr" > "/dev/stderr" }' 2>/dev/null; echo '(hidden)'
(hidden)

close() ends a file or a command. It is needed to read a file back, to run the same command again, and to make a pipe finish (a sort prints only when its input is closed) before the next line of output:

$ awk 'NR==1 { print; next } { print | "sort -k2" } END { close("sort -k2"); print "-- total rows", NR-1 }' sales.txt
region product qty price
north apples 10 1.50
south apples 12 1.40
east apples 3 1.50
south pears 4 2.25
north pears 7 2.25
east plums 0 3.10
-- total rows 6

The > trap. In print a > b the > is a redirection, so this wrote a file called 2, not a comparison. Put the comparison in parentheses:

$ awk 'BEGIN { print 1 > 2 }'; ls 2 && cat 2; rm -f 2
2
1
$ awk 'BEGIN { print (1 > 2) }'
0

system() runs a shell command and returns its exit status. awk's own buffered output may come out after the command's, so flush first when the order matters:

$ awk 'BEGIN { r = system("exit 3"); print r }'
3
$ awk 'BEGIN { printf "x"; fflush(); system("echo from-shell") }' | cat
xfrom-shell

Buffering. When awk's output goes to a pipe (tail -f log | awk ... | next), it is held in a buffer, and the next program sees nothing until the buffer fills. The lab sent "a", waited two seconds, sent "b", and printed when each line arrived at the other end:

program                          a arrived    b arrived
mawk '{ print }'                 +2s          +2s        (buffered)
gawk '{ print }'                 +2s          +2s        (buffered)
mawk -W interactive '{ print }'  +0s          +2s
mawk '{ print; fflush() }'       +2s          +3s        (the first line still waited)
gawk '{ print; fflush() }'       +0s          +2s
original-awk '{ print }'         +2s          +2s        (buffered)
busybox awk '{ print }'          +0s          +2s

Use fflush() after print in gawk, mawk -W interactive, or stdbuf-like tools when a stream must show up at once.

Regular expressions in awk

awk uses POSIX extended regular expressions (ERE): + ? | ( ) { } are special without a backslash, and a literal one needs \ or brackets. A regex goes between slashes, or in a string (a dynamic regex).

.  any character      *  zero or more      +  one or more      ?  zero or one      |  or
^  start of the string      $  end      [abc]  a set      [^abc]  not in the set      ( )  group      {n,m}  repeat

$ echo 'aaa' | awk '/a{3}/ { print "yes" } !/a{4}/ { print "no4" }'
yes
no4
$ echo 'foo123' | awk '{ print match($0, /[0-9]+/), RSTART, RLENGTH, substr($0, RSTART, RLENGTH) }'
4 4 3 123

The {n} interval worked in all four awks of the lab. Character classes such as [[:digit:]], [[:space:]], [[:alpha:]], [[:upper:]] and [[:punct:]] work everywhere:

$ printf 'a b9\n' | mawk '{ print match($0, /[[:digit:]]/), match($0, /(a|b)+9/) }'
4 3

The shortcuts differ. The lab ran match() with each:

shortcut       gawk     mawk     original-awk     busybox
\s  \w         yes      no       no               yes
\y (word edge) yes      no       no               no
\d             no       no       no               no

gawk warns that \d is not a known operator. Use [[:space:]], [[:alnum:]_] and [[:digit:]] and the line runs on every awk. There are no backreferences in the pattern and no lookahead (ERE), in any of them:

$ printf 'abab\n' | gawk '{ print match($0, /(ab)\1/) }'
0

(0 means no match: \1 was not treated as a repeat.) The same line gave 0 in mawk, the original awk and busybox awk.

Case. Only gawk has IGNORECASE (and BusyBox, in the lab). The portable way is to lower-case the text:

$ echo 'Foo' | gawk 'BEGIN{IGNORECASE=1} /foo/ { print "ci" }'
ci
$ echo 'Foo' | mawk 'BEGIN{IGNORECASE=1} /foo/ { print "ci" } END{print "done"}'
done
$ echo 'Foo' | awk 'tolower($0) ~ /foo/ { print "tolower ok" }'
tolower ok

Dynamic regex. A string on the right of ~ is turned into a regex, so backslashes are processed twice: once as a string, once as a regex. Write \\. for a literal dot:

$ echo 'a.b' | awk '$0 ~ "\\." { print "escaped" }'
escaped
$ awk 'BEGIN { s = "a.b.c"; n = gsub(".", "-", s); print n, s }'
5 -----
$ awk 'BEGIN { s = "a.b.c"; n = gsub(/\./, "-", s); print n, s }'
2 a-b-c
$ awk 'BEGIN { s = "a.b.c"; n = gsub("[.]", "-", s); print n, s }'
2 a-b-c

In the first of the three, the dot in the string was a regex and replaced every character. A bracket, [.], is the easiest way to write a literal special character in a string, and index() finds plain text with no regex at all:

$ awk 'BEGIN { s = "a.b.c"; i = index(s, "."); print substr(s, 1, i-1), substr(s, i+1) }'
a b.c

match() returns the position (0 for none) and sets RSTART and RLENGTH. Only gawk can return the groups, with a third argument:

$ echo 'foo123bar45' | gawk '{ match($0, /([a-z]+)([0-9]+)/, m); print m[1], m[2] }'
foo 123
$ echo 'foo123bar45' | mawk '{ match($0, /([a-z]+)([0-9]+)/, m); print m[1] }'
mawk: line 1: syntax error at or near ,

Without gawk, take the text with substr(): substr($0, RSTART, RLENGTH).

sub() and gsub() change the first match or all matches, in $0 or in a variable, and return how many they changed. In the replacement, & stands for the matched text, and a literal ampersand is written \\& inside the awk string:

$ echo 'a b c' | awk '{ n = gsub(/ /, "_"); print n, $0 }'
2 a_b_c
$ echo 'hello' | awk '{ sub(/l+/, "[&]"); print }'
he[ll]o
$ echo 'hello' | awk '{ sub(/l+/, "[\\&]"); print }'
he[&]o
$ echo 'abc' | awk '{ gsub(//, "-"); print }'
-a-b-c-
$ echo 'a b' | awk '{ gsub(/[[:space:]]+/, ""); print }'
ab

Only gawk has gensub(), which can pick the nth match and use groups (\1) in the replacement:

$ echo 'a-b-c' | gawk '{ print gensub(/-/, "+", 2) }'
a-b+c

String functions

length(s)             number of characters (of $0 if there is no argument; of an array in all four awks of the lab)
substr(s, start, n)   the part of s from start (counting from 1), n characters long, or to the end
index(s, t)           position of t in s, 0 if not found (plain text, no regex)
split(s, a, sep)      split s into the array a on sep (a regex, or a single character); returns the count
sub(re, repl, s)  gsub(...)   replace the first / every match in s (default $0)
match(s, re)          position of the first match, sets RSTART and RLENGTH
sprintf(fmt, ...)     like printf, returns the string
tolower(s)  toupper(s)

$ awk 'BEGIN { print index("hello", "ll"), index("hello", "z"), length("hello"), toupper("abc") tolower("DEF") }'
3 0 5 ABCdef
$ awk '{ print toupper(substr($1,1,1)) substr($1,2) }' <<< 'word'
Word
$ awk 'BEGIN { print length(12345), length(1/3), substr(123456, 2, 3), index(12345, 34) }'
5 8 234 3

substr(): the start counts from 1. A start below 1, or a length that runs past the end, is handled differently by each awk. Keep the start at 1 or more when the code has to be portable:

$ awk 'BEGIN { print substr("hello", 2, 3), substr("hello", 2), substr("hello", 2, 100) }'
ell ello ello

substr("hello", 0, 2) and substr("hello", -1, 3), the lab output of each awk:

awk             substr("hello", 0, 2)    substr("hello", -1, 3)
mawk            he                       hell
gawk            he                       hel
original-awk    he                       hel
busybox awk     he                       hel

split() fills an array (numbered from 1) and returns how many pieces. A single space as separator means any run of blanks, a regex is allowed, an empty string gives no pieces, and an empty separator splits into characters:

$ awk 'BEGIN { n = split("a,b,c", arr, ","); print n, arr[1], arr[3] }'
3 a c
$ awk 'BEGIN { n = split("a1b22c", arr, /[0-9]+/); print n, arr[1], arr[2], arr[3] }'
3 a b c
$ awk 'BEGIN { n = split("  a  b ", arr); print n, arr[1] }'
2 a
$ awk 'BEGIN { n = split("", arr); print n, length(arr) }'
0 0
$ echo 'abc' | mawk '{ n = split($0, ch, ""); print n, ch[2] }'
3 b

The last form split abc into characters in mawk, gawk, the original awk and BusyBox.

Parsing a date or a key=value pair is split() plus array use:

$ echo 'k=v; x=y' | awk -F'[=;] *' '{ print $1, $2, $3, $4 }'
k v x y

Other functions: int(x), sqrt(x), exp(x), log(x), sin(x), cos(x), atan2(y, x), rand(), srand(). rand() is the same sequence on every run in gawk, and different on every run in mawk (it seeds itself from the clock):

$ gawk 'BEGIN { print rand(), rand() }'; gawk 'BEGIN { print rand() }'
0.924046 0.593909
0.924046
$ mawk 'BEGIN { print rand() }'; mawk 'BEGIN { print rand() }'
0.0146918
0.833972
$ awk 'BEGIN { srand(1); a = rand(); srand(1); print (a == rand()) }'
1

Call srand(seed) with a fixed seed for a repeatable sequence on every awk, and srand() (no argument) when you want a different one each run.

Time. gawk has systime(), strftime() and mktime(), and mawk 1.3.4 and BusyBox have them as well; the original awk does not. The result of strftime depends on the time zone unless you pass 1 as the third argument for UTC:

$ gawk 'BEGIN { print strftime("%Y-%m-%d %H:%M", 1760659200, 1) }'
2025-10-17 00:00
$ mawk 'BEGIN { print strftime("%Y-%m-%d", 1760659200, 1); print (systime() > 1700000000) }'
2025-10-17
1

Arrays

Arrays in awk are associative: the index is a string (a number is converted to its string), and the array grows as you use it. There is no declaration.

$ awk 'NR>1 { n[$2]++ } END { for (p in n) print p, n[p] }' sales.txt | sort
apples 3
pears 2
plums 1
$ awk 'NR>1 { sum[$1] += $3 * $4 } END { for (r in sum) print r, sum[r] }' sales.txt | sort
east 4.5
north 30.75
south 25.8

The for (key in array) order is not defined. gawk happened to give 1 2 3 4 5 and mawk gave 3 5 2 1 4 for the same array, so sort the output (above) or use gawk's sorting:

$ gawk 'BEGIN { for (i=1;i<=5;i++) a[i]=i; for (k in a) printf "%s ", k; print "" }'
1 2 3 4 5
$ mawk 'BEGIN { for (i=1;i<=5;i++) a[i]=i; for (k in a) printf "%s ", k; print "" }'
3 5 2 1 4
$ gawk 'BEGIN { a["b"]=1; a["a"]=2; a["c"]=3; PROCINFO["sorted_in"]="@ind_str_asc"; for (k in a) printf "%s ", k; print "" }'
a b c
$ gawk 'BEGIN { a["x"]=3; a["y"]=1; a["z"]=2; PROCINFO["sorted_in"]="@val_num_desc"; for (k in a) printf "%s=%s ", k, a[k]; print "" }'
x=3 z=2 y=1
$ gawk 'BEGIN { n=split("30 5 100", a); asort(a); for (i=1;i<=n;i++) printf "%s ", a[i]; print "" }'
5 30 100
$ mawk 'BEGIN { n=split("30 5 100", a); asort(a) }'
mawk: line 2: function asort never defined

On mawk, print the lines to a pipe and let sort do it: print k, a[k] | "sort -k2 -nr".

Testing for a key. The in operator tests without creating the key, but reading a missing element creates it (as an empty string), which changes length() and what a later loop sees:

$ awk 'BEGIN { a["x"]=1; print ("x" in a), ("y" in a), length(a); if (a["y"] == "") print "y read"; print ("y" in a), length(a) }'
1 0 1
y read
1 2

Use if (k in a) when you only want to ask. delete removes one element, delete a removes all of them (this form is in gawk, mawk, the original awk and BusyBox):

$ awk 'BEGIN { a[1]=1; a[2]=2; delete a[1]; print length(a); delete a; print length(a) }'
1
0

The index is a string, so "01" and 1 are different keys. A number is converted to a string before it is used, so the number 1.0 is the same key as 1, but the string "1.0" is not:

$ awk 'BEGIN { a["01"]=1; a[1]=2; print length(a) }'
2
$ awk 'BEGIN { a[1]="x"; print (1.0 in a), ("1.0" in a) }'
1 0

Multi-dimensional arrays join the parts with SUBSEP (the character \034). Test with (i,j) in a:

$ awk 'BEGIN { a[1,2]="x"; for (k in a) { split(k, p, SUBSEP); print p[1], p[2] }; if ((1,2) in a) print "in" }'
1 2
in

Useful patterns.

Remove duplicates, keeping the first and the order:

$ awk '!seen[$2]++' sales.txt
region product qty price
north apples 10 1.50
south pears 4 2.25
east plums 0 3.10

Reverse the lines (like tac):

$ awk '{ a[NR]=$0 } END { for (i=NR;i>0;i--) print a[i] }' sales.txt | head -2
east plums 0 3.10
south apples 12 1.40

Transpose rows and columns (ids.txt has three rows of two columns):

$ awk '{ for (i=1;i<=NF;i++) m[i,NR]=$i; if (NF>nf) nf=NF } END { for (i=1;i<=nf;i++) { row=m[i,1]; for (j=2;j<=NR;j++) row=row OFS m[i,j]; print row } }' ids.txt
ann ed zed
1000 1001 1

Count by key, then sort by the count:

$ awk '{ n[$1]++ } END { for (k in n) print n[k], k }' access.log | sort -rn | head -3
3 10.0.1.11
2 10.0.3.5
1 10.0.2.31

An array passed to a function is passed by reference, so the function can fill it (see the section on functions). Storing every line of a file in an array uses memory in proportion to the file (see speed).

Control flow, functions and getline

Statements are separated by a newline or a semicolon, and braces group them. The control statements are the same as in C:

if (cond) stmt else stmt       while (cond) stmt       do stmt while (cond)
for (init; cond; step) stmt    for (key in array) stmt
break   continue   next (skip to the next record)   nextfile (skip to the next file)   exit [status]   return value

$ awk '{ if ($3 > 5) print $1, "high"; else print $1, "low" }' sales.txt | head -3
region high
north high
south low
$ awk 'BEGIN { for (i = 1; i <= 5; i++) { if (i == 2) continue; if (i == 4) break; print i } }'
1
3
$ awk 'BEGIN { while (i++ < 3) print i; do { j++ } while (j < 5); print j; for (;;) { if (++k > 2) break }; print k }'
1
2
3
5
3

next ends the work on this record. It is how you write "skip header lines" or "handle this case and nothing else":

$ awk 'NR % 2 == 0 { next } 1' sales.txt | head -2
region product qty price
south pears 4 2.25

exit stops the program. END still runs, and exit inside END stops at once. The status is that of the last exit, so a script can test for a match:

$ awk '{ exit 2 } END { print "END still runs"; exit }' app.log; echo rc=$?
END still runs
rc=2
$ awk 'BEGIN { exit } END { print "END after BEGIN exit" }'
END after BEGIN exit
$ awk '/ERROR/ { found=1 } END { exit !found }' app.log; echo rc=$?
rc=0
$ awk '/NOPE/ { found=1 } END { exit !found }' app.log; echo rc=$?
rc=1

An exit status in an END with no value keeps the earlier one (the first example kept 2). Use it as grep does, to use awk in an if.

User-defined functions. A function can be defined anywhere in the program. The parameters are local and passed by value, except arrays, which are passed by reference. Extra parameters work as local variables, and the convention is to write them after a gap:

$ awk 'function max(a, b) { return a > b ? a : b } BEGIN { print max(3, 9), max("abc", "abd") }'
9 abd
$ awk 'function f(n,   i, s) { for (i=1;i<=n;i++) s = s i; return s } BEGIN { i="outer"; print f(3), i }'
123 outer
$ awk 'function f(n,   i) { for (i=1;i<=n;i++) ; return i } BEGIN { i="outer"; print f(3), i }'
4 outer
$ awk 'function fill(arr) { arr["k"]="v" } BEGIN { fill(z); print z["k"] }'
v
$ awk 'function f(x) { x = 5 } BEGIN { y = 1; f(y); print y }'
1
$ awk 'function fact(n) { return n<=1 ? 1 : n*fact(n-1) } BEGIN { print fact(10) }'
3628800

A variable used in a function without being a parameter is global, so it can overwrite the caller's. Declare the locals as extra parameters. Recursion works. func is a gawk, original-awk and BusyBox synonym for function (not mawk).

Calling a function: no space is allowed between the name and the bracket, and the awks disagree:

$ awk 'function f(x) { return x } BEGIN { print f (1) }'
awk: cmd. line:1: error: function `f' called with space between name and `(',
or used as a variable or an array
$ mawk 'function f(x) { return x } BEGIN { print f (1) }'
1
$ original-awk 'function f(x) { return x } BEGIN { print f (1) }'
original-awk: can't read value of f; it's a function.

getline reads input from somewhere other than the main loop. The forms:

getline                 the next record into $0 (updates NR, NF)
getline var             the next record into var
getline < "file"        the next line of a file into $0
getline var < "file"    ... into var
"command" | getline     the next line of a command's output into $0
"command" | getline var

$ awk 'NR==1 { getline; print "after getline:", $0, NR }' sales.txt
after getline: north apples 10 1.50 2
$ awk 'BEGIN { while ((getline line < "passwd.txt") > 0) n++; print n }'
5
$ awk 'BEGIN { "date +%Y" | getline y; close("date +%Y"); print (y > 2000) }'
1
$ awk 'BEGIN { while (("echo a; echo b" | getline x) > 0) print "got", x }'
got a
got b

getline returns 1 for a line, 0 at the end and -1 on an error. The test must be > 0. A file that does not exist returns -1 every time, so while (getline line < "file") never ends. The lab stopped it with timeout after 3 seconds:

$ awk 'BEGIN { r = (getline line < "nonexistent"); print r }'
-1
$ timeout 3 awk 'BEGIN { while (getline line < "nofile") n++; print n }' ; echo "rc=$?"
rc=124
$ timeout 3 awk 'BEGIN { while ((getline line < "nofile") > 0) n++; print n + 0 }'; echo rc=$?
0
rc=0

A command or file stays open after it is read, so a second read of the same command returns nothing until close() is called:

$ awk 'BEGIN { cmd = "echo hi"; cmd | getline a; cmd | getline b; print a "|" b "|" }'
hi||
$ awk 'BEGIN { cmd = "echo hi"; cmd | getline a; close(cmd); cmd | getline b; print a "|" b }'
hi|hi

Several files: NR, FNR, joins and ranges of input

NR keeps counting across files and FNR starts again for each one. FILENAME is the file being read. When there is more than one file, the two lets you tell them apart:

$ printf 'ann 1000\ned 1001\nzed 1\n' > ids.txt
$ printf 'ann Ann Lee\ned Ed Kay\nkim Kim Fox\n' > names.txt
$ awk 'FNR==1 { print FILENAME }' ids.txt names.txt
ids.txt
names.txt

The join idiom. NR==FNR is true only while the first file is being read, so the first file fills an array and next skips the rest of the rules; the second file then looks things up:

$ awk 'NR==FNR { id[$1]=$2; next } $1 in id { print $1, id[$1], $2 }' ids.txt names.txt
ann 1000 Ann
ed 1001 Ed

Lines in the second file with no match in the first (an anti-join):

$ awk 'NR==FNR { seen[$1]; next } !($1 in seen)' ids.txt names.txt
kim Kim Fox

The empty file trap. If the first file is empty, NR and FNR stay equal while the second file is read, so the second file is taken as the first one. The join then printed nothing at all, with no error:

$ : > empty.txt
$ awk 'NR==FNR { a[$1]; next } { print FILENAME, $1, ($1 in a) }' empty.txt ids.txt
$ awk 'FILENAME == ARGV[1] { a[$1]; next } { print FILENAME, $1, ($1 in a) }' empty.txt ids.txt
ids.txt ann 0
ids.txt ed 0
ids.txt zed 0

Comparing FILENAME with ARGV[1] is safe when the first file may be empty. gawk also has ARGIND (the index of the current file), and it gave the same result:

$ gawk 'ARGIND == 1 { a[$1]; next } { print FILENAME, $1, ($1 in a) }' empty.txt ids.txt
ids.txt ann 0
ids.txt ed 0
ids.txt zed 0

(the lines are not in the empty file, so each answer is 0.)

nextfile stops reading the current file and goes on with the next (gawk, mawk, the original awk and BusyBox all took it):

$ awk 'FNR==2 { nextfile } { print FILENAME, $1 }' ids.txt names.txt
ids.txt ann
names.txt ann

gawk has BEGINFILE and ENDFILE, which run before and after each file, and ERRNO to detect a file that cannot be opened. That is how to skip a missing file without an error:

$ gawk 'BEGINFILE { print "start", FILENAME } ENDFILE { print "end", FILENAME, FNR }' ids.txt names.txt
start ids.txt
end ids.txt 3
start names.txt
end names.txt 3
$ gawk 'BEGINFILE { if (ERRNO) { print "skip", FILENAME; nextfile } } END { print NR }' ids.txt nope.txt names.txt 2>&1
skip nope.txt
6

Without BEGINFILE a missing file is fatal in awk, with status 2 (not in BusyBox, which gives 1):

$ awk '{ print }' no-such-file; echo rc=$?
awk: fatal: cannot open file `no-such-file' for reading: No such file or directory
rc=2

A file name that contains an equal sign. A command line argument name=value is an assignment, not a file, so awk never opens a file called x=1 and, with no other file, reads standard input. Add a path to make it a file:

$ printf 'l1\n' > 'x=1'
$ awk '{ print "x is", x, $0 }' x=1 'x=1'
$ awk '{ print "x is", x, $0 }' ./x=1
x is  l1

The first command printed nothing: both arguments were assignments, so awk waited for input. Use ./ in front of names that come from a variable or a glob.

Records, separators and real-world input: RS, CSV, CRLF, Unicode

RS is the record separator, newline by default. Setting it to an empty string puts awk in paragraph mode: records are blocks separated by blank lines, and newline becomes a field separator as well as the blanks:

$ printf 'a b\nc\n\nd e\nf\n' | awk 'BEGIN { RS="" } { print NR": "$1 "-" $2 "-" $3 }'
1: a-b-c
2: d-e-f
$ printf 'a b\nc\n\nd e\nf\n' | awk 'BEGIN { RS=""; FS="\n" } { print NR": "$1 " / NF=" NF }'
1: a b / NF=2
2: d e / NF=2

A single character is a separator. The last record keeps a trailing newline when the data ends with one:

$ printf 'a;b;c' | awk 'BEGIN { RS=";" } { print NR, $0 }'
1 a
2 b
3 c
$ printf 'a;b;c\n' | awk 'BEGIN { RS=";" } { print NR"["$0"]" }'
1[a]
2[b]
3[c
]

A longer RS is a regex in gawk, mawk, the original awk and BusyBox in the lab. Only gawk (and BusyBox) tell you what text matched, with RT:

$ printf 'a12b345c' | gawk 'BEGIN { RS="[0-9]+" } { print NR, $0, RT }'
1 a 12
2 b 345
3 c
$ printf 'a12b345c' | mawk 'BEGIN { RS="[0-9]+" } { print NR, $0 }'
1 a
2 b
3 c

ORS and OFS are the output side:

$ printf 'x y\n' | awk 'BEGIN { ORS="|"; OFS="-" } { print $1, $2 } END { printf "\n" }'
x-y|

Windows line ends (CRLF). The carriage return stays at the end of the last field, and a comparison with that field silently fails. Remove it with sub(), or set RS to a regex in gawk or mawk:

$ printf 'a b\r\n' | awk '{ print $2 "|" }' | od -c | head -2
0000000   b  \r   |  \n
0000004
$ printf 'a b\r\n' | awk '{ sub(/\r$/, ""); print $2 "|" }'
b|
$ printf 'a b\r\n' | gawk 'BEGIN{RS="\r?\n"} { print $2 "|" }'
b|
$ printf 'a b\r\n' | mawk 'BEGIN{RS="\r?\n"} { print $2 "|" }' | od -c | head -2
0000000   b   |  \n
0000003

CSV. A comma in a quoted field breaks -F, (shown in the section on separators). gawk can describe what a field looks like with FPAT:

$ gawk 'BEGIN { FPAT = "([^,]*)|(\"[^\"]+\")" } NR>1 { print $2 "|" $3 "|" $4 }' data.csv
ann|"likes tea, hot"|10
ed|plain|20
kim|"says ""hi"""|30

That keeps the quotes on the field and does not understand a doubled quote inside a field, which is why it is for simple files. For real CSV (embedded newlines, escaped quotes) use a CSV tool such as csvkit, Miller or Python's csv module (none run in this lab).

Fixed-width files. gawk has FIELDWIDTHS:

$ gawk 'BEGIN { FIELDWIDTHS="3 2 *" } { print $1 "|" $2 "|" $3 }' fw.txt
abc|de|fghij

(fw.txt is the line abcdefghij.) With mawk use substr($0, 1, 3) and so on.

Unicode. mawk and BusyBox count bytes, while gawk and the original awk count characters when the locale is UTF-8. The lab string was héllo (5 characters, 6 bytes); the container had no LANG set, so each command names its locale:

command                                        length
LC_ALL=C.UTF-8 gawk                            5   (toupper gave HÉLLO, substr($0,2,1) gave é)
LC_ALL=C.UTF-8 mawk                            6   (toupper gave HéLLO: the é was not changed)
LC_ALL=C gawk                                  6
LC_ALL=C.UTF-8 gawk -b                         6
LC_ALL=C.UTF-8 original-awk                    5
LC_ALL=C.UTF-8 busybox awk                     6

In the lab's default (no LANG) gawk also gave 6. So on a server with a plain locale, or with mawk, length() and substr() work in bytes, and a cut in the middle of a multi-byte character produces broken text. Use gawk with a UTF-8 locale for text with accents.

gawk extras, and what mawk does not have

Many guides use gawk features without saying so. On Ubuntu's default mawk they give an error or do nothing. What the lab found:

feature                          gawk    mawk    original-awk   busybox
length(array), delete array      yes     yes     yes            yes
nextfile, fflush()               yes     yes     yes            yes
systime(), strftime(), mktime()  yes     yes     no             yes
toupper/tolower                  yes     yes     yes            yes
\s and \w in a regex             yes     no      no             yes
gensub()                         yes     no      no             yes
match(s, re, array)              yes     no      no             no
asort(), asorti(), PROCINFO      yes     no      no             no
IGNORECASE                       yes     no      no             yes
switch / case                    yes     no      no             no
BEGINFILE / ENDFILE              yes     no      no             no
strtonum(), typeof()             yes     no      no             no
and(), or(), xor(), lshift()     yes     no      no             yes (and)
RT, FPAT, FIELDWIDTHS            yes     no      no             RT yes
** (power)                       yes     no      yes            no
printf %*d                       yes     yes     yes            no
func (for function)              yes     no      yes            yes
RS as a regex                    yes     yes     yes            yes
-e 'program', @include           yes     no      no             -e yes

Working examples of the gawk-only features:

$ gawk 'BEGIN { print and(12, 10), or(12, 10), xor(12, 10), lshift(1, 4), 2 ** 3 }'
8 14 6 16 8
$ gawk 'BEGIN { switch (3) { case 1: print "one"; break; case 3: print "three"; break; default: print "other" } }'
three
$ gawk 'BEGIN { x[1]; print typeof(x), typeof(1), typeof("a") }'
array number string
$ gawk 'BEGIN { print typeof(y) }'
untyped
$ gawk -f inc.awk                    (inc.awk is: @include "lib.awk" and a BEGIN rule that calls f(21))
42
$ mawk -f inc.awk
mawk: 1: unexpected character '@'

Edit a file in place (gawk 4.1 and later), with an optional backup. The in-place mode is a plug-in, loaded with -i:

$ printf 'one old\ntwo old\n' > ip.txt
$ gawk -i inplace '{ gsub(/old/, "new") } 1' ip.txt; cat ip.txt
one new
two new
$ gawk -i inplace -v inplace::suffix=.bak '{ gsub(/new/, "newer") } 1' ip.txt; cat ip.txt ip.txt.bak
one newer
two newer
one new
two new

Put the rule 1 at the end (it prints the line). Without it nothing is printed, and the file is rewritten empty, as with sed -n -i: the lab left a 0-byte file. A file that does not exist is an error (rc=2) and nothing is created. mawk has no -i, so with it use a temporary file: awk '...' file > file.tmp && mv file.tmp file (this replaces the file and its permissions; see the sed guide's section on -i).

Time and dates:

$ gawk 'BEGIN { t = mktime("2026 10 17 12 30 00"); print (t > 0) }'
1
$ mawk 'BEGIN { print mktime("2026 10 17 12 30 00") }'
1792240200

(mktime uses the local time zone of the machine; the lab was on UTC.)

Tools that help to see what a program does (gawk only):

--lint                 warns about code that is probably wrong
--profile              writes a copy of the program with a run count by each line
--pretty-print         writes the program neatly formatted
--dump-variables       prints every variable at the end
--sandbox              blocks system(), pipes, redirection and getline from files (see the shell scripts section)
--posix, --traditional switch off the gawk extras so that you can check that code is portable

$ gawk --posix 'BEGIN { print toupper("a"); print gensub(/a/, "b", 1, "a") }'
gawk: cmd. line:1: warning: regexp constant for parameter #1 yields boolean value
A
gawk: cmd. line:1: fatal: function `gensub' not defined
$ gawk --traditional 'BEGIN { print systime() }'
gawk: cmd. line:1: fatal: function `systime' not defined

--posix and --traditional are a quick way to find out whether a script uses an extension: they stop at the first one. A script that is meant to run on mawk can be tested by running it with mawk itself.

Awk programs in files, and scripts

When a program is longer than a line, put it in a file. Comments start with #, newlines separate statements (a trailing backslash continues a line, and so does a line ending in a comma, &&, || or {), and a program may start with a shebang:

$ cat report.awk
#!/usr/bin/awk -f
# sales report: totals per region, then a grand total
BEGIN {
    printf "%-8s %5s %9s\n", "region", "units", "revenue"
}
NR > 1 {
    units[$1]   += $3
    revenue[$1] += $3 * $4
    total       += $3 * $4
}
END {
    for (r in units)
        printf "%-8s %5d %9.2f\n", r, units[r], revenue[r] | "sort"
    close("sort")
    printf "%-8s %5s %9.2f\n", "total", "", total
}
$ chmod +x report.awk; ./report.awk sales.txt
region   units   revenue
east         3      4.50
north       17     30.75
south       16     25.80
total              61.05
$ awk -f report.awk sales.txt | tail -1
total              61.05

The header is printed by BEGIN, the rows are summed by one rule that skips line 1, and END prints the groups through sort (a pipe) and then the total. The close("sort") makes sort finish before the total line is printed, so the total comes last.

Run a program from a file against several inputs with the same command, or keep a library of functions and combine files with several -f options (the -f lib.awk -f prog.awk form worked; -e is the gawk way to mix a file with text):

$ awk -f lib.awk -f prog.awk x
from script x
$ gawk -f lib.awk -e 'BEGIN { print f(4) }'
8

Statements and line breaks. A newline ends a statement, and so does a semicolon. Awk allows a newline after else, after && and || and after a comma, but not in the middle of an expression, so a long condition continues with a backslash or after an operator:

$ awk 'BEGIN { print 1 +\
2 }'
3
$ awk 'BEGIN { if (1) print "a"
else print "b" }'
a

Writing a script is also a chance to use the syntax that does not fit on one line: a function with local variables, a multi-line pipeline into sort, arrays of arrays (gawk) and so on.

Passing the file name to the program, rather than the other way round, is useful in shell loops:

$ for f in sales.txt; do awk -v name="$f" 'END { print name, NR }' "$f"; done
sales.txt 7

Using awk safely in shell scripts

Most trouble comes from where the data goes: into the program text, into a command, or into a file name. The lab ran each of these.

1. Keep the program in single quotes, and pass values with -v or the environment. If you use double quotes the shell expands $1 and the program is not what you wrote:

$ echo 'ann' | awk '{ print $1 }'
ann
$ echo 'ann' | awk "{ print $1 }"
awk: cmd. line:1: { print echo 'ann' | awk "{ print $1 }" }
awk: cmd. line:1:              ^ invalid char ''' in expression

(in that second command the shell replaced $1 with its own first argument, which was the command's text, so awk received a broken program.)

2. -v processes backslashes in the value, and ENVIRON does not. A value with \n in it is turned into a newline by -v. Use the environment for text you do not control:

$ U='a\nb'; awk -v u="$U" 'BEGIN { print u }'
a
b
$ U='a\nb' awk 'BEGIN { print ENVIRON["U"] }'
a\nb
$ U='ann'; awk -v u="$U" '$1 == u' ids.txt
ann 1000

3. Never put data into a command that awk runs. system() and "cmd" | getline and print | "cmd" all go through the shell. A line of a log or a file that contains a semicolon becomes code:

$ cat evil.txt
x; touch /tmp/pwned
ok
$ awk '{ system("echo " $0) }' evil.txt; ls /tmp/pwned
x
ok
/tmp/pwned
$ awk '{ print "echo", $0 | "sh" }' evil.txt >/dev/null; ls /tmp/pwned
/tmp/pwned

Both ran touch. Do not build commands from data. If you must, quote it (this is not shown in the lab and must be done with care), or use shell tools that take the data as arguments, such as xargs with -0 or -d '\n' and a quoted "$@".

4. A file name taken from data can point anywhere. The target of a redirection is whatever the field holds:

$ cat names2.txt
/tmp/owned-by-awk data
ok data
$ awk '{ print $2 > $1 }' names2.txt; ls -l /tmp/owned-by-awk ok
-rw-r--r-- 1 ubuntu ubuntu 5 Oct 18 00:58 /tmp/owned-by-awk
-rw-r--r-- 1 ubuntu ubuntu 5 Oct 18 00:58 ok

Build the name from a fixed directory and a checked part, for example print > ("out-" $1), and reject a name with a slash first: $1 !~ /\// . (With a slash in the data, awk looked for out-/tmp/owned-by-awk and stopped with an error, which is also what you want, but do not depend on it.)

5. gawk --sandbox blocks the dangerous parts, for programs you did not write. system(), redirection to files and pipes, and getline from a file or command are refused:

$ gawk --sandbox 'BEGIN { system("echo hi") }'
gawk: cmd. line:1: fatal: 'system' function not allowed in sandbox mode
$ gawk --sandbox 'BEGIN { print "x" > "/tmp/sb" }'
gawk: cmd. line:1: fatal: redirection not allowed in sandbox mode
$ gawk --sandbox 'BEGIN { "echo hi" | getline }'
gawk: cmd. line:1: fatal: redirection not allowed in sandbox mode

6. Quotes inside a program. A single quote cannot appear inside a single-quoted program. Use \047 (the octal code), a variable from -v, or close and reopen the shell quotes:

$ awk 'BEGIN { print "\047q\047" }'
'q'
$ awk -v q="'" 'BEGIN { print q "quoted" q }'
'quoted'
$ awk 'BEGIN { print "it'\''s" }'
it's

7. File names. A name with = in it is taken as an assignment (shown in the section on several files), so write ./name for names from a variable. A name that starts with - is read as an option by many tools; prefix it with ./ too.

8. Exit status. awk returns 0 for success. A syntax error or a missing file is 2 in mawk, the original awk and gawk's missing-file case; gawk gives 1 for a syntax error and for division by zero, BusyBox gives 1. Use exit in END for a yes/no test:

$ awk '/ERROR/ { found=1 } END { exit !found }' app.log; echo rc=$?
rc=0

A program that is syntactically valid but nonsense does nothing and exits with 0, which hides mistakes. This one has three words in a row; awk reads them as the concatenation of three empty variables, which is false:

$ awk 'bad syntax here' app.log; echo rc=$?
rc=0

Run --lint (gawk) over scripts that you keep.

Portability: mawk, gawk, the original awk and BusyBox

A one-liner that works on your laptop (gawk) may fail on a server (mawk) or in a container (BusyBox). The POSIX features work everywhere. The lab ran the same lines on all four, and the differences that mattered are these (the feature table in the gawk section has the rest):

1. The default awk. On Ubuntu a fresh server has mawk. On a desktop or after installing gawk it may be gawk (the alternatives link). In a script that needs gawk, call gawk by name, or check with: command -v gawk.
2. Unicode. mawk and BusyBox count bytes; gawk and the original awk count characters in a UTF-8 locale.
3. Regex shortcuts. \s and \w only exist in gawk and BusyBox. Use [[:space:]] and [[:alnum:]_] instead.
4. Sorting: gawk has asort and PROCINFO["sorted_in"]; elsewhere pipe to sort.
5. IGNORECASE, 3-argument match, gensub, strtonum, switch, BEGINFILE, -i inplace, @include, -e: gawk only. A script that uses one should start with a check, or say gawk in its first line.
6. Hex numbers: mawk reads 0x1A as 26, gawk as 0.
7. -v and ENVIRON differ over escapes (the same in every awk).
8. printf %d of a very large number and of 2^64: three different answers.
9. Division by zero: fatal in gawk, inf in mawk.
10. rand(): the same numbers each run in gawk, different each run in mawk.
11. substr() with a start below 1: the same for gawk, original awk and BusyBox; different in mawk.
12. A space between a function name and the bracket: an error in gawk and the original awk; fine in mawk and BusyBox.
13. -F'\t' is the portable tab. -Ft is not.
14. The order of for (k in a) is not defined, and differs between mawk and gawk.
15. BusyBox awk (this build) has no ^ or math functions; mawk has no ** or func.

mac OS and the BSDs ship the original awk (an older build than the lab's) and it behaves like the "original-awk" column. Not run in the lab: macOS awk itself, and Solaris or AIX awk.

How to write portable awk, in short: use POSIX features only, quote the program in single quotes, set separators in BEGIN or with -F, use [[:class:]] in regexes, test with mawk (the strictest in the lab) and with gawk --posix, and write gawk when you need gawk.

Errors and debugging

Each awk phrases errors differently. The lab gave a program with a missing closing brace to all four:

$ awk '{ print $1 ' app.log
awk: cmd. line:1: { print $1
awk: cmd. line:1:            ^ unexpected newline or end of string
$ mawk '{ print $1 ' app.log
mawk: line 2: missing } near end of file
$ original-awk '{ print $1 ' app.log
original-awk: syntax error at source line 1
 context is
	{ print $1 >>>   <<<
original-awk: illegal statement at source line 1
	missing }
$ busybox awk '{ print $1 ' app.log
awk: cmd. line:1: Unexpected token

gawk shows the line with a caret under the problem; the others are shorter. A syntax error stops the program before it reads any input, with status 1 (gawk, BusyBox) or 2 (mawk, the original awk).

Other messages you will see:

$ awk 'BEGIN { print substr("abc") }'
awk: cmd. line:1: BEGIN { print substr("abc") }
awk: cmd. line:1:                           ^ 1 is invalid as number of arguments for substr
$ mawk 'BEGIN { print substr("abc") }'
mawk: line 1: wrong number of arguments in call to substr
$ awk '/[/ { print }' app.log
awk: cmd. line:1: /[/ { print }
awk: cmd. line:1:  ^ unterminated regexp
$ mawk '/[/ { print }' app.log
mawk: line 1: runaway regular expression /[/ { print ...
$ awk 'BEGIN { print log(-1), sqrt(-1), int("abc") }'
awk: cmd. line:1: warning: log: received negative argument -1
awk: cmd. line:1: warning: sqrt: received negative argument -1
-nan -nan 0
$ mawk '{ print $-1 }' app.log
mawk: run time error: negative field index $-1
	FILENAME="app.log" FNR=1 NR=1

Mistakes that give no error. These are valid awk, so nothing warns you: a variable with a typo (it is a new empty variable), = in a condition, a > in a print that writes a file, a header line that is read as data, FS set in the main rule, and a nonsense program of three words. gawk --lint finds some of them:

$ gawk --lint 'BEGIN { print undefinedvar; x = 1 } { print $1 }' /dev/null
gawk: cmd. line:1: warning: reference to uninitialized variable `undefinedvar'

$ gawk --lint 'BEGIN { if (x = 1) print "assign in cond" }'
gawk: cmd. line:1: warning: assignment used in conditional context
assign in cond
$ gawk --lint '{ print $1 > "/dev/null" } END { print total }' sales.txt
gawk: cmd. line:1: (FILENAME=sales.txt FNR=7) warning: reference to uninitialized variable `total'

gawk: (FILENAME=sales.txt FNR=7) warning: no explicit close of file `/dev/null' provided

Print to find out. print to standard error does not mix with the data: print "debug: " $1 > "/dev/stderr". Print the fields with a marker so that blanks are visible: print "[" $1 "][" $2 "]", and print NF.

gawk can show what it understood and where the time goes:

$ cat count.awk
{ n[$2]++ }
END { for (k in n) print k, n[k] }
$ gawk --profile=/dev/stdout -f count.awk sales.txt | tail -12
     7  {
     7  	n[$2]++
	}

	# END rule(s)

	END {
     4  	for (k in n) {
     4  		print k, n[k]
		}
	}

$ gawk --pretty-print=/dev/stdout -f count.awk
{
	n[$2]++
}

END {
	for (k in n) {
		print k, n[k]
	}
}
$ gawk --dump-variables=/dev/stdout 'BEGIN { a=1; b="x" }' | head -8
ARGC: 1
ARGIND: 0
ARGV: array, 1 elements
BINMODE: 0
CONVFMT: "%.6g"
ENVIRON: array, 10 elements
ERRNO: ""
FIELDWIDTHS: ""

The profile shows how many times each line ran: 7 records, and 4 distinct products in END. gawk has a debugger as well (gawk -D, with -f only), not run beyond starting it.

Exit status in the lab, for the same errors:

program                    mawk   gawk   original-awk   busybox
syntax error               2      1      2              1
file not found             2      2      2              1
division by zero           0      1      2              1

Speed and big files

awk reads a line at a time, so memory does not grow with the file unless you store lines in an array. The lab used a log of 1,000,000 lines (63 MB), and timed several jobs. The times are indicative.

job                                    mawk     gawk     original-awk   busybox
count the values of field 3            0.23 s   0.28 s   0.77 s         0.96 s
count lines matching /ERROR/           0.07 s   0.22 s   9.69 s         0.59 s
gsub on every line, add up length      0.17 s   0.65 s   11.48 s        (not run)

For comparison in the same run: grep -c ERROR took 0.04 s, sed -n '/ERROR/p' 0.17 s and cut | sort | uniq -c on field 3 took 0.44 s. For a plain search grep is the fastest, so use awk when you need fields, arithmetic or a report.

mawk was the fastest awk in all three jobs, and the original awk was very slow on regex work (11.5 s against 0.17 s for the same gsub job). gawk is slower than mawk but still handles a million lines a second or less.

The locale changes gawk. The same gsub job on gawk took 0.65 s in the default locale, 0.80 s with LC_ALL=C.UTF-8 and 0.65 s with LC_ALL=C. A length() job took 0.21 s and 0.30 s. For ASCII logs LC_ALL=C is the faster setting.

Memory. Peak memory, measured by watching /proc while the job ran:

job                                       mawk        gawk
count lines, keep nothing                 2.2 MB      3.6 MB
store every line in an array (63 MB)      139 MB      176 MB

Storing the file in an array costs two to three times the file size. When the answer needs only counts or sums, use an array of counters (the number of distinct keys, not the number of lines).

Speed tips:
1. Select early: put the cheap, restrictive pattern first, and use next so that later rules are skipped.
2. Avoid getline from a command and system() inside a loop; each starts a process. Run one command that does all the work, or read a file once and keep an array.
3. Close files you will not write again. awk keeps every file it writes open until you close it, and the system limits how many can be open. After a close(), print > truncates the file again, so use >> if you write to it after closing:

   $ awk 'BEGIN { print "x" > "/tmp/cl.txt"; close("/tmp/cl.txt"); print "y" > "/tmp/cl.txt"; close("/tmp/cl.txt"); while ((getline l < "/tmp/cl.txt") > 0) print l }'
   y
   $ awk 'BEGIN { print "x" > "/tmp/cl.txt"; close("/tmp/cl.txt"); print "y" >> "/tmp/cl.txt"; close("/tmp/cl.txt"); while ((getline l < "/tmp/cl.txt") > 0) print l }'
   x
   y
4. Use mawk for plain field and number work when it is installed. Use gawk when you need its features.
5. Do a first pass with grep, and send only the lines you need to awk.
6. gawk: LC_ALL=C for ASCII data, or -b for bytes.

When not to use awk

awk is the right tool for text that is a table. It is the wrong one when the text has structure that is not columns, and the lab showed the failure each time:

CSV with quoted commas. -F, cuts a quoted field in two (shown above). gawk FPAT works for simple files only. Use a CSV tool or a language with a CSV library.
JSON. There are no columns, and a line break may be anywhere. Use jq (see the jq guide on this site).
YAML. Indentation is meaning, not columns. Use yq (see the yq guide).
XML and HTML. Use a parser (xmllint, xmlstarlet, python).
Editing a file in place. mawk has no in-place mode, and a file written by a redirect and renamed gets new permissions and ownership. Use sed -i for line edits, or gawk -i inplace if you have it.
A line edit that only needs a substitution. sed 's/old/new/' is shorter. awk is for when you also need a column or a sum.
Multi-line records that vary. Use RS="" for blank-line blocks; for anything more complicated, a script in Python or Perl is easier to read.
Floating point with money. awk uses doubles, so 0.1 + 0.2 is not exactly 0.3. For accounting use integers (cents), or bc / Python decimal.
Huge sorts and joins. Use sort and join (or a database); awk keeps arrays in memory.

For simple jobs the other tools are shorter and faster:

want                                    use
pick columns by a single separator      cut -d: -f1,3
search lines                            grep
count the distinct values               sort | uniq -c
line edits                              sed
make columns line up                    column -t
sum a column                            awk (or paste -sd+ | bc)

The sed and awk guides on this site cover the pair: sed for editing lines, awk for columns and totals.

One-liners

All of these ran in the lab. They use the sample files from the start of the guide.

# Print the first and last fields
$ awk '{ print $1, $NF }' sales.txt | head -2
region price
north 1.50

# Add up a column and print the average (skipping the header)
$ awk 'NR>1 { s += $3 } END { printf "total %d, average %.2f\n", s, s/(NR-1) }' sales.txt
total 36, average 6.00

# Total of qty * price
$ awk 'NR>1 { total += $3 * $4 } END { print total }' sales.txt
61.05

# Count the lines (like wc -l)
$ awk 'END { print NR }' sales.txt
7

# Users with a login shell, from /etc/passwd format
$ awk -F: '$7 !~ /nologin|false/ { print $1 }' passwd.txt
root
ann
ed

# Count users by shell
$ awk -F: '{ shells[$NF]++ } END { for (s in shells) print shells[s], s }' passwd.txt | sort -rn | head -3
2 /usr/sbin/nologin
2 /bin/bash
1 /bin/zsh

# Top clients in a web log
$ awk '{ n[$1]++ } END { for (k in n) print n[k], k }' access.log | sort -rn | head -3
3 10.0.1.11
2 10.0.3.5
1 10.0.2.31

# Requests per status code
$ awk '{ c[$9]++ } END { for (k in c) print k, c[k] }' access.log | sort
200 3
302 1
404 2
500 1

# Show the failing requests (status 400 or more): the status is the second-to-last field
$ awk '$(NF-1) >= 400 { print $1, $7, $(NF-1) }' access.log
10.0.3.5 /missing 404
10.0.3.5 /missing 404
10.0.2.31 /api/items 500

# Bytes sent, in KB
$ awk '{ b += $NF } END { printf "%.1f KB\n", b/1024 }' access.log
12.2 KB

# The requested path, cut out with the quote as the separator
$ awk -F'"' '{ split($2, r, " "); print r[2] }' access.log | sort | uniq -c | sort -rn | head -3
      2 /missing
      2 /index.html
      2 /api/items

# From a log line, the user: the field that starts with user=
$ awk '/ERROR/ { for (i = 1; i <= NF; i++) if ($i ~ /^user=/) print substr($i, 6) }' app.log
kim
ed

# Group by level (field 3) and print the count
$ awk '{ n[$3]++ } END { for (k in n) print k, n[k] }' app.log | sort
ERROR 2
INFO 4
WARN 1

# Print lines 3 to 5, or just line 4
$ awk 'NR>=3 && NR<=5' sales.txt
south pears 4 2.25
north pears 7 2.25
east apples 3 1.50
$ awk 'NR==4' sales.txt
north pears 7 2.25

# Print the last line, or the last two
$ awk 'END { print }' sales.txt
east plums 0 3.10
$ awk '{ a[NR] = $0 } END { print a[NR-1]; print a[NR] }' sales.txt
south apples 12 1.40
east plums 0 3.10

# Remove blank lines, and lines that are only blanks
$ printf 'a\n\n  \nb\n' | awk 'NF'
a
b

# Trim leading and trailing blanks
$ printf '  a b  \n' | awk '{ gsub(/^[ \t]+|[ \t]+$/, ""); print "[" $0 "]" }'
[a b]

# Join every three lines into one
$ awk 'NR % 3 == 1 { printf "%s", $1; next } { printf " %s", $1 } NR % 3 == 0 { print "" }' <<< $'a\nb\nc\nd\ne\nf'
a b c
d e f

# Uniq without sorting, keeping the first
$ awk '!seen[$2]++ { print $2 }' sales.txt
product
apples
pears
plums

# Sum per key and print a table
$ awk 'NR>1 { u[$1] += $3 } END { for (k in u) printf "%-6s %3d\n", k, u[k] }' sales.txt | sort
east     3
north   17
south   16

# Use a shell variable safely
$ U=ann; awk -v u="$U" '$1 == u' ids.txt
ann 1000

# Add a column from a calculation
$ awk 'NR>1 { print $0, $3 * $4 }' sales.txt | head -2
north apples 10 1.50 15
south pears 4 2.25 9

# Memory and load, from /proc
$ awk '/^(MemTotal|MemAvailable)/ { print $1, $2/1024 " MB" }' /proc/meminfo
MemTotal: 31778.5 MB
MemAvailable: 24478.9 MB
$ awk '{ print $1 }' /proc/loadavg
0.58
$ free -m | awk '/Mem:/ { printf "%.0f%% used\n", $3/$2*100 }'
23% used

# Sum the size column of ls -l (skip the "total" line)
$ ls -l /etc | awk 'NR>1 { s += $5 } END { print s " bytes" }'
268236 bytes

# Memory by user, from ps
$ ps -eo user=,rss= | awk '{ s[$1] += $2 } END { for (u in s) print u, s[u] }' | sort | head -3
root 1472
ubuntu 18388

# Disk use above zero, one per mount (df -P keeps each entry on one line)
$ df -P | awk 'NR>1 && $5+0 > 0 { print $6, $5 }' | head -3
/ 25%
/etc/hosts 25%

# Which network addresses are up
$ ip -brief addr | awk '{ print $1, $3 }' | head -3
lo 127.0.0.1/8
eth0@if62 172.17.0.5/16

Cheat sheet

Run                       awk 'program' file     awk -f prog.awk file     awk -F: -v n=5 'prog' file     cmd | awk 'prog'     awk 'prog' f1 f2
Rule                      pattern { action }     no pattern = every line     no action = print the line     BEGIN { } END { }
Fields                    $0 whole line    $1 $2 ...    $NF last    $(NF-1)    NF count    NR line number    FNR line in this file    FILENAME
Separators                -F:    -F'\t'    -F'[,;]'    BEGIN { FS=","; OFS="," }    RS="" (paragraphs)    ORS    -v OFS='\t'
Patterns                  /re/    !/re/    $2 ~ /re/    $2 !~ /re/    $3 > 10    NR==5    NR>1    /a/,/b/    NF    $1 == "x" && $2 > 3
Print                     print $1, $2    print $1 $2    printf "%-10s %5.2f\n", $1, $2    print > "file"    print | "sort"    close("sort")
Numbers                   + - * / % ^    int()    sqrt()    printf "%.2f"    (print shows 6 digits: use printf)    x + 0 forces a number
Strings                   length  substr(s,start,n)  index  split(s,a,sep)  sub  gsub  match  sprintf  tolower  toupper     s1 s2 joins
Regex (ERE)               . * + ? | ( ) { } [abc] [^abc] ^ $    [[:digit:]] [[:space:]] [[:alpha:]]    (\s \w only in gawk/BusyBox)
Arrays                    a[k]=v    k in a    delete a[k]    delete a    for (k in a)    a[i,j]    split(s,a)    length(a)    (order not defined)
Counting idioms           { n[$1]++ }    !seen[$0]++ (unique)    { s[$1]+=$2 }    END { for (k in n) print k, n[k] } | sort
Control                   if () else    for (;;)    for (k in a)    while    do while    break continue    next    nextfile    exit n
Functions                 function f(a, b,   local) { return a+b }    arrays are by reference    no space before ( when calling
getline                   getline    getline v    getline v < file    "cmd" | getline v    close("cmd")    > 0 test, never bare while (getline)
Several files             NR==FNR { a[$1]=$2; next } $1 in a    (empty first file: use FILENAME==ARGV[1])    FNR==1    nextfile
Variables in              -v n=5    n=5 between files    ENVIRON["N"]    never "$var" inside the program text
Shell                     single quotes    system() and "cmd"| use sh: no data inside    print > $1 can write anywhere    gawk --sandbox
gawk only                 IGNORECASE  gensub  match(s,re,m)  asort  PROCINFO["sorted_in"]  strtonum  switch  BEGINFILE  -i inplace  FPAT  FIELDWIDTHS  @include  --lint  --profile
mawk                      fastest    no gawk extras    bytes not characters    -W version    -W interactive    rand() differs per run
Exit status               0 ok    1 or 2 syntax error / missing file (differs by awk)    exit !found in END

Common problems

output is a number like 1.23457e+07                    print uses %.6g for non-whole numbers: use printf "%.2f"
a total or counter prints an empty line               the variable was never set: print c + 0
min or max is wrong, includes the header              the header is a line of text: use NR>1
every line is printed twice                           an action prints and the pattern 1 (or no-action rule) prints too: remove one
first line is split the wrong way                     FS set in the main rule: use BEGIN { FS=":" } or -F:
a column is lost or shifted with spaces in values     the default separator is any blanks: set -F with the real separator
edited line has the wrong separator                   OFS was not set: use -v OFS=: or BEGIN { OFS=FS }
$2 = "" leaves a double space                         the separator is kept: use sub(/^ /, "") or rebuild the line in a loop
a file named 2 or 0 appeared                          print a > b is a redirect: write print (a > b), or use if
a file named x=1 is ignored, awk waits on stdin       name=value is an assignment: use ./x=1
a join prints nothing                                 the first file was empty: use FILENAME==ARGV[1] instead of NR==FNR
syntax error at the end of a long line                a missing } or a quote: count them, and use a program file for long ones
the shell says unexpected token or awk gets a broken program  the program has a single quote inside: use \047 or -v q="'"
sub or gsub with a dot changes everything             a string is a regex: use "[.]" or /\./
a regex works in gawk, not in mawk                    \s \w \y \d, IGNORECASE: use [[:space:]] and tolower()
mawk: function never defined                          gensub, asort, strtonum, and so on are gawk only
mawk: syntax error at or near ,                       match(s, re, arr) is gawk only
gawk: function called with space between name and (   remove the space between a function name and the bracket
0x1A is 26 in mawk and 0 in gawk                      hex strings are converted differently: use strtonum in gawk, or avoid them
printf: not enough arguments                          a % in your data used as the format: printf "%s\n", $0
length() or substr() cuts an accented letter          mawk and the C locale count bytes: use gawk in a UTF-8 locale
last field has an invisible \r                        the file has CRLF line ends: sub(/\r$/, "") first, or RS="\r?\n" in gawk or mawk
a quoted CSV field is split at its comma              FS cannot read CSV: use gawk FPAT, or a CSV tool
the order of for (k in a) changes                     the order is not defined: pipe to sort, or use gawk's PROCINFO["sorted_in"]
a pipe to sort prints at the wrong time               the pipe is flushed at close(): call close("sort") before printing a total
the next command in a pipe sees nothing for a while   awk is buffering: use fflush(), or mawk -W interactive
a pipeline with tail -f shows nothing                 same cause: gawk '{ print; fflush() }'
while (getline line < file) never ends                the file does not exist (getline returns -1): test with > 0
getline from a command only works once               the command is still open: close(cmd) first
a variable in a function changed the caller's         it was not a parameter: add it as an extra parameter
data from a file ran a command                        system() or print | "sh" with data in the command: never build commands from data
awk -i inplace: command not found, or an unknown option  mawk has no -i: use gawk -i inplace, or a temp file and mv
different random numbers each run on Ubuntu           mawk seeds from the clock: call srand(1) for a repeatable sequence
syntax error exit status differs between servers      1 in gawk and BusyBox, 2 in mawk and the original awk: test for non-zero, not for a number
It works on my Mac and not on the server             macOS awk is the original awk; the server has mawk: use only POSIX features, test with mawk

Tips

Quote the program with single quotes. Put values in with -v or the environment, never into the program text.
Start with a pattern and print, then add actions: awk '/ERROR/', then awk '/ERROR/ { print $1, $NF }'.
Look at your fields before you use them: awk '{ print NF; print "[" $1 "]" }' shows what awk thinks the line is.
Skip the header with NR > 1, and remember END sees the last line.
Use printf for anything that goes in a report, and %.2f for money. print is for quick output.
Set FS and OFS together: -F: -v OFS=: keeps the separator the same on the way out.
Write 1 at the end of a rule list to print every line after the edits.
Use the parentheses form for comparisons inside print: print (a > b).
Initialise counters with + 0 when you print them, or set them in BEGIN.
Use if (k in a) to test a key. Reading a[k] creates it.
Count and group with arrays, then pipe to sort: the for-in order is not defined.
Use close() on files and commands you finish with, and always test getline with > 0.
Never build a shell command or a file name from data without checking it. Use gawk --sandbox for a program you did not write.
Know which awk you have: awk -W version, awk --version. On Ubuntu it is usually mawk; call gawk by name when you need it.
Write portable awk (POSIX, [[:class:]], no gensub) when the script has to run on many machines, and test it with mawk.
Put a long program in a file with comments, run it with awk -f, and use gawk --lint on it while you write it.
Stop early with exit when you only want the first match, and use grep first to cut the input down on big files.
When the data is CSV with quotes, JSON, YAML or HTML, use a tool made for it, and keep awk for the columns.