← назад к разделу

Любая программа рано или поздно встречает то, чего не ожидала: файл не открылся, сеть отвалилась, на вход пришёл мусор. В Python такие ситуации описываются исключениями (exceptions), а освобождение ресурсов после них — контекстными менеджерами и оператором with. Разберёмся, как пользоваться и тем и другим, чтобы ошибки не терялись, а ресурсы не утекали.

Обязательно

Зачем вообще исключения

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

Python идёт другим путём. Когда что-то идёт не так, функция возбуждает (raise) исключение — особый объект, который прерывает обычный ход выполнения и «всплывает» вверх по стеку вызовов, пока его кто-нибудь не перехватит (except). Если не перехватил никто — программа падает с трассировкой (traceback), и это правильное поведение по умолчанию: лучше громко упасть, чем тихо продолжить с испорченными данными.

Короткая формула: исключения — для ситуаций, которые мешают функции выполнить свою работу. Не для обычного ветвления логики.

try / except: перехват

Базовая конструкция — try с одним или несколькими except:

def parse_age(raw: str) -> int:
    try:
        return int(raw)            # может бросить ValueError
    except ValueError:
        return 0                   # подставляем безопасное значение

Можно ловить несколько типов — по отдельности или группой:

try:
    data = load_config(path)
except FileNotFoundError:
    data = default_config()        # файла нет — берём дефолт
except (PermissionError, IsADirectoryError) as err:
    # одна ветка на несколько типов; err — сам объект исключения
    raise RuntimeError(f"не удалось прочитать {path}") from err

Несколько важных мелочей:

  • as err даёт доступ к объекту исключения — его сообщению, атрибутам.
  • Порядок except важен: интерпретатор берёт первый подходящий. Более конкретные типы ставь выше более общих.
  • raise ... from err сохраняет исходную причину в цепочке (в трассировке будет видно «The above exception was the direct cause»). Это помогает не терять контекст при «переупаковке» ошибки.

else и finallyспросят на собеседовании

У try есть ещё две ветки, про которые часто забывают.

else выполняется, только если в try не было исключения. Туда выносят код, который должен идти после успешной операции, но сам не должен попадать под except:

try:
    conn = open_connection(url)
except ConnectionError:
    log.warning("нет связи")
else:
    # сюда попадаем, только если соединение реально открылось
    use(conn)

finally выполняется всегда — и при успехе, и при исключении, и даже если внутри try сработал return. Это место для гарантированной уборки:

f = open("data.txt")
try:
    process(f)
finally:
    f.close()                      # закроется в любом случае

Короткая формула: else — «успех», finally — «в любом случае».

Иерархия исключенийспросят на собеседовании

Все исключения — это классы, и они выстроены в дерево наследования. Когда ты пишешь except SomeError, перехватываются и SomeError, и все его потомки.

Упрощённо вершина дерева выглядит так:

BaseException SystemExit sys.exit() KeyboardInterrupt Ctrl+C Exception почти всё «обычное»

Ctrl+C и sys.exit() идут мимо Exception — поэтому except Exception их не перехватывает и программа спокойно завершается. Отсюда и правило: BaseException не ловят, иначе процесс перестанет откликаться на остановку.

От Exception уже идут знакомые типы: ValueError, TypeError, KeyError, OSError — а у него свои потомки, FileNotFoundError и PermissionError.

Практический вывод: лови Exception, а не BaseException. KeyboardInterrupt (нажатие Ctrl+C) и SystemExit наследуются напрямую от BaseException — их перехват ломает нормальное завершение программы. Обычный код их трогать не должен.

И ещё: лови как можно более узкий тип. except Exception ловит вообще всё подряд, включая ошибки, которых ты не ждал (опечатку в имени переменной выдаёт NameError — тоже потомок Exception). Чем уже тип, тем меньше шанс случайно проглотить чужую ошибку.

Не глотай ошибки

Самый частый и опасный антипаттерн:

# так делать НЕ надо
try:
    risky()
except Exception:
    pass                           # ошибка исчезла бесследно

Такой except ... : pass молча проглатывает всё. Программа продолжает работать с непредсказуемым состоянием, а причину сбоя потом не найти — трассировки нет.

Если перехватываешь исключение — сделай с ним что-то осмысленное:

import logging

log = logging.getLogger(__name__)

try:
    send_metrics(payload)
except ConnectionError:
    # метрики не критичны — логируем и идём дальше осознанно
    log.warning("метрики не отправлены", exc_info=True)

exc_info=True добавит в лог полную трассировку. Если же ты не знаешь, что делать с ошибкой, — не лови её. Пусть всплывёт к тому, кто знает. Молчаливое подавление допустимо только тогда, когда ты сознательно решил, что эта ошибка здесь не важна, и оставил комментарий почему.

Свои исключения

Когда стандартных типов не хватает, заводят свои — наследуясь от Exception:

class OrderError(Exception):
    """Базовый класс для всех ошибок модуля заказов."""

class OrderNotFound(OrderError):
    def __init__(self, order_id: int):
        self.order_id = order_id
        super().__init__(f"заказ {order_id} не найден")

Зачем это нужно:

  • Общий базовый класс (OrderError) позволяет вызывающему коду одним except OrderError поймать все ошибки твоего модуля, не перечисляя их.
  • Собственные атрибуты (order_id) передают данные об ошибке, а не только текст.

Возбуждают исключение оператором raise:

def get_order(order_id: int) -> Order:
    order = repo.find(order_id)
    if order is None:
        raise OrderNotFound(order_id)
    return order

Короткая формула: свои исключения = свой словарь ошибок предметной области. Они делают код-вызыватель чище и устойчивее.

Контекстные менеджеры и withспросят на собеседовании

Вернёмся к finally с f.close(). Это настолько частый случай, что в Python есть отдельный синтаксис — оператор with:

with open("data.txt") as f:
    process(f)
# здесь файл уже гарантированно закрыт — даже при исключении внутри

with берёт контекстный менеджер (объект, который умеет «входить» в блок и «выходить» из него), вызывает его настройку на входе и уборку на выходе. Выход срабатывает при любом исходе — нормальном завершении блока, return или исключении. По сути это try/finally, упакованный в одну строку.

Файлы — самый известный пример, но так же работают блокировки потоков, соединения с БД, транзакции:

import threading

lock = threading.Lock()

with lock:                         # lock.acquire() на входе
    update_shared_state()
# lock.release() на выходе — даже если update_shared_state бросит

Можно открыть несколько менеджеров сразу:

with open("in.txt") as src, open("out.txt", "w") as dst:
    dst.write(src.read())

Как написать свой контекстный менеджерспросят на собеседовании

Контекстным менеджером объект делают два метода: __enter__ (вызывается на входе в with, его результат попадает в as-переменную) и __exit__ (вызывается на выходе):

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self                # это значение попадёт в `as`

    def __exit__(self, exc_type, exc_value, traceback):
        elapsed = time.perf_counter() - self.start
        print(f"заняло {elapsed:.3f} c")
        return False               # False — исключение не подавляем

with Timer():
    do_heavy_work()

В __exit__ приходят три аргумента с информацией об исключении (если блок завершился ошибкой) — или три None, если всё прошло чисто. Если __exit__ вернёт True, исключение считается обработанным и не всплывает дальше; False (или None) — оно пробрасывается как обычно. Подавлять стоит только осознанно.

Часто писать класс ради этого избыточно. В стандартной библиотеке есть contextlib.contextmanager — декоратор, который превращает генератор в контекстный менеджер. Код до yield — это «вход», код после — «выход»:

from contextlib import contextmanager

@contextmanager
def opened(path: str):
    f = open(path)
    try:
        yield f                    # значение для `as`; здесь идёт тело with
    finally:
        f.close()                  # гарантированная уборка

with opened("data.txt") as f:
    process(f)

yield тут ровно один. Обрати внимание на try/finally вокруг него — он и обеспечивает уборку при исключении внутри блока. Это самый компактный способ написать одноразовый менеджер ресурса.

Дополнительно: при первом чтении можно пропустить

Глубже: группы исключений, add_note и contextlibрасширенное

Внутри except голый raise пробрасывает то же исключение с исходной трассировкой. raise err делает почти то же, но добавляет в трассировку лишнюю строку, а raise NewError(...) from err нужен, когда ошибку осознанно переупаковывают. С версии 3.11 к исключению можно приписать контекст, не заворачивая его: err.add_note(f"заказ {order_id}") — заметка появится в трассировке под сообщением.

Там же появились группы исключений: когда несколько задач работали параллельно и упали две из них, одна ошибка не может быть «причиной» другой. asyncio.TaskGroup поднимает ExceptionGroup, а разбирают её конструкцией except*, которая ловит из группы ошибки нужного типа и оставляет остальные всплывать дальше:

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_prices())
        tg.create_task(fetch_stock())
except* TimeoutError as group:
    log.warning("не дождались: %d задач", len(group.exceptions))

Модуль contextlib закрывает два частых случая. suppress глотает ошибку явно и видно для читателя, в отличие от except: pass:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("cache.tmp")           # нет файла — и не надо

А ExitStack держит заранее неизвестное число менеджеров: открыть все файлы из списка и гарантированно закрыть каждый, сколько бы их ни было.

Глубже: файлы и сериализация: open, json и pickleрасширенноеспросят на собеседовании

Файловый объект, который возвращает open, сам контекстный менеджер: with open(path, encoding="utf-8") as f закрывает файл при любом выходе из блока, и это единственная правильная форма, потому что незакрытый файл держит дескриптор операционной системы, а буфер записи может не доехать до диска. Режим задают строкой: "r" чтение, "w" запись с обнулением, "a" дописывание, буква b переключает в двоичный режим, где читаются bytes, а не str. Кодировку для текста указывают всегда: без неё она берётся из системы, и файл, записанный на одной машине, ломается на другой.

Сериализация это перевод объекта в байты или текст, чтобы отправить его по сети или положить на диск. Для обмена с другими системами берут json: json.dumps даёт строку, json.dump пишет в файл, парные loads и load читают обратно. Понимает он только словари, списки, строки, числа, булевы и None; дату, Decimal или свой класс надо превратить во что-то из этого списка самим, либо передать default=.

живой пример

import json
from dataclasses import asdict, dataclass
from datetime import date


@dataclass
class Order:
    id: int
    created: date


text = json.dumps(asdict(Order(42, date(2026, 10, 5))), default=str, ensure_ascii=False)
print(text)  # {"id": 42, "created": "2026-10-05"}
print(json.loads(text)["id"] + 1)  # 43
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

pickle сериализует почти любой объект Python вместе с его классом, но формат понимает только Python, и, главное, при загрузке он выполняет код: pickle.loads на данных из недоверенного источника равносилен запуску чужой программы. Поэтому pickle остаётся для кеша и обмена между своими процессами, а наружу уходит json, Protobuf или Avro.

Коротко

  • Исключения — для ситуаций, которые мешают функции сделать работу; не для обычного ветвления.
  • try/except перехватывает; else — ветка успеха; finally — выполняется всегда, место для уборки.
  • Лови узкий тип и Exception, но не BaseException — иначе перехватишь Ctrl+C и SystemExit.
  • except ...: pass — антипаттерн: либо обработай и залогируй (exc_info=True), либо не лови вообще.
  • Свои исключения с общим базовым классом и атрибутами дают чистый «словарь ошибок» предметной области; возбуждаются через raise, причину сохраняет raise ... from err.
  • with гарантирует уборку ресурса при любом исходе; свой менеджер — это __enter__/__exit__ или генератор под @contextmanager.
  • except* разбирает ExceptionGroup от параллельных задач, add_note добавляет контекст, contextlib.suppress глотает ошибку явно, а ExitStack держит много менеджеров сразу.
  • Файл открывают только через with open(..., encoding="utf-8"); наружу данные уходят через json с default= для дат, pickle только для своих процессов, потому что loads выполняет код.

Что почитать дальше