Regular expressions are a compact language for describing text patterns. Python's built-in re module gives you search, match, findall, finditer, split, sub, and subn. This lesson walks through the syntax, the most common metacharacters, and the patterns you will use in real code.
The basic match
re.search returns a match object for the first occurrence anywhere in the string. re.match anchors the search at the start of the string. re.fullmatch requires the entire string to match. Use re.findall to get all matches as a list of strings.
import re
text = "My phone numbers are 555-1234 and 555-5678."
m = re.search(r"\\d{3}-\\d{4}", text)
print(m.group()) # 555-1234
print(re.findall(r"\\d{3}-\\d{4}", text)) # ['555-1234', '555-5678']
The metacharacters you will use most
Metacharacters give regex its power. . matches any character except newline. \\d, \\w, and \\s match digit, word character, and whitespace. The anchors ^ and $ match the start and end of the string. Use \\ to escape a metacharacter.
import re
patterns = {
"digits": r"\\d+",
"words": r"\\w+",
"whitespace": r"\\s+",
"at start": r"^Hello",
"at end": r"\\.$",
}
text = "Hello world. 123 numbers here."
for label, pat in patterns.items():
matches = re.findall(pat, text)
print(f"{label:12} -> {matches}")
Groups and capture
Parentheses group part of a pattern and capture the matched text. m.group(n) returns the nth group. m.groups() returns all groups. Groups are numbered from 1; group 0 is the entire match.
import re
date = "2026-04-15"
m = re.match(r"(\\d{4})-(\\d{2})-(\\d{2})", date)
print(m.group(0)) # 2026-04-15
print(m.group(1)) # 2026
print(m.group(2)) # 04
print(m.group(3)) # 15
year, month, day = m.groups()
Named groups for readable code
Named groups use the syntax (?P<name>...). Access them by name on the match object. They make the code self-documenting and survive pattern changes that would renumber unnamed groups.
import re
log_line = "2026-04-15 12:34:56 INFO User logged in"
pat = r"(?P<date>\\d{4}-\\d{2}-\\d{2}) (?P<time>\\d{2}:\\d{2}:\\d{2}) (?P<level>\\w+) (?P<message>.+)"
m = re.match(pat, log_line)
print(m["date"])
print(m["time"])
print(m["level"])
print(m["message"])
Substitution
re.sub replaces matches with a replacement string. Use \\1 or \\g<name> in the replacement to refer to captured groups. Limit the number of replacements with the count argument.
import re
# Redact phone numbers
text = "Call 555-1234 or 555-5678 for help."
redacted = re.sub(r"\\d{3}-\\d{4}", "XXX-XXXX", text)
print(redacted) # Call XXX-XXXX or XXX-XXXX for help.
# Reformat dates
dates = "Today is 2026-04-15 and tomorrow is 2026-04-16."
reformatted = re.sub(r"(\\d{4})-(\\d{2})-(\\d{2})", r"\\3/\\2/\\1", dates)
print(reformatted)
compile for repeated use
When you use the same pattern many times, compile it once with re.compile. The compiled pattern object exposes the same methods (search, match, findall, sub) and is faster because the pattern is not re-parsed each call.
import re
email_pat = re.compile(r"[\\w.+-]+@[\\w-]+\\.[\\w.-]+")
text = "Contact us at [email protected] or [email protected]."
print(email_pat.findall(text))
# ['[email protected]', '[email protected]']
Common pitfalls with regular expressions
Three traps catch even experienced developers. First, greedy quantifiers match more than you expect. The pattern r"<.+>" on "<h1>title</h1>" returns the whole string because .+ is greedy. Add the lazy quantifier r"<.+?>" and you get the tags one at a time. Second, forgetting to escape a metacharacter silently widens the match. A dot inside an email pattern matches anything, including the wrong characters. Always escape characters that have special meaning: ., *, +, ?, ^, $, (, ), [, ], {, }, |, /. Third, anchored patterns can be confusing: ^ and $ match start and end of the string by default, not start and end of a line. Use re.MULTILINE to make ^ and $ match around newlines, and use \A and \Z to anchor absolutely.
Performance notes for regex
Two habits keep regex fast. First, prefer re.compile when you use a pattern more than a handful of times, especially inside a loop over a large dataset. The savings are not huge per call, but they compound. Second, watch for catastrophic backtracking. A pattern with nested quantifiers like r"(a+)+b" against "aaaaaaaaaaaaaaaaaaaaaaaac" can hang for minutes as the engine tries every partition. Rewrite the pattern to avoid ambiguity (r"a+b"), or use possessive quantifiers where the engine supports them. If you need to limit the cost of a single match, use the timeout argument added in Python 3.13 (re.search(pattern, text, timeout=1.0)) to bound how long the engine may spend.
Real-world example: validating user input
A common task is validating that a string looks like an email address. The full RFC 5321 grammar is enormous, so pragmatic validation checks a sensible subset. Combined with a small set of named captures, you get a self-documenting validator.
import re
EMAIL_RE = re.compile(
r"(?P<local>[\w.+-]+)@" # local part
r"(?P<domain>[\w-]+)" # domain label
r"(?:\.(?P<sub>[\w.-]+))?" # optional subdomain chain
)
def looks_like_email(s: str) -> bool:
if not s or len(s) > 254:
return False
m = EMAIL_RE.fullmatch(s)
if not m:
return False
return ".." not in s # cheap extra check
for sample in ["[email protected]", "no-at-sign", "[email protected]", "toolong" + "x" * 250 + "@a.co"]:
print(sample[:20], "->", looks_like_email(sample))
The validator rejects empty strings, anything longer than 254 characters (the RFC limit), and double dots in the local part. For full correctness, send the address an email and require the user to confirm; no regex is a substitute for that.
Summary
Use re.search for the first match anywhere, re.match to anchor at the start, re.findall for all matches, and re.sub to replace. Compile patterns you use many times. The next lesson covers object-oriented programming in Python: classes, attributes, methods, and inheritance.