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)
| Call | Returns | Use when |
|---|---|---|
search | Match | None | you want the first hit anywhere |
match | Match | None | the string must START with the pattern |
fullmatch | Match | None | validating an entire value |
findall | list[str] or list[tuple] | you only need the text |
finditer | Iterator[Match] | you need positions or many matches |
sub / subn | str / (str, int) | rewriting |
split | list[str] | delimiter is itself a pattern |
compile | Pattern | hot 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
| Token | Matches |
|---|---|
. | any char except \n (with re.S, newline too) |
^ $ | start / end of string, or of a line under re.M |
\A \Z | start / end of the string, never line-aware |
\b \B | word boundary / not a word boundary |
[abc] [^abc] | one of, none of |
[a-z0-9_] | ranges inside a class |
\d \D | digit / non-digit (Unicode digits too, re.A limits to ASCII) |
\w \W | [a-zA-Z0-9_] plus Unicode letters / the inverse |
\s \S | whitespace [ \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
| Quantifier | Meaning |
|---|---|
* + ? | 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
| Flag | Short | Effect |
|---|---|---|
re.IGNORECASE | re.I | case-insensitive, Unicode-aware |
re.MULTILINE | re.M | ^ and $ match at every line break |
re.DOTALL | re.S | . also matches \n |
re.VERBOSE | re.X | ignore whitespace and allow # comments |
re.ASCII | re.A | \w \d \s \b become ASCII only |
re.UNICODE | re.U | the 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:
| Input | Time |
|---|---|
"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.
| Target | Pattern |
|---|---|
| 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.