前言
先澄清标题里的一个措辞:正则表达式(regular expression,简称 regex 或 RE)不是数据分析专有的东西 ,它是 Python 标准库 re 模块提供的通用文本处理能力。在数据分析场景里之所以频繁出现,是因为原始数据往往是一堆没洗过的文本------日志、爬下来的网页、表格里的脏字段------需要用模式而不是固定子串去抽取信息。数据分析库(比如 pandas 的 Series.str 系列方法)在需要正则时会调用这一套底层能力,参数细节以对应库的官方文档为准。本文只讲标准库 re,它是这一切的基础。
初学者最集中的误解是分不清 re.match 和 re.search:match 只从字符串开头 尝试匹配,search 才扫描整个字符串 找第一个匹配位置。所以 re.match(r"\d+", "abc123") 返回 None,而 re.search(r"\d+", "abc123") 能匹配到 123。这个区别让无数人在「为什么明明有数字却匹配不到」上卡了很久。
第二个误解是把正则当成字符串的一部分来写。如果模式里含 \d、\s 这类转义,必须用原始字符串 r"..." ,否则反斜杠会被 Python 的字符串转义先吃掉一层。本文把 re 的常用函数、匹配对象和常见陷阱讲清楚。
一、五个匹配函数的分工
re 模块在顶层提供了几个一次性函数,内部会把模式编译并(带缓存地)复用:
| 函数 | 从哪里开始匹配 | 成功返回 | 失败返回 |
|---|
|------------------|----------|----------|--------|
| re.match(p, s) | 只从开头 | Match 对象 | None |
|-------------------|----------------|----------|--------|
| re.search(p, s) | 扫描整个串,找第一处 | Match 对象 | None |
|----------------------|--------------|----------|--------|
| re.fullmatch(p, s) | 要求整串完全匹配 | Match 对象 | None |
|--------------------|-------------------|----|-----|
| re.findall(p, s) | 扫描全串,取所有非重叠匹配 | 列表 | 空列表 |
|---------------------|----------|-----|------|
| re.finditer(p, s) | 同上,但惰性产出 | 迭代器 | 空迭代器 |
python
# 适用于 Python 3.8+
import re
print(re.match(r"\d+", "abc123")) # None ------ 开头不是数字
print(re.search(r"\d+", "abc123")) # <re.Match object; span=(3, 6), match='123'>
print(re.fullmatch(r"\d+", "123")) # <re.Match ...> ------ 整串都是数字才匹配
print(re.fullmatch(r"\d+", "123a")) # None
print(re.findall(r"\d+", "a1b22c333")) # ['1', '22', '333']
re.fullmatch 从 Python 3.4 起才有。要判断「整个字符串是否符合某格式」,用它比用 match 再补 $ 更清晰。
findall 的返回形状取决于模式里有几个捕获组(capturing group):
python
# 适用于 Python 3.8+
print(re.findall(r"\d+", "a1b22")) # ['1', '22'] 无分组 -> 字符串列表
print(re.findall(r"(\d+)", "a1b22")) # ['1', '22'] 一个分组 -> 取分组内容
print(re.findall(r"(\w+)=(\d+)", "w=20 h=10")) # [('w', '20'), ('h', '10')] 多分组 -> 元组列表
多分组时返回元组列表 这一点,是 findall 最常见的意外来源:本以为拿到的是整段匹配,结果拿到的是分组切片。
二、Match 对象怎么取值
search / match / fullmatch 成功时返回 Match 对象,取值靠这几个方法:
python
# 适用于 Python 3.8+
import re
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "今天日期 2026-10-07 天气晴")
if m:
print(m.group(0)) # 2026-10-07 整个匹配
print(m.group(1)) # 2026 第一个分组
print(m.groups()) # ('2026', '10', '07')
print(m.start(), m.end()) # 5 15 匹配在原串中的起止下标
用命名分组 可读性更好,语法是 (?P<名称>...),用名字取值:
python
# 适用于 Python 3.8+
import re
m = re.match(r"(?P<year>\d{4})-(?P<month>\d{2})", "2026-10")
if m:
print(m.group("year")) # 2026
print(m["month"]) # 10 ------ 3.6+ 支持下标取分组
print(m.groupdict()) # {'year': '2026', 'month': '10'}
分组可能不参与 匹配(比如整组是可选的),此时 groups() 里对应位置是 None,可以给默认值:m.groups("0")。
三、替换与切分
re.sub(pattern, repl, string, count=0, flags=0) 把匹配到的部分替换掉;count=0 表示不限次数。repl 既可以是字符串(支持 \1 这类反向引用,用原始字符串写),也可以是函数,函数接收 Match 对象并返回替换文本:
python
# 适用于 Python 3.8+
import re
print(re.sub(r"\d+", "#", "a1b22c333")) # 'a#b#c#'
print(re.sub(r"\d+", "#", "a1b22c333", count=1)) # 'a#b22c333'
# 用函数做「把价格上调 10%」这类动态替换
def bump(match):
return str(int(match.group()) * 2)
print(re.sub(r"\d+", bump, "价 100 元")) # '价 200 元'
re.split(pattern, string, maxsplit=0, flags=0) 用模式切分,和 str.split 不同的是它能按一类分隔符切:
python
# 适用于 Python 3.8+
import re
print(re.split(r"\W+", "a, b; c")) # ['a', 'b', 'c']
print(re.split(r"[,;]\s*", "a, b; c")) # ['a', 'b', 'c']
maxsplit 和 flags 从 Python 3.13 起不建议再按位置传,将来会变成仅关键字参数,新代码写成 re.split(p, s, maxsplit=1) 更稳。
四、编译模式与常用标志
模式会被 re 模块内部缓存(同一模式重复使用不会反复编译),但显式编译能省掉每次的查表开销,也更适合把模式定义在模块级常量:
python
# 适用于 Python 3.8+
import re
DATE_RE = re.compile(r"(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})")
def find_dates(text):
return [m.groupdict() for m in DATE_RE.finditer(text)]
print(find_dates("2026-10-07 和 2025-01-02"))
# [{'y': '2026', 'm': '10', 'd': '07'}, {'y': '2025', 'm': '01', 'd': '02'}]
flags 用按位或组合,常用的有:
| 标志 | 作用 |
|---|
|-------------------------|-------|
| re.IGNORECASE(re.I) | 忽略大小写 |
|------------------------|-------------------------|
| re.MULTILINE(re.M) | 让 ^ $ 匹配每行首尾,而非整串首尾 |
|---------------------|--------------|
| re.DOTALL(re.S) | 让 . 能匹配换行符 |
|--------------------|------------------------------|
| re.ASCII(re.A) | 让 \d \w \s 只按 ASCII 解释 |
|----------------------|--------------|
| re.VERBOSE(re.X) | 允许在模式里换行、写注释 |
re.VERBOSE 对写复杂正则特别有用:
python
# 适用于 Python 3.8+
import re
PHONE_RE = re.compile(r"""
(?P<area>\d{3}) # 区号
- # 横线
(?P<num>\d{8}) # 号码
""", re.VERBOSE)
m = PHONE_RE.fullmatch("010-12345678")
print(m.group("area")) # 010
五、实战:从脏文本里提取结构化字段
python
# 适用于 Python 3.8+
import re
LOG_LINE = '2026-10-07 08:30:15 [ERROR] user=alice ip=192.168.1.7 msg="timeout"'
LINE_RE = re.compile(
r"^(?P<date>\d{4}-\d{2}-\d{2})\s+"
r"(?P<time>\d{2}:\d{2}:\d{2})\s+"
r"\[(?P<level>[A-Z]+)\]\s+"
r"user=(?P<user>\w+)\s+"
r"ip=(?P<ip>[\d.]+)"
)
def parse_line(line):
m = LINE_RE.match(line)
if m is None:
return None
return m.groupdict()
print(parse_line(LOG_LINE))
# {'date': '2026-10-07', 'time': '08:30:15', 'level': 'ERROR',
# 'user': 'alice', 'ip': '192.168.1.7'}
print(parse_line("这不是日志")) # None
几个要点:用命名分组让结果自带字段名;匹配失败返回 None 而不是抛异常,交给调用方决定怎么办;IP 这类「够用就好」的模式不用追求完全严谨(真正校验 IP 要按 0-255 逐段判断)。
如果只是要判断 子串在不在,别动正则------直接 in 或 str.startswith 就够,正则只在需要「模式」时才用。真要转义一个字面量 去匹配,用 re.escape(text),它会把里面的特殊字符全部转义,避免自己手写出错。
常见坑点
- match 与 search 混用
❌ re.match(r"\d+", "id=42") 得到 None,以为模式错了
✅ 不要求从开头匹配就用 re.search;要整串匹配用 re.fullmatch
- 模式不用原始字符串
❌ re.search("\d+", s) 里 \d 先被 Python 当成字符串转义处理,可能触发 SyntaxWarning 或行为异常
✅ 一律写 r"\d+"
- findall 有分组时返回形状变了
❌ 模式里写了 (\d+) 却期待 findall 返回整段匹配文本
✅ 记住:有 1 个分组返回分组内容列表,有多个分组返回元组列表;不想取分组就用非捕获组 (?:...)
- 忘了判 Match 是否为 None
❌ re.search(p, s).group() 没匹配到就 AttributeError: 'NoneType' object has no attribute 'group'
✅ 先 if m: 再取 m.group()
.匹配不到换行
❌ 用 r"<div>.*</div>" 抓跨行内容,什么都没抓到
✅ 加 re.DOTALL,或改用非贪婪 .*? 配合 re.DOTALL
- 贪婪匹配吃掉太多
❌ re.search(r"<b>.*</b>", "<b>a</b><b>b</b>") 匹配到 <b>a</b><b>b</b>
✅ 用非贪婪 r"<b>.*?</b>" 得到 <b>a</b>
- 以为
^$自动按行
❌ 对多行文本用 ^ERROR 想匹配每一行开头的 ERROR,只匹配到第一行
✅ 加 re.MULTILINE(注意 re.match 即使在 MULTILINE 下也只从整串开头试)
- 在循环里反复 compile 同一模式
❌ 循环体内每次都 re.compile(p),白白重复工作
✅ 把 re.compile() 提到循环外或模块级,复用编译结果
总结
| 需求 | 用法 |
|---|
|--------|----------------------|
| 整串完全匹配 | re.fullmatch(p, s) |
|-------|------------------|
| 从开头匹配 | re.match(p, s) |
|----------|-------------------|
| 任意位置找第一个 | re.search(p, s) |
|-------|------------------------------------------|
| 取所有匹配 | re.findall(p, s) / re.finditer(p, s) |
|----|-------------------------------|
| 替换 | re.sub(p, repl, s, count=0) |
|-------|------------------------------|
| 按模式切分 | re.split(p, s, maxsplit=0) |
|-----|------------------------|
| 预编译 | re.compile(p, flags) |
|-------|-------------------|
| 转义字面量 | re.escape(text) |
用 re 的心法只有三条:写模式一律用原始字符串 、先想清楚是 match 还是 search 、拿到 Match 前先判 None 。再记住 findall 在分组存在时返回形状会变、. 默认不匹配换行、.* 默认贪婪这几个细节,配合 re.VERBOSE 把复杂模式写成分行带注释的形式,正则在数据清洗里就会是一个非常可靠的工具,而不是「写完不敢改」的黑盒。