Python: Regular Expressions, Patterns, Groups, Lookaheads

This is the complete Python regex reference: every pattern syntax, character class, quantifier, group type, lookaround, and re module method for regular expressions in Python, each with a tested example. It is the cheat sheet you bookmark and keep coming back to.

“Some people, when confronted with a problem, think ‘I know, I’ll use regular expressions.’ Now they have two problems.”

Jamie Zawinski

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 13 minutes

Think of a regular expression as a much more powerful search query. When you press Ctrl+F and type “cat”, you find the exact word “cat”. A regex lets you instead say “find me any 10-digit phone number” or “find every word that ends in .com”. Python’s re module is the tool that runs those queries. It gives you a tiny pattern language living inside Python: cryptic at first glance, but once you learn the 20-odd symbols that actually matter, it becomes your go-to for validation, parsing, and text cleanup.

Regex has a reputation for being unreadable. That reputation is mostly earned by people who try to write one giant pattern in a single shot instead of building it up piece by piece. This post is the Python regex reference you reach for: every symbol, every method, every pattern type, with output you can trust because each example was run on Python 3.14.6. The next post (061) puts all of it to work on real problems like email validation and log parsing.

The Cheat Sheet

re module methodsre.search()first match anywherere.match()match at start onlyre.findall()all matches as listre.finditer()all matches as iteratorre.sub()search and replacere.compile()precompile patternGroups and Lookaround()Capture group(?:)Non-capture(?P)Named group(?=)Lookahead(?<=)LookbehindQuantifiers*Zero or more+One or more?Zero or one{n} Exactly n{n,m} Betweenn and mCharacter Classes\d Digit [0-9]\w Word char[a-zA-Z0-9_]\s Whitespace[abc] Custom set[^abc] Negated setAnchors^ Start of string$ End of string\b Word boundaryPython Regex: Anchors, Character Classes, Quantifiers, Groups, and re Methods

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram breaks Python regular expression syntax into four building blocks: character classes (what to match), quantifiers (how many times), anchors (where in the string), and groups (which part to capture). Those four categories cover pretty much every regex pattern you will ever write. You read most patterns by mixing the blocks. For example, \d+ joins a character class (digit) with a quantifier (one or more), so it means “one or more digits”. The reference table below gives a tested example for each element so you can look one up in seconds.

PatternMatchesExampleResult
.Any char except newlinere.findall(r"h.t", "hat hit hot")['hat', 'hit', 'hot']
^Start of stringre.search(r"^Hello", "Hello Rahul")Match
$End of stringre.search(r"done$", "All done")Match
\dDigit [0-9]re.findall(r"\d+", "Age: 28")['28']
\wWord char [a-zA-Z0-9_]re.findall(r"\w+", "Hi Viraj!")['Hi', 'Viraj']
\sWhitespacere.split(r"\s+", "a b c")['a', 'b', 'c']
\bWord boundaryre.findall(r"\bcat\b", "cat catch")['cat']
*Zero or morere.findall(r"ab*c", "ac abc abbc")['ac', 'abc', 'abbc']
+One or morere.findall(r"ab+c", "ac abc abbc")['abc', 'abbc']
?Zero or onere.findall(r"colou?r", "color colour")['color', 'colour']
{n,m}Between n and mre.findall(r"\d{2,4}", "1 22 333 4444")['22', '333', '4444']
[abc]Character setre.findall(r"[aeiou]", "hello")['e', 'o']
[^abc]Negated setre.findall(r"[^aeiou]", "hello")['h', 'l', 'l']
()Capture groupre.search(r"(\d+)-(\d+)", "91-12345")Groups: ('91', '12345')
(?:)Non-capturing groupre.findall(r"(?:ab)+", "ababab")['ababab']
(?P<name>)Named groupre.search(r"(?P<area>\d+)-(?P<num>\d+)", "91-12345").group('area')'91'
(?=...)Lookaheadre.findall(r"\w+(?=@)", "pravin@test.com")['pravin']
(?<=...)Lookbehindre.findall(r"(?<=@)\w+", "pravin@gmail.com")['gmail']
|Alternation (OR)re.findall(r"cat|dog", "cat and dog")['cat', 'dog']

The re Module Methods

Searching & Matching

📄 search_vs_match.py: the most common confusion in regex

import re

text = "Employee: Niranjan Raut, Age: 29"

# re.search() finds the first match ANYWHERE in the string
result = re.search(r"\d+", text)
print(f"search: {result.group()}")    # 29

# re.match() only matches at the BEGINNING of the string
result = re.match(r"\d+", text)
print(f"match: {result}")             # None, the text does not start with digits

result = re.match(r"Employee", text)
print(f"match: {result.group()}")     # Employee

# re.fullmatch() needs the ENTIRE string to match (3.4+)
print(re.fullmatch(r"\d+", "12345"))  # Match
print(re.fullmatch(r"\d+", "123ab"))  # None

▶ Output

search: 29
match: None
match: Employee
<re.Match object; span=(0, 5), match='12345'>
None

Here is the one-line rule to remember: search looks through the whole string, match only checks the very start, and fullmatch insists the entire string fits. Picture a security guard looking for a visitor: search walks the whole building, match only checks whoever is standing at the front door, and fullmatch demands the building hold that one visitor and nobody else. The sample text is an HR record for an employee named Niranjan Raut, and it starts with the word “Employee”, not a digit, so re.match(r"\d+", text) returns None. Reach for search by default; use match or fullmatch only when “starts with” or “is exactly” is what you actually mean.

Finding All Matches

📄 findall_finditer.py: get every match, not just the first

import re

log = "Error at 14:30:22, Warning at 14:31:05, Error at 14:32:18"

# findall returns a list of matched strings
times = re.findall(r"\d{2}:\d{2}:\d{2}", log)
print(times)  # ['14:30:22', '14:31:05', '14:32:18']

# finditer returns Match objects (more info: span, groups)
for match in re.finditer(r"(\w+) at (\d{2}:\d{2}:\d{2})", log):
    print(f"{match.group(1)} occurred at {match.group(2)}")

▶ Output

['14:30:22', '14:31:05', '14:32:18']
Error occurred at 14:30:22
Warning occurred at 14:31:05
Error occurred at 14:32:18

Use findall when you just want the matched text as a plain list. Use finditer when you need more than the text, like which groups matched or where each match sits in the string. Notice that finditer hands you a Match object per hit, so match.group(1) and match.group(2) pull out the captured pieces one at a time.

Substitution & Splitting

📄 sub_split.py: search-and-replace and tokenizing

import re

# re.sub() replaces every match
text = "Call Anvay at 9876543210 or Aviraj at 9123456789"
redacted = re.sub(r"\d{10}", "[REDACTED]", text)
print(redacted)

# re.sub() can take a function instead of a fixed string
def censor_name(match):
    name = match.group()
    return name[0] + "*" * (len(name) - 1)

text = "Team: Rahul, Viraj, Vinay"
censored = re.sub(r"\b[A-Z][a-z]+\b", censor_name, text)
print(censored)

# re.split() splits the string wherever the pattern matches
data = "Rahul::28;;Niranjan::31;;Pravin::25"
parts = re.split(r"[:;]+", data)
print(parts)

▶ Output

Call Anvay at [REDACTED] or Aviraj at [REDACTED]
T***: R****, V****, V****
['Rahul', '28', 'Niranjan', '31', 'Pravin', '25']

The first example is a support note mentioning two engineers, Anvay and Aviraj, with their phone numbers sitting in plain text; one re.sub call redacts both numbers at once. The function trick is the part worth slowing down on. When you pass a function to re.sub, Python hands it the Match object for every hit and uses whatever the function returns as the replacement. Here censor_name keeps the first letter and masks the rest, so a 5-letter name like “Rahul” becomes “R” plus four stars (R****).

All three names happen to be 5 letters, so they all come out the same length. Look closely at the output and you will spot one more casualty: the label “Team” also got masked to T***, because the pattern [A-Z][a-z]+ matches any capitalized word, not just actual names. A good reminder that a “name” pattern is usually broader than real names. That is far more flexible than a fixed replacement string.

Groups & Lookaround

📄 named_groups.py: extract structured data with named captures

import re

# Named groups make regex self-documenting
pattern = r"(?P<name>[A-Za-z ]+),\s*age\s*(?P<age>\d+),\s*(?P<city>[A-Za-z ]+)"
text = "Viraj Patil, age 26, Pune"

match = re.search(pattern, text)
if match:
    print(match.group("name"))    # Viraj Patil
    print(match.group("age"))     # 26
    print(match.group("city"))    # Pune
    print(match.groupdict())      # {'name': 'Viraj Patil', 'age': '26', 'city': 'Pune'}

▶ Output

Viraj Patil
26
Pune
{'name': 'Viraj Patil', 'age': '26', 'city': 'Pune'}

The pattern here pulls apart a profile line for a user named Viraj Patil into name, age, and city. Plain numbered groups work, but six months later match.group(2) tells you nothing. Named groups fix that. You write (?P<age>\d+) once and then read it back as match.group("age"), which reads like English. The groupdict() call is the cherry on top: it dumps every named group into a dictionary, ready to feed straight into a database row or a JSON (JavaScript Object Notation) payload.

📄 lookaround.py: match positions, not characters

import re

# Lookbehind: match a number only if a $ sits right before it (the $ is not captured)
prices = "Item: $50, Tax: $8, Total: $58"
# Find numbers preceded by $
amounts = re.findall(r"(?<=\$)\d+", prices)
print(f"Amounts: {amounts}")    # ['50', '8', '58']

# Negative lookahead: match 'cat' only if it is NOT followed by another letter
words = "cat catch a cat here"
# 'catch' is skipped (c-a-t-c), only the standalone 'cat' words match
standalone = re.findall(r"cat(?![a-z])", words)
print(f"Standalone: {standalone}")  # ['cat', 'cat']

▶ Output

Amounts: ['50', '8', '58']
Standalone: ['cat', 'cat']

Lookaround is the tricky one, so here is the mental picture. A lookbehind (?<=\$) is like saying “I want the number, but only if a dollar sign is standing right behind it”. The dollar sign itself never shows up in the result, it just acts as a guard. The negative lookahead (?![a-z]) works the same way looking forward: it keeps “cat” only when no lowercase letter follows, so “catch” and “category” get rejected. Lookarounds check the neighbourhood without grabbing it.

Compiling & Flags

📄 compile_flags.py: pre-compile for performance, flags for behavior

import re

# Compile once, reuse many times (faster in loops)
email_pattern = re.compile(
    r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
)

emails = ["rahul@technoscripts.com", "not-an-email", "vinay@gmail.com"]
for email in emails:
    if email_pattern.match(email):
        print(f"Valid: {email}")

# Useful flags
# re.IGNORECASE (re.I): case-insensitive matching
print(re.findall(r"python", "Python PYTHON python", re.I))

# re.MULTILINE (re.M): ^ and $ match line boundaries
text = "Line 1\nLine 2\nLine 3"
print(re.findall(r"^Line \d", text, re.M))

# re.DOTALL (re.S): . matches newlines too
html = "<p>Hello\nWorld</p>"
print(re.findall(r"<p>(.+?)</p>", html, re.S))

# re.VERBOSE (re.X): lets you add whitespace and comments in patterns
phone_pattern = re.compile(r"""
    ^(\+\d{1,3})?      # Optional country code
    [-.\s]?             # Optional separator
    \(?(\d{3})\)?       # Area code (optional parens)
    [-.\s]?             # Optional separator
    (\d{3})             # First three digits
    [-.\s]?             # Optional separator
    (\d{4})$            # Last four digits
""", re.VERBOSE)

▶ Output

Valid: rahul@technoscripts.com
Valid: vinay@gmail.com
['Python', 'PYTHON', 'python']
['Line 1', 'Line 2', 'Line 3']
['Hello\nWorld']

Compiling is like saving a phone number to your contacts instead of dialing all ten digits every time. re.compile() turns your pattern into a reusable object once, and inside a loop that saves real work. The flags then tune how matching behaves: re.I ignores case, re.M makes ^ and $ hug each line, re.S lets . swallow newlines, and re.X lets you spread a scary pattern over several lines with comments. The phone pattern at the bottom never runs a match here, it is just there to show how much friendlier a verbose pattern reads.

Head-to-Head Comparisons

Seven methods, one table. When you cannot remember which one returns what, scan this instead of the docs. The “Use When” column is the tiebreaker: most Python regex bugs start with picking the wrong method, not writing the wrong pattern.

MethodReturnsUse When
search()First Match object or NoneYou need the first occurrence anywhere
match()Match at start or NoneChecking if string STARTS with pattern
fullmatch()Match or NoneValidating entire string (email, phone)
findall()List of stringsExtracting all matches as a flat list
finditer()Iterator of Match objectsLarge text, need positions/groups per match
sub()Modified stringSearch and replace
split()List of stringsTokenizing by complex delimiters

Common Mistakes

❌ Mistake: Forgetting raw strings

# Bad: Python reads \b as a backspace character (ASCII 8), not a word boundary
re.findall("\bcat\b", "the cat sat")    # [] (no matches!)

# Good: a raw string r"..." passes \b straight to the regex engine
re.findall(r"\bcat\b", "the cat sat")   # ['cat']

# Rule: ALWAYS use r"..." for regex patterns. Always.

❌ Mistake: Greedy vs lazy quantifiers

import re

html = "<b>bold</b> and <b>more bold</b>"

# Greedy (default): grabs as MUCH as it can
print(re.findall(r"<b>(.+)</b>", html))
# ['bold</b> and <b>more bold'] (way too much!)

# Lazy (add ?): grabs as LITTLE as it can
print(re.findall(r"<b>(.+?)</b>", html))
# ['bold', 'more bold'] (just right)

Think of a greedy quantifier as someone at a buffet who piles the plate to the ceiling and only puts food back if the plate will not close. That is literally how the engine works: .+ first swallows everything to the end of the string, then backtracks just enough for the rest of the pattern to fit, which is why the greedy version stretches from the first <b> all the way to the last </b>. Adding ? flips the strategy: take the minimum, extend only when forced. Whenever you extract text between two delimiters, reach for the lazy form.

Conclusion

Python regex is a small language built just for text patterns. Python’s re module hands you match, search, findall, sub, and split for the everyday jobs. Compiling a pattern speeds up loops, flags like re.IGNORECASE and re.VERBOSE change how matching behaves, and named groups plus lookaround handle the trickier extraction work. Keep this page open in a tab; nobody memorizes all of it, and you are not supposed to.

You now have the syntax. In the regex in practice tutorial, you will put it to work on real problems: email validation, log parsing, data extraction, and the text cleanup patterns you reach for in production. For every post in the series, from absolute basics to AI/ML, head over to the Python + AI/ML tutorial series home.

Practice Exercises

  1. Exercise 1: Validate email format. Test with 5 valid and 5 invalid.
  2. Exercise 2: Extract phone numbers from text with re.findall().
  3. Exercise 3: Build a tokenizer for math expressions into numbers and operators.

Frequently Asked Questions

What is the difference between re.search() and re.match() in Python?

re.search() finds the first match anywhere in the string. re.match() only matches at the beginning of the string. Use re.search() unless you specifically need start-of-string matching. Use re.fullmatch() for validating an entire string.

Why should I use raw strings for regex patterns?

Raw strings (r"...") prevent Python from interpreting backslashes. Without r, \b means backspace (ASCII 8) to Python, not word boundary to regex. Always use r"..." for patterns to avoid subtle bugs.

What is the difference between greedy and lazy quantifiers?

Greedy quantifiers (*, +, ?) match as much text as possible. Lazy quantifiers (*?, +?, ??) match as little as possible. Use lazy when extracting content between delimiters like HTML tags.

When should I use re.compile()?

Use re.compile() when you reuse the same pattern many times (in a loop, across function calls). It pre-compiles the pattern into a regex object for faster matching. For one-off searches, the module-level functions are fine, since Python caches recent patterns internally.

What is a lookahead in regex?

A lookahead (?=...) matches a position where the pattern inside would match, but doesn’t consume any characters. (?!...) is a negative lookahead, which matches a position where the pattern does NOT match. Lookaheads are zero-width assertions used for conditional matching.

How do I match across multiple lines?

By default, . doesn’t match newlines and ^/$ only match string boundaries. Use re.DOTALL (re.S) to make . match newlines. Use re.MULTILINE (re.M) to make ^/$ match line boundaries.

Interview Questions on Python Regex

Interviewers rarely ask for definitions. They ask what happens in situations like these.

Q: re.findall() suddenly returns tuples instead of strings. What changed in your pattern?

You added capture groups. With no groups, findall returns the full matched strings. With exactly one group it returns just that group, and with two or more groups it returns a tuple per match. If you only added parentheses for grouping, switch them to non-capturing (?:...), or use finditer and read match.group(0) for the full match.

Q: Your username validator is re.search(r”^[a-z]+$”, value), yet the input “hello\n” passes and later breaks a downstream system. Why, and what is the fix?

In Python regex, $ matches at the end of the string or just before a single trailing newline, so "hello\n" sails through. The fix is to anchor with \Z, which matches only at the true end of the string, or to use re.fullmatch(r"[a-z]+", value), which rejects the trailing newline. This exact edge case shows up whenever input comes from files or web forms where a newline can tag along.

Q: After a deploy, one Application Programming Interface (API) endpoint pins the Central Processing Unit (CPU) at 100% for certain user inputs, and profiling points at a regex like (a+)+$. What is happening and what do you check first?

That is catastrophic backtracking: nested quantifiers give the engine exponentially many ways to split the input, and on a non-matching string it tries them all. First check the pattern for nested or overlapping quantifiers and rewrite them so each character can only be consumed one way. Since Python 3.11 you can also use atomic groups (?>...) or possessive quantifiers like a++ to forbid backtracking, and always cap the length of untrusted input before matching it.

Q: When would you pick re.split() over str.split(), and what happens if your split pattern contains a capture group?

str.split() only handles one fixed separator, so reach for re.split() when the delimiters vary, like r"[,;|]+" or runs of mixed whitespace. If the pattern contains a capture group, the captured delimiter text is kept in the result list, which is handy when tokenizing math expressions and you need the operators too. Wrap the group as (?:...) if you do not want the separators back.

Q: How would you find accidentally repeated words like “the the” in a document using regex?

Use a backreference: r"\b(\w+)\s+\1\b" captures a word and then \1 demands the exact same text again. Add re.IGNORECASE so “The the” is caught too. In a replacement string the same idea works as \1 or \g<name>, so re.sub(r"\b(\w+)\s+\1\b", r"\1", text) collapses the duplicate down to one word.

Q: An interviewer asks why you should not parse HTML with regex. What is the honest answer?

Regular expressions cannot track arbitrarily nested structure, and real HTML nests tags inside tags, so a regex-based parser will always break on some valid page. For real parsing use html.parser or BeautifulSoup. The honest nuance: grabbing one flat, predictable snippet, like every href from a page you control, is fine with regex; building anything that must understand the document tree is not.

Go deeper: when you outgrow this post, Python re module documentation is the next stop.

Previous: Python: Context Managers, with Statement & Custom Managers

Next: Python: Regex in Practice (Validation, Parsing, Text Processing)

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *