01The model: describe the shape, not the text
A regex describes the shape of text: "three digits, a dash, four digits". The engine slides that description along your string looking for a place it fits. That's the entire mechanism. And the first rule of regex: if you're looking for fixed text, you don't need regex — in / includes() is faster and can't surprise you.
# shape: "digits, dash, digits" — matches phone extensions
\d{3}-\d{4}
"call 555-0199 today" → matches "555-0199"
# but fixed text needs NO regex:
✗ re.search("error", line) # overkill
✓ "error" in line # same result, no surprises
02Character classes: what's allowed here
A class answers "what characters may stand in this position?" Three shorthands cover most needs — digit, word-character, whitespace — plus square brackets for a custom allowlist, and a caret inside them for a blocklist.
\d # one digit 0-9
\w # one "word" char letters, digits, _
\s # one whitespace space, tab, newline
. # ANY one character (except newline) — careful, see 07
[abc] # exactly one of: a, b, or c
[a-f0-9] # one hex digit (ranges with -)
[^0-9] # one char that is NOT a digit (^ inside [] = not)
\D \W \S # capitals = the opposite of their lowercase twin
03Quantifiers: how many of it
A quantifier sits after a thing and says how many times it repeats. Four cover everything: optional, any number, at least one, and exact counts in braces.
colou?r # ? = 0 or 1 → color, colour
go+al # + = 1 or more → goal, gooooal
ab*c # * = 0 or more → ac, abc, abbbc
\d{4} # exactly 4 → 2026
\d{2,4} # 2 to 4 → 26, 202, 2026
\d{2,} # 2 or more
# reading practice — a simple date shape:
\d{4}-\d{2}-\d{2} # 2026-07-22
04The greedy trap: .* eats everything
The most important 90 seconds on this page. Quantifiers are greedy: they grab as much as possible while still letting the match succeed. Combine that with dot-star and you get regex's most classic bug — a match that spans from the first opening to the last closing, swallowing everything between.
text: <b>bold</b> and <i>italic</i>
<.*> # greedy: matches <b>bold</b> and <i>italic</i> — ALL of it!
<.*?> # lazy (add ?): matches <b>, then </b>, then <i>... ✓
<[^>]*> # often better: "anything except the closer" — fast & explicit
When your match is way longer than expected, you've been bitten by greed. Fix with the lazy modifier (
? after the quantifier) or — usually better — replace the dot with a negated class: "anything except the ending character". The negated class says what you actually mean.
05Anchors: where, not what
Anchors match positions, not characters: start of string, end of string, edge of a word. They fix the sneaky class of bug where your pattern is right but matches in the wrong place — validating "abc123abc" as a number because \d+ found the 123 in the middle.
^\d+$ # ^ start, $ end → the WHOLE string is digits
# without them: "abc123abc" passes! (finds 123 inside)
\bcat\b # \b = word boundary → "cat" the word,
# not the cat in "category" or "concatenate"
^ERROR # lines STARTING with ERROR (with multiline flag)
Any regex used to validate user input (is this a valid amount / ID / code?) must be anchored with start and end. Unanchored validation is the bug behind a thousand bypasses: the attacker's string just needs to contain a valid-looking piece. Search = unanchored; validate = anchored. Always.
06Groups: capture the parts you want
Parentheses do two jobs: they bundle (so a quantifier or alternation applies to the whole chunk) and they capture (the matched part comes back to your code, numbered left to right). Alternation — the pipe — means "or".
# bundle + or:
\.(jpg|png|gif)$ # files ending in any of the three
# capture: pull the parts OUT of a date
(\d{4})-(\d{2})-(\d{2})
# └ group 1: year └ 2: month └ 3: day
# in Python:
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", text)
m.group(1) # "2026"
# swap with backreferences: "Lastname, First" → "First Lastname"
re.sub(r"(\w+), (\w+)", r"\2 \1", "Lovelace, Ada")
07The escaping trap: the dot that matched too much
Fifteen characters have special meanings. Use one literally and forget the backslash, and your pattern silently matches more than you meant — the classic being the unescaped dot in domains and prices, which means "any character at all".
# special characters (escape with \ to mean them literally):
. * + ? ( ) [ ] { } ^ $ | \ /
swiftgrasp.com # the . matches ANY char:
# also matches "swiftgraspXcom" ⚠
swiftgrasp\.com # ✓ a literal dot
$19.99 # $ means "end of string" — never matches!
\$19\.99 # ✓ literal dollar, literal dot
An unescaped dot still matches the correct text too —
swiftgrasp.com does match "swiftgrasp.com". Your tests pass. It just also matches things it shouldn't, and you find out in production, from a weird edge case. Silent over-matching is why regexes need negative tests: assert what must not match.
08The pattern library: copy, paste, adapt
The patterns everyone actually needs, tested and annotated. One honest note first: the "perfect email regex" is a famous trap — the spec allows monstrosities, so production systems use a simple pattern plus a confirmation email. Pragmatism beats spec-lawyering.
# email (pragmatic — pair with a confirmation mail)
^[\w.+-]+@[\w-]+\.[\w.]+$
# URL (http/https)
https?://[^\s]+
# ISO date 2026-07-22
\b\d{4}-\d{2}-\d{2}\b
# time 14:30 or 9:05
\b\d{1,2}:\d{2}\b
# number with optional decimals (prices, amounts)
-?\d+(\.\d+)?
# IPv4 (pragmatic)
\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b
# collapse repeated whitespace → single space
\s+ # replace with " "
# extract everything between quotes
"([^"]*)"
# hex color #fff or #1a2b3c
#[0-9a-fA-F]{3,6}\b
09When NOT to use regex
The most senior regex skill is declining to use it. Three honest rules: fixed text needs no regex; nested structures (HTML, JSON, code) can't be parsed with regex — that's a mathematical limit, not a skill issue — use a real parser; and if the pattern doesn't fit on one line, future-you will curse present-you. Regex is a scalpel, not a chainsaw.
✗ parsing HTML with regex # use an HTML parser (BeautifulSoup...)
✗ parsing JSON with regex # use json.loads — it exists, it's right there
✗ a 200-char regex # split into steps, or write a small parser
✓ log line extraction # regex's home turf
✓ validation (anchored!) # with negative tests
✓ find & replace in editor # daily superpower
Never debug a regex by staring at it. Paste it into regex101.com — it explains every symbol, shows matches live, and lets you build a test set. Two minutes there beats twenty minutes of squinting. (And every editor's find-replace accepts regex — the daily superpower most people never turn on.)
10Cheat sheet
Everything above, compressed.
# classes # quantifiers
\d digit [abc] one of ? 0-1 * 0+
\w word [a-z] range + 1+ {n,m} counted
\s space [^x] not x add ? after → lazy version
# anchors # groups
^ start $ end (x) capture
\b word edge (a|b) or
validate = ^...$ always \1 backreference
# the two traps
.* too greedy → .*? or [^X]*
literal . $ + ? → escape: \. \$ \+ \?
# debug
regex101.com — explain, test, save
# decline politely
fixed text → in/includes · HTML/JSON → real parser
That completes the beginner developer stack: Python → Git → command line → SQL → HTTP → Docker → regex. Seven pages, about seventy minutes of audio, and a working vocabulary for your first year of building things. Go build one.