Chapter 22 — Regex and Text Processing: Digging Data out of Messy Writing

Series I2 — Data and the Internet

NoteIn this chapter, you will:
  • Understand regex (regular expression): a tiny language that describes text patterns
  • Find and extract pieces of text with re.search, re.findall, and groups
  • Replace and split text with re.sub and re.split
  • Recognise classic traps: greedy repeaters and forgetting to check for None
  • Clean fictional field notes for Running Project I2

All scripts for this chapter are in the kode/ch22/ folder. On the web version, you can also download them all at once (zip).

Data from an API (Chapter 21) is usually tidy. Data from humans is not. One field officer types “dia 32,5 cm”, a colleague “diameter=32.5cm”, another “TEAK” in all capitals. Filtering writing like that with if and split quickly turns into a jungle. Regex is like a stencil card for patterns: you punch holes in the card in the shape you are looking for (“two digits, a slash, two digits…”), lay it over the text, and whatever fits the holes is what you take out.

One pattern with named groups cuts a note line into parts that are easy to use.

Searching: search, match, findall

The re module comes with Python; nothing to install.

01_search_basics.py
# Chapter 22 - Basics of the re module: search, match, fullmatch, findall
import re

text = "Plot A: diameter 32,5 cm, height 18 m"

print(re.search(r"diameter", text))                 # looks ANYWHERE
print(re.match(r"diameter", text))                  # match: only from the START of the text
print(re.match(r"Plot", text) is not None)
print(re.fullmatch(r"\d+", "2026"), re.fullmatch(r"\d+", "2026 "))

found = re.search(r"\d+,\d+", text)                 # a decimal number with a comma
print(found.group(), "is at position", found.start(), "to", found.end())

print(re.findall(r"\d+", text))                     # all runs of digits
for hit in re.finditer(r"[A-Z]", text):             # every capital letter with its position
    print(hit.group(), hit.start(), end=" | ")
print()
<re.Match object; span=(8, 16), match='diameter'>
None
True
<re.Match object; span=(0, 4), match='2026'> None
32,5 is at position 17 to 21
['32', '5', '18']
P 0 | A 5 | 
  • re.search looks anywhere; the result is a Match object (with a position such as span=(8, 16)) or None if nothing is found.
  • re.match only succeeds if the pattern is at the start of the text, which is why diameter gives None.
  • re.fullmatch demands that the whole text matches: "2026" passes, "2026 " (with a space) fails.
  • findall gives a list of all matches: three runs of digits ['32', '5', '18']. Notice: 32,5 is split into 32 and 5, because the pattern \d+ knows nothing about commas.
  • The r"..." prefix (raw string) makes backslashes be read as they are. Always use it for patterns.

Ingredients of patterns

Pattern Meaning
\d one digit; \w one letter/digit/underscore; \s one space (or tab)
. any one character (except a newline)
[A-C] one character from that set; [^"] = anything except a quote mark
+ / * / ? one or more / zero or more / zero or one
{4} / {2,3} exactly 4 times / 2 to 3 times
(...) group; (?:...) unnumbered group; (?P<name>...) named group
\| or; \b word boundary
02_common_patterns.py
# Chapter 22 - Common patterns: character classes and repeaters
import re

text = "Plot A: 32,5 cm; Plot B: 45 cm; code JT-0042; Plot C: 28,0 cm"

patterns = {
    r"\d":           "one digit",
    r"\d+":          "one or more digits",
    r"\d+,\d+":      "a number with a comma",
    r"\d+(?:,\d+)?": "a number, the comma part optional",
    r"[A-C]":        "one letter between A and C",
    r"Plot \w":      "the word Plot, a space, one letter/digit",
    r"[A-Z]{2}-\d{4}": "two capital letters, a minus, four digits",
}
for p, meaning in patterns.items():
    print(f"{p:20} {meaning:44} -> {re.findall(p, text)}")
\d                   one digit                                    -> ['3', '2', '5', '4', '5', '0', '0', '4', '2', '2', '8', '0']
\d+                  one or more digits                           -> ['32', '5', '45', '0042', '28', '0']
\d+,\d+              a number with a comma                        -> ['32,5', '28,0']
\d+(?:,\d+)?         a number, the comma part optional            -> ['32,5', '45', '0042', '28,0']
[A-C]                one letter between A and C                   -> ['A', 'B', 'C']
Plot \w              the word Plot, a space, one letter/digit     -> ['Plot A', 'Plot B', 'Plot C']
[A-Z]{2}-\d{4}       two capital letters, a minus, four digits    -> ['JT-0042']

Trace the lines: \d+(?:,\d+)? captures 32,5 and 45 together because the comma part is made optional (?). The last pattern finds the code JT-0042.

TipTIP

Do not write a long pattern all at once. Write one piece, test it with findall on sample text, then add the next piece. Sites such as regex101.com help you see matches visually.

Groups: taking out the parts

Brackets make a group, a part of the pattern whose result can be taken separately. Named groups (?P<name>) are far easier to read than numbers.

03_groups.py
# Chapter 22 - Groups: picking out the matched parts
import re

line = "10/01/2026 | Plot A | Teak | dia 32,5 cm"

# plain brackets = numbered group
m = re.search(r"(\d{2})/(\d{2})/(\d{4})", line)
print(m.groups())                                   # ('10', '01', '2026')
print("Year:", m.group(3))

# (?P<name>...) = named group: easier to read
pattern = r"(?P<day>\d{2})/(?P<mon>\d{2})/(?P<year>\d{4}) \| Plot (?P<plot>\w) \| (?P<kind>\w+)"
m = re.match(pattern, line)
print(m.groupdict())
print(f"{m['year']}-{m['mon']}-{m['day']}")          # building an ISO date
('10', '01', '2026')
Year: 2026
{'day': '10', 'mon': '01', 'year': '2026', 'plot': 'A', 'kind': 'Teak'}
2026-01-10

m.groups() gives a tuple, m.groupdict() gives a dict, and m['year'] takes one group. We rebuild the date 10/01/2026 as 2026-01-10 (ISO format, easy to sort).

Replacing and splitting

04_replace_and_split.py
# Chapter 22 - Replacing (sub) and splitting (split)
import re

text = "dia 32,5 cm; dia 45,2 cm; dia 28 cm"

print(re.sub(r"(\d),(\d)", r"\1.\2", text))          # decimal comma -> dot; \1 \2 = groups 1 and 2
print(re.sub(r"\s+", " ", "Plot    A   Teak"))        # many spaces -> one space
print(re.subn(r"cm", "CM", text))                    # subn also gives the number of replacements


def to_metres(hit):
    """Called for every match; returns the replacement text."""
    cm = float(hit.group(1).replace(",", "."))
    return f"{cm / 100:.3f} m"


print(re.sub(r"(\d+(?:,\d+)?) cm", to_metres, text))
print(re.split(r"\s*[;,|]\s*", "A ; B,C |D"))         # split at any one of the separators
dia 32.5 cm; dia 45.2 cm; dia 28 cm
Plot A Teak
('dia 32,5 CM; dia 45,2 CM; dia 28 CM', 3)
dia 0.325 m; dia 0.452 m; dia 0.280 m
['A', 'B', 'C', 'D']
  • re.sub(pattern, replacement, text) replaces all matches. \1 and \2 in the replacement refer to groups 1 and 2, so 32,5 becomes 32.5.
  • re.subn also reports the number of replacements: 3.
  • The replacement may be a function called for each match: we turn centimetres into metres (32,5 cm becomes 0.325 m).
  • re.split cuts at any one of the separators, even with spaces around them.

compile, flags, and VERBOSE

05_compile_flags.py
# Chapter 22 - compile, flags (IGNORECASE) and VERBOSE patterns
import re

# A pattern used repeatedly: compile once, use many times
species_pattern = re.compile(r"\b(teak|meranti|mahogany|sengon)\b", re.IGNORECASE)
print(species_pattern.findall("TEAK healthy, Meranti sick, mahogany, another tree"))

# VERBOSE: spaces and comments inside the pattern are ignored, so long patterns can be explained
date_pattern = re.compile(r"""
    (?P<day>\d{1,2})      # day
    [/-]                  # separator: slash or minus
    (?P<mon>\d{1,2})      # month
    [/-]
    (?P<year>\d{4})       # year
""", re.VERBOSE)
for s in ["10/01/2026", "5-3-2026", "2026-01-10"]:
    m = date_pattern.fullmatch(s)
    print(f"{s:11} ->", m.groupdict() if m else "no match")
['TEAK', 'Meranti', 'mahogany']
10/01/2026  -> {'day': '10', 'mon': '01', 'year': '2026'}
5-3-2026    -> {'day': '5', 'mon': '3', 'year': '2026'}
2026-01-10  -> no match

re.compile prepares a pattern once for repeated use. re.IGNORECASE ignores upper/lower case (which is why TEAK is included). re.VERBOSE allows spaces and comments inside the pattern, so a long pattern can be explained line by line. Notice that 2026-01-10 does not match because our pattern expects the year at the end.

Traps: greedy and forgetting None

06_greedy_and_lazy.py
# Chapter 22 - Trap: greedy and lazy repeaters
import re

text = 'note "healthy" then note "sick" finished'

print(re.findall(r'".*"', text))        # greedy: swallows as much as possible
print(re.findall(r'".*?"', text))       # lazy (?): stops as soon as possible
print(re.findall(r'"([^"]*)"', text))   # another way: "anything except a quote mark"
['"healthy" then note "sick"']
['"healthy"', '"sick"']
['healthy', 'sick']

The pattern ".*" is greedy: it swallows as much as it can, from the first quote to the last quote, so two notes merge into one. Adding ? makes it lazy: it stops at the nearest quote. The third way, [^"]*, is often the clearest.

WarningWARNING

re.search returns None when nothing matches. Calling .group() on None raises an AttributeError. Always check first: if result:.

07_errors_and_none.py
# Chapter 22 - Two classic errors: a broken pattern and forgetting to check for None
import re

try:
    re.compile(r"(\d+")                     # opening bracket without its partner
except re.error as error:
    print("Broken pattern:", error)

result = re.search(r"\d+", "there is no number here")
print("Search result:", result)              # None, not an error

try:
    print(result.group())
except AttributeError as error:
    print("Error:", error)

# The safe way: check first
if result:
    print(result.group())
else:
    print("No number; carrying on safely.")
Broken pattern: missing ), unterminated subpattern at position 0
Search result: None
Error: 'NoneType' object has no attribute 'group'
No number; carrying on safely.

A pattern with an unpaired bracket raises re.error at compile time. The wording of the error message can differ between Python versions; what matters is that you know the error lives in the pattern, not in the text.

Running Project I2: cleaning field notes

Step 2: five fictional field notes written in different styles, plus one broken line. One VERBOSE pattern accepts all the styles, then the code tidies up: dates become ISO, plots become capital letters, species get an initial capital, comma numbers become Python decimals, and “healthy/sick” becomes True/False.

08_project_clean.py
# Chapter 22 - Running project I2, step 2: cleaning field notes with regex
import json
import re
from pathlib import Path

notes = [
    "10/01/2026 | Plot A | Teak | dia 32,5 cm | height 18 m | HEALTHY",
    "10/01/2026 | plot a | meranti | diameter=45.2cm | height: 25,5 m | sick",
    "11-01-2026 | Plot B | Mahogany | dia 28 cm | height 15 m | healthy",
    "11/01/2026 | Plot C | sengon | dia  19,8cm | height 12 m | Healthy ",
    "12/01/2026 | Plot B | TEAK | dia 36,1 cm | height 20,5 m | sick",
    "broken: a line without clear data",
]

PATTERN = re.compile(r"""
    (?P<day>\d{1,2})[/-](?P<mon>\d{1,2})[/-](?P<year>\d{4}) \s*\|\s*
    plot\s+(?P<plot>[a-z]) \s*\|\s*
    (?P<species>[a-z]+) \s*\|\s*
    (?:dia|diameter)\s*[=:]?\s*(?P<dia>\d+(?:[.,]\d+)?)\s*cm \s*\|\s*
    height\s*[=:]?\s*(?P<height>\d+(?:[.,]\d+)?)\s*m \s*\|\s*
    (?P<condition>healthy|sick)
""", re.VERBOSE | re.IGNORECASE)


def number(text):
    return float(text.replace(",", "."))


clean, rejected = [], []
for line in notes:
    m = PATTERN.fullmatch(line.strip())
    if m is None:
        rejected.append(line)
        continue
    clean.append({
        "date": f"{m['year']}-{int(m['mon']):02d}-{int(m['day']):02d}",
        "plot": m["plot"].upper(),
        "species": m["species"].capitalize(),
        "diameter_cm": number(m["dia"]),
        "height_m": number(m["height"]),
        "healthy": m["condition"].lower() == "healthy",
    })

for c in clean:
    print(c)
print("Rejected:", rejected)

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
(tmp / "clean_notes.json").write_text(json.dumps(clean, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{len(clean)} clean rows, {len(rejected)} rejected")
{'date': '2026-01-10', 'plot': 'A', 'species': 'Teak', 'diameter_cm': 32.5, 'height_m': 18.0, 'healthy': True}
{'date': '2026-01-10', 'plot': 'A', 'species': 'Meranti', 'diameter_cm': 45.2, 'height_m': 25.5, 'healthy': False}
{'date': '2026-01-11', 'plot': 'B', 'species': 'Mahogany', 'diameter_cm': 28.0, 'height_m': 15.0, 'healthy': True}
{'date': '2026-01-11', 'plot': 'C', 'species': 'Sengon', 'diameter_cm': 19.8, 'height_m': 12.0, 'healthy': True}
{'date': '2026-01-12', 'plot': 'B', 'species': 'Teak', 'diameter_cm': 36.1, 'height_m': 20.5, 'healthy': False}
Rejected: ['broken: a line without clear data']
5 clean rows, 1 rejected

Of six lines, 5 are clean and 1 is rejected. Rejecting honestly is better than guessing: rejected lines are recorded so a human can check them. The clean result is saved in _tmp/clean_notes.json, ready to be merged with other data in the next chapter.

Exercises

  1. Write a pattern that finds every code like JT-0042 in "JT-0042, MR-0107, xx-12". (Only two results.)
  2. With re.sub, turn "Plot A,Plot B , Plot C" into "Plot A, Plot B, Plot C".
  3. Add a new note line to 08_project_clean.py in a style not yet covered (e.g. height=14 m without spaces). Is it accepted? Change the pattern if needed.
  4. Challenge: extend the pattern so that a temperature such as temp 27,5 C at the end of a line becomes an optional group (may be present or not).

Chapter Summary

  • Regex = a language for text patterns; the re module is built into Python; write patterns as r"...".
  • search (anywhere), match (from the start), fullmatch (the whole text), findall (all).
  • Groups (...) and (?P<name>...) take out parts; groupdict() gives a dict.
  • sub replaces (optionally with a function), split splits, compile prepares a pattern, VERBOSE makes patterns readable.
  • Watch out for greedy repeaters and always check for a None result.
  • Dirty data is cleaned with one clear pattern; lines that do not match are rejected and recorded.

Next, in Chapter 23: the data is clean, but tens of thousands of rows do not fit ordinary loops. Meet numpy and pandas.