✧ the unix text toolkit ── 49 terms in plain english ── ♡ no jargon, no hype ♡ ── キラ✧キラ ── ✧ the unix text toolkit ── 49 terms in plain english ── ♡ no jargon, no hype ♡ ── キラ✧キラ ──
grep, sed, awk and the bash around them — with the traps that return a plausible wrong answer instead of an error.
$ grep -i ""
49 terms
01 grep — finding things
The one everybody already uses, and the one with the most quietly wrong results.
-c counts <em>lines</em>, not matches
aka the classic wrong number
The single most common silent error in shell work. grep -c reports how many lines matched, not how many matches there were. A line with six matches counts once. It bit me twice while building this site — both times the number looked plausible, which is exactly the problem.
🔮Counting how many pages mention a word, and reporting it as how many times the word appears.
Count matches insteadgrep -o pat f | wc -l
-o
aka only matching
Print just the matched text rather than the whole line, one match per line. Turns grep into a crude extractor, and combined with wc -l it is how you actually count occurrences.
-q
aka quiet
Print nothing, exit 0 if found. The right form for a test — it stops at the first hit instead of reading the whole file. if grep -q pattern file; then
-F
aka fixed strings
Treat the pattern as a literal, not a regex. Faster, and it means you stop escaping dots and slashes in things like IP addresses and file paths. Badly underused.
-E and -P
aka extended / perl
-E gives you +, ?, | and () without backslashes. -P gives real PCRE — lookarounds, lazy quantifiers, \d — but it is a GNU extension and is missing on macOS and BSD, so it is a portability landmine in anything you ship.
-r with --include
aka recursive
Search a tree, filtered by filename: grep -rn --include="*.ts" TODO src/. Add --exclude-dir=node_modules or the results are worthless.
-A / -B / -C
aka context
Lines after, before, or around each match. -C3 is the one to reach for when reading logs — a match without context is usually just a taunt.
exit codes
aka 0, 1, 2
grep exits 0 when it found something, 1 when it did not, and 2 on error. That "1" is not a failure, but set -e cannot tell the difference and will kill your script on a perfectly normal no-match. Guard it: grep -q x f || true.
🔮The smoke detector that reports "no smoke" by setting the house on fire.
ripgrep
aka rg
Faster, sane defaults, and it respects .gitignore — which is the feature and the trap. It silently skips ignored files, so if you are grepping build output or vendored code you will get a confident empty result. rg -u (or -uu) turns that off.
02 sed — changing things
A stream editor. Most sed in the wild is one substitution and a prayer, and that is genuinely fine.
s/old/new/flags
aka substitute
The whole language, for most people. g replaces every match on the line rather than the first, i ignores case, p prints, and a bare number replaces only the Nth match. Without g you get one per line — the cause of a lot of "it only half worked."
choose your delimiter
aka s|a|b|
The delimiter after s is whatever you want. Substituting a file path with / produces a thicket of \/; using s|/usr/local|/opt| is the same command, legible.
🔮Nobody insists on writing in a language with no spaces. Pick a different separator.
-i is not portable
aka the in-place trap
GNU takes sed -i. BSD and macOS require an argument: sed -i ''. Get it wrong and macOS silently eats your next argument as a backup suffix, or GNU creates a file literally named ''. Use -i.bak on both until you trust the expression — it works everywhere and leaves you a way back.
-n with p
aka print only what matched
By default sed prints every line. -n suppresses that, so sed -n '/pattern/p' behaves like grep, and sed -n '5,10p' prints a line range — handy on a file too big to open.
addresses
aka which lines
Every command can be scoped. /error/d deletes matching lines; 2,5d deletes a range; $d deletes the last line; /start/,/end/ spans between two patterns. The address is where sed stops being search-and-replace and starts being an editor.
no lazy quantifiers
aka greedy only
POSIX sed has no .*?. .* takes the longest match it can, which is why your regex ate the rest of the line. The workaround is a negated character class — [^"]* instead of .* — or a different tool.
when to stop
aka the honest limit
sed sees one line at a time and has no idea what nesting is. HTML, JSON, YAML and XML are not line-oriented, and a sed expression that appears to work on them is working by luck on your particular file. Reach for jq, yq, or a parser the moment structure matters.
Use insteadjq for jsonyq for yamla real parser for html
03 awk — the one that is actually a language
Most people know $1 and stop. The next twenty minutes of awk replaces a spreadsheet.
the pattern–action model
awk is pattern { action } repeated. No pattern means every line; no action means print the line. So awk '/error/' is grep, awk '{print $2}' is cut, and awk '/error/ {print $2}' is both — which is the moment it clicks.
fields
aka $1, $NF, NF, NR
$1…$n are fields, $0 the whole line, NF the field count (so $NF is the last field, and $(NF-1) the one before), NR the record number. Printing the last field regardless of width is where awk starts beating cut.
why not cut
aka whitespace runs
awk treats any run of whitespace as one separator by default. cut -d' ' treats every single space as a separator, so aligned output turns into a mess of empty fields. For ls -l, ps, or anything column-aligned, awk simply works.
associative arrays
aka the killer feature
awk arrays are hash maps, and they are why a one-liner can replace a pivot table: awk '{sum[$1] += $3} END {for (k in sum) print k, sum[k]}' file That is "total column 3, grouped by column 1" in nine tokens.
🔮The moment you realise the pocket calculator has a memory button, and it holds a thousand named values.
BEGIN and END
BEGIN runs before input — set separators, print a header. END runs after the last line, which is where totals, counts and averages live. awk 'END {print NR}' is wc -l.
-F and OFS
aka separators
-F, sets the input separator (-F'\t' for tabs). Output uses OFS, and the classic surprise: setting OFS alone changes nothing, because awk only rebuilds $0 if you modify a field. The idiom is awk -F, '{$1=$1; print}' OFS='\t' — assigning a field to itself forces the rebuild.
printf
Same formatting as C, and the reason your numbers stop looking like 1.0000000001e+09. awk '{printf "%-20s %8.2f\n", $1, $2}' gives you aligned columns without a spreadsheet.
04 bash — quoting, where the bugs live
Almost every shell bug is an unquoted expansion. Learn this chapter and the rest is detail.
always quote expansions
aka "$var"
Unquoted, the shell splits the value on whitespace and expands globs in it. So rm $file on a file named my report.txt deletes two things that do not exist, and a value of * means something much worse. Quote every expansion unless you have a specific reason not to.
🔮Unquoted is not "the value." It is "the value, then reinterpreted as shell syntax."
"$@" versus $*
aka passing arguments
"$@" expands to each argument as its own word — the only correct way to forward arguments. "$*" joins them into one string, and bare $@ re-splits them. If a wrapper script mangles filenames with spaces, this is why.
[[ ]] over [ ]
aka test
[[ ]] is a bash keyword rather than a command: it does not word-split its operands, supports &&, ||, and gives you =~ for regex. [ ] is old test and needs paranoid quoting. Use [[ ]] in bash; use [ ] only when POSIX sh is genuinely a requirement.
$(...) not backticks
Nests cleanly, does not mangle backslashes, and is readable. Backticks inside backticks require escaping that nobody enjoys. There is no case where backticks are better.
parameter expansion
aka ${var:-} ${var%} ${var#}
Defaults and trimming without calling out to another process. ${v:-default} if unset, ${v:?message} to fail loudly, ${f%.txt} strips a suffix, ${f##*/} strips everything up to the last slash — basename without the fork.
arrays
aka arr=(a b c)
Real arrays exist; use them instead of a space-separated string. "${arr[@]}" expands element-per-word, ${#arr[@]} is the length. Building command arguments in a string and hoping the splitting works out is how spaces in paths become an outage.
read -r
aka always -r
Without -r, read treats backslashes as escapes and quietly eats them. There is essentially no situation where you want that. while IFS= read -r line; do …; done < file is the loop — IFS= also preserves leading and trailing whitespace.
never parse ls
aka for f in $(ls)
It breaks on spaces, newlines and glob characters in filenames, and it is unnecessary. Use a glob (for f in ./*.txt) or, for a tree, find … -print0 | xargs -0, which is safe against every filename a filesystem allows.
05 bash — safety and where it lies to you
The famous safety line is worth using and does less than its reputation suggests.
set -euo pipefail
aka the incantation
-e exit on error, -u error on undefined variable, -o pipefail make a pipeline fail if any stage failed. Worth having in every script. It is not a safety net so much as a smoke alarm with known blind spots — listed below.
where -e does not fire
aka the blind spots
set -e is ignored for any command in a condition — the whole left side of && or ||, anything in if, while, or after !. So if my_broken_function; then will not exit no matter what happens inside. This is the single biggest gap between what people think -e does and what it does.
🔮A seatbelt that unbuckles itself whenever you are asking a question.
pipefail matters
Without it, a pipeline reports only the last command's status. curl bad-url | tee out.txt exits 0 because tee was fine, and you happily process an empty file. With pipefail, the failure surfaces.
grep will kill your script
aka exit 1 is normal
Under set -e, a grep that legitimately finds nothing returns 1 and terminates everything. Same for diff when files differ. Neutralise deliberately: count=$(grep -c x f || true).
trap for cleanup
trap 'rm -rf "$tmp"' EXIT runs on normal exit and on error, so your temp directory disappears even when the script dies at line 40. Pair with tmp=$(mktemp -d) and never invent your own /tmp/myscript.$$ path again.
shellcheck
aka run it
A linter that catches unquoted expansions, useless cat, set -e misunderstandings and a hundred portability bugs. It is the highest-value tool in this entire guide, and it takes one command.
Do thisshellcheck script.sh
06 pipelines, process, and when to stop
Composition is the point of all of this. So is knowing when the composition has stopped being a good idea.
xargs -0
aka safe filenames
find . -name "*.log" -print0 | xargs -0 rm. The -print0/-0 pair uses NUL as the separator — the one byte that cannot appear in a filename — so spaces and newlines stop mattering. -P4 also gives you free parallelism.
process substitution
aka <(cmd)
Feeds a command's output where a filename is expected: diff <(sort a) <(sort b) compares two things that were never files. Bash only — it will not work under sh.
heredocs
aka <<EOF vs <<'EOF'
Unquoted <<EOF expands variables and command substitutions inside the body; quoted <<'EOF' passes it through literally. If you are writing a script that contains $ or backticks, you almost certainly want the quoted form. They do not nest. A heredoc inside another heredoc ends at the first terminator it sees, and the outer script runs truncated — silently. I hit this writing this very site.
tee
Split a stream: write to a file and keep it flowing down the pipe. … | tee build.log | grep -i error keeps the full record while you watch only the bad news. tee -a appends; sudo tee is the standard way to write a root-owned file from a non-root pipeline.
LC_ALL=C
aka the locale trap
Locale changes how sort orders and how character classes match, so the same script produces different output on two machines. For anything mechanical — deduping, comparing, hashing sorted output — pin it: LC_ALL=C sort. Also noticeably faster.
when to stop
aka the real skill
A pipeline should become a script when you need a second look at it. A script should become a program when it grows a data structure, needs tests, or handles errors in more than one way. The tell is editing: if changing the behaviour means rebuilding the pipe from scratch rather than editing a line, it stopped being a pipeline a while ago.
🔮Everyone has a five-stage pipe they are too proud of. That pride is the diagnostic, not the achievement.
07 traps I actually walked into
Every one of these produced a plausible wrong answer rather than an error, which is what makes them worth writing down.
the regex that ate nine terms
aka exact-match matching
Parsing an HTML glossary, I matched class="entry" — and silently missed class="entry buzz" and class="entry faq". It returned 43 of 52 entries with no error at all. The only reason I caught it was checking the count against a number I already knew.
🔮An anchored pattern is a claim that nothing will ever be added to that attribute. That claim is always wrong eventually.
grep -c, again
Verifying that same job, grep -c told me 62 articles existed where there were 52 — because minified HTML put several matches on one line. Both of my verification numbers were wrong in different directions on the same afternoon.
heredocs do not nest
A <<'PY' python heredoc inside a <<'EOF' shell heredoc: the outer one terminated early, python received a truncated script, and the error pointed at a line that was fine.
Git Bash path mangling
aka MSYS_NO_PATHCONV
On Windows, Git Bash rewrites arguments that look like Unix paths into Windows paths — so curl .../posts became C:/Program Files/Git/posts. Set MSYS_NO_PATHCONV=1. Then remember to unset it for native Windows binaries that want Windows paths, which is its own small hell.
$? after a pipeline
cmd | head then echo $? reports head's status, not cmd's. I read an exit code of 0 as "the clone worked" when the clone had failed and head had merely succeeded at reading nothing. Use PIPESTATUS, or set -o pipefail.
the lesson
None of these threw an error. Each returned a plausible number or an empty result, and every one was caught by checking against a figure known independently — a stated total, a count from another method, a file that should have existed. Verify the effect, not the exit status.