The re Module

Seven functions cover every regex job: search, match, fullmatch, findall, finditer, sub and split. Knowing which one to reach for is most of the skill.

The seven functions

FunctionReturnsUse when
re.searchThe first match, or NoneIs it anywhere?
re.matchA match at the start, or NoneDoes it start with this?
re.fullmatchA match of the whole string, or NoneValidation
re.findallA list of strings or tuplesEvery match, all at once
re.finditerAn iterator of match objectsEvery match, with positions
re.subA new stringReplacing
re.splitA list of stringsSplitting on a pattern

search, match and fullmatch

import re

text = "order 12345 confirmed"

print(re.search(r"\d+", text))         # found at position 6
print(re.match(r"\d+", text))          # None - the string starts with 'order'
print(re.match(r"order", text))        # matches at the start
print(re.fullmatch(r"\d+", "12345"))   # the entire string is digits
print(re.fullmatch(r"\d+", "123 45"))  # None
import re


def is_valid_pin(value):
    return re.fullmatch(r"\d{6}", value) is not None


for candidate in ["411001", "41100", "4110011", "41100a"]:
    print(f"{candidate:<10}{is_valid_pin(candidate)}")
Use fullmatch for validation. re.match(r"\d{6}", "1234567") succeeds, because match only anchors the start. That mistake accepts input it should reject.

The match object

import re

match = re.search(r"(?P<key>\w+)=(?P<value>\d+)", "config timeout=30 here")

if match:
    print(match.group(0))        # timeout=30
    print(match.group(1))        # timeout
    print(match.group("value"))  # 30
    print(match.groups())        # ('timeout', '30')
    print(match.groupdict())     # {'key': 'timeout', 'value': '30'}
    print(match.start(), match.end(), match.span())
    print(match.string[match.start():match.end()])
import re

# Always test before using the result
match = re.search(r"\d+", "no digits here")
# print(match.group())         # AttributeError: 'NoneType' has no attribute 'group'

if match:
    print(match.group())
else:
    print("no match")

# Or with the walrus operator
if (match := re.search(r"\d+", "order 42")):
    print(match.group())

findall

import re

text = "a1 b22 c333"

print(re.findall(r"\d+", text))              # ['1', '22', '333'] - no groups
print(re.findall(r"([a-z])(\d+)", text))     # [('a', '1'), ('b', '22'), ('c', '333')]
print(re.findall(r"(?:[a-z])(\d+)", text))   # ['1', '22', '333'] - one group
Groups in the patternfindall returns
NoneThe whole match for each hit
OneThat group for each hit
Two or moreA tuple of the groups for each hit

This changes silently when you add a group for an unrelated reason. Use (?:...) to group without capturing, or use finditer.

finditer

import re

text = "Meera scored 92, Arun scored 78, Sara scored 85"

for match in re.finditer(r"(?P<name>\w+) scored (?P<score>\d+)", text):
    print(f"{match.group('name'):<8}{match.group('score'):>4}"
          f"   at {match.start()}")

finditer gives full match objects and is lazy, so it works on very large text without building a list. Prefer it whenever you need positions, named groups, or more than a handful of matches.

sub

import re

text = "call 080-1234567 or 022-7654321"

print(re.sub(r"\d", "X", text))                  # every digit
print(re.sub(r"\d{7}", "*******", text))         # the numbers only
print(re.sub(r"\s+", " ", "too    many   spaces"))
print(re.sub(r"\d", "X", text, count=3))         # only the first three

Referring to groups in the replacement

import re

text = "2026-08-22 and 2025-01-15"

print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", text))
# 22/08/2026 and 15/01/2025

print(re.sub(r"(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})",
             r"\g<d>/\g<m>/\g<y>", text))

A function as the replacement

import re


def double(match):
    return str(int(match.group()) * 2)


print(re.sub(r"\d+", double, "a1 b22 c333"))         # a2 b44 c666


def title_case(match):
    return match.group().title()


print(re.sub(r"\b[a-z]+\b", title_case, "the quick brown fox"))


def redact(match):
    value = match.group()
    return value[:2] + "*" * (len(value) - 4) + value[-2:]


print(re.sub(r"\b\d{10,16}\b", redact, "card 1234567812345678 saved"))

When the replacement depends on what was matched, pass a function. It receives the match object and returns the replacement string.

import re

text = "old value"
new_text, count = re.subn(r"old", "new", text)
print(new_text, count)                # new value 1

split

import re

print(re.split(r",\s*", "a, b,c,  d"))               # ['a', 'b', 'c', 'd']
print(re.split(r"[;,\s]+", "a,b; c  d"))             # several separators
print(re.split(r"(\d+)", "a1b22c"))                  # keeps the separators
print(re.split(r",", "a,b,c", maxsplit=1))           # ['a', 'b,c']

print("a,b,c".split(","))                             # use str.split when it suffices

Compiling

import re

pattern = re.compile(r"(?P<key>\w+)\s*=\s*(?P<value>.+)")

for line in ["host = localhost", "port=8080", "not a setting"]:
    match = pattern.match(line)
    if match:
        print(match.groupdict())

print(pattern.pattern)
print(pattern.groupindex)
import re
import time

lines = ["value=%d" % i for i in range(200_000)]

start = time.perf_counter()
for line in lines:
    re.search(r"\d+", line)
print(f"module level: {time.perf_counter() - start:.3f}s")

pattern = re.compile(r"\d+")
start = time.perf_counter()
for line in lines:
    pattern.search(line)
print(f"compiled:     {time.perf_counter() - start:.3f}s")

re caches compiled patterns, so the difference is modest. Compile anyway when a pattern is used in a loop or reused across a module - it also gives the pattern a name, which documents it.

A worked example: parsing a log

import re
from collections import Counter

LOG_LINE = re.compile(r"""
    ^(?P<date>\d{4}-\d{2}-\d{2})       # 2026-08-22
    \s
    (?P<time>\d{2}:\d{2}:\d{2})        # 14:30:45
    \s+
    (?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)
    \s+
    (?P<module>[\w.]+)
    \s+
    (?P<message>.+)$
""", re.VERBOSE)

log = """
2026-08-22 14:30:45  INFO      app.server   service started
2026-08-22 14:30:46  WARNING   app.db       slow query: 1.4s
2026-08-22 14:31:02  ERROR     app.db       connection refused
not a log line at all
2026-08-22 14:31:05  ERROR     app.server   request failed
"""

levels = Counter()
errors = []
skipped = 0

for line in log.strip().splitlines():
    match = LOG_LINE.match(line.strip())
    if not match:
        skipped += 1
        continue
    record = match.groupdict()
    levels[record["level"]] += 1
    if record["level"] in {"ERROR", "CRITICAL"}:
        errors.append(record)

print(dict(levels))
print(f"{skipped} unparsable lines")
for record in errors:
    print(f"  {record['time']}  {record['module']:<12}{record['message']}")

Validation patterns

import re

PATTERNS = {
    "pin code":     re.compile(r"\d{6}"),
    "phone":        re.compile(r"[6-9]\d{9}"),
    "identifier":   re.compile(r"[A-Za-z_]\w*"),
    "hex colour":   re.compile(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"),
    "time 24h":     re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d"),
    "simple email": re.compile(r"[^@\s]+@[^@\s]+\.[a-zA-Z]{2,}"),
}


def validate(kind, value):
    return PATTERNS[kind].fullmatch(value) is not None


tests = [
    ("pin code", "411001"), ("pin code", "41100"),
    ("phone", "9876543210"), ("phone", "1234567890"),
    ("hex colour", "#a1b2c3"), ("hex colour", "#xyz"),
    ("time 24h", "23:59"), ("time 24h", "24:00"),
]

for kind, value in tests:
    print(f"{kind:<14}{value:<14}{validate(kind, value)}")
The email pattern above is deliberately simple. A fully correct email regex is enormous and still wrong at the edges. For real validation, check that there is exactly one @ with text on both sides, then send a confirmation message - that is the only test that actually matters.

Common mistakes

  • Using match where fullmatch was needed, accepting trailing junk.
  • Calling .group() without checking for None.
  • Being surprised when findall returns tuples after a group is added.
  • Forgetting that sub returns a new string and does not modify in place.
  • Using re.split where str.split would do.
  • Writing one enormous pattern instead of two clear ones.

Best practices

  • Use fullmatch for validation, search for finding, finditer for many matches.
  • Compile patterns used more than once and name them in capitals.
  • Use re.VERBOSE with comments for anything non-trivial.
  • Always check a match object before using it.
  • Pass a function to sub when the replacement depends on the match.
  • Use named groups and groupdict() to turn matches into records.

Practice

  1. Validate six field types with fullmatch and report which inputs fail.
  2. Parse a log format of your own design into dictionaries with named groups.
  3. Use sub with a function to mask all but the last four digits of any long number.
  4. Explain why adding a group changes what findall returns, and show it.
  5. Rewrite a long inline pattern using re.VERBOSE with comments.

Conclusion

Reach for fullmatch to validate, search to find, finditer to walk every match with its position, and sub with a function to transform. Compile named patterns, use named groups, and always check the match before using it.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.