Skip to content

Auto-possessify greedy repeats in regular expressions #153044

Open

Description

@serhiy-storchaka

Feature or enhancement

Proposal

Make the re compiler turn a greedy repeat into a possessive one when this cannot change what the pattern matches: when the repeated atom and every character that can possibly follow the repeat are provably disjoint, backtracking into the repeat is always futile, so a+b can be compiled as if it were a++b. PCRE2 performs the same optimization under the name "auto-possessification".

This is a pure compile-time transform: the POSSESSIVE_REPEAT opcodes exist since Python 3.11, and the analysis only fires when it can prove disjointness, so any imprecision can only cost an optimization, never change a match result.

The main benefit is failing or backtracking-heavy matches whose repeat is followed by a character set or category: the existing REPEAT_ONE fast path only covers literal tails, so for example \d+\. or \w+\s currently backtrack character by character. Measured speedups are 2.2–4.5× on such failing matches, and some patterns with catastrophic backtracking are defused as a side effect (though this is an optimization, not a security fix). Correction: where disjointness is provable, every give-back already fails at the first following atom, so this removes only a constant factor; the catastrophic nested-repeat patterns like (a+)+b are among the possible extensions below.

It also composes with the character class set operations (gh-152100): a repeat of a difference class that excludes its follower, such as [\w--\d]+\d, becomes possessive.

I have a working implementation validated by a differential fuzzer and will open a PR.

This is only a first step: the initial implementation is deliberately conservative, covering repeats of single-character atoms and of rigid multi-atom bodies, with the follower analysis traversing group boundaries, alternations, anchors and atomic groups. It can be extended later, for example to alternation bodies with pairwise-disjoint first characters, to word-boundary and lookahead followers, and to repeats of groups (the (a+)+b class).

Has this already been discussed elsewhere?

This is a minor feature, which does not need previous discussion elsewhere.

Linked PRs

Activity

  1. added
    type-featureA feature request or enhancement
    performancePerformance or resource usage
    stdlibStandard Library Python modules in the Lib/ directory
    on Jul 4, 2026
  2. added a commit that references this issue on Jul 4, 2026
  3. serhiy-storchaka commented on Jul 4, 2026

    @serhiy-storchaka
    MemberAuthor

    I surveyed the compiled literal regexes of the stdlib and of several regex-heavy projects (Pygments harvested by instantiating all 598 lexers). Of the patterns containing a greedy repeat, the share where at least one repeat becomes possessive:

    corpus possessified / candidates rate
    stdlib (Lib/, except test_re) 260 / 301 86%
    docutils 39 / 47 83%
    Django 27 / 33 82%
    Pygments (9065 unique patterns) 4578 / 5632 81%
    packaging 16 / 20 80%
    setuptools 66 / 85 78%
    Sphinx 69 / 90 77%
    requests 7 / 9 78%
    urllib3 7 / 8 88%
    Jinja2 12 / 14 86%
    dateutil 3 / 3 100%
    soupsieve 12 / 18 67%

    So the optimization applies to roughly four out of five real-world patterns with greedy repeats.

  4. tim-one commented on Jul 5, 2026

    @tim-one
    Member

    Fine idea! I'm unclear on where the claimed speedups come from, though. It's been standard practice "forever" to advise crafting regexps so that all possible follow-sets are disjoint. Which is likely why the pattern is so common.

    But when that's so, backtracking a character a time can happen, but never goes on to resume heavy searching because there was only one possible branch to follow to begin with. That's linear-time backtracking at worst: "only backward, never forward again". Which seems to rule out catastrophic time behavior in fails-to-match cases (which is why that advice was given to begin with).

    So are these speedups on "organic" (naturally occurring in real life) cases, or contrived ones?

    Examples would help more than words 😉.

  5. serhiy-storchaka commented on Jul 5, 2026

    @serhiy-storchaka
    MemberAuthor

    This is possible exactly because the repeat and the follower are disjoint. \w+\d cannot be possesified.

    The speedup claim came from fuzzing. Example with real world pattern:

    $ python3 -m timeit -s 'import re; p = re.compile(r"\$\(([A-Za-z][A-Za-z0-9_]*)\)"); s = "$(" + "a"*20000' 'p.match(s)'
    5000 loops, best of 5: 73 usec per loop
    $ python3 -m timeit -s 'import re; p = re.compile(r"\$\(([A-Za-z][A-Za-z0-9_]*+)\)"); s = "$(" + "a"*20000' 'p.match(s)'
    20000 loops, best of 5: 14.9 usec per loop
    

    This is 5x speedup, larger than claimed, because benchmarks were performed on the debug build -- it only shows 4x speed up.

    "defuses some catastrophic backtracking" was overclaim, this was left for follow-ups, the current PR does not do it.

  6. serhiy-storchaka commented on Jul 5, 2026

    @serhiy-storchaka
    MemberAuthor

    BTW, the third-party module regex also performs auto-possesisation. It may be not so deep, or deeper than in the proposed PR. @mrabarnett, you may find something interesting in the tricks used in this PR and follow-ups.

    Candidates for the follow-ups (by their value for the real code): ① lazy after + group-repeat followers ([ \t]*(\r\n|\r|\n)?$, (\{%)(-?\s*)(filter)(\s+)(…)), ② \b uniform-wordness (\w+\b), ③ assertion transparency (^([ ]*)(?=\S)), ④ gc universe (\p{Lu}+\p{Z}), ⑤ disjoint-first alternation bodies ((ab|cd)+x). Then I will try to do something with most common examples of catastrophic backtracking.

  7. added a commit that references this issue on Jul 5, 2026
  8. tim-one commented on Jul 5, 2026

    @tim-one
    Member

    Cool! I had tried simpler examples, and saw much more modest speed gains in non-matching cases. Like

    $ py -m timeit -s "import re;p=re.compile(r'\d+k');s='8'*1000000" "p.match(s)"
    50 loops, best of 5: 5.84 msec per loop
    
    $ py -m timeit -s "import re;p=re.compile(r'\d++k');s='8'*1000000" "p.match(s)"
    50 loops, best of 5: 4.17 msec per loop

    Which is more like what I expected from skipping (just!) linear-time "one way" backtracking.

    YMMV, of course, and it's clear that yours is varying a lot 😉.

  9. serhiy-storchaka commented on Jul 5, 2026

    @serhiy-storchaka
    MemberAuthor

    It depends on the cost of the backtrack and the follower check. Use more costly check (\d+(?:k|foo), \d+(?=k)1) or add a group ((\d+)k) and observe the difference.

    Footnotes

    1. Not yet auto-possessified. ↩

  10. added 2 commits that reference this issue on Jul 5, 2026
  11. tim-one commented on Jul 5, 2026

    @tim-one
    Member

    Thanks! Makes sense. I'm not familiar with our implementation. In the limit, a DFA picks the next state based solely on the next character to match, and never backtracks - but at the potential cost of constructing an exponential number of compile-time states. I tried a careful non-catastrophic regexp to match Python string literals (bounded by a double quote on each end). Follow sets are disjoint, burt there is a choice to make depending on whether the next character is a backslash (consume it and the next character) or double quote (rhw string literal ends)) or neither (consume it). In long nan-matching cases, adding possessive quantifiers sped it by a factor of 10. Impressive!

  12. added 2 commits that reference this issue on Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performancePerformance or resource usagestdlibStandard Library Python modules in the Lib/ directorytopic-regextype-featureA feature request or enhancement

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions