Linux Basic β sed & awk Practice Exercises
sed & awk in Scripts β Practice Exercises
Five exercises spanning capture, sed, awk, and Bash regex. Each one mirrors a real scripting task.
Exercise 1 β Safe Capture With Exit Code
Goal: Write a run function that takes a command, runs it, and captures both stdout+stderr and the exit code. If the command fails, print FAIL <name> (exit N) and the captured output on stderr, then return the same exit code. If it succeeds, print OK <name> and any output indented under it. Demonstrate with ls /tmp, ls /nope, and false.
π‘ Show Answer
run() {
local name=$1
shift
local output rc
output=$("$@" 2>&1)
rc=$?
if (( rc != 0 )); then
printf '[FAIL] %s (exit %d)\n' "$name" "$rc" >&2
[[ -n "$output" ]] && printf ' %s\n' "$output" >&2
return "$rc"
fi
printf '[OK] %s\n' "$name"
[[ -n "$output" ]] && printf ' %s\n' "$output"
}
run "list tmp" ls /tmp >/dev/null
run "list nope" ls /nope-does-not-exist
run "false" false
echo "---"
echo "exit of last: $?"
The output=$(β¦) then rc=$? order is critical β anything between them overwrites $?.
Exercise 2 β Cross-Platform sed -i Wrapper
Goal: Write a sedi function that performs sed -i correctly on both GNU sed (Linux) and BSD sed (macOS). It should accept the same arguments as sed, minus the -i flag itself. Demonstrate by replacing every occurrence of old with new in a test file.
π‘ Show Answer
sedi() {
case "$OSTYPE" in
darwin*) sed -i '' "$@" ;;
*) sed -i "$@" ;;
esac
}
# Test
cat > /tmp/test.txt <<'EOF'
this is old text
old again
nothing here
EOF
sedi 's/old/new/g' /tmp/test.txt
cat /tmp/test.txt
# this is new text
# new again
# nothing here
Robust alternative β backup + replace + cleanup, no -i at all:
sedi_portable() {
local file=${@: -1} # last arg
local args=( "${@:1:$#-1}" ) # all but last
cp -p "$file" "$file.tmp.$$"
sed "${args[@]}" "$file.tmp.$$" > "$file"
rm "$file.tmp.$$"
}
Exercise 3 β Validate Semver With =~
Goal: Write a function parse_semver that takes a string and:
- returns 0 and prints
major minor patch [prerelease]if itβs valid semver (v1.2.3or1.2.3-rc1, with optional leadingv) - returns 1 silently otherwise
Test with: v1.2.3, 2.0.0, 1.2.3-beta.4, 1.2, v1.2.x.
π‘ Show Answer
parse_semver() {
local s=$1
local re='^v?([0-9]+)\.([0-9]+)\.([0-9]+)(-[A-Za-z0-9.]+)?$'
if [[ "$s" =~ $re ]]; then
local major=${BASH_REMATCH[1]}
local minor=${BASH_REMATCH[2]}
local patch=${BASH_REMATCH[3]}
local pre=${BASH_REMATCH[4]#-} # strip leading dash if present
printf '%s %s %s' "$major" "$minor" "$patch"
[[ -n "$pre" ]] && printf ' %s' "$pre"
echo
return 0
fi
return 1
}
for v in v1.2.3 2.0.0 1.2.3-beta.4 1.2 v1.2.x; do
if out=$(parse_semver "$v"); then
printf '%-15s β %s\n' "$v" "$out"
else
printf '%-15s β INVALID\n' "$v"
fi
done
# v1.2.3 β 1 2 3
# 2.0.0 β 2 0 0
# 1.2.3-beta.4 β 1 2 3 beta.4
# 1.2 β INVALID
# v1.2.x β INVALID
Note the unquoted $re β quoting it would make it match literally.
Exercise 4 β Top 5 Source IPs From a Mini Access Log
Goal: Given the access-log-shaped data below, print the top 5 source IPs by request count, format <count> <ip>, descending.
Setup:
cat > /tmp/access.log <<'EOF'
10.0.0.1 - - [17/Jun/2026:09:00:01] "GET / HTTP/1.1" 200 1024
10.0.0.2 - - [17/Jun/2026:09:00:02] "GET /a HTTP/1.1" 200 512
10.0.0.1 - - [17/Jun/2026:09:00:03] "GET /b HTTP/1.1" 200 800
10.0.0.3 - - [17/Jun/2026:09:00:04] "GET / HTTP/1.1" 404 256
10.0.0.1 - - [17/Jun/2026:09:00:05] "POST /api HTTP/1.1" 200 600
10.0.0.2 - - [17/Jun/2026:09:00:06] "GET /a HTTP/1.1" 200 512
10.0.0.4 - - [17/Jun/2026:09:00:07] "GET / HTTP/1.1" 200 1024
10.0.0.1 - - [17/Jun/2026:09:00:08] "GET /c HTTP/1.1" 200 400
10.0.0.5 - - [17/Jun/2026:09:00:09] "GET / HTTP/1.1" 500 0
10.0.0.2 - - [17/Jun/2026:09:00:10] "GET /a HTTP/1.1" 200 512
EOF
π‘ Show Answer
awk '{ c[$1]++ } END { for (ip in c) print c[ip], ip }' /tmp/access.log \
| sort -rn \
| head -5
# 4 10.0.0.1
# 3 10.0.0.2
# 1 10.0.0.5
# 1 10.0.0.4
# 1 10.0.0.3
Three commands; no shell loop. Awk does the counting in one pass through the file.
Bonus β total bytes per IP too:
awk '{ c[$1]++; b[$1]+=$NF } END { for (ip in c) printf "%d %12d %s\n", c[ip], b[ip], ip }' \
/tmp/access.log | sort -rn | head -5
Exercise 5 β Update or Insert a Config Setting
Goal: Write a function set_config FILE KEY VALUE that:
- If
KEY=β¦already exists inFILE, replaces the line - If not, appends
KEY=VALUEat the end - Preserves a backup at
FILE.bak - Works on both Linux and macOS
Demonstrate by setting timeout=60 (update an existing key) and retries=3 (insert new).
π‘ Show Answer
set_config() {
local file=$1 key=$2 val=$3
if grep -q "^${key}=" "$file"; then
# Existing: update
case "$OSTYPE" in
darwin*) sed -i.bak "s|^${key}=.*|${key}=${val}|" "$file" ;;
*) sed -i.bak "s|^${key}=.*|${key}=${val}|" "$file" ;;
esac
else
# Missing: backup + append
cp -p "$file" "$file.bak"
printf '%s=%s\n' "$key" "$val" >> "$file"
fi
}
cat > /tmp/app.conf <<'EOF'
host=example.com
port=8080
timeout=30
EOF
set_config /tmp/app.conf timeout 60
set_config /tmp/app.conf retries 3
cat /tmp/app.conf
# host=example.com
# port=8080
# timeout=60
# retries=3
A pure-awk version that does it all in one atomic pass:
set_config_awk() {
local file=$1 key=$2 val=$3
awk -v k="$key" -v v="$val" '
BEGIN { FS=OFS="=" }
$1 == k { print k OFS v; found=1; next }
{ print }
END { if (!found) print k OFS v }
' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
}
The awk version is atomic (no half-written file if interrupted) and platform-independent.
Whatβs Next?
You now have the full Bash text toolkit: parameter expansion for strings in memory, regex for in-process matching, sed/awk for streams and files. The next chapter pivots to data structures β how to slice, sort, copy, and otherwise manipulate arrays.
Continue β Manipulating Arrays