Skip to content
Thèmes
Python
Python Circular Import: How to Fix It (With Working Examples)

Import circulaire Python : comment le corriger (exemples concrets)

Publié le

Mis à jour le

Un import circulaire (circular import) survient lorsque le module A importe le module B pendant que B (directement ou via une chaîne) importe aussi A. Python ne tourne en général pas en boucle infinie. Vous obtenez plutôt un import partiel : un module est encore en cours de chargement quand l'autre tente d'en lire un nom, d'où ImportError / AttributeError, ou des objets à moitié initialisés (partially initialized module).

Correction rapide (commencer ici)

SituationPremière action
Deux modules s'importent mutuellement au niveau supérieurDéplacer le code partagé dans un troisième module importable par les deux
Vous n'avez besoin d'un nom qu'à l'intérieur d'une fonctionImporter dans cette fonction (import paresseux / lazy import)
Vous n'avez besoin que de types pour les annotationsUtiliser from typing import TYPE_CHECKING et coter les annotations si besoin
Gros package avec cycles profondsRedesigner les dépendances pour que les couches basses n'importent jamais les couches hautes

Motif minimal qui débloque souvent immédiatement :

# Instead of a top-level cycle:
# models.py imports services.py
# services.py imports models.py
 
# services.py — import only where used
def process_user(user_id: int):
    from models import User  # lazy import breaks the load-time cycle
    return User.get(user_id)

Si cela ressemble à un pansement, continuez la lecture. La solution durable est presque toujours le sens des dépendances, pas une astuce d'import.

À quoi ressemble un import circulaire

Exemple minimal qui échoue

Créez deux modules frères :

# a.py
import b
 
def hello_from_a():
    return "A"
 
print("a loaded", b.hello_from_b())
# b.py
import a
 
def hello_from_b():
    return "B"
 
print("b loaded", a.hello_from_a())

Exécution :

python a.py

Échec typique (le libellé varie selon la version de Python) :

ImportError: cannot import name 'hello_from_a' from partially initialized module 'a'
(most likely due to a circular import)

Ce message est la forme « requête de recherche » du problème. Python a commencé à charger a, qui a commencé à charger b, qui a tenté de terminer l'import depuis a avant que a n'ait fini de s'exécuter.

Ce qui se passe réellement

Lorsqu'il importe un module, Python :

  1. Crée un objet module vide et le place dans sys.modules.
  2. Exécute le corps du module de haut en bas.
  3. Ce n'est qu'après la fin de ce corps que tous les noms de niveau supérieur sont garantis d'exister.

Pendant un cycle, l'étape 2 du module A est encore en cours quand B demande quelque chose défini plus bas dans A. Le nom manque → erreur d'import. C'est un échec d'initialisation partielle, pas une boucle sans fin.

Démarche de diagnostic

Suivez cet ordre. Arrêtez-vous dès que le cycle a disparu.

  1. Reproduire avec un point d'entrée propre
    Lancez le même fichier que mentionnent les erreurs Google/recherche (python -m package.module ou le chemin du script). Les imports circulaires sont sensibles au point d'entrée.

  2. Lire la traceback complète de bas en haut
    Les dernières frames montrent en général module_x qui importe module_y alors que module_y est déjà sur la pile.

  3. Esquisser un graphe de dépendances à sens unique
    Listez chaque module et ce qu'il importe. Toute arête qui pointe « vers le haut » (modèles de base vers app/UI/API) est suspecte.

  4. Classer le besoin

    • Valeur / fonction à l'exécution → restructurer ou import paresseux
    • Type uniquement → TYPE_CHECKING
    • Constantes / modèles partagés → extraire vers un module feuille
  5. Appliquer la plus petite correction correcte
    Préférez extraire un module partagé > import paresseux > refonte du package. Évitez le cargo-cult « les imports absolus vont tout résoudre ».

  6. Relancer depuis le même point d'entrée
    Vérifiez à la fois le style python a.py et les points d'entrée package -m si vous utilisez les deux.

Correction 1 : extraire un troisième module (meilleur défaut)

Quand models et services ont tous deux besoin de User, ne les faites pas s'importer mutuellement. Placez User dans un module feuille qui ne dépend d'aucune couche supérieure.

app/
  models/user.py      # leaf: no import from services
  services/billing.py # imports models.user
  api/routes.py       # imports services
# models/user.py
class User:
    def __init__(self, user_id: int, email: str):
        self.user_id = user_id
        self.email = email
 
    @classmethod
    def get(cls, user_id: int) -> "User":
        return cls(user_id, f"user{user_id}@example.com")
# services/billing.py
from models.user import User
 
def invoice(user_id: int) -> str:
    user = User.get(user_id)
    return f"Invoice for {user.email}"
# api/routes.py
from services.billing import invoice
 
def handle(user_id: int) -> str:
    return invoice(user_id)

Le sens des dépendances est à sens unique : api → services → models. Les cycles disparaissent car rien en bas n'importe rien en haut.

Correction 2 : imports paresseux (locaux)

Importez dans la fonction ou la méthode qui a besoin du symbole lorsqu'un cycle temporaire est difficile à démêler.

# reporters.py
def build_report(order_id: str) -> dict:
    from orders import Order  # imported at call time, not module load time
 
    order = Order.load(order_id)
    return {"order_id": order.id, "total": order.total}
# orders.py
class Order:
    def __init__(self, id: str, total: float):
        self.id = id
        self.total = total
 
    @classmethod
    def load(cls, order_id: str) -> "Order":
        return cls(order_id, 19.99)
 
    def pretty(self) -> str:
        from reporters import build_report  # only if you truly need this edge
        return str(build_report(self.id))

Quand les imports paresseux conviennent

  • Casser rapidement un cycle tenace dans du code legacy
  • Dépendances lourdes optionnelles (importer uniquement sur le chemin de code qui en a besoin)

Quand les éviter comme design à long terme

  • Chemins chauds où le coût d'import compte (souvent mineur, mais mesurable)
  • Architecture qui continue de créer de nouveaux cycles ; extrayez plutôt des modules

Correction 3 : TYPE_CHECKING pour les imports d'annotations uniquement

Si la seule raison de l'import est le typage, tenez-le hors du runtime :

from __future__ import annotations
from typing import TYPE_CHECKING
 
if TYPE_CHECKING:
    from models.user import User  # not imported at runtime
 
def notify(user: User, message: str) -> None:
    print(user.email, message)

Avec from __future__ import annotations (ou des annotations "User" cotées), Python n'a pas besoin de User à l'exécution pour que l'annotation fonctionne. C'est le bon outil pour les cycles liés aux type hints, pas pour appeler des méthodes sur User.

Correction 4 : injecter les dépendances au lieu de les importer

Pour des services qui « ont besoin l'un de l'autre », passez les collaborateurs en argument plutôt que d'importer globalement.

# notifications.py
class Notifier:
    def send(self, email: str, body: str) -> None:
        print(f"to={email} body={body}")
 
 
# checkout.py
class Checkout:
    def __init__(self, notifier: "Notifier"):
        self.notifier = notifier
 
    def complete(self, email: str) -> None:
        # business logic...
        self.notifier.send(email, "Order complete")
# main.py
from notifications import Notifier
from checkout import Checkout
 
checkout = Checkout(Notifier())
checkout.complete("a@example.com")

Ni notifications ni checkout n'importe l'autre. La composition à la frontière (main.py) gère le câblage. Même idée que l'injection par constructeur dans les applications et frameworks plus larges.

Pièges courants (conseils qui ne cassent pas les cycles)

Ces conseils apparaissent souvent dans d'anciens guides. Ils résolvent d'autres problèmes, pas les imports circulaires.

Conseil que vous pouvez croiserRéalité
« Utilisez des imports absolus »Les imports absolus clarifient les chemins. Ils ne suppriment pas une dépendance mutuelle.
« Définissez __all__ »Contrôle uniquement la surface d'export de from module import *. Aucun effet sur les cycles.
« Utilisez toujours importlib.import_module »L'import dynamique peut retarder le chargement, mais si vous importez encore au mauvais moment, le cycle reste. Préférez un import local explicite ou une restructuration.
« Python entre en boucle infinie »Le résultat habituel est ImportError / initialisation partielle, pas une boucle qui tourne.
« Renommez le fichier et croisez les doigts »Les collisions de noms peuvent provoquer des erreurs d'import confuses, mais renommer seul ne corrige pas une vraie dépendance A↔B.

Motifs d'organisation de package qui évitent les cycles

Les packages sains ressemblent à un DAG (graphe dirigé acyclique) :

package/
  __init__.py          # keep thin; avoid importing everything eagerly
  domain/              # pure models, no IO
  services/            # use domain
  adapters/            # DB, HTTP, filesystem
  api/                 # entrypoints; imports services only

Règles pratiques :

  1. Les modules feuilles n'importent rien de l'application.
  2. __init__.py doit rester mince. Des chaînes from .a import * / from .b import * avides sont une usine à cycles courante.
  3. Préférez les imports explicites depuis des modules feuilles stables aux réexports « de commodité » qui tirent la moitié du package.
  4. Exécutez en package lors du développement de bibliothèques : python -m package.api évite les bricolages de chemin qui masquent de vrais problèmes de dépendances. Voir aussi comment exécuter des scripts Python.

Si vous déplacez beaucoup de fichiers pendant un refactor, pathlib rend les scripts de déplacement/renommage plus clairs que du collage de chaînes de chemins.

Boîte à outils de débogage

Tracer où le cycle entre

# debug_import.py
import sys
import trace
 
tracer = trace.Trace(count=False, trace=True)
tracer.runfunc(lambda: __import__("your_package.entry"))

Au quotidien, la traceback suffit. Pour de grandes bases de code, des outils aident :

  • python -X importtime -c "import your_package" — montre l'ordre et le coût des imports
  • import-linter (tiers) — encoder « services ne doit pas importer api » comme règles CI
  • pyright / mypy — attraper tôt les erreurs TYPE_CHECKING avec les annotations

Capturer les symptômes d'init partielle dans les tests

def test_package_imports_cleanly():
    import importlib
    import your_package.api as api
 
    importlib.reload(api)  # optional stress
    assert hasattr(api, "handle")

Si les tests unitaires n'importent que de minuscules modules isolés, ils peuvent manquer des cycles qui n'apparaissent que via le point d'entrée de l'app. Ajoutez un test de fumée qui importe le vrai module d'entrée.

Tableau de décision : quelle correction utiliser ?

SignalPréférer
Modèle / constante partagé(e) des deux côtésExtraire un troisième module
Un seul chemin d'appel a besoin de l'autre moduleImport paresseux dans cette fonction
L'import n'existe que pour les vérificateurs de typesTYPE_CHECKING
Deux services s'orchestrent mutuellementInjection de dépendances / callbacks / événements
Les cycles reviennent après des rustinesRègles de couches de package + lint en CI

FAQ

Conclusion

Les imports circulaires sont un problème de graphe de dépendances. Python les expose comme des erreurs d'initialisation partielle, surtout le message familier « most likely due to a circular import ». Corrigez-les en rendant les dépendances à sens unique : extrayez des modules feuilles partagés, n'utilisez l'import paresseux que là où c'est nécessaire, gardez les imports d'annotations derrière TYPE_CHECKING, et câblez les collaborateurs depuis l'extérieur quand deux services ont besoin l'un de l'autre.

Ignorez le folklore — imports absolus, __all__ et renommages de fichiers ne dissolvent pas un vrai cycle. Une fois le graphe propre, les imports redeviennent ennuyeux, ce qui est exactement ce qu'il faut.

Guides connexes