Импорт в Python: sys.path, importlib и циклические импорты
Откуда Python берёт модули, почему ImportError не всегда о пропущенном пакете и как один звёздочный импорт превращает старт приложения в ловушку с циклической зависимостью.

ImportError на старте продакшена почти никогда не значит «пакет не установлен». Чаще это неверный порядок инициализации, конфликт имён или циклическая зависимость, которая срабатывает только при определённом порядке загрузки модулей. Разберём механизм импорта в CPython 3.11–3.12: как строится sys.path, что делает importlib под капотом стандартного import, где живёт кеш модулей и почему два модуля, ссылающиеся друг на друга, падают с ImportError: cannot import name.
Что происходит при import: три фазы
Оператор import package.module — это синтаксический сахар над вызовом importlib._bootstrap.__import__. Механизм проходит три фазы, и понимание границ между ними снимает большинство вопросов «почему падает именно здесь».
- Поиск (finding). Python спрашивает у объектов из
sys.meta_path, знает ли кто-нибудь, где лежит модуль. Стандартные finder'ы ищут поsys.path, среди встроенных модулей и среди уже замороженных. - Загрузка (loading). Найденный loader создаёт объект модуля, кладёт его в
sys.modulesдо выполнения тела, а затем выполняет код модуля сверху вниз. - Привязка имени (binding). Имя из
importсвязывается с объектом в текущем пространстве имён.
Ключевой момент второй фазы: объект модуля попадает в sys.modules раньше, чем выполнится его тело. Именно на этом ломаются циклические импорты — к моменту повторного захода модуль в кеше уже есть, но заполнен наполовину.
Модуль в sys.modules — не гарантия, что его тело выполнено до конца. Это гарантия только того, что оно начало выполняться.
sys.path: откуда берутся директории поиска
Список sys.path — обычный список строк, по которому идёт файловый finder. Он формируется при старте интерпретатора из нескольких источников, и порядок здесь определяет, какой модуль победит при совпадении имён.
| Источник | Что добавляет | Приоритет |
|---|---|---|
| Директория скрипта или текущая | Папка запускаемого файла (или '' — текущая, при python -c и REPL) | первым |
| PYTHONPATH | Пути из переменной окружения, в порядке перечисления | после скрипта |
| Стандартная библиотека | Пути к stdlib текущей сборки Python | средний |
| site-packages | Установленные пакеты, включая содержимое venv | обычно последним |
Проверить фактический список — одной командой:
python -c "import sys; print('\n'.join(sys.path))"
Частая ловушка: файл queue.py или logging.py в корне проекта. Директория скрипта стоит в sys.path раньше stdlib, поэтому ваш локальный logging.py перекроет стандартный, и импорты внутри сторонних библиотек начнут падать в неожиданных местах. Не называйте модули именами из стандартной библиотеки.
Изменять sys.path в рантайме — крайняя мера
Строки вида sys.path.insert(0, '../lib') в начале модуля работают, но делают порядок импорта зависимым от того, какой файл запустили первым. Это источник плавающих багов: в тестах путь один, при запуске через WSGI-сервер — другой. Предпочтительнее устанавливать пакет в режиме разработки (pip install -e .) и импортировать по имени пакета, а не подкручивать путь руками.
importlib: программный импорт
Когда имя модуля известно только в рантайме — из конфига, из плагинной системы — вместо exec и __import__ используйте importlib.import_module. Он возвращает объект модуля и кладёт его в sys.modules так же, как обычный import.
import importlib
# Имя пришло из конфигурации, например "handlers.email"
def load_handler(dotted_path: str):
# import_module принимает точечный путь, как в обычном import
module = importlib.import_module(dotted_path)
# get_handler — соглашение, которое вы задаёте сами для плагинов
return module.get_handler()
Если модуль мог измениться на диске после первого импорта (горячая перезагрузка в дев-среде), помогает importlib.reload. В проде им пользоваться не стоит: старые объекты, уже импортированные другими модулями, останутся ссылаться на прежнюю версию, и вы получите два несовместимых определения одного класса.
import importlib
import handlers.email
# Перечитывает тело модуля заново; ссылки на старые объекты не обновятся
importlib.reload(handlers.email)
Проверить, установлен ли пакет, не импортируя его целиком, можно через importlib.util.find_spec — он возвращает None, если finder ничего не нашёл:
import importlib.util
if importlib.util.find_spec("orjson") is not None:
import orjson as json_lib
else:
import json as json_lib
Циклические импорты: где именно ломается
Пусть есть два модуля. models.py в начале делает from services import notify, а services.py в начале делает from models import User. Запуск начинается с импорта models.
- Python кладёт полупустой
modelsвsys.modulesи начинает выполнять его тело. - Строка
from services import notifyзапускает импортservices. - Тело
servicesдоходит доfrom models import User. Модульmodelsв кеше уже есть — повторного выполнения не будет. - Но
Userвmodelsещё не объявлен: до него тело не дошло, оно застряло на шаге 2. Результат —ImportError: cannot import name 'User' from partially initialized module 'models'.
Формулировка «partially initialized module» в тексте ошибки — прямое указание на цикл, а не на отсутствующий пакет. С Python 3.5+ трассировка обычно даёт понять оба конца цепочки.
Четыре способа разорвать цикл
| Приём | Что выигрываете | Чем платите |
|---|---|---|
| Импорт внутри функции | Импорт выполнится в момент вызова, когда оба модуля уже загружены | Микрооверхед на поиск в sys.modules при каждом вызове; хуже читается |
Импортировать модуль целиком, а не имя (import services вместо from services import notify) | Ссылка на модуль резолвится лениво — при обращении services.notify | Обращение через префикс модуля многословнее |
| Вынести общий код в третий модуль | Разрывает цикл на уровне архитектуры, а не хака | Требует рефакторинга; иногда рождает модуль-свалку |
if TYPE_CHECKING для аннотаций | Импорт нужен только тайпчекеру, в рантайме его нет | Работает только для типов, не для реального использования объекта |
Последний приём — самый чистый, когда цикл возник из-за подсказок типов. Аннотации при from __future__ import annotations (по умолчанию — в PEP 563, доступно с 3.7) не вычисляются в рантайме, поэтому импорт нужен только статическому анализатору:
from __future__ import annotations
from typing import TYPE_CHECKING
# Блок выполняется только тайпчекером, в рантайме тело пропускается
if TYPE_CHECKING:
from models import User
def notify(user: "User") -> None:
# Аннотация — строка, объект User в рантайме не нужен
send_email(user.email)
Когда что выбирать
- Цикл только из-за аннотаций —
TYPE_CHECKING. Ничего в рантайме не меняется. - Цикл из-за реального вызова функции в редком пути кода — импорт внутри функции. Дёшево и локально.
- Цикл из-за того, что два модуля постоянно дёргают друг друга — это сигнал архитектуры. Не лечите импортом внутри функции, выносите общее в третий модуль.
Что ломается в проде
- Разный порядок входа. В тестах первым импортируется один модуль, под gunicorn — другой. Цикл, невидимый локально, всплывает при первом же запросе. Проверяйте импорт всех модулей на старте, а не лениво.
- Затенение stdlib. Локальный
types.pyилиemail.pyломает импорты глубоко внутри зависимостей. Ищите такие файлы линтером и переименовывайте. - reload в рантайме. После
importlib.reloadв системе живут два определения класса;isinstanceначинает возвращатьFalseтам, где не должен. - Побочные эффекты в теле модуля. Подключение к БД или чтение файла на верхнем уровне модуля выполняется при импорте — раньше, чем вы ожидаете, и в порядке, который зависит от графа импортов.
Что делать дальше
Постройте граф импортов проекта инструментом вроде pydeps или grimp — циклы становятся видны сразу, до того как проявятся ошибкой. Держите тело модуля свободным от побочных эффектов: только определения, а инициализацию выносите в явные функции старта. В CI добавьте шаг, который импортирует каждый модуль пакета по отдельности — так вы поймаете циклы и затенения раньше пользователя.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: The import system — Python documentation, importlib — The implementation of import
Частые вопросы
У меня Python 3.8, а не 3.11 — приёмы сработают?
Я добавил sys.path.insert, а импорт всё равно не находит модуль — почему?
Как понять, что ImportError вызван именно циклом, а не отсутствием пакета?
Импорт внутри функции — это медленно?
Можно ли вообще избежать циклических импортов, а не разрывать их?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.