Python Regex & the re Module

Everything the Python re module does, with runnable examples — search, match, findall, groups, substitution, flags, and the performance traps worth knowing about.

Last updated

In short

Python's regular expression support lives in the standard-library re module. Import it, write your pattern as a raw string, and pick the function that matches your intent:re.fullmatch to validate, re.search to find, re.finditerto iterate, and re.sub to replace. Everything else — groups, flags, compilation — builds on those four.
The 30-second version
import re

text = "Order ENG-2481 shipped on 2026-03-14 to [email protected]"

# Validate: the whole string must match
re.fullmatch(r"[A-Z]{2,4}-\d{3,}", "ENG-2481")      # <re.Match ...>

# Find: first match anywhere
m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", text)
m.group("year")                                       # '2026'
m.groupdict()                                         # {'year': '2026', ...}

# Collect: every match
re.findall(r"[\w.+-]+@[\w-]+\.[\w.]+", text)         # ['[email protected]']

# Replace: rewrite with a backreference
re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", text)

Getting started

There is nothing to install — re ships with Python. The two habits that prevent most beginner problems are writing patterns as raw strings and checking for Nonebefore touching the result:

import re

m = re.search(r"\berror\b", log_line, re.IGNORECASE)
if m:
    print(m.group(), "at", m.start())
else:
    print("no match")

re.search returns a Match object on success and None on failure. Calling .group() on None raisesAttributeError: 'NoneType' object has no attribute 'group' — comfortably the most common Python regex error. Since Python 3.8 the walrus operator makes the guard compact:if (m := re.search(...)):.

The re module functions

Ten functions cover everything. The rest of this guide is about choosing between them correctly.

FunctionWhat it does
re.search(p, s)Find the first match anywhere in the string. Returns a Match or None.
re.match(p, s)Match only at the start of the string. Returns a Match or None.
re.fullmatch(p, s)The entire string must match. The right choice for validation.
re.findall(p, s)Return a list of all matches as strings — or as tuples if the pattern has groups.
re.finditer(p, s)Return an iterator of Match objects, one per match. Memory-friendly and keeps positions.
re.sub(p, r, s)Replace every match with r. Accepts a function as the replacement.
re.subn(p, r, s)Like sub, but returns (new_string, number_of_substitutions).
re.split(p, s)Split the string on every match of the pattern.
re.compile(p)Compile once into a Pattern object with the same methods bound to it.
re.escape(s)Escape every metacharacter in s so it can be used as a literal inside a pattern.

re.search vs re.match vs re.fullmatch

This is the distinction that trips up nearly everyone arriving from another language, because the names do not mean what they suggest.

text = "hello world"

re.match(r"world", text)      # None      — anchored at position 0 only
re.search(r"world", text)     # <Match>   — scans the whole string
re.match(r"hello", text)      # <Match>   — starts at position 0, so it matches
re.fullmatch(r"hello", text)  # None      — the entire string must match
re.fullmatch(r"hello world", text)  # <Match>   — the whole string matched
  • Validating a value? Use re.fullmatch. It is equivalent to wrapping the pattern in ^...$ but harder to get wrong.
  • Looking for something inside a larger string? Use re.search.
  • Use re.match only when you genuinely mean "must start with this and the rest is irrelevant" — parsing a prefix, for instance.

One subtlety: $ in Python also matches immediately before a trailing newline, sore.match(r"^\d+$", "42\n") succeeds. re.fullmatch(r"\d+", "42\n")correctly fails. That difference matters when validating input read withinput() or from a file.

findall and finditer

re.findall is convenient but has a return type that shifts with the number of capturing groups — a real source of bugs:

log = "GET /a 200\nPOST /b 404\nGET /c 500"

# No groups → list of whole matches
re.findall(r"\d{3}", log)
# ['200', '404', '500']

# One group → list of that group's captures (not the whole match!)
re.findall(r"(GET|POST) /\w+", log)
# ['GET', 'POST', 'GET']

# Two groups → list of tuples
re.findall(r"(GET|POST) (/\w+)", log)
# [('GET', '/a'), ('POST', '/b'), ('GET', '/c')]

When you want the whole match and its groups, or you are working with a large file, reach for re.finditer instead. It yields Match objects lazily, so memory stays flat and you keep positions:

pattern = re.compile(r"(?P<method>GET|POST) (?P<path>/\S+) (?P<status>\d{3})")

for m in pattern.finditer(log):
    print(m.group("method"), m.group("status"), m.span())

# Straight into dicts
rows = [m.groupdict() for m in pattern.finditer(log)]

Capture groups

Parentheses capture. Groups are numbered by the position of their opening parenthesis, starting at 1 — group 0 is always the entire match.

MethodReturns
m.group(0)The whole matched substring. m.group() is the same thing.
m.group(n)The text captured by group n, counting opening parentheses left to right.
m.group('name')The text captured by the named group (?P<name>...).
m.groups()A tuple of every capturing group. Non-participating groups appear as None.
m.groupdict()A dict of every named group to its captured text.
m.start() / m.end()Character offsets of the match, optionally per group: m.start(1).
m.span()The (start, end) tuple — useful for slicing the original string.
m.lastindexThe index of the last matched capturing group, or None.
m = re.search(r"(\w+)@(\w+)\.(\w+)", "write to [email protected] today")

m.group(0)    # '[email protected]'  — the whole match
m.group(1)    # 'ada'
m.groups()    # ('ada', 'example', 'com')
m.span(2)     # (13, 20)

# Named groups use Python's (?P<name>...) syntax
m = re.search(r"(?P<user>\w+)@(?P<domain>[\w.]+)", "[email protected]")
m.group("user")   # 'ada'
m.groupdict()     # {'user': 'ada', 'domain': 'example.com'}

Three group variants are worth knowing beyond the plain one:

  • Non-capturing(?:...) groups for structure without storing the result. Use it whenever you group only so a quantifier or alternation has the right scope.
  • Named(?P<name>...). Note the P: this is Python-specific syntax, and (?<name>...) from JavaScript is a syntax error in Python before 3.12 (which added it as an accepted alias in some contexts — write theP form for portability across versions).
  • Backreference(?P=name) inside the pattern,\1 for numbered groups. Matches the same text the group captured earlier, which is how you find doubled words: r"\b(\w+)\s+\1\b".

A group inside an alternation that did not participate in the match yields None, not an empty string:

m = re.fullmatch(r"(\d+)|(\w+)", "abc")
m.groups()   # (None, 'abc')   ← group 1 never participated

Substitution with re.sub

re.sub replaces every match. The replacement is itself a mini-language: write it as a raw string, and use \1 or \g<name> to reuse captured text.

# Reorder an ISO date into DD/MM/YYYY
re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "due 2026-03-14")
# 'due 14/03/2026'

# Named groups read better in longer replacements
re.sub(r"(?P<first>\w+) (?P<last>\w+)", r"\g<last>, \g<first>", "Ada Lovelace")
# 'Lovelace, Ada'

# Limit how many are replaced
re.sub(r"\s+", " ", messy, count=1)

# Get the count back too
clean, n = re.subn(r"\s{2,}", " ", messy)

When the replacement needs logic, pass a callable. It receives each Match and returns the replacement string — far cleaner than building the result with string surgery:

def redact(m):
    user, domain = m.group("user"), m.group("domain")
    return f"{user[0]}***@{domain}"

re.sub(r"(?P<user>[\w.+-]+)@(?P<domain>[\w.-]+)", redact, text)

Two traps. First, \g<0> is the whole match in a replacement — plain\0 is not. Second, if the replacement text is user-supplied, escape it withre.escape or a stray backslash will raiseerror: bad escape.

Splitting on a pattern

re.split(r"[,;]\s*", "a, b;c ,  d")
# ['a', 'b', 'c', 'd']

# A capturing group keeps the delimiters in the output
re.split(r"([+\-*/])", "3+4*2")
# ['3', '+', '4', '*', '2']

# maxsplit caps the number of splits
re.split(r"\s+", "one two three four", maxsplit=2)
# ['one', 'two', 'three four']

Since Python 3.7 a pattern that can match an empty string is allowed in re.splitand will split between every character — usually a bug rather than an intent, so check your pattern requires at least one character.

Flags

FlagEffectShort form
re.IGNORECASECase-insensitive matchingre.I
re.MULTILINE^ and $ match at every line break, not just string edgesre.M
re.DOTALL. also matches newline charactersre.S
re.VERBOSEIgnore unescaped whitespace and allow # comments inside the patternre.X
re.ASCIIMake \w, \d, \s, and \b match ASCII only instead of full Unicodere.A
re.UNICODEUnicode matching — already the default for str patterns in Python 3re.U
re.NOFLAGExplicit "no flags" value, added in Python 3.11

Combine flags with the bitwise or operator, or set them inline at the start of the pattern with(?imsx). re.VERBOSE is the underrated one — it turns an unreadable pattern into something a colleague can review:

phone = re.compile(r"""
    (?P<country>\+\d{1,3})?   # optional +44 style prefix
    [\s.-]?
    (?P<area>\d{3})            # area code
    [\s.-]?
    (?P<line>\d{4})            # line number
""", re.VERBOSE)

phone.fullmatch("+44 020 7946")

Inside a re.VERBOSE pattern, literal spaces must be escaped as \ or written in a character class as [ ], and # starts a comment unless escaped.

Compiling patterns

re.compile returns a Pattern object with the same operations as methods. The re module caches the last 512 compiled patterns internally, so compiling is not the dramatic optimisation it is sometimes described as — but it removes a cache lookup per call, and it gives the pattern a name.

EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")

def extract_emails(text: str) -> list[str]:
    return EMAIL_RE.findall(text)

# Pattern methods take optional pos/endpos to search a slice
# without copying the string
EMAIL_RE.search(big_text, pos=1000, endpos=2000)

Compile at module level, not inside the function — otherwise you pay the lookup on every call anyway. The pos and endpos arguments are only available on compiled patterns and let you scan a window of a large string without slicing it.

Raw strings and escaping

Backslash means something to both the Python parser and the regex engine, and that double meaning causes real bugs:

"\b"       # a BACKSPACE character (U+0008) — not a word boundary!
r"\b"      # the two characters \ and b — a word boundary

"\d"       # SyntaxWarning in 3.12+, invalid escape sequence
r"\d"      # a digit class

# Escape user input before interpolating it into a pattern
term = "c++ (beginner)"
re.findall(re.escape(term), text)     # matches the literal text
re.findall(term, text)                # error: multiple repeat

The rule is simple: always write regex patterns as raw strings, and pass anything user-supplied through re.escape before it becomes part of a pattern.

Unicode behaviour

In Python 3, str patterns are Unicode-aware by default. \w matches accented letters, Cyrillic, and CJK characters; \d matches Devanagari and fullwidth digits as well as ASCII ones. That is usually what you want, and occasionally not:

re.findall(r"\w+", "naïve café 日本語")
# ['naïve', 'café', '日本語']

re.findall(r"\w+", "naïve café 日本語", re.ASCII)
# ['na', 've', 'caf']

re.fullmatch(r"\d+", "١٢٣")        # matches — Arabic-Indic digits are digits
re.fullmatch(r"\d+", "١٢٣", re.A)  # None
int("١٢٣")                          # 123 — int() agrees with the Unicode reading

If you are validating something that must be ASCII — a slug, a hostname label, a numeric ID you will pass to a system that only accepts ASCII — say so explicitly with re.ASCIIor an explicit class like [0-9]. The standard library's re does not support \p{...} Unicode property escapes; the third-partyregex package does, along with atomic groups and variable-width lookbehind.

Performance

Python's re is a backtracking engine, which makes it expressive and makes it possible to write a pattern that takes exponential time. Five rules cover almost all of it:

  1. Do not nest quantifiers over overlapping alternatives.(a+)+$, (\w+\s?)*$, and (\d+|\w+)* can all blow up on input that almost matches. Rewrite so each character has exactly one way to be consumed.
  2. Prefer negated classes to lazy dots. "[^"]*" beats".*?" — it cannot overshoot, so it never backtracks.
  3. Anchor when you can. A pattern starting with ^ or a literal prefix lets the engine reject non-matches immediately instead of retrying at every offset.
  4. Put the cheap, discriminating test first in an alternation, and make sure alternatives are mutually exclusive so a failure does not retry the next branch pointlessly.
  5. Do not use regex where a string method will do.s.startswith("x"), "x" in s, and s.split(",") are faster and clearer than the equivalent pattern.
Measuring it
import re, timeit

evil = re.compile(r"(a+)+$")
safe = re.compile(r"a+$")
text = "a" * 28 + "b"

timeit.timeit(lambda: evil.search(text), number=1)   # seconds — and it doubles per extra 'a'
timeit.timeit(lambda: safe.search(text), number=1)   # microseconds

For patterns applied to untrusted input, the third-party regex module offers atimeout argument and possessive quantifiers (a++), both of which cap the damage. See the performance chapter for the underlying mechanics.

Edge cases worth knowing

  • $ matches before a trailing newline.re.match(r"^\d+$", "42\n") succeeds. Use re.fullmatch, or\Z which anchors at the true end of the string.
  • Zero-width matches advance the position. Since Python 3.7,re.sub(r"x*", "-", "abc") inserts a dash between every character, matching other engines' behaviour but differing from Python 3.6.
  • re.findall with one group hides the full match. Covered above — use finditer when the shape matters.
  • Non-participating groups are None, not "". Guard before calling string methods on a group result.
  • Flags must be passed, not assumed. A pattern developed withre.IGNORECASE behaves differently the moment it is copied into code that omits it.
  • bytes patterns and str patterns do not mix.Matching a bytes pattern against a str raises TypeError. For binary data, write the pattern as rb"...".
  • Lookbehind must be fixed width. (?<=\d{3}) is fine;(?<=\d+) raises error: look-behind requires fixed-width pattern. The regex package lifts this restriction.

Ready-to-use recipes

Validate common formats with re.fullmatch
import re

PATTERNS = {
    "email":    re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"),
    "ipv4":     re.compile(r"(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}"
                           r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"),
    "iso_date": re.compile(r"\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])"),
    "hex":      re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}"),
    "slug":     re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*"),
}

def is_valid(kind: str, value: str) -> bool:
    return PATTERNS[kind].fullmatch(value) is not None
Parse structured log lines into dicts
LINE = re.compile(
    r"(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "
    r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" '
    r"(?P<status>\d{3}) (?P<bytes>\d+|-)"
)

def parse(path):
    with open(path) as fh:
        for line in fh:
            if m := LINE.match(line):
                yield m.groupdict()
Normalise whitespace and strip control characters
WS = re.compile(r"\s+")
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")

def clean(text: str) -> str:
    return WS.sub(" ", CONTROL.sub("", text)).strip()

Every pattern in the pattern library includes a Python snippet alongside the breakdown, and you can paste any of them into theregex tester to see the matches highlighted before wiring them into code. If you want the syntax itself rather than the Python API, start with theregular expressions guide or thecheat sheet.

Python Regex Questions

What is the difference between re.search and re.match in Python?

re.match only looks for a match at the very beginning of the string, while re.search scans the whole string and returns the first match wherever it occurs. re.match(r"world", "hello world") returns None; re.search(r"world", "hello world") returns a Match. If you want the entire string to match, use re.fullmatch rather than adding anchors to re.match.

What does re.findall return when the pattern has groups?

re.findall changes its return type based on the number of capturing groups. With no groups it returns a list of whole matches. With exactly one group it returns a list of just that group's captures. With two or more groups it returns a list of tuples, one per match. If that variability is a problem, use re.finditer instead — it always yields Match objects, and you call .group() yourself.

Why should Python regex patterns be raw strings?

Because backslash is an escape character in both Python string literals and regex syntax. Without a raw string, "\d" is not a valid Python escape and "\b" silently becomes a backspace character rather than a word boundary. Writing r"\d" and r"\b" passes the backslash straight to the regex engine, which is almost always what you mean.

How do I use named groups in Python?

Python uses the (?P<name>...) syntax rather than the (?<name>...) used by JavaScript. Define a group as (?P<year>\d{4}), then read it back with m.group('year') or get all named groups at once with m.groupdict(). In a re.sub replacement string, refer to it as \g<year>.

Does re.compile make Python regex faster?

Somewhat, and mainly in loops. The re module already caches the most recently compiled patterns, so the parsing cost is paid once either way, but re.compile skips the cache lookup and the argument handling on every call. The bigger benefit is clarity: a compiled Pattern object named at module level documents intent and gives you methods like .finditer and .sub bound to it.

How do I make a Python regex case-insensitive?

Pass the flags argument: re.search(r"error", line, re.IGNORECASE), or its short form re.I. When compiling, pass it to re.compile instead. You can also set it inline at the start of the pattern with (?i), and combine flags with the bitwise or operator, as in re.I | re.M.

How do I replace text with a Python regex?

Use re.sub(pattern, replacement, string). Refer to capture groups in the replacement with \1 or \g<name>, and remember to write the replacement as a raw string too. If the replacement needs logic, pass a function instead of a string — it receives each Match object and returns the text to substitute.

Is Python's re module vulnerable to catastrophic backtracking?

Yes. Python's re uses a backtracking engine, so nested quantifiers over overlapping alternatives — patterns like (a+)+$ — can take exponential time on input that nearly matches. Avoid nesting quantifiers, prefer negated character classes to lazy dots, anchor patterns so failure is immediate, and for untrusted input consider the third-party regex module, which offers atomic groups and possessive quantifiers, or a timeout wrapper.