Regular Expressions in Bash

Chapter 5 — Quantifiers: How Many Times?

What Are Quantifiers?

A quantifier tells the regex engine how many times the preceding element — a literal character, a dot, or a character class — must appear for a match to succeed. Without a quantifier, every element matches exactly once. Quantifiers unlock patterns like "one or more digits" or "between 2 and 5 letters".

* Zero or more The preceding element may appear any number of times — including zero. Always matches (even empty string). BRE: * (same)
+ One or more The preceding element must appear at least once. Fails if there is no match at all. BRE: \+ (GNU) or [x][x]* workaround
? Zero or one (optional) The preceding element is optional — it may appear once or not at all. Used for optional parts of a pattern. BRE: \? (GNU) or (x|) workaround
{n} Exactly n times The preceding element must appear exactly n times. No more, no less. BRE: \{n\}
{n,} At least n times The preceding element must appear n or more times. No upper limit. BRE: \{n,\}
{n,m} Between n and m times The preceding element must appear at least n and at most m times. Both bounds inclusive. BRE: \{n,m\}

Quantifiers apply to the single element immediately to their left. To quantify a group of characters, wrap them in parentheses (covered in Chapter 6): (abc)+ matches one or more repetitions of abc.

* — Zero or More

* is the oldest and most common quantifier. It matches zero or more occurrences of the preceding element. Because it accepts zero occurrences, it always produces a match — even on an empty string or one where the element is absent.

# ab*c — 'b' appears zero or more times
echo -e "ac\nabc\nabbc\nabbbc\nxc" | grep 'ab*c'
ac       # zero b's
abc      # one b
abbc     # two b's
abbbc    # three b's
# "xc" has no match — 'a' is required (not quantified)

# .* — match any number of any characters (the wildcard pattern)
grep 'start.*end' file.txt      # "start" then anything then "end"

# [0-9]* — zero or more digits (matches empty string too!)
echo "abc" | grep -oE '[0-9]*'
           # matches empty string before 'a', before 'b', before 'c', after 'c'
# This is the "zero or more matches empty" gotcha — see below
The zero-or-more trap. [0-9]* matches the empty string, so grep -oE '[0-9]*' on the string abc will match four empty strings — one between every character. This is almost never what you want. Use + (one or more) when you require at least one match.

+ — One or More

+ requires at least one occurrence. It is the version of * you should reach for by default when you know the element must be present. In ERE (grep -E, sed -E, awk, [[ =~ ]]) write +; in BRE write \+ (GNU extension).

# [0-9]+ — one or more digits (no empty-string problem)
echo "order 42 items, total 199 units" | grep -oE '[0-9]+'
42
199

# \w+ — one or more word characters (GNU grep)
grep -oE '\w+' <<< "hello_world 2026"
hello_world
2026

# [[:alpha:]]+ — one or more letters (POSIX, portable)
grep -oE '[[:alpha:]]+' <<< "error: code 42"
error
code

# BRE equivalent (GNU extension — note \+)
grep '[0-9]\+' data.txt

# Validate non-empty input in a script
[[ -z "$input" ]] && echo "empty!" || echo "has content"
# or with regex — must contain at least one non-space char:
[[ "$input" =~ [^[:space:]]+ ]] && echo "not blank"

? — Zero or One (Optional)

? makes the preceding element optional — it may appear once or not at all. It is perfect for handling optional characters like a sign, a delimiter, or a variant spelling. In BRE write \? (GNU extension).

# colou?r — the 'u' is optional
echo -e "colour\ncolor\ncoluur" | grep -E 'colou?r'
colour
color
# "coluur" has no match — only ONE optional u is allowed

# Match an optional leading minus sign on a number
grep -oE '-?[0-9]+' <<< "temps: -5 0 23 -18"
-5
0
23
-18

# https? — match http or https
grep -E 'https?://' urls.txt

# Match dates with optional separator: 20260611 or 2026-06-11 or 2026/06/11
grep -E '[0-9]{4}[-/]?[0-9]{2}[-/]?[0-9]{2}' dates.txt

# BRE version (GNU sed)
sed -n '/https\?:\/\//p' urls.txt   # \? in BRE
Pattern: -?[0-9]+ applied to various inputs -42 ✓ optional minus present, digits follow 42 ✓ optional minus absent, digits follow 0 ✓ zero is a valid digit - ✗ minus present but no digits follow (+ requires at least one) abc ✗ no digits at all

{n}, {n,}, {n,m} — Exact and Bounded Counts

Curly-brace quantifiers give you precise control over repetition counts. They are indispensable for matching fixed-length formats like dates, postal codes, credit card numbers, and phone numbers.

# {n} — exactly n occurrences
grep -E '[0-9]{4}' data.txt        # exactly 4 consecutive digits
grep -E '[A-Z]{3}' text.txt        # exactly 3 uppercase letters
grep -E '^.{80}$' file.txt         # lines that are exactly 80 characters

# {n,} — n or more occurrences
grep -E '[0-9]{3,}' data.txt       # 3 or more consecutive digits
grep -E '[[:alpha:]]{8,}' words.txt # words with 8 or more letters

# {n,m} — between n and m occurrences (inclusive)
grep -E '[0-9]{2,4}' data.txt      # 2, 3, or 4 consecutive digits
grep -E '[[:alpha:]]{4,8}' words.txt# words between 4 and 8 letters long

# Practical: match a 4-digit year
grep -E '\b[0-9]{4}\b' text.txt

# Practical: match a UK postcode pattern (simplified: A9 9AA or A99 9AA)
grep -E '[A-Z]{1,2}[0-9]{1,2} [0-9][A-Z]{2}' addresses.txt

# Practical: match lines longer than 120 characters
grep -E '^.{121}' source.py         # 121+ chars means line exceeds 120

BRE requires backslashes around braces

BRE — default grep / sed [0-9]\{4\} # exactly 4 [0-9]\{3,\} # 3 or more [0-9]\{2,4\} # 2 to 4 x\+ # one or more (GNU ext) x\? # zero or one (GNU ext) \(abc\) # group (see Ch 6)
ERE — grep -E / sed -E / awk [0-9]{4} # exactly 4 [0-9]{3,} # 3 or more [0-9]{2,4} # 2 to 4 x+ # one or more x? # zero or one (abc) # group (see Ch 6)

Greedy Matching — The Default Behaviour

All quantifiers in BRE and ERE are greedy — they consume as many characters as possible while still allowing the overall pattern to succeed. The engine tries the longest possible match first, then backtracks if needed.

Pattern: <.*> on input: <b>bold</b> and <i>italic</i> Engine attempt: <b>bold</b> and <i>italic</i> .* consumed everything from <b> to the last > Result: ONE match — the entire string   Pattern: <[^>]*> on same input (negated class workaround): Match 1: <b> — stops at first > Match 2: </b> Match 3: <i> Match 4: </i>
# Greedy: .* swallows as much as possible
echo '[one] and [two] and [three]' | grep -oE '\[.*\]'
[one] and [two] and [three]   # one giant match

# Fix with negated class — stop at the first ]
echo '[one] and [two] and [three]' | grep -oE '\[[^]]*\]'
[one]
[two]
[three]   # three separate matches

# Greedy with + on digits
echo "abc12345def" | grep -oE '[0-9]+'
12345   # + is greedy — matches all 5 digits as one match, not 5 single ones

# Greedy with {2,4}: takes 4 when it can
echo "123456" | grep -oE '[0-9]{2,4}'
1234   # takes 4 (the maximum) first, leaving "56" for the next match
56

No lazy quantifiers in BRE/ERE — the workaround

Most modern regex flavours have a lazy (non-greedy) quantifier: *?, +?, ??. These match as few characters as possible. BRE and ERE do not have lazy quantifiers. The standard workaround is to replace .* with a negated character class that stops at the boundary:

Greedy (wrong)Intended behaviourBRE/ERE workaround
<.*>Each HTML tag individually<[^>]*>
".*"Each quoted string individually"[^"]*"
\(.*\)Each parenthesised group\([^)]*\)
\[.*\]Each bracketed item\[[^]]*\]
start.*endShortest start…end spanstart[^e]*end (if safe)
grep -P has lazy quantifiers. If you genuinely need non-greedy matching and negated classes won't work, grep -P (PCRE) supports .*? and .+?. This is covered fully in Chapter 11.

Quantifier Portability

Quantifier ERE meaning grep (BRE) grep -E sed (BRE) sed -E awk [[ =~ ]]
*Zero or more
+One or more \+ GNU\+ GNU
?Zero or one \? GNU\? GNU
{n,m}n to m times \{n,m\}\{n,m\}
*? +?Lazy (non-greedy)

Practical Quantifier Recipes

Matching structured formats

# ISO date: YYYY-MM-DD
grep -E '\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b' file.txt

# Time HH:MM or HH:MM:SS
grep -E '\b[0-9]{2}:[0-9]{2}(:[0-9]{2})?\b' log.txt

# IPv4 address (simplified — allows invalid ranges like 999.999.9.9)
grep -E '\b[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\b' file.txt

# Email address (simplified)
grep -E '[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}' contacts.txt

# Version string: 1 to 3 dot-separated number groups
grep -E '\b[0-9]+(\.[0-9]+){1,2}\b' changelog.txt

Using quantifiers in sed substitutions

# Replace any run of whitespace with a single space
sed -E 's/[[:space:]]+/ /g' file.txt

# Remove all standalone numbers (whole words that are digits)
sed -E 's/\b[0-9]+\b//g' text.txt

# Truncate lines longer than 80 characters (keep first 80)
sed -E 's/^(.{80}).*/\1/' file.txt   # capture 80 chars, drop the rest

# Normalise multiple blank lines to one blank line
cat -s file.txt   # cat -s squeezes blank lines (easiest way)
# or with sed:
sed '/./,/^$/!d' file.txt

Validation functions using quantifiers

# Is this a valid port number (1-65535)?
is_port() {
    [[ "$1" =~ ^[0-9]{1,5}$ ]] && [[ "$1" -ge 1 ]] && [[ "$1" -le 65535 ]]
}
# regex checks format; arithmetic checks range

# Is this a valid IPv4 octet (0-255)?
is_octet() {
    [[ "$1" =~ ^[0-9]{1,3}$ ]] && [[ "$1" -le 255 ]]
}

# Does this filename have a valid extension (2-4 alpha chars)?
[[ "$file" =~ \.[[:alpha:]]{2,4}$ ]] && echo "has extension"

Common Quantifier Mistakes

MistakePatternProblemFix
Using * when + is needed [0-9]* Matches empty string — passes validation even with no digits [0-9]+
Greedy .* consuming too much <.*> Matches from first < to last > <[^>]*>
Missing anchors with {n} [0-9]{4} Matches any 4 digits anywhere — including inside "123456" \b[0-9]{4}\b or ^[0-9]{4}$
BRE braces without backslash grep '[0-9]{4}' In BRE, {4} is literal — matches the characters {, 4, } grep '[0-9]\{4\}' or grep -E '[0-9]{4}'
+ or ? unescaped in BRE grep 'ab+c' In BRE, + is a literal plus sign — matches ab+c grep 'ab\+c' or grep -E 'ab+c'
{n,m} with no comma for range [0-9]{3} Matches exactly 3 — if you want "3 to 5" you need a comma: {3,5} [0-9]{3,5}

Quick Reference — Chapter 5

Quantifiers — ERE syntax (grep -E, sed -E, awk, [[ =~ ]])

x* Zero or more of x — always matches (even empty)
x+ One or more of x — requires at least one occurrence
x? Zero or one of x — makes x optional
x{n} Exactly n occurrences of x
x{n,} At least n occurrences of x
x{n,m} Between n and m occurrences of x (inclusive)

BRE equivalents (default grep / sed)

x\+ x\? One or more / zero or one (GNU extension)
x\{n\} x\{n,m\} Exact and bounded counts (backslashes required)

Greedy workaround

[^delimiter]* Match up to (not including) the delimiter — the BRE/ERE lazy substitute
grep -P '.*?' True lazy/non-greedy quantifier — PCRE only (Chapter 11)
What is coming next: Chapter 6 covers grouping and alternation — using (...) to treat multiple characters as a single unit for quantifiers, and | to match one of several alternatives. It also covers backreferences: capturing what a group matched and reusing it in the pattern or the replacement string.