Intermediate Examples
Examples 29-60 cover case dispatch, functions (definition, local scoping, return status),
positional parameters ($1/$#/shift/"$@"), reading input (read, here-docs, here-strings),
the core text-pipeline toolkit (grep/sed/awk/cut/sort/uniq/tr/find/xargs), I/O
redirection (2>&1, /dev/null), getopts flag parsing, parameter-expansion defaults, and
pipefail. Every example is a complete, self-contained .sh script colocated under
learning/code/; run each one with bash example.sh from inside its own directory, unless a
caption says otherwise.
Example 29: Case Statement -- Dispatching on an Action Word
ex-29 · exercises co-11
A case statement matches one value against a list of glob-style patterns, running only the first
matching branch. This example wraps case in a function and drives it with three sample inputs to
show every branch, including the catch-all.
learning/code/ex-29-case-statement-dispatch/example.sh
#!/usr/bin/env bash
# Example 29: case statement -- dispatching on an action word
set -euo pipefail
dispatch() { # => dispatch takes one action word and routes it
case "$1" in # => case matches $1 against each pattern below, top to bottom
start) # => matches the literal word "start"
echo "starting service" # => branch body: runs only for the start case
;; # => ;; ends this branch and skips every other pattern
stop) # => matches the literal word "stop"
echo "stopping service" # => branch body: runs only for the stop case
;; # => ;; ends this branch
*) # => * is the catch-all pattern, matches anything else
echo "unknown action: $1" # => branch body for every action that is not start or stop
;; # => ;; ends the catch-all branch
esac # => esac closes the case statement
} # => closes the dispatch function
for action in start stop restart; do # => drives dispatch with three sample inputs
dispatch "$action" # => calls dispatch, routing through case each time
done # => closes the for-loop
# => Output: "starting service", then "stopping service", then "unknown action: restart"Run: bash example.sh
Output:
starting service
stopping service
unknown action: restartKey takeaway: case "$value" in pattern) ... ;; esac matches top to bottom and stops at the
first hit; a trailing *) branch catches everything nothing else matched.
Why it matters: case is the idiomatic way to dispatch on a fixed set of known values --
start/stop/restart subcommands, -v/-h flags spelled out by hand, or a value pulled from
$OSTYPE. It reads far more clearly than a chain of if/elif string comparisons once there are
more than two or three branches, and every pattern can itself be a glob (a|b, *.txt), not just
a literal word.
Example 30: Defining and Calling a Function
ex-30 · exercises co-13
A function groups reusable commands under a name; name() { ... } defines it, and name arg1 arg2 calls it exactly like any other command. Arguments arrive as $1, $2, and so on, scoped
to that call.
learning/code/ex-30-function-define-call/example.sh
#!/usr/bin/env bash
# Example 30: defining and calling a function
set -euo pipefail
greet() { # => defines a function named greet
echo "Hi $1" # => prints a greeting using the function's first argument
} # => closes the function body
greet "Ada" # => calls greet with one argument: "Ada"
# => Output: Hi AdaRun: bash example.sh
Output:
Hi AdaKey takeaway: greet() { ... } defines a function; calling it with greet "Ada" runs the body
with $1 bound to "Ada", exactly like calling any other command.
Why it matters: Functions are the primary way to avoid repeating the same handful of commands
throughout a script -- a logging helper, a validation check, or a cleanup routine all become one
named call instead of copy-pasted lines. Every later example in this tier that reasons about
local scoping (Example 31), return status (Example 32), or getopts parsing (Examples 56-57)
builds directly on this definition-and-call shape.
Example 31: Function-Local Variables with local
ex-31 · exercises co-13
By default, a variable assigned inside a function is global -- it leaks out and overwrites any
same-named variable in the caller. local scopes a variable to the current function call only,
restoring it (or leaving it undefined) once the function returns.
learning/code/ex-31-function-local-var/example.sh
#!/usr/bin/env bash
# Example 31: function-local variables with `local`
set -euo pipefail
name="global" # => an outer variable named name, holding "global"
set_name() { # => defines a function that tries to change name
local name="local-only" # => local creates a NEW name, scoped only to this function call
echo "inside: $name" # => reads the function-local name, not the outer one
} # => closes the function; the local name is destroyed here
set_name # => calls set_name; its local name never touches the outer one
echo "outside: $name" # => the outer name is completely unchanged
# => Output: "inside: local-only", then "outside: global"Run: bash example.sh
Output:
inside: local-only
outside: globalKey takeaway: local name=value inside a function creates a variable that shadows any
outer name for the duration of the call, without ever touching the caller's copy.
Why it matters: Without local, every variable a function assigns is global by default --
a helper function that happens to use a loop counter named i can silently clobber the caller's
own i. Reaching for local on every function-internal variable (as Example 30's greet did not
need to, since it never assigned anything) is the single habit that prevents this entire class of
bug.
Example 32: Checking a Function's Return Status
ex-32 · exercises co-13, co-09
A function's return N sets its exit status, exactly like a command's own exit code; return
with no argument uses the status of the last command run inside it. Calling the function directly
inside an if checks that status without a separate $? lookup.
learning/code/ex-32-function-return-status/example.sh
#!/usr/bin/env bash
# Example 32: checking a function's return status
set -euo pipefail
is_even() { # => defines a function that reports even/odd via exit status
local n="$1" # => n is the number to check, scoped to this call
if ((n % 2 == 0)); then # => arithmetic test: true when n has no remainder mod 2
return 0 # => return 0 means "success" (n is even)
else # => otherwise n is odd
return 1 # => return 1 means "failure" (n is odd)
fi # => closes the if/else
} # => closes the function
if is_even 3; then # => calling is_even inside `if` checks its return status directly
echo "3 is even" # => this branch runs only if is_even returned 0
else # => is_even returned non-zero (1), so the failure branch runs
echo "3 is odd" # => branch body confirming the failure path was taken
fi # => closes the if/else
# => Output: 3 is oddRun: bash example.sh
Output:
3 is oddKey takeaway: return 0 signals success and return 1 (or any non-zero) signals failure from
a function, exactly like a command's exit code; if is_even 3; then reads that status directly.
Why it matters: Treating functions as boolean-ish predicates -- success means "yes", any
non-zero return means "no" -- lets a function slot directly into if, while, and &&/||
without any extra $? bookkeeping. Example 59 reuses this exact same "call it inside if" shape,
but with a real command (grep -q) instead of a hand-written function.
Example 33: Positional Parameters -- $1, $2, $#
ex-33 · exercises co-19
A script's (or function's) arguments arrive as positional parameters: $1 is the first, $2 the
second, and $# holds the total count. set -- ... rewrites the current positional parameters,
which is how this self-contained example simulates real command-line arguments.
learning/code/ex-33-positional-params/example.sh
#!/usr/bin/env bash
# Example 33: positional parameters -- $1, $2, $#
set -euo pipefail
set -- alpha beta gamma # => set -- simulates script arguments: $1=alpha, $2=beta, $3=gamma
echo "first: $1" # => $1 is the first positional parameter
echo "second: $2" # => $2 is the second positional parameter
echo "count: $#" # => $# is the total number of positional parameters (3)
# => Output: "first: alpha", "second: beta", "count: 3"Run: bash example.sh
Output:
first: alpha
second: beta
count: 3Key takeaway: $1, $2, ... refer to arguments by position, and $# reports how many there
are in total; set -- a b c is the standard way to simulate arguments inside a self-contained
example.
Why it matters: Positional parameters are how every Bash script receives its command-line
arguments -- there is no named-parameter syntax like a typical function signature. Examples 34
and 35 build directly on this: looping over every argument safely, and consuming them one at a
time with shift.
Example 34: Looping Over All Arguments with "$@"
ex-34 · exercises co-19, co-06
"$@" expands to every positional parameter as its own separate, correctly quoted word -- even
one that itself contains spaces. This is different from $* or an unquoted $@, both of which
can split an argument containing spaces into multiple words.
learning/code/ex-34-all-args-quoted/example.sh
#!/usr/bin/env bash
# Example 34: looping over all arguments with "$@"
set -euo pipefail
set -- "alpha beta" gamma # => two arguments: one contains a space, one does not
for arg in "$@"; do # => "$@" expands to each argument as its OWN word, spaces intact
echo "$arg" # => prints the argument exactly as it was passed
done # => closes the for-loop
# => Output: "alpha beta" (kept as ONE line, space intact), then "gamma"Run: bash example.sh
Output:
alpha beta
gammaKey takeaway: for arg in "$@"; do (quotes required) preserves each argument's word
boundaries exactly as the caller passed them; dropping the quotes reintroduces word-splitting on
every space.
Why it matters: This is the single most common quoting bug in real scripts: forgetting the
quotes around "$@" breaks the moment a caller passes a filename or argument containing a space.
"$@" inside a loop, and "$@" when forwarding arguments to another command, should be treated
as one inseparable idiom -- always quoted, never $@ bare.
Example 35: Consuming Arguments with shift
ex-35 · exercises co-19
shift drops $1 and renumbers every remaining positional parameter down by one, so $2 becomes
the new $1. Combined with a while [ "$#" -gt 0 ] loop, this processes arguments one at a time
without ever needing an index variable.
learning/code/ex-35-shift-args/example.sh
#!/usr/bin/env bash
# Example 35: consuming arguments with shift
set -euo pipefail
set -- alpha beta gamma # => three simulated arguments to consume one at a time
while [ "$#" -gt 0 ]; do # => loops while at least one positional parameter remains
echo "processing: $1" # => $1 always refers to the CURRENT first remaining argument
shift # => drops $1 and renumbers the rest down by one ($2 becomes $1)
done # => closes the while-loop
echo "remaining: $#" # => all arguments have been shifted away, so $# is 0
# => Output: three "processing: ..." lines (alpha, beta, gamma), then "remaining: 0"Run: bash example.sh
Output:
processing: alpha
processing: beta
processing: gamma
remaining: 0Key takeaway: shift removes $1 and slides every other positional parameter down by one, so
a while [ "$#" -gt 0 ]; do ... shift; done loop drains the argument list completely.
Why it matters: shift is the workhorse behind hand-written flag parsers -- peek at $1,
decide what to do with it, shift, repeat -- before reaching for getopts (Examples 56-57) for
anything more structured than a handful of positional arguments. It is also how a script forwards
"everything after the first two arguments" to another command without any index arithmetic.
Example 36: Reading a Line from Piped stdin
ex-36 · exercises co-17
read -r line reads a single line of input into the variable line, stopping at the first
newline. Piping text into a command group ({ ...; }) lets that group's read consume the piped
input directly.
learning/code/ex-36-read-from-stdin/example.sh
#!/usr/bin/env bash
# Example 36: reading a line from piped stdin
set -euo pipefail
printf 'hello from stdin\n' | { # => pipes one line of text into the command group below
read -r line # => read -r reads exactly one line into $line (no backslash escaping)
echo "got: $line" # => echoes back exactly what was read from the pipe
} # => closes the command group
# => Output: got: hello from stdinRun: bash example.sh
Output:
got: hello from stdinKey takeaway: read -r line pulls one line from whatever is connected to stdin -- here, the
output of printf piped in -- and the -r flag stops backslashes from being treated as escape
characters.
Why it matters: read is Bash's general-purpose way to bring outside data into a variable,
whether that data comes from a pipe (this example), a redirected file (Example 37), or an
interactive terminal. Always pairing it with -r avoids a subtle, easy-to-miss bug where a
backslash in the input silently disappears or joins the next line.
Example 37: Reading a File Line by Line
ex-37 · exercises co-17, co-12
while IFS= read -r line; do ... done < file is the standard idiom for processing a file one line
at a time. IFS= prevents read from trimming leading/trailing whitespace, and redirecting the
file into the while loop (rather than piping it in) keeps the loop in the current shell, not a
subshell.
learning/code/ex-37-read-loop-file/names.txt
alice
bob
carollearning/code/ex-37-read-loop-file/example.sh
#!/usr/bin/env bash
# Example 37: reading a file line by line
set -euo pipefail
# names.txt (colocated in this directory) holds three names, one per line: alice, bob, carol
while IFS= read -r line; do # => IFS= keeps leading/trailing whitespace intact; -r keeps backslashes literal
echo "line: $line" # => prints each line exactly as it appears in the file
done <names.txt # => redirects names.txt as the loop's stdin, one read per iteration
# => Output: "line: alice", "line: bob", "line: carol"Run: bash example.sh
Output:
line: alice
line: bob
line: carolKey takeaway: while IFS= read -r line; do ... done < file reads a file one line at a time
inside the current shell, so any variables the loop body sets are still visible after the loop
ends.
Why it matters: This exact idiom -- while IFS= read -r line; do ... done < file -- is
the single most reliable way to process a file line by line in Bash, and it shows up constantly in
real scripts: reading a list of hostnames, a CSV export, or a config file one entry at a time. The
< file redirection (rather than cat file |) is what keeps the loop's variables from vanishing
into a subshell once the loop ends.
Example 38: Heredoc with Variable Expansion
ex-38 · exercises co-16
A heredoc (<<DELIMITER) feeds a multi-line block of text to a command's stdin. With an unquoted
delimiter, that block behaves like a double-quoted string: variables and other expansions inside
it are substituted before the command ever sees the text.
learning/code/ex-38-heredoc/example.sh
#!/usr/bin/env bash
# Example 38: heredoc with variable expansion
set -euo pipefail
name="Ada" # => a variable to interpolate inside the heredoc body
# This example proves an unquoted heredoc behaves like a double-quoted string: variables expand
# The unquoted delimiter <<EOF below means variables ARE expanded inside the block
# $name is substituted with its value BEFORE cat ever receives the text
cat <<EOF
Hello, $name!
Welcome.
EOF
# => the bare EOF on its own line ends the heredoc
# => output: "Hello, Ada!" followed by "Welcome." -- $name was expanded to "Ada"Run: bash example.sh
Output:
Hello, Ada!
Welcome.Key takeaway: cat <<EOF ... EOF (unquoted delimiter) expands $variables inside the block
exactly like a double-quoted string would, before the receiving command ever sees the text.
Why it matters: Heredocs are how a script builds a multi-line piece of text -- an email body,
a config file, a usage message -- without a wall of echo calls chained together with \n. This
unquoted form is the right choice whenever the block needs to include a live variable's value;
Example 39 shows the opposite, quoted form for when it must not.
Example 39: Heredoc with a Quoted Delimiter (No Expansion)
ex-39 · exercises co-16
Quoting the heredoc's delimiter (<<'EOF', with the quotes around EOF itself) disables every
kind of expansion inside the block. Variables, command substitutions, and backslash escapes all
print exactly as typed -- useful whenever the text is a literal template rather than live data.
learning/code/ex-39-heredoc-quoted/example.sh
#!/usr/bin/env bash
# Example 39: heredoc with a quoted delimiter (no expansion)
set -euo pipefail
# shellcheck disable=SC2034 # name is deliberately unused below -- that IS the point of this example
name="Ada" # => a variable that will NOT be expanded inside the heredoc below
# The quoted delimiter <<'EOF' below disables ALL expansion inside the block
# $name prints LITERALLY here, exactly as typed, because the delimiter is quoted
cat <<'EOF'
Hello, $name!
EOF
# => the bare EOF on its own line ends the heredoc
# => output: "Hello, $name!" -- the dollar sign and variable name print unexpandedRun: bash example.sh
Output:
Hello, $name!Key takeaway: cat <<'EOF' ... EOF (delimiter wrapped in quotes) treats the block as a
literal template -- $name prints as the two literal characters $name, with no substitution at
all.
Why it matters: This quoted form is exactly what to reach for when generating a script,
template, or example file whose OWN content should contain literal $variable syntax --
generating a .env.example file, a shell-script template, or documentation showing Bash syntax
all need this, since the unquoted form from Example 38 would substitute those variables away
instead of preserving them.
Example 40: Here-String Matching with grep
ex-40 · exercises co-16, co-18
<<< (a here-string) feeds a single string directly to a command's stdin, without a temporary
file or a separate echo | pipe. It is the most concise way to run a stdin-reading command, like
grep, against a value already held in a variable.
learning/code/ex-40-here-string/example.sh
#!/usr/bin/env bash
# Example 40: here-string matching with grep
set -euo pipefail
text="the quick foo fox" # => the string to search, held in a variable
if grep -q foo <<<"$text"; then # => <<< feeds $text to grep's stdin as a single line, no temp file needed
echo "matched" # => runs because grep found "foo" inside $text
else # => runs only if grep found no match
echo "no match" # => not reached in this run
fi # => closes the if/else
# => Output: matchedRun: bash example.sh
Output:
matchedKey takeaway: command <<< "$var" feeds $var to command's stdin as if it were piped in,
without spawning a subshell for a pipe or writing a temporary file.
Why it matters: Here-strings are the concise alternative to echo "$text" | grep foo: same
result, one fewer process, and no risk of the pipe's echo mangling a value that starts with a
dash. Reach for <<< any time a variable's contents (rather than a real file) need to feed a
stdin-reading command like grep, sed, wc, or read.
Example 41: Piping printf into grep
ex-41 · exercises co-15, co-18
A pipe (|) connects one command's stdout directly to the next command's stdin. Here, printf
generates four lines of text and grep '^a' filters them down to just the ones starting with
a.
learning/code/ex-41-pipe-grep/example.sh
#!/usr/bin/env bash
# Example 41: piping printf into grep
set -euo pipefail
# printf writes four fruit lines; grep '^a' keeps only the lines that START with "a"
printf 'apple\nbanana\navocado\ncherry\n' | grep '^a' # => grep '^a' matches "apple" and "avocado", not "banana"/"cherry"
# => Output: apple, then avocadoRun: bash example.sh
Output:
apple
avocadoKey takeaway: producer | grep 'pattern' filters the producer's output down to only the lines
that match; ^ anchors the pattern to the start of the line.
Why it matters: grep filtering a pipeline is the most common single step in any real text
pipeline -- narrowing a large stream of lines (log output, a file listing, a command's verbose
output) down to just the ones worth looking at. Examples 42 through 53 build up the rest of this
toolkit (sed, awk, cut, sort, find, xargs) around this same pipe-and-filter shape.
Example 42: Counting Matches with grep -c
ex-42 · exercises co-18
grep -c pattern file prints only the count of matching lines, not the lines themselves --
useful whenever the answer needed is "how many", not "which ones".
learning/code/ex-42-grep-count/sample.log
error: disk full
info: startup complete
error: timeout
warning: low memorylearning/code/ex-42-grep-count/example.sh
#!/usr/bin/env bash
# Example 42: counting matches with grep -c
set -euo pipefail
# sample.log (colocated in this directory) has 4 lines, 2 of which contain "error"
grep -c error sample.log # => -c prints only the COUNT of matching lines, not the lines themselves
# => Output: 2Run: bash example.sh
Output:
2Key takeaway: grep -c pattern file prints a single number -- the count of matching lines --
instead of printing the matching lines themselves.
Why it matters: -c turns grep into a quick tally tool: counting how many lines contain
error in a log file, how many files in a listing end in .txt, or how many rows in a CSV match
a condition. It avoids piping into wc -l just to count what grep can count directly.
Example 43: Substituting Text with sed
ex-43 · exercises co-18
sed 's/pattern/replacement/g' substitutes every occurrence of pattern with replacement on
each line; the trailing g (global) flag is what makes it replace every match per line, not just
the first.
learning/code/ex-43-sed-substitute/example.sh
#!/usr/bin/env bash
# Example 43: substituting text with sed
set -euo pipefail
# the input line contains "old" three times, once per word
echo "old cat, old hat, old mat" | sed 's/old/new/g' # => s/old/new/g substitutes EVERY "old" with "new" (g = global)
# => Output: new cat, new hat, new matRun: bash example.sh
Output:
new cat, new hat, new matKey takeaway: sed 's/old/new/g' is sed's substitute command -- s, then the pattern to
find, the replacement, and a trailing g to replace every match on the line rather than only the
first.
Why it matters: Text substitution is one of the two things sed is used for constantly in
real scripts (the other being line deletion, Example 44) -- renaming a placeholder in a template,
normalizing whitespace, or rewriting a path across many lines all reduce to one s///g pattern.
Dropping the trailing g is a common bug: without it, sed stops after the first match per line.
Example 44: Deleting Matching Lines with sed
ex-44 · exercises co-18
sed '/pattern/d' deletes every line that contains pattern, printing everything else
unchanged. This is sed's other everyday mode: instead of transforming text in place, it filters
whole lines out.
learning/code/ex-44-sed-delete-lines/queries.txt
SELECT * FROM users;
DROP TABLE users;
INSERT INTO users VALUES (1);
DROP TABLE logs;learning/code/ex-44-sed-delete-lines/example.sh
#!/usr/bin/env bash
# Example 44: deleting matching lines with sed
set -euo pipefail
# queries.txt (colocated in this directory) has 4 lines; 2 contain "DROP"
sed '/DROP/d' queries.txt # => /DROP/d deletes every line that CONTAINS the pattern "DROP"
# => Output: "SELECT * FROM users;" then "INSERT INTO users VALUES (1);" -- both DROP lines are goneRun: bash example.sh
Output:
SELECT * FROM users;
INSERT INTO users VALUES (1);Key takeaway: sed '/pattern/d' removes every line matching pattern from the output; every
non-matching line passes through completely unchanged.
Why it matters: Line deletion by pattern is how a script strips comment lines, blank lines, or
(as here) specific unwanted statements out of a text stream before further processing. Combined
with Example 43's substitution, these two sed one-liners cover the large majority of real-world
sed usage without ever needing sed's more obscure scripting features.
Example 45: Extracting a Field with awk
ex-45 · exercises co-18
awk splits each input line into whitespace-separated fields, numbered $1, $2, and so on
(note: these are awk's own field variables, unrelated to Bash's positional parameters from
Example 33). {print $2} prints just the second field of every line.
learning/code/ex-45-awk-field/example.sh
#!/usr/bin/env bash
# Example 45: extracting a field with awk
set -euo pipefail
# two space-separated records: name, age, role
printf 'alice 30 engineer\nbob 25 designer\n' | awk '{print $2}' # => $2 is the 2nd whitespace-separated field on each line
# => Output: 30, then 25 -- the age field from each recordRun: bash example.sh
Output:
30
25Key takeaway: awk '{print $2}' splits each line on whitespace and prints only the second
field; $1, $3, and so on address any other column the same way.
Why it matters: awk's field-splitting is the natural tool for pulling one column out of
loosely space-separated output -- ps output, df output, or any command whose columns are not a
strict CSV. Example 47 shows cut doing something similar for a fixed-delimiter format like CSV;
knowing when each tool fits is part of building the right pipeline quickly.
Example 46: Summing a Column with awk
ex-46 · exercises co-18
awk can accumulate a running total across every input line in its main block, then print that
total once at the end in an END {} block, which runs exactly once after all input has been
consumed.
learning/code/ex-46-awk-sum/example.sh
#!/usr/bin/env bash
# Example 46: summing a column with awk
set -euo pipefail
# three numbers, one per line, to be totaled
printf '10\n20\n30\n' | awk '{sum += $1} END {print sum}' # => sum accumulates $1 per line; END{} prints it once, after all lines
# => Output: 60 -- 10 + 20 + 30Run: bash example.sh
Output:
60Key takeaway: awk '{sum += $1} END {print sum}' accumulates $1 from every line into sum,
then the END {} block prints the final total exactly once, after the last line.
Why it matters: This {accumulate} END {print} shape is awk's answer to "total up a column
of numbers" -- disk usage per file, bytes transferred per log entry, or scores per record. It
needs no external bc or arithmetic-in-a-loop workaround; the accumulation and the final print
are both native to awk.
Example 47: Extracting a CSV Column with cut
ex-47 · exercises co-18
cut -d, -f2 splits each line on a fixed delimiter (here, a comma) and prints only the requested
field number. Unlike awk, cut has no scripting language of its own -- it does exactly one
thing, quickly.
learning/code/ex-47-cut-columns/people.csv
name,age,role
alice,30,engineer
bob,25,designerlearning/code/ex-47-cut-columns/example.sh
#!/usr/bin/env bash
# Example 47: extracting a CSV column with cut
set -euo pipefail
# people.csv (colocated in this directory) has a header row plus two data rows
cut -d, -f2 people.csv # => -d, sets the delimiter to a comma; -f2 keeps only the 2nd field
# => Output: "age" (the header), then "30", then "25"Run: bash example.sh
Output:
age
30
25Key takeaway: cut -d<delim> -f<n> splits each line on <delim> and keeps only field <n>;
-d, picks a comma delimiter for a simple CSV.
Why it matters: For a well-formed, fixed-delimiter file like plain CSV, cut is faster to
reach for than awk -F,, since there is no pattern-matching syntax to remember. cut does not
handle quoted commas inside a CSV field, though -- a genuinely quoted CSV needs a real CSV parser,
not cut or awk.
Example 48: Counting Duplicates with sort | uniq -c
ex-48 · exercises co-18
uniq -c only collapses adjacent duplicate lines, prefixing each with a count -- so sort
must run first to bring equal lines next to each other before uniq -c can count them correctly.
learning/code/ex-48-sort-uniq-count/example.sh
#!/usr/bin/env bash
# Example 48: counting duplicates with sort | uniq -c
set -euo pipefail
# six fruit names, with "apple" and "banana" each repeated, in no particular order
printf 'apple\nbanana\napple\ncherry\nbanana\napple\n' |
sort | # => uniq -c only counts ADJACENT duplicates, so sort groups equal lines together first
uniq -c # => -c prefixes each unique line with how many times it appeared
# => Output: "3 apple", "2 banana", "1 cherry" -- each count right-aligned by uniq -cRun: bash example.sh
Output:
3 apple
2 banana
1 cherryKey takeaway: sort | uniq -c is a fixed idiom: sort groups equal lines together, then
uniq -c collapses each group into one line prefixed with its count.
Why it matters: This exact sort | uniq -c pipeline is the standard one-liner for "how many
times does each distinct value appear" -- counting repeated log messages, repeated IP addresses,
or repeated words in a file. Skipping the sort is the classic mistake: uniq -c alone would
report three separate one-line groups instead of the correct totals, since it never merges
non-adjacent duplicates.
Example 49: Translating Characters with tr
ex-49 · exercises co-18
tr set1 set2 translates each character in set1 to the character at the same position in
set2, one character at a time -- it has no concept of "lines" or "fields" the way sed and
awk do.
learning/code/ex-49-tr-translate/example.sh
#!/usr/bin/env bash
# Example 49: translating characters with tr
set -euo pipefail
# a lowercase greeting to be uppercased
echo "hello world" | tr 'a-z' 'A-Z' # => tr maps each char in set 1 (a-z) to the char at the same position in set 2 (A-Z)
# => Output: HELLO WORLDRun: bash example.sh
Output:
HELLO WORLDKey takeaway: tr 'a-z' 'A-Z' maps every lowercase letter to its uppercase counterpart,
character by character, across the entire input stream.
Why it matters: tr is the lightest-weight tool for character-level transforms --
uppercasing/lowercasing text, collapsing repeated characters (tr -s), or deleting a specific
character class (tr -d) -- without the overhead of sed's pattern syntax for a job that never
needed patterns in the first place.
Example 50: Finding Files by Name
ex-50 · exercises co-18
find dir -name 'pattern' walks a directory tree and prints every path matching a glob pattern,
at any nesting depth. This example builds a small real directory tree first, so the find below
has something concrete to search.
learning/code/ex-50-find-files/example.sh
#!/usr/bin/env bash
# Example 50: finding files by name
set -euo pipefail
mkdir -p sample-dir/sub # => builds a small real directory tree to search
touch sample-dir/a.txt sample-dir/b.txt sample-dir/sub/c.txt # => three .txt files at two nesting depths
touch sample-dir/notes.md # => one non-matching file, to prove find filters it out
find sample-dir -name '*.txt' | sort # => -name '*.txt' matches only .txt files, at any depth
# => sort makes the listing order deterministicRun: bash example.sh
Output:
sample-dir/a.txt
sample-dir/b.txt
sample-dir/sub/c.txtKey takeaway: find dir -name 'pattern' recurses through every subdirectory of dir,
printing every path whose filename matches the glob pattern -- sample-dir/notes.md was
correctly excluded since it does not end in .txt.
Why it matters: find is the tool for locating files by name or type across a directory tree,
something neither ls nor a shell glob does recursively by default. Piping its output into sort
(as here) makes the listing order deterministic, which matters whenever that output feeds into a
later pipeline stage that depends on a stable order.
Example 51: Deleting Files Found by find
ex-51 · exercises co-18
find dir -name 'pattern' -delete removes every matching file in place, without a separate xargs rm step. -delete must come last in the find expression; it is an action, not a filter.
learning/code/ex-51-find-exec-delete/example.sh
#!/usr/bin/env bash
# Example 51: deleting files found by find
set -euo pipefail
mkdir -p tmp-dir # => builds a small real directory to clean up
touch tmp-dir/keep.txt tmp-dir/old.tmp tmp-dir/cache.tmp # => one file to keep, two .tmp files to remove
find tmp-dir -name '*.tmp' -delete # => -delete removes every file find matches, in place
find tmp-dir -type f | sort # => lists what remains, to prove only the .tmp files are gone
# => Output: tmp-dir/keep.txt -- both old.tmp and cache.tmp were deletedRun: bash example.sh
Output:
tmp-dir/keep.txtKey takeaway: find dir -name 'pattern' -delete deletes every matching file directly, with no
separate rm or xargs step needed for the deletion itself.
Why it matters: -delete is the safe, built-in way to prune matched files -- temp files,
build artifacts, old logs -- from a find expression, and it is generally preferable to piping
into xargs rm for deletion specifically, since it avoids spawning an external rm process per
batch. As with any bulk delete, it is worth testing the find ... -name 'pattern' part alone
first (without -delete) to confirm exactly what will be removed.
Example 52: Piping find Results into xargs grep
ex-52 · exercises co-18, co-15
xargs takes a stream of lines on stdin and turns them into arguments for another command --
here, turning a list of filenames from find into the file arguments for a single grep -l
call, rather than running grep once per file.
learning/code/ex-52-xargs-pipeline/example.sh
#!/usr/bin/env bash
# Example 52: piping find results into xargs grep
set -euo pipefail
mkdir -p project # => builds a small real project directory to search
printf 'TODO: fix this\n' >project/a.txt # => a.txt contains a TODO marker
printf 'nothing here\n' >project/b.txt # => b.txt has no TODO marker
printf 'TODO: refactor\n' >project/c.txt # => c.txt also contains a TODO marker
find project -name '*.txt' | sort | xargs grep -l TODO # => xargs turns the file list into grep's file arguments
# => -l prints only the NAMES of files containing a matchRun: bash example.sh
Output:
project/a.txt
project/c.txtKey takeaway: find ... | xargs grep -l pattern turns a list of filenames into arguments for
one grep invocation across all of them; -l prints only the names of files that matched, not
the matching lines themselves.
Why it matters: This find | xargs shape is the standard way to run a command across every
file find locates, in far fewer processes than looping and invoking the command once per file.
The synthetic filenames here are deliberately space-free to keep the example focused; when
filenames might contain spaces, the safe general form is find ... -print0 | xargs -0 ..., which
uses a NUL byte instead of whitespace to separate entries.
Example 53: A Multi-Stage find | grep | awk | sort Pipeline
ex-53 · exercises co-15, co-18
Real text-processing chores usually need more than one tool chained together. This example builds
a small logs/ directory, then finds every .log file, greps their ERROR lines, extracts the
service name with awk, and de-duplicates the result with sort -u -- the exact same building
blocks from Examples 41-52, composed into one pipeline.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart LR
A["find logs -name '*.log'<br/>locate every log file"]:::blue
B["xargs grep ERROR<br/>keep only ERROR lines"]:::orange
C["awk '#123;print $3#125;'<br/>extract the service name"]:::teal
D["sort -u<br/>de-duplicate, alphabetize"]:::purple
A --> B --> C --> D
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-53-pipeline-chore/example.sh
#!/usr/bin/env bash
# Example 53: a multi-stage find | grep | awk | sort pipeline
set -euo pipefail
mkdir -p logs # => builds a small real logs directory for the chore below
# auth.log gets one INFO line and one ERROR line
printf '2024-01-01 INFO auth started\n2024-01-01 ERROR auth timeout\n' >logs/auth.log
# billing.log gets one ERROR line and one INFO line
printf '2024-01-01 ERROR billing failed\n2024-01-01 INFO billing ok\n' >logs/billing.log
# extra.log gets a second ERROR line, also for the auth service
printf '2024-01-01 ERROR auth retry\n' >logs/extra.log
# => three log files simulate a real "find every service with an ERROR line" chore
find logs -name '*.log' | sort | # => finds every .log file, sorted for a deterministic file order
xargs grep ERROR | # => greps ERROR lines across all files; output is prefixed "path:line"
awk '{print $3}' | # => field 3 is the service name (field 1 is "path:date", field 2 is ERROR)
sort -u # => sorts and de-duplicates the service names into a final report
# => Output: "auth" then "billing" -- two services had at least one ERROR line, alphabetically sortedRun: bash example.sh
Output:
auth
billingKey takeaway: find | xargs grep | awk | sort -u chains four single-purpose tools into one
report; each stage's output becomes the next stage's input, with no intermediate files ever
written to disk.
Why it matters: This is what a "real" shell one-liner chore looks like in practice --
combining find, grep, awk, and sort from Examples 41-52 into a single pipeline that
answers a genuine question ("which services logged an error today") without writing a single line
of a general-purpose scripting language. Recognizing which single-purpose tool handles which stage
is the core skill this whole co-18 toolkit builds toward.
Example 54: Merging stderr into stdout
ex-54 · exercises co-14, co-15
2>&1 redirects file descriptor 2 (stderr) to wherever file descriptor 1 (stdout) currently
points. Placed before a pipe, this merges both streams so that a downstream command like
grep can see stderr output too, since a plain | only ever carries stdout.
learning/code/ex-54-stderr-to-stdout/example.sh
#!/usr/bin/env bash
# Example 54: merging stderr into stdout
set -euo pipefail
noisy() { # => a function that writes to both stdout and stderr
echo "info: starting" # => an ordinary stdout line
echo "error: something broke" >&2 # => explicitly written to stderr (fd 2), not stdout
echo "info: done" # => a second ordinary stdout line
} # => closes the function
noisy 2>&1 | grep error # => 2>&1 redirects stderr into stdout BEFORE the pipe, so grep sees both streams
# => Output: error: something broke -- the two "info:" lines don't match "error" and are filtered outRun: bash example.sh
Output:
error: something brokeKey takeaway: command 2>&1 | next merges stderr into stdout before the pipe runs, so next
receives both streams interleaved; a bare command | next (no 2>&1) would only forward stdout.
Why it matters: A plain pipe silently drops stderr from the receiving command's view -- a
common source of confusion when grepping a command's output for an error message that turns out to
have gone to stderr, not stdout. 2>&1 (placed before the pipe symbol) is the fix, and it is worth
memorizing exactly where it goes: cmd 2>&1 | grep, not cmd | grep 2>&1.
Example 55: Discarding Output to /dev/null
ex-55 · exercises co-14
/dev/null is a special file that silently discards anything written to it. Redirecting both
stdout and stderr there (> /dev/null 2>&1) silences a command completely while still preserving
its exit status for the caller to inspect.
learning/code/ex-55-devnull-discard/example.sh
#!/usr/bin/env bash
# Example 55: discarding output to /dev/null
set -euo pipefail
noisy_cmd() { # => a function that prints to both streams, then fails on purpose
echo "stdout noise" # => an ordinary stdout line
echo "stderr noise" >&2 # => an ordinary stderr line
return 3 # => a non-zero exit status to prove it survives the redirection below
} # => closes the function
if noisy_cmd >/dev/null 2>&1; then # => `if` guards the call so set -e does not abort on the non-zero return
status=0 # => not reached in this run, since noisy_cmd always returns 3
else # => runs because noisy_cmd returned non-zero
status=$? # => captures the real exit status (3) inside the else branch
fi # => closes the if/else; no stdout/stderr from noisy_cmd was printed
echo "exit status: $status" # => proves the exit status survived even though all output was discarded
# => Output: exit status: 3 -- no "stdout noise" or "stderr noise" ever appearsRun: bash example.sh
Output:
exit status: 3Key takeaway: cmd > /dev/null 2>&1 discards every byte a command writes to stdout or stderr,
but its exit status still comes through unaffected -- redirection never changes what a command
returns, only where its output goes.
Why it matters: Silencing a noisy command's output while still checking whether it succeeded
is a routine need -- probing whether a tool is installed, whether a network host is reachable, or
whether a background setup step worked, without cluttering the script's own output with that
command's internal chatter. Wrapping the call in if (rather than calling it bare) is what keeps
this safe under set -e, since a bare failing command would otherwise abort the script.
Example 56: Parsing Flags with getopts
ex-56 · exercises co-20
getopts optstring name is Bash's built-in flag parser: each letter in optstring is a valid
flag, and a trailing : after a letter means that flag takes a value (captured in $OPTARG). A
while getopts ...; do case "$opt" in ... esac; done loop is the standard shape for using it.
learning/code/ex-56-getopts-flags/example.sh
#!/usr/bin/env bash
# Example 56: parsing flags with getopts
set -euo pipefail
set -- -v -o result.txt # => simulates script arguments: a flag (-v) and a flag with a value (-o result.txt)
verbose="false" # => default value, overwritten only if -v is present
outfile="" # => default value, overwritten only if -o is present
while getopts "vo:" opt; do # => "vo:" declares flag -v (no value) and flag -o (colon means it TAKES a value)
case "$opt" in # => $opt holds the option letter getopts just parsed
v) # => matches when -v was passed
verbose="true" # => records that verbose mode was requested
;; # => ends this branch
o) # => matches when -o was passed
outfile="$OPTARG" # => $OPTARG holds the value that followed -o (here, "result.txt")
;; # => ends this branch
*) ;; # => catch-all for any other option; nothing to do here
esac # => closes the case statement
done # => closes the while-loop; getopts returns non-zero once options are exhausted
echo "verbose: $verbose" # => shows the flag was recorded
echo "outfile: $outfile" # => shows the flag's value was captured
# => Output: "verbose: true" then "outfile: result.txt"Run: bash example.sh
Output:
verbose: true
outfile: result.txtKey takeaway: getopts "vo:" opt parses -v as a value-less flag and -o value as a
flag-with-value (the trailing : after o), placing the flag letter in $opt and any value in
$OPTARG.
Why it matters: getopts is Bash's built-in, standard way to parse short flags -- more
structured than hand-rolled shift-based parsing (Example 35) once a script accepts more than one
or two flags, and it correctly handles combined short flags and flags-with-values without any
extra code. Example 57 extends this same loop with usage text and proper error handling for an
invalid flag.
Example 57: Usage Text and Errors with getopts
ex-57 · exercises co-20, co-09
Prefixing getopts's option string with a leading : switches it into "silent errors" mode: an
invalid flag sets $opt to ? (matched with the pattern \?) instead of printing its own
built-in error message, letting the script report the error -- and its exit status -- on its own
terms.
learning/code/ex-57-getopts-usage/example.sh
#!/usr/bin/env bash
# Example 57: usage text and errors with getopts
set -euo pipefail
usage() { # => prints one-line usage text, shared by every caller that needs it
echo "usage: example.sh [-h] [-v]" # => the text itself
} # => closes usage
parse() { # => parses one simulated set of flags, passed in as "$@"
local OPTIND=1 opt # => local OPTIND resets getopts state on every call to parse
while getopts ":hv" opt "$@"; do # => leading : enables SILENT error handling (no built-in message)
case "$opt" in # => $opt holds the parsed option letter, or ? on an invalid one
h) # => matches when -h was passed
usage # => prints the shared usage text
return 0 # => -h exits successfully (status 0)
;; # => ends this branch
v) # => matches when -v was passed
echo "verbose mode" # => confirms verbose mode was recognized
;; # => ends this branch
\?) # => matches any option NOT in ":hv" (an invalid flag)
echo "invalid option: -$OPTARG" >&2 # => $OPTARG holds the invalid letter; reported on stderr
return 1 # => an invalid option exits with a non-zero status
;; # => ends this branch
esac # => closes the case statement
done # => closes the while-loop
return 0 # => reached only if every flag parsed cleanly with no -h
} # => closes parse
# two probes: a valid -h flag, then an invalid -x flag, to exercise both branches of parse
parse -h # => runs the -h branch above
echo "exit after -h: $?" # => $? here is parse's return status from the -h call (0)
parse -x && echo "unexpected success" || echo "exit after -x: $?" # => -x is invalid, so the || branch runs
# => $? here is parse's return status (1)
# => Output: usage line, "exit after -h: 0", "invalid option: -x", "exit after -x: 1"Run: bash example.sh
Output:
usage: example.sh [-h] [-v]
exit after -h: 0
invalid option: -x
exit after -x: 1Key takeaway: A leading : in the option string (":hv", not "hv") hands error handling to
the script itself: an unrecognized flag sets $opt to ? and $OPTARG to the offending letter,
instead of printing getopts's own generic error message.
Why it matters: Every real command-line tool needs to do two things well: print usage text on
-h, and fail clearly (with a non-zero exit status) on a bad flag. This ":optstring" pattern is
what makes both possible from inside a single getopts loop, without needing a second pass over
the arguments or a separate validation step afterward.
Example 58: Default Values with Parameter Expansion
ex-58 · exercises co-05, co-19
${var:-default} expands to $var if it is set and non-empty, or to the literal default
otherwise -- without ever modifying $var itself. Applied to $1, this is the standard way to
give an optional argument a fallback value.
learning/code/ex-58-default-value-param/example.sh
#!/usr/bin/env bash
# Example 58: default values with parameter expansion
set -euo pipefail
show_name() { # => a function that greets by name, or falls back to a default
local name="${1:-default}" # => ${1:-default} uses $1 if set and non-empty, else the literal "default"
echo "name: $name" # => prints whichever value was chosen
} # => closes the function
show_name # => called with NO argument, so $1 is unset and "default" is used
show_name "Ada" # => called WITH an argument, so "Ada" is used instead of the default
# => Output: "name: default" then "name: Ada"Run: bash example.sh
Output:
name: default
name: AdaKey takeaway: ${1:-default} reads as "use $1, or fall back to default if $1 is unset
or empty" -- one expression, no if statement needed to check whether the argument was provided.
Why it matters: This is the standard way to make a positional argument optional without an
explicit if [ -z "$1" ] check beforehand -- an output path that defaults to ./out, a log level
that defaults to info, or (as here) a name that defaults to a placeholder. It composes cleanly
with local (Example 31): the default lives right where the variable is declared.
Example 59: Branching on a Command's Exit Status
ex-59 · exercises co-09, co-10
Any command run directly as an if condition has its exit status checked automatically: 0
(success) takes the then branch, anything else takes the else branch. grep -q (quiet mode,
no output) is a common command to use this way, since its exit status alone answers "did it
match".
learning/code/ex-59-check-command-success/example.sh
#!/usr/bin/env bash
# Example 59: branching on a command's exit status
set -euo pipefail
printf 'apple\nbanana\ncherry\n' >fruits.txt # => builds a small real data file to search
if grep -q banana fruits.txt; then # => -q runs grep silently; `if` checks its exit status directly
echo "found banana" # => runs because grep found a match (exit status 0)
else # => runs only if grep found no match (exit status 1)
echo "banana missing" # => not reached in this run
fi # => closes the if/else
# => Output: found bananaRun: bash example.sh
Output:
found bananaKey takeaway: if grep -q pattern file; then ... fi checks grep's exit status directly --
0 if it found a match, 1 if it did not -- with -q suppressing grep's normal line-printing
output entirely.
Why it matters: This is the general shape behind Example 32's function-return-status check,
now applied to a real external command instead of a hand-written one: any command that reports
success/failure via its exit status can drive an if directly, with no separate $? comparison
needed. It is the idiomatic way to ask a yes/no question of the filesystem or another program.
Example 60: pipefail Catches a Failing First Stage
ex-60 · exercises co-03, co-15
Without pipefail, a pipeline's exit status is only ever the last command's status -- an
earlier stage can fail silently. set -o pipefail changes this: the pipeline's exit status
becomes the rightmost command that failed, or 0 if every stage succeeded.
learning/code/ex-60-pipefail-catches-failure/example.sh
#!/usr/bin/env bash
# Example 60: pipefail catches a failing first stage
set -uo pipefail # => deliberately omits -e here, so the script can inspect $? after a failing pipeline
grep quux /dev/null | wc -l # => grep finds no "quux" in empty input, so grep itself exits 1 (no match)
# => wc -l still succeeds (exit 0), printing the line count "0"
echo "pipeline exit status: $?" # => with pipefail ON, $? is grep's FAILING status (1), not wc -l's success (0)Run: bash example.sh
Output:
0
pipeline exit status: 1Key takeaway: set -o pipefail makes a pipeline's exit status reflect ANY failing stage, not
just the last one; here wc -l succeeds (printing a right-justified 0 -- BSD/macOS wc -l pads
single counts with leading spaces, unlike GNU wc -l's unpadded form) but grep's earlier failure
(no match) still makes the whole pipeline report exit status 1.
Why it matters: Without pipefail, a pipeline like curl ... | jq ... would report success
even if curl failed outright, since jq's own exit status (likely 0, having successfully
processed empty input) is all that gets checked by default. pipefail closes this real,
easy-to-miss gap, which is exactly why set -euo pipefail (all three flags together) is the
standard strict-mode preamble for any script that is not deliberately inspecting a pipeline's
failure the way this example does.
← Previous: Beginner Examples · Next: Advanced Examples →
Last updated July 13, 2026