Регулярные выражения в Python на практических примерах

Регулярное выражение - это шаблон, по которому ищут, проверяют и заменяют фрагменты текста. В Python за них отвечает встроенный модуль re. Ниже нет длинного справочника по всем метасимволам: вместо этого разобраны задачи, которые реально встречаются в работе, от проверки email и нормализации телефонов до разбора логов и замены через функцию. Каждый пример можно скопировать и запустить.
Базовые функции модуля re
Почти вся работа с регулярками сводится к пяти функциям. Различие между ними важно понимать до того, как писать шаблоны, иначе правильный шаблон будет давать неправильный результат.
| Функция | Что делает | Что возвращает |
|---|---|---|
re.search(p, s) | ищет первое совпадение в любом месте строки | Match или None |
re.match(p, s) | проверяет совпадение только с начала строки | Match или None |
re.fullmatch(p, s) | требует, чтобы шаблону соответствовала вся строка | Match или None |
re.findall(p, s) | находит все непересекающиеся совпадения | список строк или кортежей |
re.finditer(p, s) | то же, но лениво | итератор объектов Match |
re.sub(p, repl, s) | заменяет совпадения | новая строка |
re.split(p, s) | делит строку по шаблону | список строк |
import re
text = "Заказ 1542 оплачен 12.10.2026, заказ 1543 ожидает"
print(re.search(r"\d+", text).group()) # -> 1542
print(re.match(r"\d+", text)) # -> None (строка начинается не с цифры)
print(re.findall(r"\d+", text)) # -> ['1542', '12', '10', '2026', '1543']
print(re.fullmatch(r"\d{4}", "1542")) # -> <re.Match object; span=(0, 4), match='1542'>
Шаблоны всегда пишите в "сырых" строках с префиксом r. Без него "\b" превратится в символ backspace, а "\d" начиная с Python 3.12 вызывает SyntaxWarning о неизвестной escape-последовательности. Общий обзор модуля есть в статье модуль re, здесь сосредоточимся на практике.
Короткая шпаргалка по синтаксису
\d- цифра,\w- буква, цифра или подчеркивание (включая кириллицу),\s- пробельный символ;.- любой символ, кроме перевода строки;[abc]- один символ из набора,[^abc]- любой, кроме перечисленных;*- 0 и более,+- 1 и более,?- 0 или 1,{2,4}- от 2 до 4 повторений;^и$- начало и конец строки,\b- граница слова;(...)- группа,(?:...)- группа без захвата,a|b- альтернатива.
Проверка email
Полный стандарт адреса электронной почты (RFC 5322) настолько сложен, что "идеальная" регулярка занимает несколько строк и все равно не гарантирует, что ящик существует. На практике хватает разумной проверки формы: есть локальная часть, знак @, домен с точкой.
import re
EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+(\.[\w-]+)+")
def is_email(value: str) -> bool:
return EMAIL_RE.fullmatch(value.strip()) is not None
for e in ["ivan.petrov@mail.ru", "user+tag@sub.example.com", "bad@domain", "@no-user.ru"]:
print(e, is_email(e))
# -> ivan.petrov@mail.ru True
# -> user+tag@sub.example.com True
# -> bad@domain False
# -> @no-user.ru False
Ключевой момент здесь - fullmatch. Если взять search, строка "мусор a@b.ru мусор" тоже пройдет проверку. Чтобы найти все адреса внутри текста, наоборот, нужен findall или finditer:
import re
text = "Пишите на support@shop.ru или sales@shop.ru, копию на boss@shop.ru."
emails = [m.group() for m in re.finditer(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+", text)]
print(emails) # -> ['support@shop.ru', 'sales@shop.ru', 'boss@shop.ru']
Заметьте, что во втором примере группа записана как (?:...). С обычной группой findall вернул бы содержимое группы ('.ru'), а не все совпадение. Это одна из самых частых ловушек, о ней ниже отдельно.
Номер телефона: поиск и нормализация
Пользователи вводят телефон как угодно: 8 (912) 345-67-89, +7 912 345 67 89, 89123456789. Проверять все варианты одной сложной регуляркой неудобно. Надежнее сначала выбросить все, кроме цифр, а потом проверить длину и первую цифру.
import re
def normalize_phone(raw: str) -> str | None:
digits = re.sub(r"\D", "", raw) # оставить только цифры
if len(digits) == 11 and digits[0] in "78":
digits = "7" + digits[1:]
elif len(digits) == 10:
digits = "7" + digits
else:
return None
return f"+{digits[0]} ({digits[1:4]}) {digits[4:7]}-{digits[7:9]}-{digits[9:]}"
for p in ["8 (912) 345-67-89", "+7 912 345 67 89", "9123456789", "12345"]:
print(normalize_phone(p))
# -> +7 (912) 345-67-89
# -> +7 (912) 345-67-89
# -> +7 (912) 345-67-89
# -> None
Когда же нужно вытащить телефоны из свободного текста, шаблон описывает допустимые разделители между блоками цифр:
import re
PHONE_RE = re.compile(r"(?:\+7|8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}")
text = "Звоните: 8 (912) 345-67-89, офис +7 495 123 45 67, факс отключен."
print(PHONE_RE.findall(text))
# -> ['8 (912) 345-67-89', '+7 495 123 45 67']
Даты в тексте
Регулярное выражение проверяет только форму даты, а не ее корректность: шаблон \d{2}\.\d{2}\.\d{4} пропустит 31.02.2026. Правильная схема - найти кандидатов регуляркой, а затем проверить их через datetime.strptime.
import re
from datetime import datetime
text = "Сдать до 15.11.2026, перенос на 31.02.2026 невозможен, старт 01.12.2026"
dates = []
for m in re.finditer(r"\b(\d{2})\.(\d{2})\.(\d{4})\b", text):
try:
dates.append(datetime.strptime(m.group(), "%d.%m.%Y").date())
except ValueError:
print("Некорректная дата:", m.group())
print(dates)
# -> Некорректная дата: 31.02.2026
# -> [datetime.date(2026, 11, 15), datetime.date(2026, 12, 1)]
Про разбор дат из строк подробнее в статье strptime в Python.
Числа: целые, дробные, с минусом
Задача "достать все числа из строки" выглядит простой, пока не встречаются отрицательные значения, десятичная точка или запятая. Шаблон ниже учитывает необязательный минус и дробную часть через точку или запятую:
import re
NUM_RE = re.compile(r"-?\d+(?:[.,]\d+)?")
text = "Температура -3,5 градуса, давление 745 мм, влажность 81.2%"
numbers = [float(n.replace(",", ".")) for n in NUM_RE.findall(text)]
print(numbers) # -> [-3.5, 745.0, 81.2]
Если в числах встречаются разделители тысяч (1 250 000), их проще удалить до поиска, чем учитывать в шаблоне. Регулярное выражение, которое пытается покрыть все форматы сразу, быстро становится нечитаемым.
Группы захвата
Круглые скобки выделяют часть совпадения, к которой можно обратиться отдельно. Группы нумеруются слева направо по открывающей скобке, начиная с 1. Группа 0 - это все совпадение целиком.
import re
m = re.search(r"(\d{2}):(\d{2})", "Встреча в 14:30 в переговорной")
print(m.group(0)) # -> 14:30
print(m.group(1)) # -> 14
print(m.group(2)) # -> 30
print(m.groups()) # -> ('14', '30')
print(m.span()) # -> (10, 15)
Как findall ведет себя с группами
Поведение findall зависит от количества групп в шаблоне, и это нужно просто запомнить:
- групп нет - список строк с полными совпадениями;
- одна группа - список строк с содержимым этой группы;
- несколько групп - список кортежей.
import re
s = "a=1, b=22, c=333"
print(re.findall(r"\w=\d+", s)) # -> ['a=1', 'b=22', 'c=333']
print(re.findall(r"\w=(\d+)", s)) # -> ['1', '22', '333']
print(re.findall(r"(\w)=(\d+)", s)) # -> [('a', '1'), ('b', '22'), ('c', '333')]
print(dict(re.findall(r"(\w)=(\d+)", s))) # -> {'a': '1', 'b': '22', 'c': '333'}
Последняя строка - удобный прием: пары "ключ-значение" сразу превращаются в словарь.
Именованные группы
Когда групп больше двух, номера начинают путаться. Синтаксис (?P<name>...) дает группе имя, и код становится самодокументируемым. Метод groupdict() возвращает все именованные группы словарем.
import re
LOG_RE = re.compile(
r"(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) "
r"\[(?P<level>[A-Z]+)\] (?P<msg>.*)"
)
line = "2026-10-25 08:15:42 [ERROR] Не удалось подключиться к базе"
m = LOG_RE.match(line)
print(m["level"]) # -> ERROR
print(m.groupdict())
# -> {'date': '2026-10-25', 'time': '08:15:42', 'level': 'ERROR', 'msg': 'Не удалось подключиться к базе'}
Соседние строковые литералы склеиваются автоматически, поэтому длинный шаблон удобно разбить на части. К группе можно обратиться через m["level"] или m.group("level"). Внутри самого шаблона на именованную группу ссылаются через (?P=name), а в строке замены через \g<name>.
Замена через re.sub
Функция re.sub заменяет все совпадения. В строке замены можно ссылаться на группы: \1, \2 или \g<name>. Например, перевернем формат даты из ДД.ММ.ГГГГ в ISO:
import re
text = "Начало 05.11.2026, конец 20.12.2026"
iso = re.sub(r"(\d{2})\.(\d{2})\.(\d{4})", r"\3-\2-\1", text)
print(iso) # -> Начало 2026-11-05, конец 2026-12-20
clean = re.sub(r"\s+", " ", " много лишних \n пробелов ").strip()
print(clean) # -> много лишних пробелов
Аргумент count ограничивает число замен, а функция re.subn возвращает кортеж из новой строки и количества сделанных замен. Для простых замен без шаблонов достаточно метода строки, см. Python string replace.
re.sub с функцией вместо строки
Самая мощная возможность re.sub: вместо строки замены передать функцию. Она получает объект Match и возвращает строку, которую нужно подставить. Так решаются задачи, где замена зависит от найденного значения.
import re
prices = "Тетрадь 120 руб, ручка 45 руб, рюкзак 2300 руб"
def add_vat(m: re.Match) -> str:
value = int(m.group(1))
return f"{round(value * 1.2)} руб"
print(re.sub(r"(\d+) руб", add_vat, prices))
# -> Тетрадь 144 руб, ручка 54 руб, рюкзак 2760 руб
# Маскировка карт и телефонов: оставить последние 4 цифры
masked = re.sub(r"\d{12}(\d{4})", lambda m: "*" * 12 + m.group(1), "Карта 4276123456789012")
print(masked) # -> Карта ************9012
# snake_case в camelCase
print(re.sub(r"_([a-z])", lambda m: m.group(1).upper(), "user_first_name")) # -> userFirstName
Флаги
Флаги меняют поведение шаблона. Их передают аргументом flags или встраивают прямо в шаблон в начале, например (?i). Несколько флагов объединяют через |.
| Флаг | Встроенная форма | Эффект |
|---|---|---|
re.IGNORECASE, re.I | (?i) | без учета регистра |
re.MULTILINE, re.M | (?m) | ^ и $ работают для каждой строки |
re.DOTALL, re.S | (?s) | точка совпадает и с переводом строки |
re.VERBOSE, re.X | (?x) | пробелы и комментарии в шаблоне игнорируются |
re.ASCII, re.A | (?a) | \w, \d только для ASCII |
import re
text = """ERROR: диск заполнен
info: все хорошо
Error: таймаут"""
print(re.findall(r"^error: (.+)$", text, flags=re.I | re.M))
# -> ['диск заполнен', 'таймаут']
PASSWORD_RE = re.compile(r"""
^(?=.*\d) # хотя бы одна цифра
(?=.*[a-z]) # хотя бы одна строчная латинская
(?=.*[A-Z]) # хотя бы одна заглавная латинская
.{8,}$ # длина от 8 символов
""", re.VERBOSE)
print(bool(PASSWORD_RE.match("Secret2026"))) # -> True
print(bool(PASSWORD_RE.match("secret"))) # -> False
Флаг re.VERBOSE недооценен: сложный шаблон с комментариями через полгода читается в разы легче, чем строка из сплошных скобок. В нем же использованы опережающие проверки (?=...): они проверяют условие, не "съедая" символы.
Отдельно про \w и \d: в Python 3 они по умолчанию работают с Unicode. Это значит, что \w совпадает с кириллицей, а \d с цифрами других алфавитов, например арабско-индийскими. Если нужны только латиница и цифры 0-9, укажите флаг re.ASCII или пишите явные наборы [A-Za-z0-9_].
Жадные и ленивые квантификаторы
Квантификаторы *, +, ? и {n,m} по умолчанию жадные: захватывают как можно больше символов. Добавленный знак вопроса (*?, +?) делает их ленивыми, то есть захватывающими как можно меньше.
import re
html = '<b>первый</b> и <b>второй</b>'
print(re.findall(r"<b>(.*)</b>", html)) # -> ['первый</b> и <b>второй']
print(re.findall(r"<b>(.*?)</b>", html)) # -> ['первый', 'второй']
print(re.findall(r"<b>([^<]*)</b>", html)) # -> ['первый', 'второй']
Жадный вариант захватил все от первого открывающего тега до последнего закрывающего. Ленивый остановился на первом подходящем закрытии. Третий вариант с отрицающим набором [^<]* обычно быстрее ленивого, потому что движку не приходится откатываться. Для настоящего разбора HTML регулярки не подходят, берите парсер, например BeautifulSoup.
Компиляция и производительность
Функция re.compile превращает шаблон в объект, у которого есть те же методы: search, findall, sub. Модуль re кэширует недавние шаблоны сам, поэтому выигрыш в скорости от компиляции обычно небольшой. Главная польза в другом: шаблон получает имя, объявляется один раз на уровне модуля и не дублируется по коду.
Несколько практических правил:
- Если задача решается методами строк (
in,startswith,split,replace), используйте их: это быстрее и понятнее. - Не вкладывайте квантификаторы друг в друга вроде
(a+)+: на некоторых строках это приводит к экспоненциальному перебору и зависанию. - Данные от пользователя, которые нужно искать буквально, экранируйте через
re.escape(). - Для разделения по нескольким разделителям берите
re.split(r"[,;\s]+", s)вместо цепочкиsplit.
import re
user_input = "цена (руб.)"
print(re.escape(user_input)) # -> цена\ \(руб\.\)
print(re.split(r"[,;\s]+", "яблоко, груша;слива вишня"))
# -> ['яблоко', 'груша', 'слива', 'вишня']
Практический сценарий: статистика по логу веб-сервера
Регулярные выражения особенно хороши там, где строки имеют стабильный формат, но разбивать их через split неудобно: в полях встречаются пробелы, кавычки и скобки. Классический пример - журнал доступа веб-сервера. Задача: посчитать, какие адреса чаще всего отвечают ошибкой 404.
import re
from collections import Counter
ACCESS_RE = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+|-)'
)
log = """\
10.0.0.5 - - [25/Oct/2026:10:01:12 +0300] "GET /catalog HTTP/1.1" 200 5120
10.0.0.8 - - [25/Oct/2026:10:01:15 +0300] "GET /old-page HTTP/1.1" 404 210
10.0.0.5 - - [25/Oct/2026:10:02:01 +0300] "POST /api/cart HTTP/1.1" 201 -
10.0.0.9 - - [25/Oct/2026:10:02:44 +0300] "GET /old-page HTTP/1.1" 404 210
битая строка без формата
10.0.0.9 - - [25/Oct/2026:10:03:10 +0300] "GET /favicon.ico HTTP/1.1" 404 0"""
not_found = Counter()
skipped = 0
for line in log.splitlines():
m = ACCESS_RE.match(line)
if m is None:
skipped += 1
continue
if m["status"] == "404":
not_found[m["path"]] += 1
print(not_found.most_common()) # -> [('/old-page', 2), ('/favicon.ico', 1)]
print("пропущено строк:", skipped) # -> пропущено строк: 1
Обратите внимание на три приема. Шаблон компилируется один раз вне цикла. Битые строки не роняют программу, а подсчитываются отдельно: в реальных логах всегда найдется обрезанная или нестандартная запись. Наконец, именованные группы делают код обработки читаемым без комментариев. Для файлов в несколько гигабайт тот же цикл работает построчно по открытому файлу и не загружает его в память целиком.
Частые ошибки при работе с регулярками
Большинство проблем с регулярными выражениями в Python связано не с синтаксисом шаблона, а с тем, как его применяют. Вот ошибки, которые встречаются в ревью кода чаще всего:
- Забытая сырая строка. Шаблон
"\bслово\b"без префиксаrищет символ backspace и ничего не находит, причем молча. - Неэкранированная точка. Шаблон
r"site.ru"совпадет и с"siteXru". Точка в шаблоне означает любой символ, для буквальной точки нужен\.. - search вместо fullmatch при валидации. Проверка ИНН через
re.search(r"\d{10}", s)пропустит строку из пятнадцати цифр, потому что десять из них подходят. - Обращение к group() без проверки на None. Код работает на тестовых данных и падает с
AttributeErrorна первой нестандартной строке в продакшене. - Попытка решить все одной регуляркой. Часто проще сделать два шага: грубо найти кандидатов, затем проверить их обычным кодом, как в примере с датами выше.
- Разбор HTML, JSON или CSV регулярками. Для этих форматов есть парсеры, которые корректно обрабатывают вложенность, экранирование и кавычки.
Отлаживать сложный шаблон удобнее по частям: начните с минимального фрагмента, убедитесь, что он находит нужное, и наращивайте его постепенно, проверяя результат на нескольких строках, включая заведомо неправильные. Такие проверки стоит сразу оформить в виде тестов, чтобы правка шаблона через полгода не сломала уже работающие случаи.
Частые вопросы
Чем re.match отличается от re.search?
re.match ищет совпадение только в начале строки, re.search в любом месте. Для проверки всей строки целиком используйте re.fullmatch, это надежнее, чем добавлять ^ и $ вручную.
Почему re.search возвращает None и падает .group()?
Если совпадения нет, функция возвращает None, и вызов .group() дает AttributeError. Проверяйте результат: if m := re.search(p, s): print(m.group()).
Почему findall возвращает кортежи вместо строк?
В шаблоне есть несколько групп захвата. Замените вспомогательные группы на незахватывающие (?:...), и findall вернет список строк.
Как искать русские буквы регулярным выражением?
\w в Python 3 уже включает кириллицу. Если нужны только русские буквы, используйте набор [а-яёА-ЯЁ]: буква "ё" стоит вне основного диапазона, ее нужно указывать отдельно.
Как найти точку, скобку или другой спецсимвол?
Экранируйте его обратной косой чертой (\., \() или поместите в квадратные скобки ([.]). Для строки целиком подойдет re.escape().
Как найти пересекающиеся совпадения?
Функции findall и finditer возвращают только непересекающиеся совпадения: после найденного фрагмента поиск продолжается с его конца. Чтобы найти, например, все пары одинаковых соседних цифр в строке "1112", оберните шаблон в опережающую проверку с группой: re.findall(r"(?=(\d)\1)", "1112") вернет ['1', '1']. Опережающая проверка имеет нулевую длину, поэтому движок сдвигается на один символ и проверяет каждую позицию заново. Для более сложных случаев есть сторонний модуль regex с параметром overlapped=True.