Extracting with Regular Expressions
On this page
Surgical String Extraction#
HTML parsers are the right tool for navigating the DOM, but a lot of valuable data is not shaped like a DOM. It sits inside an inline <script> as a JavaScript assignment, inside a messy text string like Price: $1,299.99 (In Stock), in a URL (/product/48213-mechanical-keyboard), or in a plain-text file. Regular expressions are a compact pattern language for exactly these jobs: finding, validating, and pulling apart strings.
Regex or Parser?#
| Task | Better tool |
|---|---|
| Find elements by tag, class, or nesting | CSS selectors or XPath |
| Pull a number, date, or code out of an element's text | Regex |
| Locate a JSON blob inside a script tag | Regex to find the start, a JSON decoder to parse it |
| Extract an ID from a URL | Regex, or urllib.parse if it is a query parameter |
| Validate a field's format before saving | Regex with fullmatch |
| Parse a whole HTML document | Never regex |
The last row is the classic rule. HTML allows attributes in any order, optional quotes, nested elements, and comments, and a regex that handles all of those is unreadable and still wrong. Use a parser to get down to the element, then regex on its text.
The Core Functions#
import re
text = "SKU: AB-1234 | SKU: CD-99"
re.search(r"SKU: [A-Z]{2}-\d+", text) # first match anywhere, or None
re.match(r"SKU", text) # match only at the start of the string
re.fullmatch(r"[A-Z]{2}-\d+", "AB-1234") # whole string must match: use for validation
print(re.findall(r"SKU: [A-Z]{2}-\d+", text)) # ['SKU: AB-1234', 'SKU: CD-99']
print(re.findall(r"SKU: ([A-Z]{2})-(\d+)", text)) # [('AB', '1234'), ('CD', '99')]
for m in re.finditer(r"SKU: (?P<prefix>[A-Z]{2})-(?P<num>\d+)", text):
print(m.group("prefix"), m.group("num"), m.span())
The findall behavior surprises everyone once: with no groups it returns full matches, with one group it returns that group's strings, and with several groups it returns tuples. finditer is usually clearer, because each item is a match object with named groups and positions.
re.match only checks the beginning of the string, which is a common source of "my pattern never matches". For validation, prefer fullmatch, so that AB-1234xyz does not slip through a pattern that only checks the prefix.
Greedy vs. Lazy#
Quantifiers like * and + are greedy: they take as much as possible and give back only when forced. Across a line of HTML, that eats far more than you meant:
html = '<a href="/a">One</a> <a href="/b">Two</a>'
print(re.findall(r'<a href="(.*)">', html)) # ['/a">One</a> <a href="/b']
print(re.findall(r'<a href="(.*?)">', html)) # ['/a', '/b']
print(re.findall(r'<a href="([^"]*)">', html)) # ['/a', '/b']
The lazy .*? fixes it, but the third form is better: [^"]* says precisely "anything except a closing quote". Negated character classes are faster, because the engine never has to backtrack, and they cannot run past the boundary you care about.
Named Groups and a Real Price Parser#
Prices are the most common regex job in scraping, and they come in many formats. A re.VERBOSE pattern lets you lay the pattern out with comments:
PRICE = re.compile(r'''
(?P<currency>[$€£]|USD|EUR|GBP)? # optional symbol or code before the number
\s*
(?P<amount>
\d{1,3}(?:[,.\s]\d{3})*(?:[.,]\d{2})? # 1,299.99 1.299,00 1 299,00
| \d+(?:[.,]\d{2})? # 45 45.00 45,00
)
''', re.VERBOSE)
for s in ["Price: $1,299.99 (In Stock)", "Total USD 422.50", "Now only £45", "Prix : 1.299,00 €"]:
print(s, "->", PRICE.search(s).groupdict())
Output:
Price: $1,299.99 (In Stock) -> {'currency': '$', 'amount': '1,299.99'}
Total USD 422.50 -> {'currency': 'USD', 'amount': '422.50'}
Now only £45 -> {'currency': '£', 'amount': '45'}
Prix : 1.299,00 € -> {'currency': None, 'amount': '1.299,00'}
The last line shows a real limitation: the euro sign comes after the number, so the pattern misses it. Rather than making one regex handle every locale, detect the currency separately, or better, take it from a structured source such as JSON-LD's priceCurrency. Converting the amount to integer cents needs one more decision, which separator is the decimal:
def to_cents(amount: str) -> int:
digits = re.sub(r"[^\d.,]", "", amount) # drop spaces and symbols
if re.search(r"[.,]\d{2}$", digits): # ends with a 2-digit decimal part
whole, frac = digits[:-3], digits[-2:]
else:
whole, frac = digits, "00"
return int(re.sub(r"\D", "", whole) + frac)
print([to_cents(a) for a in ["1,299.99", "1.299,00", "1 299,00", "45"]])
# [129999, 129900, 129900, 4500]
This heuristic works for two-decimal currencies. Currencies with zero or three minor digits need a locale-aware library or explicit per-site rules, which the data cleaning and validation lesson discusses.
Extracting Inline JSON Safely#
Client-rendered pages often embed their state as window.__INITIAL_STATE__ = {...};. The common recipe re.search(r"= (\{.*?\});", html) breaks the moment a string inside the JSON contains };:
import json
page = '''<script>window.__INITIAL_STATE__ = {"items": [{"name": "A; b};"}], "n": 1};
var other = 2;</script>'''
print(re.search(r"window\.__INITIAL_STATE__\s*=\s*(\{.*?\});", page).group(1))
# {"items": [{"name": "A; b} <- cut off inside a string
m = re.search(r"window\.__INITIAL_STATE__\s*=\s*", page)
start = page.index("{", m.end())
data, end = json.JSONDecoder().raw_decode(page, start)
print(data)
# {'items': [{'name': 'A; b};'}], 'n': 1}
Use the regex only to find where the value begins, then let raw_decode parse exactly one JSON value from that position. It understands strings and nesting, and returns where the value ended. If the blob is a JavaScript object literal rather than strict JSON (unquoted keys, single quotes, undefined), raw_decode raises, and you need a JavaScript-aware parser or the underlying API instead. The JavaScript-rendered pages lesson covers where these blobs come from.
Note the escaped dot in window\.__INITIAL_STATE__. An unescaped . matches any character; it usually still works, which is why the bug survives until it matches something unexpected.
Flags You Will Use#
re.IGNORECASE(re.I): case-insensitive matching.re.DOTALL(re.S): lets.match newlines, needed when a target spans lines.re.MULTILINE(re.M): makes^and$match at every line, useful for text files androbots.txt-style formats.re.VERBOSE(re.X): ignores whitespace and allows#comments in the pattern.re.ASCII(re.A): restricts\d,\w, and\sto ASCII.
The last one matters more than it looks. In Python 3, \d on a str matches any Unicode decimal digit, including Arabic-Indic and Devanagari digits. re.findall(r"\d+", ...) can therefore return strings that int() accepts but that you did not expect. When you mean ASCII digits, write [0-9] or pass re.ASCII.
Catastrophic Backtracking#
Some patterns take exponential time on inputs that almost match. Nested quantifiers are the usual cause:
import time
for n in (16, 18, 20, 22):
s = "a" * n + "!"
t = time.perf_counter()
re.match(r"^(a+)+$", s)
print(n, f"{time.perf_counter() - t:.3f}s")
On a typical machine the time roughly doubles with each extra character; a few more characters and one regex call hangs a worker for minutes. On scraped input, which is long and unpredictable, this is a real outage risk. Avoid nested quantifiers over overlapping classes ((\w+\s?)*, (a|aa)+), prefer specific negated classes, and on Python 3.11 or later use atomic groups (?>...) or possessive quantifiers like a++, which never give back what they matched: ^(?>a+)+$ fails instantly.
Performance and Testing Habits#
- Raw strings always.
r"\d+"passes the backslash to the regex engine;"\d+"relies on Python leaving unknown escapes alone and raises a warning in recent versions. - Compile once.
re.compileat module level documents the pattern and avoids repeated lookups in hot loops. Python caches recently used patterns, so the speed gain is modest; the readability gain is not. - Test against real samples. Keep a list of strings you have actually seen, including the ugly ones, and assert the results:
CASES = {"$1,299.99": 129999, "1.299,00 €": 129900, "USD 45": 4500}
for raw, cents in CASES.items():
assert to_cents(PRICE.search(raw).group("amount")) == cents, raw
Regex failures are silent: an empty list looks exactly like "no data on this page". Every new format you meet in production goes into the test list before you change the pattern.
Practice#
Collect twenty real price strings from pages you are allowed to scrape, in at least two locales. Write a pattern and a to_cents function that handle all of them, a test list asserting each result, and one deliberately malformed string that must return None rather than a wrong number.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.