What you'll learn
- Read the core pattern syntax: literals, classes, quantifiers, anchors, and groups
- Search, match, find all, and substitute with the
remodule - Extract pieces of text with groups and named groups
- Know when a regex is the wrong tool and a string method or a parser is right
Concept
A regular expression is a pattern that describes a set of strings. \d{4}-\d{2}-\d{2} describes dates like 2026-09-08; [A-Z][a-z]+ describes a capitalized word. The re module compiles a pattern and applies it to text: does the text contain a match, where, and what were the pieces. Regexes are compact, powerful, and easy to write badly. The goal of this lesson is to make you fluent in the ninety percent of syntax that covers real work and honest about the cases where a regex is the wrong choice.
| Syntax | Matches | Example → matches |
|---|---|---|
abc |
those literal characters | cat → cat |
. |
any character except newline | c.t → cat, cut |
\d \w \s |
digit, word character, whitespace | \d\d → 42 |
[aeiou] [^0-9] |
one of these / anything but these | [A-Z] → Q |
* + ? |
0 or more, 1 or more, 0 or 1 | go+d → god, good |
{n} {n,m} |
exactly n, between n and m | \d{4} → 2026 |
^ $ |
start and end of the string (or line with M) |
^# → a comment line |
(...) |
a group, captured | (\d+)-(\d+) → two numbers |
(?P<name>...) |
a named group | (?P<year>\d{4}) |
a|b |
alternation | cat|dog |
\. \( |
a literal special character | \.py$ |
The functions differ in where they look. re.search finds the first match anywhere. re.match matches only at the start, and re.fullmatch requires the whole string to match, which is what validation wants. re.findall returns every match as a list of strings, or of tuples when the pattern has groups. re.finditer yields match objects one at a time. re.sub replaces matches, and the replacement can refer to groups. Always write patterns as raw strings, r"...", so backslashes reach the regex engine intact, and compile a pattern you use repeatedly with re.compile.
Groups are how you extract. Parentheses capture the part they enclose, and match.group(1) returns it; (?P<year>\d{4}) names it, so match["year"] reads well and survives reordering.

Quantifiers are greedy by default, taking as much as they can, so <.+> on <a><b> matches the whole thing; <.+?> is lazy and stops at the first >. Flags change behavior: re.IGNORECASE, re.MULTILINE for ^/$ per line, and re.VERBOSE to spread a pattern over commented lines, which is how any pattern longer than a line should be written.
Regexes are the wrong tool more often than beginners expect. Checking whether a string starts with a prefix is startswith. Splitting on a fixed separator is split. Parsing HTML, JSON, or a programming language is a parser's job, and a regex will break on the first nested or escaped case. Validating an email address fully is famously impossible with a regex; check the shape loosely and send a confirmation. The rule: reach for re when you need a pattern, a class of characters, or a repeat; reach for string methods when you need a fixed substring; reach for a real parser when the text has a grammar.

Code
The search family: search, match, fullmatch, findall, and finditer on the same text, showing what each one looks at.
# search_family.py
import re
text = "Order 42 shipped 2026-09-08; order 7 shipped 2026-09-10"
date = r"\d{4}-\d{2}-\d{2}"
print(re.search(date, text).group())
print(re.match(date, text))
print(re.match(r"Order \d+", text).group())
print(re.fullmatch(r"\d{4}-\d{2}-\d{2}", "2026-09-08") is not None, re.fullmatch(date, "2026-9-8") is not None)
print(re.findall(date, text))
print([(m.start(), m.group()) for m in re.finditer(r"\d+", text)][:3])
2026-09-08
None
Order 42
True False
['2026-09-08', '2026-09-10']
[(6, '42'), (17, '2026'), (22, '09')]Groups, named groups, and findall with groups returning tuples. A compiled pattern with VERBOSE keeps a longer regex readable.
# groups.py
import re
log = """2026-09-08 14:30:15 ERROR db: connection timed out
2026-09-08 14:30:16 INFO web: request served in 12ms
2026-09-08 14:31:02 ERROR web: upstream 502"""
line = re.compile(r"""
(?P<date>\d{4}-\d{2}-\d{2})\s+
(?P<time>\d{2}:\d{2}:\d{2})\s+
(?P<level>[A-Z]+)\s+
(?P<component>\w+):\s+
(?P<message>.*)
""", re.VERBOSE)
for m in line.finditer(log):
if m["level"] == "ERROR":
print(m["time"], m["component"], "->", m["message"])
print(re.findall(r"(\w+): (\w+)", "a: 1, b: 2"))
m = re.search(r"(\d+)ms", log)
print(m.group(1), int(m.group(1)) * 2, m.groups())
14:30:15 db -> connection timed out
14:31:02 web -> upstream 502
[('a', '1'), ('b', '2')]
12 24 ('12',)Substitution, greedy versus lazy, and flags. sub can use a group reference or a function; the lazy +? stops at the first closing tag.
# substitute.py
import re
print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "due 2026-09-08 and 2026-12-01"))
print(re.sub(r"\s+", " ", "too many spaces").strip())
print(re.sub(r"\d+", lambda m: str(int(m.group()) * 2), "3 apples, 10 pears"))
html = "<b>bold</b> and <i>italic</i>"
print(re.findall(r"<.+>", html))
print(re.findall(r"<(.+?)>", html))
print(re.findall(r"^\w+", "first line\nsecond line\nthird", re.MULTILINE))
print(re.search(r"python", "I like PYTHON", re.IGNORECASE).group())
due 08/09/2026 and 01/12/2026
too many spaces
6 apples, 20 pears
['<b>bold</b> and <i>italic</i>']
['b', '/b', 'i', '/i']
['first', 'second', 'third']
PYTHONValidation with fullmatch, and the right tool for the wrong jobs. The string-method versions are shorter and clearer; the loose email check is honest about what it can promise.
# right_tool.py
import re
for candidate in ["ab12", "AB-12", "ab123", "ab1"]:
print(candidate, bool(re.fullmatch(r"[a-z]{2}\d{2}", candidate)))
path = "reports/2026/q3.csv"
print(path.endswith(".csv"), path.split("/")[-1], "2026" in path)
loose_email = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
for email in ["ada@example.com", "not an email", "a@b.c"]:
print(email, "looks like an email:", bool(loose_email.match(email)))
ab12 True
AB-12 False
ab123 False
ab1 False
True q3.csv True
ada@example.com looks like an email: True
not an email looks like an email: False
a@b.c looks like an email: TrueTry it yourself
Create extract.py with a block of text containing several phone numbers in different formats, some dates, and a few URLs. Write one VERBOSE pattern with named groups for phone numbers, extract them with finditer, normalize them to digits only with sub, and print each before and after. Then write a fullmatch validator for a product code format like AB-1234, and write down in a comment one job in your text that you deliberately did not use a regex for.
Common mistakes
- Forgetting the raw string.
"\d"in a normal string is not a regex digit; writer"\d"for every pattern. - Greedy
.*where you meant one item.<.*>eats to the last>; use.*?or a character class like[^>]*. - Regex for structured formats. HTML, JSON, and code have parsers; a regex works on the demo and breaks on the first real file.
What's next
- Literals, classes, quantifiers, anchors, and groups cover most patterns; always write them as raw strings.
search,match,fullmatch,findall,finditer, andsubdiffer in where they look and what they return; named groups extract pieces.- Use string methods for fixed text and parsers for grammars; save regex for patterns.
You can find and shape text by pattern. Next you will make your programs tell you what they are doing in production, replacing print with the logging module.