Skip to content

Greedy vs. Non-Greedy Regex: Why the Same Pattern Can Match Differently

A regex quantifier like * or + means “the preceding element can repeat any number of times,” but how much it actually matches depends on whether the engine tries to match as much as possible or as little as possible. That’s the greedy vs. non-greedy distinction, and adding a single ? to a quantifier can produce a completely different result. This post works through that distinction using real parsing code from this app’s WP-CLI output handling.

Quantifiers Are Greedy by Default

Note: a quantifier is the part of a regex pattern that says how many times the preceding element may repeat — * (zero or more), + (one or more), ? (zero or one), and {n,m} (n to m times) are the common ones.

Unless told otherwise, a regex quantifier tries to match as much of the input as it possibly can. That’s the default “greedy” behavior.

Notation Meaning Behavior
.* zero or more (greedy) tries to match as much as possible
.*? zero or more (non-greedy) tries to match as little as possible
.+ one or more (greedy) tries to match as much as possible
.+? one or more (non-greedy) tries to match as little as possible

A greedy .* starts by consuming the entire remaining input, then backtracks one character at a time until the rest of the pattern can be satisfied. A non-greedy .*? does the opposite: it starts by consuming nothing, then expands one character at a time only when forced to. Same input, same pattern text — but the direction of that search can change which substring ends up matching.

Example 1: Pulling JSON Out of a Noisy Output Stream

This app fetches structured output from WP-CLI commands like wp plugin list --format=json to detect plugin updates, but on some environments PHP deprecation warnings get mixed in before or after the actual JSON payload (the module docstring in core/wpcli_json.py has the full background). If you reach for a non-greedy pattern to “grab from the first { to the first },” it breaks on nested JSON.

import re, json

noisy = 'PHP Deprecated: something in file on line 5\n{"a": {"b": 1}, "c": 2}\n'

# non-greedy: stops at the first closing brace it finds
m = re.search(r'\{.*?\}', noisy, re.DOTALL)
print(m.group(0))
# => '{"a": {"b": 1}'  <- stops at the inner object's brace, outer one never closes

json.loads(m.group(0))
# => json.decoder.JSONDecodeError: Expecting ',' delimiter

The non-greedy .*? tries to match as little as possible, so the moment the inner object {"b": 1} closes, the engine decides the whole pattern (\{.*?\}) is already satisfied and stops right there — leaving the outer JSON truncated. A greedy \{.*\}, by contrast, first consumes to the end of the string and backtracks from there, so it correctly reaches the last closing brace instead.

m = re.search(r'\{.*\}', noisy, re.DOTALL)
print(m.group(0))
# => '{"a": {"b": 1}, "c": 2}'  <- correctly includes the outer object

json.loads(m.group(0))  # OK

The actual core/wpcli_json.py::extract_json_from_wpcli_output() implements this “first opening bracket to last closing bracket” idea not with a regex, but with plain str.find() / str.rfind():

def _try_slice(open_ch, close_ch):
    first = stdout.find(open_ch)
    if first == -1:
        return None, False
    last = stdout.rfind(close_ch)   # last occurrence — same outcome as greedy matching
    if last == -1 or last <= first:
        return None, False
    candidate = stdout[first:last + 1]
    try:
        return json.loads(candidate), True
    except (json.JSONDecodeError, ValueError):
        return None, False

The reason it avoids a greedy regex like \{.*\} here is performance: on a long, noisy string with many stray brackets, backtracking over .* can blow up in the worst case, because the regex engine has to re-check candidate positions repeatedly. find / rfind each scan the string once, so that kind of slowdown can’t happen by construction. It’s a case of getting the same outcome greedy matching would produce, through a lighter-weight mechanism.

Example 2: Sometimes the Choice Isn’t Greedy vs. Non-Greedy at All

core/db_backup_diagnostics.py parses progress lines emitted by a remote shell while monitoring a database backup over SSH:

PROGRESS_LINE_RE = re.compile(
    r'^__WPMM_PROGRESS__\s+elapsed=(\d+)s\s+size=\s*(\d+)B\s*$'
)

The \s* (zero or more whitespace characters) here isn’t a greedy/non-greedy decision — it solves a different, practical problem: whether whitespace appears right after size= depends on the remote server’s OS. wc -c < file prints its number left-aligned on Linux, but right-justified on FreeBSD-family systems (used by some Japanese hosting providers), producing output like size= 6747025B with a leading space. A pattern like size=(\d+) that assumes no whitespace would silently fail to match on FreeBSD, leaving the progress log empty. Adding \s* absorbs that OS-level difference in a single pattern.

Example 3: Sometimes Non-Greedy Doesn’t Change the Outcome Either

core/site_paths.py has a regex that splits a screenshot filename into a site-name portion and a timestamp portion:

after_re = re.compile(r'^(.+?)_(\d{8}_\d{6})\.(jpg|png)$')

It uses a non-greedy (.+?) here, but testing it shows that swapping in a greedy (.+) produces the exact same result for this particular pattern:

import re
name = "Site_20260101_20260615_093000.jpg"
re.match(r'^(.+?)_(\d{8}_\d{6})\.(jpg|png)$', name).groups()
# => ('Site_20260101', '20260615_093000', 'jpg')
re.match(r'^(.+)_(\d{8}_\d{6})\.(jpg|png)$', name).groups()
# => ('Site_20260101', '20260615_093000', 'jpg')  <- identical

The reason is that the rest of the pattern — _(\d{8}_\d{6})\.(jpg|png)$ — is a fixed-length, strict shape: 8 digits, underscore, 6 digits, extension, end of string. Only one position in the string can satisfy that shape, so whichever direction the engine searches from, backtracking lands on the same unique split point.

What this example shows is that reaching for ? “just to be safe” isn’t automatically a meaningful decision. When the rest of a pattern is strict enough to pin down a unique position in the input, greedy vs. non-greedy doesn’t affect the outcome at all. When the rest of the pattern is loose enough that any closing character would satisfy it — as in the JSON example — the choice directly determines whether the match is correct.

Takeaway

A greedy quantifier searches by consuming everything first and giving ground only when forced to; a non-greedy one searches by consuming nothing first and expanding only when forced to. Whether that difference in search direction actually changes the result depends entirely on how strictly the rest of the pattern pins down a position in the input. When extracting something that can contain nested structure — JSON being the classic case — watch out for a non-greedy .*? stopping at the first closing character it meets, and consider a greedy match or a manual find/rfind approach instead. But when the tail of a pattern is rigid and fully anchored, greedy vs. non-greedy can turn out not to matter at all. Checking which situation you’re in, before reaching for ? out of habit, is the shortcut to predicting how a regex will actually behave.