Mathew K Analytics

Lesson 6 · Python Beginner Course

Python Regex & f-strings for Beginners: Clean Messy Text (Beginner Python Course, Part 6)

In this lesson you'll be able to: Line up tidy tables, prices, percentages and dates with f-string format specs Wrap, trim and clean text with textwrap and…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Text Wrangling: f-strings, Regular Expressions and Unicode#

In this lesson you'll be able to:

  • Line up tidy tables, prices, percentages and dates with f-string format specs
  • Wrap, trim and clean text with textwrap and the string module
  • Find, extract, replace and split text with regular expressions (re)
  • Validate emails, UK mobile numbers and postcodes before they reach your data
  • Handle accents, bytes and encodings without your text turning into gibberish
  • Build a capstone: a messy customer notes cleaner that prints a tidy report

Warm-Up#

Real-world text is messy: extra spaces, random capitals, stray punctuation. Let's poke at some with the string methods you already know.

note = "  Table 4: two FLAT whites, one croissant  "
print(repr(note))
print(repr(note.strip()))
print(note.strip().lower().replace("flat whites", "lattes"))
'  Table 4: two FLAT whites, one croissant  '
'Table 4: two FLAT whites, one croissant'
table 4: two lattes, one croissant
words = note.split()
print(len(words), "words:", words)
print("Shouty words:", [w for w in words if w.isupper()])
print("croissant" in note, note.count("o"))
print(note.strip().title())
print(note.strip().startswith("Table"), note.find("croissant"))
7 words: ['Table', '4:', 'two', 'FLAT', 'whites,', 'one', 'croissant']
Shouty words: ['FLAT']
True 3
Table 4: Two Flat Whites, One Croissant
True 32

f-string Format Specs: Width and Alignment#

A format spec is a seating plan: you tell each value how many seats it gets and where to sit.

Everything after the colon in {value:spec} is the spec. A number sets the width, then <, > or ^ choose left, right or centre.

drink = "Latte"
price = 3.5
print(f"{drink} costs {price}")
print(f"{drink} costs ${price:.2f}")
print(f"[{drink:10}]")
print(f"[{price:10}]")
Latte costs 3.5
Latte costs $3.50
[Latte     ]
[       3.5]
print(f"[{drink:<10}]  left")
print(f"[{drink:>10}]  right")
print(f"[{drink:^10}]  centre")
print(f"[{drink:*^10}]  centre, padded with *")
[Latte     ]  left
[     Latte]  right
[  Latte   ]  centre
[**Latte***]  centre, padded with *
menu = {"Espresso": 2.2, "Flat white": 3.4, "Hot chocolate": 3.95, "Tea": 1.8}
print(f"{'Item':<15}{'Price':>8}")
print("-" * 23)
for item, cost in menu.items():
    print(f"{item:<15}{cost:>8.2f}")
Item              Price
-----------------------
Espresso           2.20
Flat white         3.40
Hot chocolate      3.95
Tea                1.80
width = 30
print(f"{' Cafe Menu ':=^{width}}")
for item, cost in menu.items():
    print(f"{item:.<{width - 6}}${cost:>5.2f}")
print("=" * width)
========= Cafe Menu ==========
Espresso................$ 2.20
Flat white..............$ 3.40
Hot chocolate...........$ 3.95
Tea.....................$ 1.80
==============================
steps = 8432
goal = 10000
print(f"{steps=}")
print(f"{goal - steps=}")
print(f"{steps / goal=:.1%}")
steps=8432
goal - steps=1568
steps / goal=84.3%
name = "  Zoe "
print(f"Hello {name}!")
print(f"Hello {name!r}!")
print(f"Hello {name.strip()!r}!")
Hello   Zoe !
Hello '  Zoe '!
Hello 'Zoe'!
playlist = [("Morning Run", 215), ("Slow Jam", 368), ("Cooldown", 74)]
for number, (song, seconds) in enumerate(playlist, start=1):
    mins, secs = divmod(seconds, 60)
    print(f"{number:>2}. {song:<12}{mins:>2}:{secs:02d}")
total_secs = sum(s for _, s in playlist)
print(f"    {'Total':<12}{total_secs // 60:>2}:{total_secs % 60:02d}")
 1. Morning Run  3:35
 2. Slow Jam     6:08
 3. Cooldown     1:14
    Total       10:57

Numbers: Decimals, Thousands and Percentages#

Same number, different outfits: the value never changes, only how it's dressed for display.

.2f two decimals, , thousands separators, % percentage, + always show the sign, 05d pad with zeros.

total = 1234567.891
print(f"{total}")
print(f"{total:.2f}")
print(f"{total:,.2f}")
print(f"{total:_.0f}")
print(f"{total:.3e}")
1234567.891
1234567.89
1,234,567.89
1_234_568
1.235e+06
rate = 0.0768
print(f"{rate}")
print(f"{rate:%}")
print(f"{rate:.1%}")
print(f"{0.5:.0%} off all pastries after 4pm")
0.0768
7.680000%
7.7%
50% off all pastries after 4pm
for change in [12.5, -3.4, 0]:
    print(f"Weight change: {change:+.1f} kg")

for order_no in [7, 42, 315]:
    print(f"Order #{order_no:05d}")
Weight change: +12.5 kg
Weight change: -3.4 kg
Weight change: +0.0 kg
Order #00007
Order #00042
Order #00315
budget = {"Rent": 950, "Food": 310.5, "Travel": 86.2, "Fun": 120}
total = sum(budget.values())
for item, cost in budget.items():
    print(f"{item:<8}${cost:>8,.2f}  {cost / total:>6.1%}")
print(f"{'Total':<8}${total:>8,.2f}  {1:>6.0%}")
Rent    $  950.00   64.8%
Food    $  310.50   21.2%
Travel  $   86.20    5.9%
Fun     $  120.00    8.2%
Total   $1,466.70    100%

Common mistake: a number spec on a string#

Text that looks like a number is still text. .2f only works on real numbers.

price_text = "3.50"
print(f"${price_text:.2f}")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[15], line 2
      1 price_text = "3.50"
----> 2 print(f"${price_text:.2f}")

ValueError: Unknown format code 'f' for object of type 'str'
price_text = "3.50"
print(f"${float(price_text):.2f}")
print(f"${float(price_text) * 3:.2f} for three")
$3.50
$10.50 for three

Quick check#

What does f"{0.256:.0%}" print? And f"{7:03d}"?

print(f"{0.256:.0%}")
print(f"{7:03d}")
26%
007

Dates in f-strings#

Dates have their own spec codes: %d day, %m month, %Y year, %A weekday name, %H:%M time. We'll use fixed example dates so your output matches mine.

from datetime import datetime, date

booking = datetime(2026, 3, 14, 19, 30)
print(f"{booking}")
print(f"{booking:%d/%m/%Y}")
print(f"{booking:%A %d %B %Y at %H:%M}")
2026-03-14 19:30:00
14/03/2026
Saturday 14 March 2026 at 19:30
print(f"{booking:%I:%M %p}")
print(f"{booking:%a %d %b}")
print(f"Day {booking:%j} of the year")
07:30 PM
Sat 14 Mar
Day 073 of the year
classes = [date(2026, 3, 16), date(2026, 3, 18), date(2026, 3, 21)]
for day in classes:
    kind = "weekend" if day.weekday() >= 5 else "weekday"
    print(f"{day:%a %d %b}  Spin class  ({kind})")
Mon 16 Mar  Spin class  (weekday)
Wed 18 Mar  Spin class  (weekday)
Sat 21 Mar  Spin class  (weekend)

Mini-Project: The Cafe Receipt Printer#

Width, alignment, decimals, percentages and dates, all on one receipt.

order = [("Flat white", 2, 3.40), ("Croissant", 1, 2.75), ("Banana bread", 3, 2.95)]
for item, qty, unit in order:
    print(f"{qty}x {item:<14}@ ${unit:.2f} = ${qty * unit:>5.2f}")
2x Flat white    @ $3.40 = $ 6.80
1x Croissant     @ $2.75 = $ 2.75
3x Banana bread  @ $2.95 = $ 8.85
def receipt(order, when, discount=0.0, width=32):
    lines = [f"{' THE DAILY GRIND ':*^{width}}", f"{when:%d %b %Y  %H:%M}".center(width)]
    subtotal = 0
    for item, qty, unit in order:
        subtotal += qty * unit
        lines.append(f"{qty}x {item:<{width - 10}}${qty * unit:>6.2f}")
    lines.append("-" * width)
    if discount:
        lines.append(f"{'Discount ' + format(discount, '.0%'):<{width - 8}}-${subtotal * discount:>6.2f}")
    lines.append(f"{'TOTAL':<{width - 7}}${subtotal * (1 - discount):>6.2f}")
    return lines

print(len(receipt(order, booking)), "lines ready")
7 lines ready
print("\n".join(receipt(order, booking)))
******* THE DAILY GRIND ********
       14 Mar 2026  19:30       
2x Flat white            $  6.80
1x Croissant             $  2.75
3x Banana bread          $  8.85
--------------------------------
TOTAL                    $ 18.40
print("\n".join(receipt(order, datetime(2026, 3, 15, 8, 5), discount=0.1)))
******* THE DAILY GRIND ********
       15 Mar 2026  08:05       
2x Flat white            $  6.80
1x Croissant             $  2.75
3x Banana bread          $  8.85
--------------------------------
Discount 10%            -$  1.84
TOTAL                    $ 16.56

The string Module and textwrap#

Ready-made alphabets and a polite word-wrapper, both free with Python.

import string

print(string.ascii_lowercase)
print(string.ascii_uppercase)
print(string.digits)
print(string.punctuation)
print(len(string.ascii_letters), "letters,", len(string.punctuation), "punctuation marks")
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
0123456789
!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
52 letters, 32 punctuation marks
review = "Great coffee!!! Friendly staff, but... the Wi-Fi? Terrible :("
no_punct = "".join(ch for ch in review if ch not in string.punctuation)
print(no_punct)
print(string.capwords("the daily grind cafe"))
Great coffee Friendly staff but the WiFi Terrible 
The Daily Grind Cafe
code = "AB12-cd34"
allowed = string.ascii_letters + string.digits + "-"
print(all(ch in allowed for ch in code))
print(sum(ch.isdigit() for ch in code), "digits")
True
4 digits
import textwrap

blurb = ("Our beans are roasted every Tuesday in small batches, then rested for "
         "three days so the flavours settle before they reach your cup.")
print(textwrap.fill(blurb, width=40))
print()
print(textwrap.fill(blurb, width=60))
Our beans are roasted every Tuesday in
small batches, then rested for three
days so the flavours settle before they
reach your cup.

Our beans are roasted every Tuesday in small batches, then
rested for three days so the flavours settle before they
reach your cup.
for line in textwrap.wrap(blurb, width=30):
    print(f"| {line:<30} |")
| Our beans are roasted every    |
| Tuesday in small batches, then |
| rested for three days so the   |
| flavours settle before they    |
| reach your cup.                |
print(textwrap.shorten(blurb, width=50, placeholder="..."))
print(textwrap.indent(textwrap.fill(blurb, 36), "> "))
Our beans are roasted every Tuesday in small...
> Our beans are roasted every Tuesday
> in small batches, then rested for
> three days so the flavours settle
> before they reach your cup.
message = """
    Hi Sam,
        Your order is ready for collection.
    Thanks!
"""
print(textwrap.dedent(message).strip())
Hi Sam,
    Your order is ready for collection.
Thanks!
pasted = "\u201cBest\u201d caf\u00e9 in town\u2026 isn\u2019t it?"
fixes = str.maketrans({"\u201c": '"', "\u201d": '"', "\u2019": "'", "\u2026": "..."})
print(pasted)
print(pasted.translate(fixes))
“Best” café in town… isn’t it?
"Best" café in town... isn't it?

Your turn#

  1. Print the cafe menu inside a box with | borders, exactly 28 characters wide.
  2. Use textwrap.shorten to make a 25-character preview of the review text.

Regular Expressions: Finding Patterns#

A regex is a search by description: "any five digits" instead of "12345".

Pro tip: always write patterns as raw strings, r"...", so backslashes reach re untouched.

import re

text = "Order 4471 shipped. Order 4472 is delayed."
match = re.search(r"\d+", text)
print(match)
print(match.group(), match.start(), match.end())
<re.Match object; span=(6, 10), match='4471'>
4471 6 10
print(re.findall(r"\d+", text))
print(re.findall(r"Order \d+", text))
print(re.findall(r"\d+", "No numbers here"))
['4471', '4472']
['Order 4471', 'Order 4472']
[]
sample = "Room 7B!"
shortcuts = {r"\d": "a digit", r"\w": "letter/digit/_", r"\s": "whitespace", r".": "any character"}
for pat, meaning in shortcuts.items():
    print(f"{pat:<4}{meaning:<16}{re.findall(pat, sample)}")
\d  a digit         ['7']
\w  letter/digit/_  ['R', 'o', 'o', 'm', '7', 'B']
\s  whitespace      [' ']
.   any character   ['R', 'o', 'o', 'm', ' ', '7', 'B', '!']
flight = "Gate 7, flight 123, seat 4567"
for pat in [r"\d", r"\d+", r"\d{3}", r"\d{2,4}"]:
    print(f"{pat:<8}", re.findall(pat, flight))
print(re.findall(r"colou?r", "colour or color? Colour!"))
\d       ['7', '1', '2', '3', '4', '5', '6', '7']
\d+      ['7', '123', '4567']
\d{3}    ['123', '456']
\d{2,4}  ['123', '4567']
['colour', 'color']
print(re.findall(r"[aeiou]", "espresso"))
print(re.findall(r"[A-Z]\w+", "Meet Ada and Ben at Bristol Temple Meads"))
print(re.findall(r"\bcat\b", "cat catalogue bobcat cat."))
print(re.findall(r"[^0-9 ]+", "Bus 42 to 7 Dials"))
print(re.search(r"^Hello", "Hello there") is not None, re.search(r"^Hello", "Oh, Hello") is not None)
['e', 'e', 'o']
['Meet', 'Ada', 'Ben', 'Bristol', 'Temple', 'Meads']
['cat', 'cat']
['Bus', 'to', 'Dials']
True False
print(re.match(r"\d+", "42 lattes"))
print(re.match(r"\d+", "lattes 42"))
print(re.fullmatch(r"\d+", "42"))
print(re.fullmatch(r"\d+", "42a"))
<re.Match object; span=(0, 2), match='42'>
None
<re.Match object; span=(0, 2), match='42'>
None

Regex cheat sheet:

  • \d digit, \w letter, digit or underscore, \s whitespace, . any character
  • + one or more, * zero or more, ? optional, {3} exactly three, {2,4} two to four
  • [abc] any one of a, b or c, [A-Z] a capital letter, [^0-9] anything except a digit
  • ^ start of the text, $ end, \b the edge of a word
order_text = "1x Latte (large) $3.50, 2x Latte (small) $2.90"
print(re.findall(r"Latte \((\w+)\)", order_text))
literal = re.escape("(large) $3.50")
print(literal)
print(re.search(literal, order_text) is not None)
['large', 'small']
\(large\)\ \$3\.50
True

search looks anywhere, match only at the start, fullmatch must cover the whole string.

Common mistake: calling .group() when nothing matched#

m = re.search(r"\d{5}", "No order number here")
print(m.group())
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[40], line 2
      1 m = re.search(r"\d{5}", "No order number here")
----> 2 print(m.group())

AttributeError: 'NoneType' object has no attribute 'group'
m = re.search(r"\d{5}", "No order number here")
if m:
    print("Found", m.group())
else:
    print("No match, so search returned", m)
No match, so search returned None

Groups and Named Groups#

Brackets in a pattern are labelled boxes: you get the whole match, plus the pieces you care about.

line = "Flight BA2490 departs 14:35 from gate 22"
m = re.search(r"(\d{2}):(\d{2})", line)
print(m.group(0), m.group(1), m.group(2))
print(m.groups())
14:35 14 35
('14', '35')
hours = int(m.group(1))
minutes = int(m.group(2))
print(f"{hours * 60 + minutes} minutes after midnight")
875 minutes after midnight
schedule = "Yoga 07:30, Spin 12:15, Boxing 18:45"
pairs = re.findall(r"(\w+) (\d{2}:\d{2})", schedule)
print(pairs)
for gym_class, time in pairs:
    print(f"{gym_class:<8}{time}")
[('Yoga', '07:30'), ('Spin', '12:15'), ('Boxing', '18:45')]
Yoga    07:30
Spin    12:15
Boxing  18:45
flight_re = r"(?P<airline>[A-Z]{2})(?P<number>\d{3,4})"
m = re.search(flight_re, line)
print(m.group("airline"), m.group("number"))
print(m.groupdict())
BA 2490
{'airline': 'BA', 'number': '2490'}
tickets = "BA2490 to Rome, EZ812 to Nice, FR33 to Oslo"
for m in re.finditer(flight_re, tickets):
    print(f"{m['airline']} flight {m['number']} found at position {m.start()}")
BA flight 2490 found at position 0
EZ flight 812 found at position 16
dates = "Booked 14/03/2026, paid 02/04/2026"
for day, month, year in re.findall(r"(\d{2})/(\d{2})/(\d{4})", dates):
    print(f"{year}-{month}-{day}")
2026-03-14
2026-04-02
for booking_ref in ["Table for 4 at 19:30", "Table for 2", "table for 12 at 20:00"]:
    m = re.search(r"[Tt]able for (\d+)(?: at (\d{2}:\d{2}))?", booking_ref)
    people, time = m.groups()
    print(f"{people:>2} people, time: {time or 'not given'}")
 4 people, time: 19:30
 2 people, time: not given
12 people, time: 20:00

Quick check#

When a pattern has two groups, what does re.findall give back?

print(re.findall(r"(\w+)@(\w+)", "ann@cafe and bo@gym"))
[('ann', 'cafe'), ('bo', 'gym')]

Common mistake: an unclosed bracket in the pattern#

To match a real bracket character, escape it: \(.

re.search(r"(\d+", "Table 12")
---------------------------------------------------------------------------
PatternError                              Traceback (most recent call last)
Cell In[50], line 1
----> 1 re.search(r"(\d+", "Table 12")

File /usr/lib/python3.13/re/__init__.py:177, in search(pattern, string, flags)
    174 def search(pattern, string, flags=0):
    175     """Scan through string looking for a match to the pattern, returning
    176     a Match object, or None if no match was found."""
--> 177     return _compile(pattern, flags).search(string)

File /usr/lib/python3.13/re/__init__.py:350, in _compile(pattern, flags)
    348 if not _compiler.isstring(pattern):
    349     raise TypeError("first argument must be string or compiled pattern")
--> 350 p = _compiler.compile(pattern, flags)
    351 if flags & DEBUG:
    352     return p

File /usr/lib/python3.13/re/_compiler.py:748, in compile(p, flags)
    746 if isstring(p):
    747     pattern = p
--> 748     p = _parser.parse(p, flags)
    749 else:
    750     pattern = None

File /usr/lib/python3.13/re/_parser.py:980, in parse(str, flags, state)
    977 state.flags = flags
    978 state.str = str
--> 980 p = _parse_sub(source, state, flags & SRE_FLAG_VERBOSE, 0)
    981 p.state.flags = fix_flags(str, p.state.flags)
    983 if source.next is not None:

File /usr/lib/python3.13/re/_parser.py:459, in _parse_sub(source, state, verbose, nested)
    457 start = source.tell()
    458 while True:
--> 459     itemsappend(_parse(source, state, verbose, nested + 1,
    460                        not nested and not items))
    461     if not sourcematch("|"):
    462         break

File /usr/lib/python3.13/re/_parser.py:865, in _parse(source, state, verbose, nested, first)
    863 p = _parse_sub(source, state, sub_verbose, nested + 1)
    864 if not source.match(")"):
--> 865     raise source.error("missing ), unterminated subpattern",
    866                        source.tell() - start)
    867 if group is not None:
    868     state.closegroup(group, p)

PatternError: missing ), unterminated subpattern at position 0
print(re.search(r"(\d+)", "Table 12").group(1))
print(re.search(r"\(\d+\)", "Table (12)").group())
12
(12)

sub, split, Flags and compile#

search finds, sub replaces, split chops. Flags tweak the rules; compile saves a pattern for reuse.

messy = "Too    many     spaces   here"
print(re.sub(r"\s+", " ", messy))
print(re.sub(r"\d", "#", "Card 4929 1234 5678 9012"))
Too many spaces here
Card #### #### #### ####
card = "Card 4929 1234 5678 9012"
print(re.sub(r"\d{4} \d{4} \d{4} (\d{4})", r"**** **** **** \1", card))
print(re.sub(r"(?P<d>\d{2})/(?P<m>\d{2})/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", dates))
Card **** **** **** 9012
Booked 2026-03-14, paid 2026-04-02
def tidy_price(m):
    return f"${float(m.group(1)):.2f}"

print(re.sub(r"\$([\d.]+)", tidy_price, "Tea $2, cake $3.5, lunch $12.25"))
Tea $2.00, cake $3.50, lunch $12.25
print(re.split(r"[,;|]\s*", "milk, eggs;bread|butter,  jam"))
print(re.split(r",\s*|\s+and\s+", "tea, coffee and cake"))
print(re.split(r"\s*\|\s*", "2026-03-02 |  Ada |note"))
['milk', 'eggs', 'bread', 'butter', 'jam']
['tea', 'coffee', 'cake']
['2026-03-02', 'Ada', 'note']
shout = "WOW. Wow! wow?"
print(re.findall(r"wow", shout))
print(re.findall(r"wow", shout, flags=re.IGNORECASE))

log = "ok: fine\nERROR: disk full\nok: fine\nERROR: out of milk"
print(re.findall(r"^ERROR: (.+)$", log, flags=re.MULTILINE))
['wow']
['WOW', 'Wow', 'wow']
['disk full', 'out of milk']
time_re = re.compile(r"""
    (?P<hour>[01]\d|2[0-3])    # 00 to 23
    :
    (?P<minute>[0-5]\d)        # 00 to 59
""", re.VERBOSE)

for t in ["09:30", "23:59", "24:00", "7:5"]:
    print(f"{t:<6}", "valid" if time_re.fullmatch(t) else "invalid")
09:30  valid
23:59  valid
24:00  invalid
7:5    invalid
opening = "Open 08:00 to 17:30, late night Friday 21:00"
print(time_re.findall(opening))
print(time_re.sub("--:--", opening))
[('08', '00'), ('17', '30'), ('21', '00')]
Open --:-- to --:--, late night Friday --:--

Your turn#

  1. Use re.sub to turn every run of dots or exclamation marks into a single one.
  2. Compile a pattern that finds prices like $4.50 and use it with findall on a menu string.

Validating Emails, Phone Numbers and Postcodes#

Validation is a bouncer at the door: check the shape of the data before you let it in.

(?:...) groups without capturing, handy when you need brackets but not the piece.

EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
for e in ["ada@example.com", "ben.smith+gym@mail.co.uk", "no-at-sign.com", "eve@site", "a b@x.com"]:
    print(f"{e:<26}{'ok' if EMAIL.fullmatch(e) else 'rejected'}")

print(EMAIL.search("Reply to ada@example.com today").group())
ada@example.com           ok
ben.smith+gym@mail.co.uk  ok
no-at-sign.com            rejected
eve@site                  rejected
a b@x.com                 rejected
ada@example.com
PHONE = re.compile(r"(?:\+44\s?|0)7\d{3}\s?\d{6}")
for p in ["07700 900123", "07700900123", "+44 7700 900123", "0161 496 0000", "07700 9001"]:
    print(f"{p:<18}{'ok' if PHONE.fullmatch(p) else 'rejected'}")

found = PHONE.findall("Call 07700 900111 or 07700900222 after 6pm")
print("Inside a sentence:", found)
07700 900123      ok
07700900123       ok
+44 7700 900123   ok
0161 496 0000     rejected
07700 9001        rejected
Inside a sentence: ['07700 900111', '07700900222']
def tidy_phone(raw):
    digits = re.sub(r"\D", "", raw)
    if digits.startswith("44"):
        digits = "0" + digits[2:]
    return f"{digits[:5]} {digits[5:]}"

for p in ["+44 7700 900123", "07700-900-123", "(07700) 900 123"]:
    print(f"{p:<18} -> {tidy_phone(p)}")
+44 7700 900123    -> 07700 900123
07700-900-123      -> 07700 900123
(07700) 900 123    -> 07700 900123
POSTCODE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}", re.IGNORECASE)
for pc in ["SW1A 1AA", "M1 1AE", "b33 8th", "LS1 4AP", "12345", "EC1A1BB"]:
    print(f"{pc:<10}{'ok' if POSTCODE.fullmatch(pc) else 'rejected'}")
SW1A 1AA  ok
M1 1AE    ok
b33 8th   ok
LS1 4AP   ok
12345     rejected
EC1A1BB   ok
def tidy_postcode(raw):
    squashed = re.sub(r"\s", "", raw).upper()
    return f"{squashed[:-3]} {squashed[-3:]}"

print([tidy_postcode(pc) for pc in ["b33 8th", "EC1A1BB", " m1   1ae "]])
['B33 8TH', 'EC1A 1BB', 'M1 1AE']

Pro tip: a regex checks the shape, not that the address is real. The only true email test is sending one.

Mini-Project: The Gym Sign-Up Form#

Keep asking until each answer has the right shape, then print a member card.

CHECKS = {"email": EMAIL, "phone": PHONE, "postcode": POSTCODE}

def ask_valid(kind):
    while True:
        answer = input(f"Your {kind}: ").strip()
        if CHECKS[kind].fullmatch(answer):
            return answer
        print(f"  That doesn't look like a valid {kind}, please try again.")

print("Checks ready for:", ", ".join(CHECKS))
Checks ready for: email, phone, postcode
profile = {}  # ask_valid asks for input()
for kind in CHECKS:
    profile[kind] = ask_valid(kind)
print(profile)
Your email: ada at example dot com
  That doesn't look like a valid email, please try again.
Your email: ada@example.com
Your phone: 0770 0900
  That doesn't look like a valid phone, please try again.
Your phone: 07700 900123
Your postcode: SW1A 1AA
{'email': 'ada@example.com', 'phone': '07700 900123', 'postcode': 'SW1A 1AA'}
profile["phone"] = tidy_phone(profile["phone"])
profile["postcode"] = tidy_postcode(profile["postcode"])
print(f"{' NEW MEMBER ':=^34}")
for kind, value in profile.items():
    print(f"{kind.title():<10}{value:>24}")
print("=" * 34)
=========== NEW MEMBER ===========
Email              ada@example.com
Phone                 07700 900123
Postcode                  SW1A 1AA
==================================

Unicode: Characters, Bytes and Encodings#

Text is characters for humans; files and networks store bytes. An encoding is the dictionary between them.

UTF-8 is the encoding to use almost everywhere. Always pass encoding="utf-8" when you read or write text files.

word = "caf\u00e9"
data = word.encode("utf-8")
print(len(word), "characters")
print(data, len(data), "bytes")
4 characters
b'caf\xc3\xa9' 5 bytes
for ch in "a\u00e9\u20ac":
    print(ch, ord(ch), ch.encode("utf-8"))
print(chr(233), chr(8364))
print("\N{LATIN SMALL LETTER E WITH ACUTE}" == "\u00e9", "\u00e9".encode("latin-1"), "\u00e9".encode("utf-8"))
a 97 b'a'
é 233 b'\xc3\xa9'
€ 8364 b'\xe2\x82\xac'
é €
True b'\xe9' b'\xc3\xa9'
print(data.decode("utf-8"))
print(data.decode("latin-1"), " <- wrong dictionary, scrambled text")
café
café  <- wrong dictionary, scrambled text

Common mistake: decoding with the wrong encoding#

ASCII only knows 128 characters, so it has no idea what an accented e is.

data.decode("ascii")
---------------------------------------------------------------------------
UnicodeDecodeError                        Traceback (most recent call last)
Cell In[70], line 1
----> 1 data.decode("ascii")

UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128)
print(data.decode("utf-8"))
print(data.decode("ascii", errors="replace"))
café
caf��
from pathlib import Path

folder = Path("lesson_files")
folder.mkdir(exist_ok=True)
menu_file = folder / "menu.txt"
menu_file.write_text("Cr\u00e8me br\u00fbl\u00e9e $5\nJalape\u00f1o toastie $6\n", encoding="utf-8")
text = menu_file.read_text(encoding="utf-8")
print(text, menu_file.stat().st_size, "bytes for", len(text), "characters")
Crème brûlée $5
Jalapeño toastie $6
 40 bytes for 36 characters
print("Stra\u00dfe".lower(), "|", "Stra\u00dfe".casefold())
print("STRASSE".lower() == "Stra\u00dfe".lower())
print("STRASSE".casefold() == "Stra\u00dfe".casefold())
straße | strasse
False
True
import unicodedata

a = "caf\u00e9"
b = "cafe\u0301"
print(a, b, a == b, len(a), len(b))
for ch in b[-2:]:
    print(repr(ch), unicodedata.name(ch))
café café False 4 5
'e' LATIN SMALL LETTER E
'́' COMBINING ACUTE ACCENT
nfc = unicodedata.normalize("NFC", b)
print(nfc == a, len(nfc))
nfd = unicodedata.normalize("NFD", a)
print(nfd == b, len(nfd))
True 4
True 5
def strip_accents(text):
    pieces = unicodedata.normalize("NFD", text)
    return "".join(ch for ch in pieces if not unicodedata.combining(ch))

for person in ["Zo\u00eb", "Jos\u00e9", "Ren\u00e9e", "\u0141ukasz"]:
    print(f"{person:<8} -> {strip_accents(person)}")
Zoë      -> Zoe
José     -> Jose
Renée    -> Renee
Łukasz   -> Łukasz
def same_name(x, y):
    return strip_accents(x).casefold() == strip_accents(y).casefold()

members = ["Jos\u00e9", "Zo\u00eb Nakamura", "Ren\u00e9e", "Ben"]
query = input("Search members: ")
print([m for m in members if same_name(query, m.split()[0])])
Search members: zoe
['Zoë Nakamura']
for ch in "$5 \u00d1!":
    print(f"{ch!r:<5}{unicodedata.category(ch):<4}{unicodedata.name(ch)}")
'$'  Sc  DOLLAR SIGN
'5'  Nd  DIGIT FIVE
' '  Zs  SPACE
'Ñ'  Lu  LATIN CAPITAL LETTER N WITH TILDE
'!'  Po  EXCLAMATION MARK

Your turn#

  1. Write same_text(a, b) that ignores accents, capitals and extra spaces, then test it on " REN\u00c9E " and "renee".
  2. Save "Zo\u00eb" to a file with UTF-8, then read it back with encoding="latin-1" and look at the result.

Quick check#

Why are len("caf\u00e9") and len("caf\u00e9".encode("utf-8")) different?

print(len("caf\u00e9"), "characters,", len("caf\u00e9".encode("utf-8")), "bytes: \u00e9 needs two bytes in UTF-8")
4 characters, 5 bytes: é needs two bytes in UTF-8

Capstone: The Messy Customer Notes Cleaner#

A cafe's staff type customer notes in a hurry. Our job: pull out emails, phone numbers and order IDs, tidy names and text, flag urgent notes and print a report someone can actually read.

raw_notes = """2026-03-02 | ada  LOVELACE | Rang re order #A1042, email ADA.L@Example.com, mobile 07700 900123. Wants refund!!!
2026-03-03 | jos\u00e9 garc\u00eda | order a1043 & A1044 not arrived... contact jose.garcia@mail.co.uk / +44 7700 900456
2026-03-03 | Ben   Okafor | Cake was squashed :( call (07700) 900-789 , postcode m1 1ae. Order A1045
2026-03-04 | ZO\u00cb NAKAMURA | email zoe@cafe-fans.org ,no phone given. order #A1046 lovely!!
2026-03-05 | sam lee | email sam@@typo.com ?? phone 0770 090   no order number"""

notes_file = folder / "customer_notes.txt"
notes_file.write_text(raw_notes, encoding="utf-8")
print(f"Saved {len(raw_notes.splitlines())} notes ({notes_file.stat().st_size:,} bytes)")
Saved 5 notes (497 bytes)
rows = []
for line in notes_file.read_text(encoding="utf-8").splitlines():
    when, who, note = re.split(r"\s*\|\s*", line)
    rows.append({"date": date.fromisoformat(when), "name": who, "note": note})
print(rows[1])
{'date': datetime.date(2026, 3, 3), 'name': 'josé garcía', 'note': 'order a1043 & A1044 not arrived... contact jose.garcia@mail.co.uk / +44 7700 900456'}
FIND_EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
FIND_PHONE = re.compile(r"(?:\+44\s?|\(?0)7\d{3}\)?[\s-]?\d{3}[\s-]?\d{3}")
FIND_ORDER = re.compile(r"\bA\d{4}\b", re.IGNORECASE)
FIND_POSTCODE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\b", re.IGNORECASE)
URGENT = re.compile(r"refund|not arrived|squashed|complain", re.IGNORECASE)
print(FIND_PHONE.findall(raw_notes))
['07700 900123', '+44 7700 900456', '(07700) 900-789']
def extract(note):
    return {
        "emails": [e.lower() for e in FIND_EMAIL.findall(note)],
        "phones": [tidy_phone(p) for p in FIND_PHONE.findall(note)],
        "orders": [o.upper() for o in FIND_ORDER.findall(note)],
        "postcodes": [tidy_postcode(p) for p in FIND_POSTCODE.findall(note)],
        "urgent": bool(URGENT.search(note)),
    }

print(extract(rows[2]["note"]))
{'emails': [], 'phones': ['07700 900789'], 'orders': ['A1045'], 'postcodes': ['M1 1AE'], 'urgent': True}
def clean_name(name):
    return re.sub(r"\s+", " ", name).strip().title()

for row in rows:
    print(f"{row['name']!r:<18} -> {clean_name(row['name'])}")
'ada  LOVELACE'    -> Ada Lovelace
'josé garcía'      -> José García
'Ben   Okafor'     -> Ben Okafor
'ZOË NAKAMURA'     -> Zoë Nakamura
'sam lee'          -> Sam Lee
def clean_note(note):
    note = re.sub(r":\(|:\)", "", note)
    note = re.sub(r"([!?.])\1+", r"\1", note)
    note = re.sub(r"\s+([,.!?])", r"\1", note)
    note = re.sub(r",(?=\S)", ", ", note)
    return re.sub(r"\s+", " ", note).strip()

print(clean_note(rows[2]["note"]))
print(clean_note(rows[3]["note"]))
Cake was squashed call (07700) 900-789, postcode m1 1ae. Order A1045
email zoe@cafe-fans.org, no phone given. order #A1046 lovely!
records = []
for row in rows:
    record = {"date": row["date"], "name": clean_name(row["name"]), "note": clean_note(row["note"])}
    record.update(extract(row["note"]))
    records.append(record)
print(len(records), "records, keys:", list(records[0]))
5 records, keys: ['date', 'name', 'note', 'emails', 'phones', 'orders', 'postcodes', 'urgent']
def report_card(r, width=60):
    flag = "  [URGENT]" if r["urgent"] else ""
    print(f"{r['date']:%a %d %b}  {r['name']}{flag}")
    print(f"   Email : {', '.join(r['emails']) or '-'}")
    print(f"   Phone : {', '.join(r['phones']) or '-'}")
    print(f"   Orders: {', '.join(r['orders']) or '-'}")
    if r["postcodes"]:
        print(f"   Post  : {', '.join(r['postcodes'])}")
    print(textwrap.indent(textwrap.fill(r["note"], width - 3), "   > "))

report_card(records[0])
Mon 02 Mar  Ada Lovelace  [URGENT]
   Email : ada.l@example.com
   Phone : 07700 900123
   Orders: A1042
   > Rang re order #A1042, email ADA.L@Example.com, mobile
   > 07700 900123. Wants refund!
print(f"{' CUSTOMER NOTES REPORT ':=^60}")
for r in records[1:]:
    report_card(r)
    print("-" * 60)
================== CUSTOMER NOTES REPORT ===================
Tue 03 Mar  José García  [URGENT]
   Email : jose.garcia@mail.co.uk
   Phone : 07700 900456
   Orders: A1043, A1044
   > order a1043 & A1044 not arrived. contact
   > jose.garcia@mail.co.uk / +44 7700 900456
------------------------------------------------------------
Tue 03 Mar  Ben Okafor  [URGENT]
   Email : -
   Phone : 07700 900789
   Orders: A1045
   Post  : M1 1AE
   > Cake was squashed call (07700) 900-789, postcode m1 1ae.
   > Order A1045
------------------------------------------------------------
Wed 04 Mar  Zoë Nakamura
   Email : zoe@cafe-fans.org
   Phone : -
   Orders: A1046
   > email zoe@cafe-fans.org, no phone given. order #A1046
   > lovely!
------------------------------------------------------------
Thu 05 Mar  Sam Lee
   Email : -
   Phone : -
   Orders: -
   > email sam@@typo.com? phone 0770 090 no order number
------------------------------------------------------------

Finally, an at-a-glance table, a few headline numbers and a saved report file.

def yes_no(items):
    return "yes" if items else "no"

print(f"{'Customer':<16}{'Orders':>7}{'Email':>7}{'Phone':>7}  Postcode")
for r in records:
    print(f"{r['name']:<16}{len(r['orders']):>7}{yes_no(r['emails']):>7}{yes_no(r['phones']):>7}  "
          f"{', '.join(r['postcodes']) or '-'}")
Customer         Orders  Email  Phone  Postcode
Ada Lovelace          1    yes    yes  -
José García           2    yes    yes  -
Ben Okafor            1     no    yes  M1 1AE
Zoë Nakamura          1    yes     no  -
Sam Lee               0     no     no  -
total = len(records)
counts = {
    "with an email": sum(1 for r in records if r["emails"]),
    "with a phone": sum(1 for r in records if r["phones"]),
    "flagged urgent": sum(r["urgent"] for r in records),
}
for label, n in counts.items():
    print(f"Notes {label:<15}{n:>2} of {total}  ({n / total:.0%})")
Notes with an email   3 of 5  (60%)
Notes with a phone    3 of 5  (60%)
Notes flagged urgent  3 of 5  (60%)
all_orders = sorted({o for r in records for o in r["orders"]})
print(f"{len(all_orders)} order IDs:", ", ".join(all_orders))
follow_up = [r["name"] for r in records if not (r["emails"] or r["phones"])]
print("Need contact details:", follow_up)
5 order IDs: A1042, A1043, A1044, A1045, A1046
Need contact details: ['Sam Lee']
lines = [f"{r['date']:%Y-%m-%d} | {r['name']:<14} | {', '.join(r['orders']) or '-':<12} | "
         f"{', '.join(r['emails'] + r['phones']) or 'NO CONTACT'}" for r in records]
report_file = folder / "notes_report.txt"
report_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(report_file.read_text(encoding="utf-8"))
2026-03-02 | Ada Lovelace   | A1042        | ada.l@example.com, 07700 900123
2026-03-03 | José García    | A1043, A1044 | jose.garcia@mail.co.uk, 07700 900456
2026-03-03 | Ben Okafor     | A1045        | 07700 900789
2026-03-04 | Zoë Nakamura   | A1046        | zoe@cafe-fans.org
2026-03-05 | Sam Lee        | -            | NO CONTACT

def urgent_then_date(r):
    return (not r["urgent"], r["date"])

for r in sorted(records, key=urgent_then_date):
    label = "URGENT" if r["urgent"] else "ok"
    print(f"{label:<7}{r['date']:%d %b}  {r['name']:<14}{textwrap.shorten(r['note'], 32, placeholder='...')}")
URGENT 02 Mar  Ada Lovelace  Rang re order #A1042, email...
URGENT 03 Mar  José García   order a1043 & A1044 not...
URGENT 03 Mar  Ben Okafor    Cake was squashed call...
ok     04 Mar  Zoë Nakamura  email zoe@cafe-fans.org, no...
ok     05 Mar  Sam Lee       email sam@@typo.com? phone...
key = strip_accents("Zoe Nakamura").casefold()
match = [r for r in records if strip_accents(r["name"]).casefold() == key]
print(f"Searched without accents, found: {match[0]['name']} ({match[0]['emails'][0]})")
Searched without accents, found: Zoë Nakamura (zoe@cafe-fans.org)

Recap#

  • f-string specs: width, < > ^ alignment, fill characters, .2f, , thousands, %, +, 05d
  • {value=} for quick debugging and !r to see hidden spaces
  • Dates inside f-strings with %d %b %Y, %A, %H:%M
  • string constants and textwrap: fill, wrap, shorten, indent, dedent
  • re: search, match, fullmatch, findall, finditer, sub, split
  • Groups, named groups (?P<name>...), flags like IGNORECASE, MULTILINE, VERBOSE, and compile
  • Validating emails, UK mobiles and postcodes by shape, then tidying them into one format
  • Unicode: characters vs bytes, encode/decode with UTF-8, casefold, unicodedata.normalize

Pro tip: when a regex grows past one line, switch to re.VERBOSE and comment each piece.

Try it yourself#

  1. Teach the cleaner to spot UK landlines (like 0161 496 0000) and list them separately from mobiles.
  2. Add a mood score: count happy words (lovely, great) and unhappy words (refund, squashed) per note.
  3. Save the records as a CSV with csv.DictWriter, then open it again and print only the urgent rows.

What's next#

Lesson 7: Dates and Times: Calendars, Time Zones and Reminders. You'll calculate ages, countdowns and business days, handle time zones, and build a birthday and bill reminder planner.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.