How it arises in FastAPI
Query(pattern="^fixedquery$") and Field(pattern=...) are FastAPI's documented way to constrain a string. Validation is done by Pydantic, whose regex_engine setting defaults to rust-regex. Pydantic describes that engine as non-backtracking and therefore more DDoS resistant, with the trade-off that it does not support all regex features. For declared patterns, that default removes the class.
Backtracking returns through four doors.
regex_engine="python-re". Lookarounds and backreferences are not available in the default engine. A team that needs one sets the model's config to python-re, which Pydantic describes as supporting all regex features. Every pattern on that model now runs on the backtracking engine.
Compiled patterns. Pydantic notes that if you use a compiled regex pattern, the python-re engine is used regardless of the setting, so that flags such as re.IGNORECASE are respected. Writing pattern=re.compile(...) to get case-insensitivity silently changes engines.
Custom validators. @field_validator functions call re.match() or re.fullmatch() directly. These are ordinary Python and always backtrack.
Handler code. Tokenisers, log parsers, Markdown or mention extraction on request text, and search endpoints that accept a user's pattern.
One event loop. In an async def endpoint a catastrophic match does not just occupy a worker. It blocks the event loop, so every other in-flight request on that process stalls until the match finishes.
Vulnerable and fixed
import re
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field, field_validator
app = FastAPI()
TAGS = re.compile(r"^(\w+\s?)+$")
class Post(BaseModel):
model_config = ConfigDict(regex_engine="python-re")
slug: str = Field(pattern=r"^([a-z0-9]+-?)+$")
tags: str
@field_validator("tags")
@classmethod
def check_tags(cls, value: str) -> str:
if not TAGS.match(value):
raise ValueError("bad tags")
return value
@app.post("/posts")
async def create(post: Post):
return {"slug": post.slug}
Keep the default engine, rewrite both patterns without a quantified group that contains a quantifier, and bound the lengths so the input size is fixed before any match:
import re
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
TAG = re.compile(r"\w{1,32}")
class Post(BaseModel):
slug: str = Field(max_length=80, pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")
tags: str = Field(max_length=200)
@field_validator("tags")
@classmethod
def check_tags(cls, value: str) -> str:
if not all(TAG.fullmatch(part) for part in value.split()):
raise ValueError("bad tags")
return value
@app.post("/posts")
async def create(post: Post):
return {"slug": post.slug}
What FastAPI and Pydantic already do for you
The default regex engine is the main protection, and it applies to every pattern you declare on a parameter or field without any configuration. max_length and min_length sit beside pattern in the same call, so bounding input is one more keyword. Declarative constraints also appear in the OpenAPI schema, which keeps hand-written validators to a minimum. Pydantic documents the engine choice and the compiled-pattern exception clearly; the work is knowing which of your models have opted out.
What es-python adds
The engine switch is invisible at the call site: Field(pattern=...) reads the same whichever engine runs it. es-python reports from behaviour. When a match in Python's re engine backtracks catastrophically over data from the request, it records a redos event. The decision uses the engine's own step count, which must pass an absolute threshold and a steps-per-character threshold; the second one keeps a plain linear scan of a large body from being reported. Patterns checked by the non-backtracking default engine do not go through Python's re at all, so they produce no regex events.
In practice this sorts a codebase for you. Run the test suite or a fuzzing pass under es-python with the socket source on. A redos event on a model means that model fell back to python-re and has a dangerous pattern. A redos event in a validator or handler points at the re call. No events on the declared patterns is the expected result.
The Low noise template drops ordinary re match and re search events, which routing produces on every request, and leaves redos visible. The redos impact tag is always paired with regex bypass, since a regex on untrusted input may also be a filter being evaded. One limit to keep in mind: a blow-up contained entirely inside a single bounded repeat is not counted.
With a Raise rule on redos, the match is aborted with a RuntimeError. In an async service that matters more than usual, because it releases the event loop for everyone else.
Review checklist
- Search for
regex_engineand forpattern=re.compile(. - Search validators and handlers for direct
re.calls on request data. - Add
max_lengthwherever there is apattern. - Rewrite
(x+y?)+shapes asx+(yx+)*. - Move unavoidable heavy matching off the event loop and give it a timeout.
References
- Reference: Pydantic documentation: Configuration, regex_engine (checked 2026-10-12)
- Reference: FastAPI documentation: Query Parameters and String Validations (checked 2026-10-12)
- Reference: Python documentation: re (checked 2026-10-12)