The Quiet Terminal

← Practical Tips

Pulling emails and links out of text with re

2026-09-04 · 4 min read

Every few weeks a task turns out to be the same task: I have a chunk of text, somewhere inside it there are email addresses or links, and I want just those out. Copy-pasting by hand is fine for three items and miserable for three hundred. re.findall() does it in two lines.

The email pattern

import re

text = "Questions? Mail ada@example.com or sales@example.co.uk. CC bob.test+news@mail.example.org"
emails = re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
print(emails)
['ada@example.com', 'sales@example.co.uk', 'bob.test+news@mail.example.org']

Read it left to right: some letters, digits, dots, or plus signs, then an @, then a domain of letters, digits, dots, or hyphens, ending in a dot and two or more letters. Not perfect — a fully correct email regex is famously enormous — but it has caught everything I've thrown at it from my own mail exports.

One detail to notice: the r before the quotes. That's a raw string — Python hands the backslashes to the regex engine untouched instead of interpreting them itself. Without it, "\b" is a backspace character rather than the word boundary you meant, and the pattern fails in ways that are no fun to debug.

The URL pattern

page = "Docs at https://example.com/help, or http://example.com/search?q=py&page=2, pick one."
links = re.findall(r"https?://[^\s\"']+", page)
print(links)
['https://example.com/help,', 'http://example.com/search?q=py&page=2,']

The pattern is just: https? (the s is optional), then everything up to the next space or quote. And look closely at the output — it grabbed the trailing commas. That's the trade in one picture: a character-class pattern can't know where a URL ends and the sentence resumes, because URLs can legally contain most punctuation. I strip trailing punctuation afterward, or accept it. What I don't do is grow the pattern to chase every case.

Groups change what findall returns

Wrap parts of the pattern in parentheses and findall() returns the groups instead of the whole match:

for user, domain in re.findall(
        r"([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", text):
    print(user, "at", domain)
ada at example.com
sales at example.co.uk
bob.test+news at mail.example.org

This surprises everyone once: with groups present, you get tuples of the groups, not the full strings. If you want whole matches back, drop the parentheses, or use finditer() and read m.group(0).

Keep it simple, test on samples

Two small habits keep this comfortable. First, I work the pattern out in the REPL against a sample line before it goes into any script — a regex is cheapest to fix before it's embedded somewhere. Second, remember that findall() returns a list, so if re.findall(pattern, text): is also a clean way to ask "is there one?", and an empty list means no matches, not an error.

My rule, after one-line patterns that grew into ten-line monsters: keep the pattern as simple as the data allows, and keep a few real sample strings in the script so you can re-run the extraction whenever you touch the pattern. A regex you can't test against real text is a regex you can't safely change. A pattern that's right for your data beats one that's theoretically complete for nobody's.