Regex for Data Analysts
Character classes, quantifiers, groups, lookaheads, regex in SQL (BigQuery, PostgreSQL, MySQL), Python re module, pandas str methods, and 12 real-world patterns for cleaning messy data.
Real-world data is messy. Phone numbers come in eight different formats. Email fields contain names and emails mixed together. Product codes are buried inside free-text descriptions. Dates appear as "Oct 27, 2026", "27/10/26", and "2026-10-27" in the same column. Regular expressions — regex — are the most efficient tool for handling all of these situations.
Most analysts avoid regex because the syntax looks intimidating at first glance. It is not. Regex is a small vocabulary of about 15 building blocks, and once you know them, you can construct patterns for almost any text problem in minutes. This chapter teaches the vocabulary, then shows you how to apply it in SQL and Python.
The Regex Vocabulary: 15 Building Blocks
| Pattern | What it matches | Example |
|---|---|---|
| . | Any single character except newline | c.t matches "cat", "cut", "c9t" |
| \d | Any digit 0–9 | \d\d\d matches "123", "007" |
| \D | Any non-digit | \D+ matches "abc", "hello" |
| \w | Word character: a-z, A-Z, 0-9, _ | \w+ matches "hello_world" |
| \s | Whitespace (space, tab, newline) | \s+ matches spaces between words |
| [abc] | Any character in the set | [aeiou] matches any vowel |
| [^abc] | Any character NOT in the set | [^0-9] matches any non-digit |
| [a-z] | Any character in the range | [a-zA-Z] matches any letter |
| ^ | Start of string (or line in multiline) | ^Hello matches "Hello world" |
| $ | End of string (or line) | world$ matches "Hello world" |
| * | Zero or more of the preceding | ab* matches "a", "ab", "abb" |
| + | One or more of the preceding | ab+ matches "ab", "abb", not "a" |
| ? | Zero or one (makes preceding optional) | colou?r matches "color" and "colour" |
| {n,m} | Between n and m repetitions | \d{3,5} matches 3 to 5 digits |
| (abc) | Capturing group — extract this part | (\d{4}) captures a 4-digit year |
| a|b | Either a or b | cat|dog matches "cat" or "dog" |
| (?:abc) | Non-capturing group (group without extracting) | (?:Mr|Ms)\.\s\w+ |
| (?=abc) | Positive lookahead — followed by | \d+(?= kg) matches number before " kg" |
| (?!abc) | Negative lookahead — NOT followed by | \d+(?! kg) matches number not before " kg" |
Building Patterns: From Simple to Complex
Build patterns left to right. Start with what you know — a literal character, a digit, a word — then add quantifiers and anchors to constrain what you will accept.
-- Indian mobile number: starts with 6/7/8/9, then 9 more digits
Pattern: [6-9]\d{9}
Matches: 9876543210, 8123456789
Rejects: 1234567890 (starts with 1), 98765432 (only 8 digits)
-- With optional country code (+91 or 0):
Pattern: (?:\+91|0)?[6-9]\d{9}
Matches: +919876543210, 09876543210, 9876543210
-- Email address (practical, not RFC-complete):
Pattern: [a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}
Matches: user@example.com, name.surname+tag@company.co.in
Rejects: @missing.com, noatsign.com
-- Indian PIN code (6 digits, first not 0):
Pattern: [1-9]\d{5}
Matches: 201301, 110001
Rejects: 012345 (starts with 0)
-- Date in DD/MM/YYYY or DD-MM-YYYY:
Pattern: (0?[1-9]|[12]\d|3[01])[\-/](0?[1-9]|1[0-2])[\-/](19|20)\d{2}
Matches: 27/10/2026, 01-01-2025, 5/3/2024
-- Product SKU like "SKU-1234-AB" or "SKU-999-XZ":
Pattern: SKU-\d{3,4}-[A-Z]{2}
Matches: SKU-1234-AB, SKU-999-XZ
Rejects: sku-1234-ab (lowercase), SKU-12-AB (too few digits)Regex in SQL
MySQL
-- REGEXP_LIKE (MySQL 8+) / RLIKE (older MySQL): filter rows
SELECT *
FROM customers
WHERE phone REGEXP '^[6-9][0-9]{9}$'; -- valid Indian mobile
-- Remove all non-digits (MySQL doesn't have REGEXP_REPLACE before 8.0)
-- Use REGEXP_REPLACE (MySQL 8+):
SELECT
customer_id,
REGEXP_REPLACE(phone, '[^0-9]', '') AS clean_phone
FROM customers;
-- Extract domain from email
SELECT
email,
REGEXP_SUBSTR(email, '@(.+)$', 1, 1, NULL, 1) AS domain
FROM customers;PostgreSQL
-- ~ operator: case-sensitive regex match
SELECT * FROM customers WHERE phone ~ '^[6-9][0-9]{9}$';
-- ~* operator: case-insensitive
SELECT * FROM products WHERE description ~* 'premium|pro|enterprise';
-- regexp_replace: remove non-alphanumeric from product codes
SELECT
product_id,
regexp_replace(product_code, '[^A-Z0-9]', '', 'g') AS clean_code
FROM products;
-- 'g' flag = global (replace all occurrences, not just first)
-- regexp_matches: extract all matches (returns a set)
SELECT regexp_matches(description, '[A-Z]{2,3}-d{4}', 'g') AS sku
FROM product_descriptions;
-- substring with regex: extract first match
SELECT
log_entry,
substring(log_entry FROM 'user_id=(d+)') AS extracted_user_id
FROM event_logs;BigQuery
-- REGEXP_CONTAINS: boolean filter
SELECT *
FROM orders
WHERE REGEXP_CONTAINS(email, r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}');
-- Note: BigQuery uses r'...' raw strings (no need to double-escape )
-- REGEXP_EXTRACT: pull first match (returns NULL if no match)
SELECT
raw_text,
REGEXP_EXTRACT(raw_text, r'INV-(d{6})') AS invoice_number
FROM transactions;
-- REGEXP_EXTRACT_ALL: all matches as an array
SELECT
REGEXP_EXTRACT_ALL(description, r'[A-Z]{2,3}-d{4}') AS all_skus
FROM product_catalogue;
-- REGEXP_REPLACE: clean phone numbers
SELECT
REGEXP_REPLACE(phone, r'[^d]', '') AS digits_only
FROM customers;
-- Lookahead example: extract amount before currency symbol
SELECT
REGEXP_EXTRACT(price_text, r'(d+(?:.d{2})?)(?=s*(?:INR|₹|Rs))') AS amount
FROM price_table;Regex in Python
The re module
import re
text = "Order INV-004521 placed by user 9876543210 on 27/10/2026"
# re.search: find first match anywhere in string
m = re.search(r'INV-(d+)', text)
if m:
print(m.group(0)) # full match: 'INV-004521'
print(m.group(1)) # group 1: '004521'
# re.findall: return all matches as a list
phones = re.findall(r'[6-9]d{9}', text)
print(phones) # ['9876543210']
# re.sub: replace matches
clean = re.sub(r'[^d]', '', '+91 98765-43210')
print(clean) # '919876543210'
# re.compile: pre-compile for repeated use (faster in loops)
phone_pattern = re.compile(r'(?:+91|0)?([6-9]d{9})')
for row in df['phone']:
m = phone_pattern.search(str(row))
if m:
print(m.group(1))
# Named groups: cleaner extraction
date_pattern = re.compile(
r'(?P<day>d{1,2})[/-](?P<month>d{1,2})[/-](?P<year>d{4})'
)
m = date_pattern.search('Order date: 27/10/2026')
if m:
print(m.group('day'), m.group('month'), m.group('year'))
# 27 10 2026Regex with pandas
import pandas as pd
df = pd.DataFrame({
'phone': ['+91 98765-43210', '8123456789', '011-2345-6789', '98765'],
'email': ['user@example.com', 'BAD_EMAIL', 'name@co.in', 'test@'],
'notes': ['Ref: INV-1234 paid', 'No invoice', 'INV-5678 pending', ''],
})
# str.contains: boolean mask for filtering
valid_phones = df['phone'].str.contains(r'[6-9]d{9}', regex=True, na=False)
print(df[valid_phones])
# str.extract: capture groups → new columns (uses first match)
df[['area', 'number']] = df['phone'].str.extract(
r'(d{3,5})[-s](d{7,8})'
)
# str.extractall: all matches → multi-index DataFrame
all_invoices = df['notes'].str.extractall(r'(INV-d{4})')
print(all_invoices)
# str.replace: clean and normalise
df['phone_clean'] = df['phone'].str.replace(r'[^d]', '', regex=True)
# str.findall: list of all matches per row
df['invoice_list'] = df['notes'].str.findall(r'INV-d{4}')
# Validate with str.fullmatch (entire string must match)
df['email_valid'] = df['email'].str.fullmatch(
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}'
)12 Real-World Patterns for Data Cleaning
[6-9]\d{9}[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}[1-9]\d{5}\d{2}[A-Z]{5}\d{4}[A-Z][1-9A-Z]Z[0-9A-Z][A-Z]{5}[0-9]{4}[A-Z]https?://[^\s<>"]+\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b(0?[1-9]|[12]\d|3[01])[/\-](0?[1-9]|1[0-2])[/\-](19|20)\d{2}(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?:₹|Rs\.?|INR\s*)?\d{1,3}(?:,\d{3})*(?:\.\d{2})?<[^>]+>#\w+Use regex101.com — paste your pattern, paste sample data, see matches highlighted in real time with an explanation of each part of your pattern. Choose the engine that matches your tool: Python, MySQL, or PCRE for PostgreSQL/BigQuery.