Python Cheatsheet

Regular Expressions

Use this Python reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Module Functions

Always write the pattern as a raw string. In a normal string "\d" and "\b" are string escapes first, and \b silently becomes a backspace character.

import re

re.search(r"\d+", "abc 123")        # first match ANYWHERE, or None
re.match(r"\d+", "abc 123")         # anchored at the START only, here None
re.fullmatch(r"\d+", "123")         # the WHOLE string must match
re.findall(r"\d+", "a1 b22")        # ['1', '22'], list of strings
re.finditer(r"\d+", "a1 b22")       # iterator of Match objects, lazy
re.sub(r"\s+", " ", text)           # replace all, returns a new string
re.subn(r"a", "X", "aaa")           # ('XXX', 3), count included
re.split(r"[,;]\s*", "a, b;c")      # ['a', 'b', 'c']
re.escape("a.b*c")                  # 'a\\.b\\*c', literal-ize user input

pat = re.compile(r"^(\w+)=(\S+)$", re.M)    # compile once, reuse in a loop
pat.findall(config)
pat.search(line)
CallReturnsUse when
searchMatch | Noneyou want the first hit anywhere
matchMatch | Nonethe string must START with the pattern
fullmatchMatch | Nonevalidating an entire value
findalllist[str] or list[tuple]you only need the text
finditerIterator[Match]you need positions or many matches
sub / subnstr / (str, int)rewriting
splitlist[str]delimiter is itself a pattern
compilePatternhot loop, or you want the pattern named

findall with one group returns that group, with several it returns tuples of groups, and with none it returns whole matches. That switch is the single most common regex surprise in Python. re.split with a capturing group keeps the delimiters in the output.

Module-level functions cache compiled patterns internally (512 entries), so re.compile is about naming and readability more than speed.

Metacharacters and Classes

TokenMatches
.any char except \n (with re.S, newline too)
^ $start / end of string, or of a line under re.M
\A \Zstart / end of the string, never line-aware
\b \Bword boundary / not a word boundary
[abc] [^abc]one of, none of
[a-z0-9_]ranges inside a class
\d \Ddigit / non-digit (Unicode digits too, re.A limits to ASCII)
\w \W[a-zA-Z0-9_] plus Unicode letters / the inverse
\s \Swhitespace [ \t\n\r\f\v] / non-whitespace
|alternation, leftmost alternative wins
\\ \.escape a metacharacter
\1 \g<name>backreference, in the pattern and in a replacement

Inside [...] almost everything is literal, so [.*+] is three literal characters. Only ^ (first), - (between), ], and \ need care.

Quantifiers

QuantifierMeaning
* + ?0 or more, 1 or more, 0 or 1 (all greedy)
{3} {2,5} {2,} {,5}exact, range, at least, at most
*? +? ?? {2,5}?lazy, match as little as possible
*+ ++ ?+ {2,5}+possessive (3.11+), match greedily and never give back
(?>...)atomic group (3.11+), same no-backtracking guarantee
re.findall(r"<.+>", "<a><b>")       # ['<a><b>']   greedy, one big match
re.findall(r"<.+?>", "<a><b>")      # ['<a>', '<b>']  lazy, what you wanted
re.findall(r"<[^>]+>", "<a><b>")    # ['<a>', '<b>']  faster, no backtracking

Prefer a negated character class over a lazy dot. It expresses the same intent and cannot backtrack.

Groups, Lookaround, Anchors

# Capturing, non-capturing, named
re.search(r"(\d{4})-(\d{2})", s)              # numbered from 1
re.search(r"(?:https?)://(\S+)", s)           # (?: ) groups without capturing
m = re.search(r"(?P<year>\d{4})-(?P<mon>\d{2})", "on 2026-09-15")
m.group("year")                                # '2026'
re.sub(r"(?P<u>\w+)@(?P<h>\w+)", r"\g<h>:\g<u>", "me@host")   # 'host:me'

# Backreference: the same text twice
re.search(r"\b(\w+) \1\b", "the the cat")      # matches 'the the'

# Optional group is None, not ''
m = re.match(r"(\d+)(?:\.(\d+))?", "5")
m.groups()                                      # ('5', None)

# Lookahead and lookbehind consume nothing
re.findall(r"\d+(?= USD)", "5 USD 9 EUR")      # ['5']   positive lookahead
re.findall(r"\d+(?! USD)", "5 USD 9 EUR")      # ['9']  negative lookahead
re.findall(r"(?<=USD )\d+", "USD 42")          # ['42']  positive lookbehind
re.findall(r"(?<!USD )\d+", "EUR 42")          # ['42']  negative lookbehind

# Password-style checks stack lookaheads at position 0
re.fullmatch(r"(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{12,}", pw)

Lookbehind must be fixed width. (?<=a{1,3})b raises re.error: look-behind requires fixed-width pattern. Fixed-width alternatives of equal length are fine, unequal ones are not, so split them into separate lookbehinds or use the regex module.

Flags

FlagShortEffect
re.IGNORECASEre.Icase-insensitive, Unicode-aware
re.MULTILINEre.M^ and $ match at every line break
re.DOTALLre.S. also matches \n
re.VERBOSEre.Xignore whitespace and allow # comments
re.ASCIIre.A\w \d \s \b become ASCII only
re.UNICODEre.Uthe default for str, only meaningful for bytes
re.NOFLAG—3.11, explicit "no flags" default value
re.findall(r"^\w+", "one\ntwo", re.M)          # ['one', 'two']
re.search(r"a.c", "a\nc", re.S)                # matches
re.search(r"(?i)hello", "HELLO")               # inline, must be at the start
re.search(r"(?i:hello) world", s)              # scoped inline group (3.6+)
re.compile(r"\d+", re.I | re.M)                # combine with |

phone = re.compile(r"""
    (\d{3})      # area code
    [-.\s]?      # optional separator
    (\d{4})      # line number
""", re.VERBOSE)
phone.search("555-1234").groups()               # ('555', '1234')

Under re.X, literal spaces must be escaped (\ ) or put in a class ([ ]).

The Match Object

m = re.search(r"(?P<y>\d{4})-(?P<m>\d{2})", "on 2026-09-15")

m.group()        m.group(0)     # '2026-09'   the whole match
m.group(1)       m.group("y")   # '2026'
m.group(1, 2)                   # ('2026', '09')
m.groups()                      # ('2026', '09')
m.groups(default="")            # replaces None for unmatched groups
m.groupdict()                   # {'y': '2026', 'm': '09'}
m.start(), m.end(), m.span()    # 3, 10, (3, 10)
m.span(1)                       # (3, 7)  per-group offsets
m.expand(r"\g<m>/\g<y>")        # '09/2026'
m.string, m.re, m.pos, m.lastindex, m.lastgroup

# Walrus keeps the None check tight (3.8+)
if (m := re.search(r"id=(\d+)", line)) is not None:
    record_id = int(m.group(1))

A callable replacement receives the Match and returns the replacement string:

def double(m: re.Match) -> str:
    return str(int(m.group()) * 2)

re.sub(r"\d+", double, "a1 b22")               # 'a2 b44'
re.sub(r"\{(\w+)\}", lambda m: env[m.group(1)], template)

Catastrophic Backtracking

Nested quantifiers over overlapping text explode exponentially. (a+)+$ against "aaa...b", timed on CPython 3.13:

InputTime
"a" * 20 + "b"0.057 s
"a" * 22 + "b"0.182 s
"a" * 24 + "b"0.740 s
"a" * 26 + "b"2.946 s

Each extra character roughly triples the work, so 40 characters hangs a process. Python's re has no timeout, so a user-supplied pattern or input is a denial of service (ReDoS).

Fixes, in order:

1. Remove the nesting. (a+)+ is just a+. (\w+\s?)+ is [\w\s]+. 2. Replace a lazy dot with a negated class, <[^>]+> not <.+?>. 3. Make the inner group atomic or possessive (3.11+), (?>a+)+$ returns instantly on the same input. 4. Anchor the pattern and bound the quantifiers, {1,64} not +. 5. Cap the input length before matching, and never compile a pattern that came from a user.

When re Is Not Enough

The third-party regex module is a drop-in with the features re lacks.

python -m pip install regex
import regex

regex.search(r"(?<=\w{1,5})x", s)          # VARIABLE-width lookbehind
regex.findall(r"\p{Greek}+", s)            # Unicode property classes
regex.search(r"(?r)\d+", s)                # reverse search
regex.findall(r"(\w)(?1)", s)              # subroutine call to group 1
regex.search(r"(?e)(cat){e<=1}", "cot")    # fuzzy matching, edit distance
regex.search(r"a+", huge, timeout=1.0)     # a real timeout

Practical Patterns

Every one of these was run against real input. They are pragmatic, not authoritative.

TargetPattern
Email (pragmatic)[^@\s]+@[^@\s]+\.[A-Za-z]{2,}
URL (http/https)https?://[^\s"'<>]+
IPv4 (octet-checked)\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b
ISO date\d{4}-\d{2}-\d{2}
ISO timestamp\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?
Semver^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$
Hex color#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b
Slug^[a-z0-9]+(?:-[a-z0-9]+)*$
Duplicated word\b(\w+)\s+\1\b
Trailing whitespace[ \t]+$ (with re.M)
Apache/nginx common log^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) [^"]*" (\d{3}) (\d+|-)$
LOG = re.compile(
    r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] '
    r'"(?P<method>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+|-)$'
)
m = LOG.match(line)
m.groupdict()   # {'ip': '127.0.0.1', 'ts': '10/Oct/2026:13:55:36 -0700', ...}

A regex is the wrong tool for HTML and XML, use an HTML parser (html.parser, lxml, beautifulsoup4), because nesting is not a regular language. It is also the wrong tool for real email validation, since RFC 5322 permits quoted strings, comments, and nested parentheses. Check for one @, then send a confirmation message. That is the only validation that proves anything. Same for URLs (urllib.parse.urlparse), dates (datetime.fromisoformat), and JSON (json.loads), all of which have real parsers in the standard library.