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".
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
[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
{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
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.
# 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 behaviour | BRE/ERE workaround |
|---|---|---|
<.*> | Each HTML tag individually | <[^>]*> |
".*" | Each quoted string individually | "[^"]*" |
\(.*\) | Each parenthesised group | \([^)]*\) |
\[.*\] | Each bracketed item | \[[^]]*\] |
start.*end | Shortest start…end span | start[^e]*end (if safe) |
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
| Mistake | Pattern | Problem | Fix |
|---|---|---|---|
| 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, [[ =~ ]])
BRE equivalents (default grep / sed)
Greedy workaround
(...) 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.