Regular Expressions in Bash

Chapter 3 — Anchors: Start, End, and Word Boundaries

What Are Anchors?

An anchor does not match a character — it matches a position in the text. The two most important anchors, ^ and $, pin your pattern to the start or end of a line. Without anchors, a pattern can match anywhere on the line; with anchors, you specify exactly where it must appear.

Think of anchors as invisible markers that say: "the match must begin here" or "the match must end here".

^ Start of line Matches the position immediately before the first character of the line. The pattern that follows must appear at the very beginning. Works in: grep, sed, awk, [[ =~ ]]
$ End of line Matches the position immediately after the last character of the line (before the newline). The pattern that precedes must appear at the very end. Works in: grep, sed, awk, [[ =~ ]]
\b Word boundary (GNU) Matches the position between a word character (\w) and a non-word character (\W) — i.e. the edge of a word. Matches at both the start and end of words. Works in: GNU grep, GNU sed, GNU awk
\< \> Word start / end (POSIX) \< matches the position at the start of a word; \> matches the position at the end. Together they are the portable alternative to \b. Works in: grep, sed (POSIX BRE/ERE)

^ — The Start-of-Line Anchor

^ asserts that what follows must appear at the very beginning of the line. Without ^, the pattern can match anywhere; with ^, it must be the first thing on the line.

Pattern: ^Error — where does it match?
^ Error on line 1 ✓ match — "Error" is at the start
^ 2024 Error found ✗ no match — line starts with "2024", not "Error"
^ ·Error ✗ no match — line starts with a space
# Lines that start with "Error"
grep '^Error' app.log

# Lines that start with a digit
grep '^[0-9]' data.txt

# Lines that start with optional whitespace then a # (comment lines)
grep '^[[:space:]]*#' config.conf

# Delete all comment lines from a config file
sed '/^[[:space:]]*#/d' config.conf

# In awk: process lines that start with "WARN"
awk '/^WARN/ { print NR": "$0 }' app.log

# In [[ =~ ]]: does this string start with a digit?
[[ "$line" =~ ^[0-9] ]] && echo "starts with digit"

^ inside a character class means negation — not an anchor

# ^ at the START of the pattern = start-of-line anchor
grep '^cat'         # line must START with "cat"

# ^ as the FIRST char inside [...] = negation (match anything NOT in set)
grep '[^abc]'       # match any character that is NOT a, b, or c

# ^ anywhere else inside [...] = literal caret character
grep '[a^b]'        # matches a, ^, or b — ^ is literal here
^ has two completely different meanings depending on context. At the start of a pattern or after | it is a start-of-line anchor. As the first character inside [...] it negates the class. Everywhere else inside [...] it is just a literal caret. Memorise this — it trips up everyone.

$ — The End-of-Line Anchor

$ asserts that what precedes must appear at the very end of the line. The regex engine matches $ against the position just before the newline character (which SED and grep strip off before processing).

Pattern: \.log$ — where does it match?
app.log $ ✓ match — ends with .log
app.log.1 $ ✗ no match — ends with .1 not .log
error.log· $ ✗ no match — trailing space after .log
# Lines that end with a semicolon
grep ';$' source.js

# List only .log files
ls | grep '\.log$'

# Lines that end with one or more digits
grep -E '[0-9]+$' data.txt

# Remove trailing whitespace (common pre-commit fix)
sed 's/[[:space:]]*$//' file.txt

# Delete lines that end with a backslash (continuation lines)
grep -v '\\$' makefile   # \\ matches literal \, then $ anchors to end

Combining ^ and $ Together

When you use ^ and $ together, you anchor the pattern to the entire line — the match must cover the line from start to finish.

# Match blank lines (nothing between start and end)
grep '^$' file.txt             # exactly empty
grep '^[[:space:]]*$' file.txt  # blank or whitespace-only

# Delete blank lines
sed '/^$/d' file.txt

# Match lines that contain ONLY digits (nothing else)
grep -E '^[0-9]+$' data.txt

# Validate that a variable is a positive integer
[[ "$val" =~ ^[0-9]+$ ]] && echo "valid integer"

# Match lines that are exactly 8 characters long
grep -E '^.{8}$' file.txt

# Match lines containing ONLY uppercase letters and spaces
grep -E '^[A-Z ]+$' file.txt

# In sed: add text at the very start of every line
sed 's/^/    /' file.txt        # indent every line by 4 spaces

# In sed: add text at the very end of every line
sed 's/$/;/' file.txt           # append semicolon to every line

Word Boundaries

Line anchors pin you to the start or end of the entire line. But often you want to match a word only when it appears as a complete word — not as part of a longer word. Word boundary anchors solve this.

Word boundaries in: "the cat concatenate scat" |the| |cat| |concatenate| |scat| | marks a word boundary position (between word-char and non-word-char) Pattern \bcat\b matches: cat (standalone word only) Pattern \bcat\b does NOT match: concatenate, scat

\b — word boundary (GNU)

A word character is any letter, digit, or underscore ([[:alnum:]_]). A word boundary is the position between a word character and a non-word character (or between a word character and the start/end of the line).

# Match the word "cat" but not "concatenate" or "scat"
echo "the cat concatenate scat scatter" | grep -oE '\bcat\b'
cat

# Replace the word "error" but not "errors" or "no_error_code"
sed 's/\berror\b/ERROR/g' app.log

# Count occurrences of the exact word "the"
grep -oE '\bthe\b' essay.txt | wc -l

# \b also anchors to start/end of line
echo "error at line 5" | grep -E '\berror\b'
error at line 5   # matches — "error" is a standalone word at line start

\B — non-word boundary (GNU)

\B is the opposite of \b — it matches a position that is not at a word boundary, i.e. the middle of a word.

# Match "cat" only when it is part of a longer word
echo "cat concatenate scat" | grep -oE '\Bcat\B'
cat   # only the "cat" inside "con-cat-enate" matches

# Practical: find compound words containing "data"
grep -E '\Bdata\B' source.py   # matches metadata, database but not standalone "data"

\< and \> — POSIX word edges

These are the POSIX-portable way to match word boundaries. \< matches the start of a word; \> matches the end. They work in GNU grep and sed in BRE mode without -E, making them the safest choice in portable scripts.

# Match whole word "cat" using POSIX word boundaries
grep '\<cat\>' file.txt

# Same as \bcat\b but more portable
echo "the cat concatenate scat" | grep -o '\<cat\>'
cat

# In sed: replace whole word only
sed 's/\<error\>/ERROR/g' app.log

# Only word-start boundary (match words beginning with "pre")
grep '\<pre' dictionary.txt   # preview, prepare, prefix — but not "impressive"

# Only word-end boundary (match words ending in "ing")
grep 'ing\>' text.txt          # running, jumping — but not "rings"

Anchor Portability — What Works Where

Anchor grep (BRE) grep -E (ERE) grep -P (PCRE) sed awk [[ =~ ]]
^
$
\b GNU onlyGNU only GNU onlyGNU awkGNU bash
\B GNU onlyGNU only GNU onlyGNU awkGNU bash
\< \>
Portable scripts: use \< and \> when writing scripts for POSIX environments or macOS (BSD tools). Use \b freely in interactive one-liners on Linux where GNU tools are guaranteed. In [[ =~ ]] and awk, use character class workarounds if portability matters.

Practical Anchor Patterns

Input validation in scripts

# Is input a valid positive integer?
is_integer() {
    [[ "$1" =~ ^[0-9]+$ ]]
}
is_integer "42"   && echo "ok"    # ok
is_integer "3.14" && echo "ok"  # no match — dot disqualifies it
is_integer "12x"  && echo "ok"  # no match — trailing x

# Is input a valid ISO date (YYYY-MM-DD)?
is_date() {
    [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]
}
is_date "2026-06-11"  && echo "valid"
is_date "06/11/2026"  && echo "valid"  # no match — wrong format

# Does the string start with http:// or https://?
[[ "$url" =~ ^https?:// ]] || echo "not a URL"

Config and log file tasks

# Print only non-blank, non-comment lines from a config file
grep -Ev '^[[:space:]]*(#|$)' sshd_config

# Find lines where a key appears at the start (key=value format)
grep '^MaxSessions' /etc/ssh/sshd_config

# Find duplicate words (same word at end and start of adjacent lines)
grep -E '\bthe\b.*\bthe\b' essay.txt   # "the" twice on same line

# Show only lines that are pure section headers [SectionName]
grep -E '^[[:space:]]*\[[[:alnum:]_]+\][[:space:]]*$' app.conf

# Add a line number prefix only to non-blank lines
grep -n '.' file.txt   # . matches any char = any non-empty line

Whole-word replacement in sed

# Replace standalone "cat" — not "cats", "tomcat", "concatenate"
sed 's/\bcat\b/dog/g' file.txt           # GNU sed
sed 's/\<cat\>/dog/g' file.txt          # POSIX portable

# Rename a function across a codebase (word boundary prevents partial matches)
sed -i 's/\bgetUser\b/fetchUser/g' *.js

# Replace a word at the start of a line only
sed 's/^TODO/DONE/' tasks.txt

# Append a comma to every line that doesn't already end with one
sed '/,$/! s/$/,/' list.txt   # /,$/! = lines NOT ending in comma

Common Anchor Mistakes

Mistake Pattern used Problem Fix
Matching anywhere instead of start grep 'Error' Matches "Error" mid-line too grep '^Error'
Trailing space breaks end anchor grep '\.log$' Fails if line has trailing space grep '\.log[[:space:]]*$'
Partial word match sed 's/cat/dog/g' Changes "concatenate" → "condogenate" sed 's/\bcat\b/dog/g'
^ inside class negates instead of anchors grep '[^a-z]' Matches non-lowercase chars, not start of line Use ^[a-z] outside brackets for anchoring
Using \b on macOS/BSD grep '\bword\b' BSD grep doesn't support \b grep '\<word\>' or grep -E '\bword\b' with GNU grep
Forgetting $ in validation [[ "$v" =~ ^[0-9]+ ]] "123abc" passes — only checks the start [[ "$v" =~ ^[0-9]+$ ]]

Quick Reference — Chapter 3

Line Anchors

^pattern Pattern must appear at the start of the line
pattern$ Pattern must appear at the end of the line
^pattern$ Pattern must match the entire line
^$ Match a completely empty line
^[[:space:]]*$ Match a blank or whitespace-only line

Word Boundaries

\bword\b Match whole word — GNU tools (grep, sed, awk)
\<word\> Match whole word — POSIX portable (grep, sed)
\b Position between word-char and non-word-char
\B Position inside a word (not at a boundary)
\< Start of a word (POSIX)
\> End of a word (POSIX)
What is coming next: Chapter 4 covers character classes — [abc], [a-z], negated classes [^...], and the full set of POSIX named classes like [[:alpha:]], [[:digit:]], and [[:space:]]. Character classes give you precise control over which characters are allowed at any position in your pattern.