PythonMastery
intermediate 20 min read · lesson 10 of 13 in Python Intermediate

Regular Expressions

1 · The lesson

read

A regex (regular expression) is a small pattern language for describing the shape of a piece of text. "An email address." "A date in YYYY-MM-DD form." "Three digits followed by a dash." You write the pattern, and the regex engine finds the text that matches it (or reports that nothing matches).

Regex is known for being hard to read later, and that is often true when people use it for the wrong job. This lesson teaches the parts you will really use, when to use them, and, just as important, when to stop using regex and use a real parser instead.


1. The re Module: Your First Pattern

python
import re

text = "Order 4271 shipped on 2026-03-14"

m = re.search(r"\d+", text)            # find the first run of digits
print(m)                                # <re.Match object; span=(6, 10), match='4271'>
print(m.group())                        # '4271'
print(m.span())                         # (6, 10)  (the start and end index)

Note three things in this example:

1. The r"..." raw string prefix. Always. Regex uses \ a lot (\d, \b, \w). Without r, Python's string parser processes the backslashes first, and you get bugs or warnings. We'll come back to this in Section 10.
2. re.search finds the first match anywhere in the string. It returns a Match object or None.
3. Match.group() gives you the matched text. Match.span() gives you the position.


2. The Five Functions You'll Use Most

python
import re

text = "alice@example.com, bob@example.com, carol@other.org"

# 1. search: the first match anywhere, or None
m = re.search(r"\w+@\w+\.\w+", text)
print(m.group())                        # alice@example.com

# 2. match: a match only at the START of the string
print(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")
print(re.match(r"alice", text))         # Match object

# 3. findall: every match, as a list of strings
print(re.findall(r"\w+@\w+\.\w+", text))
# ['alice@example.com', 'bob@example.com', 'carol@other.org']

# 4. finditer: every match, as Match objects (best when you need positions)
for m in re.finditer(r"\w+@\w+\.\w+", text):
    print(m.group(), "at", m.span())

# 5. sub: find and replace
print(re.sub(r"\w+@\w+\.\w+", "[EMAIL]", text))
# [EMAIL], [EMAIL], [EMAIL]

Beginners often confuse search and match: match only matches at the start of the string, while search looks anywhere. When in doubt, use search. match is mainly useful when you're parsing a known format from position 0 (like a line in a structured log).

findall is convenient but gives you only strings, with no positions. If the pattern has groups, it returns the groups (as tuples when there are several), not the full match. When you need more than findall gives you, use finditer.


3. Character Classes

Character classes answer the question "what kind of character can be here?"

PatternMeans
\dA digit. Equivalent to [0-9] for ASCII.
\DA non-digit.
\wA "word" character: letter, digit, or underscore.
\WA non-word character.
\sWhitespace: space, tab, newline.
\SNon-whitespace.
.Any character except newline (unless re.DOTALL is set).
[abc]Any one of a, b, or c.
[^abc]Any character that is NOT a, b, or c.
[a-z]Any lowercase letter. Ranges work in sets.
[A-Za-z0-9_]\w written by hand (ASCII only). Useful when you want to be explicit.
python
re.findall(r"\d", "abc123")             # ['1', '2', '3']
re.findall(r"\d+", "abc123 def456")     # ['123', '456']
re.findall(r"[aeiou]", "regex lesson")  # ['e', 'e', 'e', 'o']
re.findall(r"[^aeiou\s]", "regex")      # ['r', 'g', 'x']  (the consonants)
+ setup added so this can run · re-runs 2 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Inside [], most metacharacters lose their special meaning: [.] is a literal dot, [+] is a literal plus. The exceptions are ^ (negation, only at start), - (range), ] (closes the class), and \ (escape).


4. Quantifiers: How Many?

PatternMeans
?0 or 1 (optional)
*0 or more
+1 or more
{n}Exactly n
{n,m}Between n and m
{n,}At least n

All of these are greedy by default: they match as much text as possible, and then give some back (backtrack) if the rest of the pattern needs it. Add ? after them to make them lazy (also called non-greedy or reluctant).

python
text = "<b>bold</b> and <i>italic</i>"

re.findall(r"<.*>", text)               # ['<b>bold</b> and <i>italic</i>']  (greedy)
re.findall(r"<.*?>", text)              # ['<b>', '</b>', '<i>', '</i>']    (lazy)
+ setup added so this can run · re-runs 3 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

The greedy version starts at the first <. Then .* matches everything to the end of the string, including the other angle brackets, and backtracks until it finds the last >. The lazy version stops at the first > after each <. Both are valid, but for tag-like delimiters you almost always want lazy.

(But if you are really parsing HTML, read Section 11 first: don't use regex for it.)


5. Anchors: Where in the String?

PatternMeans
^Start of string (or start of line with re.MULTILINE)
$End of string (or end of line with re.MULTILINE)
\bWord boundary: the position between a \w and a \W character
\BNon-word-boundary

\b is the one people forget most often. It matches the position between a word character and a non-word character (or the start or end of the string). It matches no characters itself. Use it for "match the word cat but not catalogue":

python
text = "cat caterpillar scattered cats"

re.findall(r"cat",   text)              # ['cat', 'cat', 'cat', 'cat']    (too many)
re.findall(r"\bcat\b", text)            # ['cat']                          (only the whole word)
re.findall(r"\bcat",   text)            # ['cat', 'cat', 'cat']            (cat at the start of a word)
+ setup added so this can run · re-runs 4 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

^ and $ by default match the start/end of the whole string. With re.MULTILINE, they match the start/end of each line:

python
log = "ERROR: x\nINFO: y\nERROR: z"

re.findall(r"^ERROR", log)                          # ['ERROR']         (only the ERROR on line 1)
re.findall(r"^ERROR", log, re.MULTILINE)            # ['ERROR', 'ERROR']
+ setup added so this can run · re-runs 5 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

6. Groups: Capturing Pieces

Parentheses (...) create a capture group: a part of the match that you can get by its number.

python
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "Released 2026-03-14, today.")
print(m.group(0))                       # 2026-03-14   (the full match)
print(m.group(1))                       # 2026         (the first group)
print(m.group(2))                       # 03           (the second group)
print(m.group(3))                       # 14           (the third group)
print(m.groups())                       # ('2026', '03', '14')
+ setup added so this can run · re-runs 6 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
    'log = "ERROR: x\\nINFO: y\\nERROR: z"\n\nre.findall(r"^ERROR", log)                          # [\'ERROR\']         (only the ERROR on line 1)\nre.findall(r"^ERROR", log, re.MULTILINE)            # [\'ERROR\', \'ERROR\']',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Non-capturing groups (?:...) group without capturing. They are useful when you need a group for alternation (|) or a quantifier, but don't want the result in .groups():

python
re.findall(r"(?:Mr|Mrs|Ms)\. (\w+)", "Mr. Stark, Ms. Potts")
# ['Stark', 'Potts']   (only the name is captured, not the title)
+ setup added so this can run · re-runs 7 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
    'log = "ERROR: x\\nINFO: y\\nERROR: z"\n\nre.findall(r"^ERROR", log)                          # [\'ERROR\']         (only the ERROR on line 1)\nre.findall(r"^ERROR", log, re.MULTILINE)            # [\'ERROR\', \'ERROR\']',
    'm = re.search(r"(\\d{4})-(\\d{2})-(\\d{2})", "Released 2026-03-14, today.")\nprint(m.group(0))                       # 2026-03-14   (the full match)\nprint(m.group(1))                       # 2026         (the first group)\nprint(m.group(2))                       # 03           (the second group)\nprint(m.group(3))                       # 14           (the third group)\nprint(m.groups())                       # (\'2026\', \'03\', \'14\')',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Named groups (?P<name>...) are easier to read. You access them by name instead of by number, so adding a group doesn't break your code:

python
m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", "2026-03-14")
print(m.group("year"))                  # 2026
print(m.groupdict())                    # {'year': '2026', 'month': '03', 'day': '14'}
+ setup added so this can run · re-runs 8 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
    'log = "ERROR: x\\nINFO: y\\nERROR: z"\n\nre.findall(r"^ERROR", log)                          # [\'ERROR\']         (only the ERROR on line 1)\nre.findall(r"^ERROR", log, re.MULTILINE)            # [\'ERROR\', \'ERROR\']',
    'm = re.search(r"(\\d{4})-(\\d{2})-(\\d{2})", "Released 2026-03-14, today.")\nprint(m.group(0))                       # 2026-03-14   (the full match)\nprint(m.group(1))                       # 2026         (the first group)\nprint(m.group(2))                       # 03           (the second group)\nprint(m.group(3))                       # 14           (the third group)\nprint(m.groups())                       # (\'2026\', \'03\', \'14\')',
    're.findall(r"(?:Mr|Mrs|Ms)\\. (\\w+)", "Mr. Stark, Ms. Potts")\n# [\'Stark\', \'Potts\']   (only the name is captured, not the title)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

If you have more than two groups, name them. Mixing up group 1 and group 2 is a very common bug.


7. Backreferences

Once you've captured a group, you can refer to it later: inside the pattern (to require the same text again) and in a replacement string (to reuse the captured text).

python
# In the pattern: find words that are immediately repeated
re.findall(r"\b(\w+) \1\b", "the the cat sat on on the mat")
# ['the', 'on']

# In a replacement: swap first and last name
re.sub(r"(\w+) (\w+)", r"\2 \1", "Linus Teja")
# 'Teja Linus'

# Named variant
re.sub(r"(?P<first>\w+) (?P<last>\w+)",
       r"\g<last> \g<first>",
       "Linus Teja")
# 'Teja Linus'
+ setup added so this can run · re-runs 9 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
    'log = "ERROR: x\\nINFO: y\\nERROR: z"\n\nre.findall(r"^ERROR", log)                          # [\'ERROR\']         (only the ERROR on line 1)\nre.findall(r"^ERROR", log, re.MULTILINE)            # [\'ERROR\', \'ERROR\']',
    'm = re.search(r"(\\d{4})-(\\d{2})-(\\d{2})", "Released 2026-03-14, today.")\nprint(m.group(0))                       # 2026-03-14   (the full match)\nprint(m.group(1))                       # 2026         (the first group)\nprint(m.group(2))                       # 03           (the second group)\nprint(m.group(3))                       # 14           (the third group)\nprint(m.groups())                       # (\'2026\', \'03\', \'14\')',
    're.findall(r"(?:Mr|Mrs|Ms)\\. (\\w+)", "Mr. Stark, Ms. Potts")\n# [\'Stark\', \'Potts\']   (only the name is captured, not the title)',
    'm = re.search(r"(?P<year>\\d{4})-(?P<month>\\d{2})-(?P<day>\\d{2})", "2026-03-14")\nprint(m.group("year"))                  # 2026\nprint(m.groupdict())                    # {\'year\': \'2026\', \'month\': \'03\', \'day\': \'14\'}',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

\1 is the first captured group, and \g<name> is the named-group form in replacement strings. With backreferences, a regex can describe not only the shape of the text but also relationships between parts of it (for example, "the same word twice").


8. Flags

Flags change how the engine reads the pattern. Pass them as the third argument. To use several flags, combine them with |.

FlagEffect
re.IGNORECASE (or re.I)Case-insensitive matching.
re.MULTILINE (or re.M)^ and $ match line boundaries, not just string.
re.DOTALL (or re.S). matches newlines too.
re.VERBOSE (or re.X)Allow whitespace + # comments inside the pattern.
python
re.findall(r"hello", "Hello world", re.IGNORECASE)              # ['Hello']
re.findall(r".+", "line1\nline2", re.DOTALL)                    # ['line1\nline2']
+ setup added so this can run · re-runs 10 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
    'log = "ERROR: x\\nINFO: y\\nERROR: z"\n\nre.findall(r"^ERROR", log)                          # [\'ERROR\']         (only the ERROR on line 1)\nre.findall(r"^ERROR", log, re.MULTILINE)            # [\'ERROR\', \'ERROR\']',
    'm = re.search(r"(\\d{4})-(\\d{2})-(\\d{2})", "Released 2026-03-14, today.")\nprint(m.group(0))                       # 2026-03-14   (the full match)\nprint(m.group(1))                       # 2026         (the first group)\nprint(m.group(2))                       # 03           (the second group)\nprint(m.group(3))                       # 14           (the third group)\nprint(m.groups())                       # (\'2026\', \'03\', \'14\')',
    're.findall(r"(?:Mr|Mrs|Ms)\\. (\\w+)", "Mr. Stark, Ms. Potts")\n# [\'Stark\', \'Potts\']   (only the name is captured, not the title)',
    'm = re.search(r"(?P<year>\\d{4})-(?P<month>\\d{2})-(?P<day>\\d{2})", "2026-03-14")\nprint(m.group("year"))                  # 2026\nprint(m.groupdict())                    # {\'year\': \'2026\', \'month\': \'03\', \'day\': \'14\'}',
    '# In the pattern: find words that are immediately repeated\nre.findall(r"\\b(\\w+) \\1\\b", "the the cat sat on on the mat")\n# [\'the\', \'on\']\n\n# In a replacement: swap first and last name\nre.sub(r"(\\w+) (\\w+)", r"\\2 \\1", "Linus Teja")\n# \'Teja Linus\'\n\n# Named variant\nre.sub(r"(?P<first>\\w+) (?P<last>\\w+)",\n       r"\\g<last> \\g<first>",\n       "Linus Teja")\n# \'Teja Linus\'',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Many people don't know about re.VERBOSE. It makes long, complex patterns readable:

python
phone_pattern = re.compile(r"""
    \+?                   # optional leading +
    (?P<country>\d{1,3})  # 1-3 digit country code
    [\s\-]?               # optional separator
    (?P<area>\d{3})       # area code
    [\s\-]?               # optional separator
    (?P<line>\d{4})       # line number
    """, re.VERBOSE)

m = phone_pattern.search("Call me at +1 555-7890 maybe")
print(m.groupdict())                    # {'country': '1', 'area': '555', 'line': '7890'}
+ setup added so this can run · re-runs 11 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    import re
except Exception:
    pass
_pm_earlier = [
    'import re\n\ntext = "Order 4271 shipped on 2026-03-14"\n\nm = re.search(r"\\d+", text)            # find the first run of digits\nprint(m)                                # <re.Match object; span=(6, 10), match=\'4271\'>\nprint(m.group())                        # \'4271\'\nprint(m.span())                         # (6, 10)  (the start and end index)',
    'import re\n\ntext = "alice@example.com, bob@example.com, carol@other.org"\n\n# 1. search: the first match anywhere, or None\nm = re.search(r"\\w+@\\w+\\.\\w+", text)\nprint(m.group())                        # alice@example.com\n\n# 2. match: a match only at the START of the string\nprint(re.match(r"bob@", text))          # None  (match only checks at index 0, and text starts with "alice@")\nprint(re.match(r"alice", text))         # Match object\n\n# 3. findall: every match, as a list of strings\nprint(re.findall(r"\\w+@\\w+\\.\\w+", text))\n# [\'alice@example.com\', \'bob@example.com\', \'carol@other.org\']\n\n# 4. finditer: every match, as Match objects (best when you need positions)\nfor m in re.finditer(r"\\w+@\\w+\\.\\w+", text):\n    print(m.group(), "at", m.span())\n\n# 5. sub: find and replace\nprint(re.sub(r"\\w+@\\w+\\.\\w+", "[EMAIL]", text))\n# [EMAIL], [EMAIL], [EMAIL]',
    're.findall(r"\\d", "abc123")             # [\'1\', \'2\', \'3\']\nre.findall(r"\\d+", "abc123 def456")     # [\'123\', \'456\']\nre.findall(r"[aeiou]", "regex lesson")  # [\'e\', \'e\', \'e\', \'o\']\nre.findall(r"[^aeiou\\s]", "regex")      # [\'r\', \'g\', \'x\']  (the consonants)',
    'text = "<b>bold</b> and <i>italic</i>"\n\nre.findall(r"<.*>", text)               # [\'<b>bold</b> and <i>italic</i>\']  (greedy)\nre.findall(r"<.*?>", text)              # [\'<b>\', \'</b>\', \'<i>\', \'</i>\']    (lazy)',
    'text = "cat caterpillar scattered cats"\n\nre.findall(r"cat",   text)              # [\'cat\', \'cat\', \'cat\', \'cat\']    (too many)\nre.findall(r"\\bcat\\b", text)            # [\'cat\']                          (only the whole word)\nre.findall(r"\\bcat",   text)            # [\'cat\', \'cat\', \'cat\']            (cat at the start of a word)',
    'log = "ERROR: x\\nINFO: y\\nERROR: z"\n\nre.findall(r"^ERROR", log)                          # [\'ERROR\']         (only the ERROR on line 1)\nre.findall(r"^ERROR", log, re.MULTILINE)            # [\'ERROR\', \'ERROR\']',
    'm = re.search(r"(\\d{4})-(\\d{2})-(\\d{2})", "Released 2026-03-14, today.")\nprint(m.group(0))                       # 2026-03-14   (the full match)\nprint(m.group(1))                       # 2026         (the first group)\nprint(m.group(2))                       # 03           (the second group)\nprint(m.group(3))                       # 14           (the third group)\nprint(m.groups())                       # (\'2026\', \'03\', \'14\')',
    're.findall(r"(?:Mr|Mrs|Ms)\\. (\\w+)", "Mr. Stark, Ms. Potts")\n# [\'Stark\', \'Potts\']   (only the name is captured, not the title)',
    'm = re.search(r"(?P<year>\\d{4})-(?P<month>\\d{2})-(?P<day>\\d{2})", "2026-03-14")\nprint(m.group("year"))                  # 2026\nprint(m.groupdict())                    # {\'year\': \'2026\', \'month\': \'03\', \'day\': \'14\'}',
    '# In the pattern: find words that are immediately repeated\nre.findall(r"\\b(\\w+) \\1\\b", "the the cat sat on on the mat")\n# [\'the\', \'on\']\n\n# In a replacement: swap first and last name\nre.sub(r"(\\w+) (\\w+)", r"\\2 \\1", "Linus Teja")\n# \'Teja Linus\'\n\n# Named variant\nre.sub(r"(?P<first>\\w+) (?P<last>\\w+)",\n       r"\\g<last> \\g<first>",\n       "Linus Teja")\n# \'Teja Linus\'',
    're.findall(r"hello", "Hello world", re.IGNORECASE)              # [\'Hello\']\nre.findall(r".+", "line1\\nline2", re.DOTALL)                    # [\'line1\\nline2\']',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Whitespace and #-comments inside the pattern are ignored. To match a literal space, write \ or [ ]. Any complex pattern that stays in your code for more than a week should be in re.VERBOSE form, so that you can still read it later.


9. re.compile: Compile Once for Busy Loops

re.search, re.findall, etc. keep a cache of compiled patterns, so calling them in a loop is fine, up to a point. For loops that run a very large number of times, or to give a pattern a meaningful name, compile it once and reuse it:

python
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")

for line in big_log_file:
    for match in EMAIL_RE.finditer(line):
        process(match.group())
+ setup added so this can run · defines big_log_file, re, process
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

big_log_file = ["alpha", "beta", "gamma"]
re = _AutoMock('re')
def process(*_a, **_kw):
    print('-> process() called')
    return _AutoMock('process()')

Compiled patterns have the same operations as the module-level functions: EMAIL_RE.search, .findall, .finditer, .sub. They are also normal objects: you can pass them around, store them in dicts, and, most importantly, name them, so the reader of your code knows what you're matching, not just how.


10. Always Use Raw Strings: r"..."

Without r, Python's string literal parser interprets backslashes first:

python
# Wrong: Python reads "\d" inside a normal string. "\d" is not a known escape,
# so it stays as backslash-d, BUT this raises SyntaxWarning (since 3.12) and will
# eventually be a SyntaxError. And "\b" IS a known escape (backspace, \x08),
# so Python turns it into a backspace before the regex ever sees it.
pattern = "\bword\b"                    # backspace, "word", backspace (no \b word boundaries)

# Right: a raw string keeps the backslashes as they are
pattern = r"\bword\b"

The fix is one character. Add r in front of every regex pattern, every time, until you do it without thinking.

The same applies to replacement strings in re.sub if they contain backslashes:

python
re.sub(r"(\d+)", r"<\1>", "got 42 items")     # 'got <42> items'
+ setup added so this can run · defines re
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

re = _AutoMock('re')

11. When NOT to Use Regex

This section may be the most useful one in the lesson. Regex matches patterns. It does not parse structure.

You want to…Use…Not regex because…
Parse HTML / XMLBeautifulSoup, lxml (see scraping)HTML is nested, regex isn't. Your regex will handle the simple cases and fail on the hard ones.
Parse JSONjson moduleJSON is structured, and json.loads validates and decodes in one call.
Parse CSVcsv moduleEmbedded commas, quoted fields, multi-line cells: csv handles them, and a regex will miss some of them.
Validate emails "properly"Send a confirmation emailThe RFC 5322 grammar is long and complex. Most email regexes reject some valid addresses or accept some invalid ones. The only real test is "does the email arrive?"
Parse Python / SQL / any codeAn actual parser (ast, sqlglot)Code is hierarchical, but regex is flat.
Extract a number from a sentenceRegex ✓This is a simple, flat pattern, so regex is a good choice.

The rule of thumb: if the input has nested or hierarchical structure, regex is the wrong tool. If it has flat patterns that you can recognise without context, regex is excellent.


12. Real-World Patterns You Will Reuse

python
import re

# Split on any run of whitespace (handles tabs, multiple spaces, newlines)
re.split(r"\s+", "hello   world\tfoo\nbar")
# ['hello', 'world', 'foo', 'bar']

# Strip multiple spaces down to one
re.sub(r"\s+", " ", "hello    world  ").strip()
# 'hello world'

# Find URLs (a simple pattern for common cases, not every valid URL)
URL_RE = re.compile(r"https?://[^\s<>\"']+")
URL_RE.findall("see https://example.com/x and http://foo.bar/baz here")
# ['https://example.com/x', 'http://foo.bar/baz']

# Extract YYYY-MM-DD dates
re.findall(r"\b(\d{4})-(\d{2})-(\d{2})\b", "log 2026-03-14 and 2025-12-01")
# [('2026', '03', '14'), ('2025', '12', '01')]

# Escape user input for safe inclusion in a pattern
user_query = "C++ 3.0 (final)"
safe = re.escape(user_query)
# 'C\\+\\+\\ 3\\.0\\ \\(final\\)'
# Now it is safe to put in a pattern: re.search(rf"^{safe}$", line)

Always use re.escape() when you insert text from a user into a pattern. Without it, a user typing . or ( would have those treated as regex metacharacters, and a user typing .* could match anything.


13. Common Mistakes

1. Forgetting the r prefix
"\d" is a deprecated escape in regular strings, but r"\d" is correct. The fix is one character, and the bug it prevents can be hard to see ("\b" becomes the ASCII backspace character).

2. Greedy quantifiers matching too much
<.*> over <b>bold</b> matches the whole thing, not just <b>. Use <.*?> for lazy, or be explicit: <[^>]*>.

3. Anchoring without re.MULTILINE
^ERROR on a multi-line log only matches the very first line. Pass re.MULTILINE when you mean per-line.

4. Unescaped special characters
A literal dot, plus, parenthesis, brace, bracket or pipe needs a \ in front of it. Or use [.], [+], etc. Or re.escape(s) for runtime strings.

5. Treating regex as a parser
HTML, JSON, CSV, source code, math expressions and balanced parentheses are not jobs for regex. Use a parser made for the format.

6. re.match vs re.search confusion
match is anchored at the start of the string. If your pattern doesn't start with ^, match still requires the match to begin at index 0. Use search when you mean "find anywhere".


🎯 Your Turn: Find All Emails

Write find_emails(text) that returns a list of deduplicated, lowercased email addresses found in text. The regex should handle:

  • Standard local parts: alice@example.com
  • Plus-tags: alice+filter@example.com
  • Dots in the local part: first.last@example.com
  • Subdomains: alice@mail.eng.example.com
  • Mixed case (should be returned lowercase)

Provide the regex inline with comments using re.VERBOSE.

python
text = """
Contact alice@example.com or ALICE@EXAMPLE.COM (same person).
CC bob+team@mail.example.co.uk and first.last@sub.eng.example.com.
Not an email: alice@, @example.com, just-a-word.
"""
# find_emails(text) returns ['alice@example.com',
#                            'bob+team@mail.example.co.uk',
#                            'first.last@sub.eng.example.com']

Skeleton:

python
import re

EMAIL_RE = re.compile(r"""
    # TODO 1: match the local part (letters, digits, dots, pluses, hyphens, underscores)
    # TODO 2: an @ sign
    # TODO 3: a domain, made of one or more labels separated by dots
    # TODO 4: ensure a TLD of at least 2 letters at the end
    """, re.VERBOSE)

def find_emails(text):
    # TODO 5: findall, make lowercase, remove duplicates (keep the order)
    ...
Hint 1: Local part character class Allowed characters in the local part (for our purposes): [\w.+\-]+. \w covers letters, digits, and underscore. We add ., +, and - explicitly. Note: - inside a character class is fine at the end or escaped.
Hint 2: Domain with multiple labels A domain like mail.eng.example.com is a series of label-then-dot, ending in a final TLD label. One way: (?:[\w-]+\.)+[A-Za-z]{2,} means one or more "label." groups followed by a final TLD of 2 or more letters.
Hint 3: Remove duplicates but keep the order A set removes duplicates but loses the order. To remove duplicates and keep the first-seen order: build a dict.fromkeys(items) and take its keys. Dicts in Python 3.7+ preserve insertion order.
Show full solution
python
import re

EMAIL_RE = re.compile(r"""
    \b                          # word boundary so we don't match mid-word
    (
        [\w.+\-]+               # local part: letters, digits, _ . + -
        @
        (?:[\w\-]+\.)+          # one or more "subdomain." labels
        [A-Za-z]{2,}            # top-level domain (TLD): 2 or more letters
    )
    \b
    """, re.VERBOSE)

def find_emails(text):
    """Return deduplicated, lowercase emails in first-seen order."""
    matches = (m.group(1).lower() for m in EMAIL_RE.finditer(text))
    return list(dict.fromkeys(matches))


# Demo
text = """
Contact alice@example.com or ALICE@EXAMPLE.COM (same person).
CC bob+team@mail.example.co.uk and first.last@sub.eng.example.com.
Not an email: alice@, @example.com, just-a-word.
"""

for e in find_emails(text):
    print(e)
# alice@example.com
# bob+team@mail.example.co.uk
# first.last@sub.eng.example.com

What's going on:

  • re.VERBOSE lets us comment each piece of the pattern. Six months from now, you can still read and change this regex.
  • \b word boundaries stop the match from starting or ending in the middle of a word, for example in the trailing punctuation in (alice@example.com).
  • [\w.+\-]+ is the local part. The \- is escaped (it could also go last: [\w.+-]+ works too). The other characters are literal inside the class.
  • (?:[\w\-]+\.)+ means "one or more labels, each followed by a dot": a non-capturing group, repeated. It handles any number of subdomains.
  • [A-Za-z]{2,} requires the TLD to be at least two letters. This rejects invalid input like alice@example.1.
  • dict.fromkeys(...) is the standard way to remove duplicates and keep the order in modern Python. We could have used a loop with a seen set, but this is one line.

Note: this regex is good for common cases, but it does not follow the full RFC. RFC 5322 allows quoted local parts ("weird name"@example.com), IP literals (bob@[192.168.1.1]), and other unusual forms you'll almost never see in practice. If you really need to check that an email address works, send a confirmation email. That is the only check that is always correct.


What You Learned

  • re module with five functions: search, match, findall, finditer, sub.
  • Character classes (\d \w \s ., [abc], [^abc], [a-z]) describe what one character can be.
  • Quantifiers (? * + {n,m}) say how many. Add ? for lazy.
  • Anchors (^ $ \b) match positions, not characters. \b is very useful and often forgotten.
  • Groups: (...) captures, (?:...) doesn't, (?P<name>...) names. Backreference with \1 or \g<name>.
  • Flags: IGNORECASE, MULTILINE, DOTALL, VERBOSE. The last one makes complex patterns readable, so use it.
  • re.compile for busy loops and for giving patterns names.
  • Always use r"..." raw strings.
  • Regex is for patterns, not structure. For HTML, JSON, CSV and code, use real parsers.

Next: Datetime, Timezones, and Friends: handling dates and times correctly, which is harder than it looks.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.