Python: уроки, справочник и задачи

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

Раздел: Строки в Python · Обновлено 25.10.2026
Строки кода с функциями и регулярными конструкциями
Фото: Markus Spiske, CC0

Регулярное выражение - это шаблон, по которому ищут, проверяют и заменяют фрагменты текста. В 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, здесь сосредоточимся на практике.

Короткая шпаргалка по синтаксису

Проверка 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 кэширует недавние шаблоны сам, поэтому выигрыш в скорости от компиляции обычно небольшой. Главная польза в другом: шаблон получает имя, объявляется один раз на уровне модуля и не дублируется по коду.

Несколько практических правил:

  1. Если задача решается методами строк (in, startswith, split, replace), используйте их: это быстрее и понятнее.
  2. Не вкладывайте квантификаторы друг в друга вроде (a+)+: на некоторых строках это приводит к экспоненциальному перебору и зависанию.
  3. Данные от пользователя, которые нужно искать буквально, экранируйте через re.escape().
  4. Для разделения по нескольким разделителям берите 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 связано не с синтаксисом шаблона, а с тем, как его применяют. Вот ошибки, которые встречаются в ревью кода чаще всего:

Отлаживать сложный шаблон удобнее по частям: начните с минимального фрагмента, убедитесь, что он находит нужное, и наращивайте его постепенно, проверяя результат на нескольких строках, включая заведомо неправильные. Такие проверки стоит сразу оформить в виде тестов, чтобы правка шаблона через полгода не сломала уже работающие случаи.

Частые вопросы

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.