Skip to content
Temas
Python
Python Circular Import: How to Fix It (With Working Examples)

Importación circular en Python: cómo corregirla (con ejemplos)

Publicado el

Actualizado el

Una importación circular (circular import) ocurre cuando el módulo A importa el módulo B y B (directa o indirectamente) también importa A. Python no suele quedarse en un bucle infinito: produce una importación parcial. Un módulo sigue cargándose cuando el otro intenta leer un nombre de él, y eso dispara ImportError / AttributeError, o deja objetos a medio inicializar.

Solución rápida (empieza aquí)

SituaciónQué hacer primero
Dos módulos se importan entre sí en el nivel superiorMueve el código compartido a un tercer módulo que ambos puedan importar
Solo necesitas un nombre dentro de una funciónImporta dentro de esa función (importación perezosa / lazy import)
Solo necesitas tipos para anotacionesUsa from typing import TYPE_CHECKING y comillas en las anotaciones si hace falta
Paquete grande con ciclos profundosRediseña las dependencias para que las capas bajas nunca importen las altas

Patrón mínimo que suele desbloquearte al instante:

# 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 te parece un parche, sigue leyendo. La solución duradera casi siempre es la dirección de las dependencias, no un truco de importación ingenioso.

Cómo se ve una importación circular

Ejemplo mínimo que falla

Crea dos módulos hermanos:

# 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())

Ejecuta:

python a.py

Fallo típico (el texto varía según la versión de Python):

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

Ese mensaje es la forma en que la gente busca este problema. Python empezó a cargar a, que empezó a cargar b, que intentó terminar de importar desde a antes de que a terminara de ejecutarse.

Qué falla en realidad

Cuando Python importa un módulo:

  1. Crea un objeto de módulo vacío y lo pone en sys.modules.
  2. Ejecuta el cuerpo del módulo de arriba abajo.
  3. Solo cuando ese cuerpo termina, todos los nombres de nivel superior están garantizados.

Durante un ciclo, el paso 2 del módulo A sigue en marcha cuando B pide algo definido más abajo en A. El nombre no existe → error de importación. Es un fallo de inicialización parcial (partially initialized module), no un bucle sin fin.

Flujo de diagnóstico

Sigue este orden. Detente cuando el ciclo desaparezca.

  1. Reproduce con un punto de entrada limpio
    Ejecuta el mismo archivo que menciona el error (python -m package.module o la ruta del script). Las importaciones circulares dependen del entrypoint.

  2. Lee el traceback completo de abajo hacia arriba
    Los últimos frames suelen mostrar que module_x importa module_y mientras module_y ya está en la pila.

  3. Dibuja un esquema de dependencias en una sola dirección
    Lista cada módulo y qué importa. Cualquier arista que apunte “hacia arriba” (de modelos centrales hacia UI/API) es sospechosa.

  4. Clasifica la necesidad

    • Valor o función en tiempo de ejecución → reestructurar o lazy import
    • Solo tipos → TYPE_CHECKING
    • Constantes o modelos compartidos → extraer a un módulo hoja
  5. Aplica el arreglo más pequeño y correcto
    Prefiere extraer un módulo compartido > lazy import > rediseñar el layout del paquete. Evita el mito de “las importaciones absolutas lo arreglan”.

  6. Vuelve a ejecutar desde el mismo entrypoint
    Confirma tanto el estilo python a.py como los entrypoints de paquete con -m si usas ambos.

Solución 1: Extraer un tercer módulo (mejor opción por defecto)

Cuando models y services necesitan User, no hagas que se importen entre sí. Pon User en un módulo hoja que no dependa de capas superiores.

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)

La dirección de dependencias es unidireccional: api → services → models. Los ciclos desaparecen porque nada de abajo importa nada de arriba.

Solución 2: Importaciones perezosas (locales)

Importa dentro de la función o método que necesita el símbolo cuando un ciclo temporal es difícil de desenredar.

# 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))

Cuándo tienen sentido las lazy imports

  • Romper un ciclo rebelde con rapidez en código legado
  • Dependencias pesadas opcionales (importar solo en la ruta de código que las necesita)

Cuándo evitarlas como diseño a largo plazo

  • Rutas calientes donde el coste de importar importa (suele ser menor, pero medible)
  • Arquitectura que sigue generando ciclos nuevos; mejor extraer módulos

Solución 3: TYPE_CHECKING para importaciones solo de anotaciones

Si el único motivo de la importación son type hints, sácala del 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)

Con from __future__ import annotations (o anotaciones entre comillas "User"), Python no necesita User en runtime para que la anotación funcione. Es la herramienta correcta para ciclos de type hints, no para llamar métodos de User.

Solución 4: Inyectar dependencias en lugar de importarlas

Para servicios que “se necesitan entre sí”, pasa los colaboradores como argumentos en vez de importarlos globalmente.

# 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 importan al otro. La composición en el borde (main.py) es quien cablea. Es la misma idea que la inyección por constructor en apps y frameworks más grandes.

Trampas frecuentes (consejos que no arreglan ciclos)

Aparecen mucho en guías antiguas. Resuelven otros problemas, no importaciones circulares.

Consejo que verásRealidad
“Usa importaciones absolutas”Mejoran la claridad. No eliminan una dependencia mutua.
“Define __all__Solo controla la superficie de from module import *. Sin efecto en ciclos.
“Siempre usa importlib.import_modulePuede retrasar la carga, pero si sigues importando en el momento equivocado, el ciclo permanece. Prefiere un import local explícito o una reestructuración.
“Python entra en un bucle infinito”El resultado habitual es ImportError / partial init, no un bucle girando.
“Renombra el archivo y espera”Los choques de nombres pueden generar errores confusos, pero renombrar solo no arregla una dependencia real A↔B.

Patrones de layout de paquetes que evitan ciclos

Los paquetes sanos se parecen a un DAG (grafo dirigido acíclico):

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

Reglas prácticas:

  1. Los módulos hoja no importan nada de la app.
  2. __init__.py debe ser delgado. Cadenas ansiosas de from .a import * / from .b import * son una fábrica habitual de ciclos.
  3. Prefiere imports explícitos desde módulos hoja estables frente a reexportaciones “cómodas” que arrastran media paquete.
  4. Ejecuta como paquete al desarrollar librerías: python -m package.api evita hacks de path que enmascaran problemas reales de dependencias. Ver también cómo ejecutar scripts de Python.

Si mueves muchos archivos al refactorizar, pathlib mantiene los scripts de renombrado más claros que pegar rutas como strings.

Caja de herramientas de depuración

Rastrear dónde entra el ciclo

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

En el día a día, el traceback basta. En codebases grandes ayudan herramientas:

  • python -X importtime -c "import your_package" — muestra orden y coste de importación
  • import-linter (terceros) — codifica “services no debe importar api” como reglas de CI
  • pyright / mypy — detectan errores de TYPE_CHECKING cuando usas anotaciones

Capturar síntomas de partial-init en tests

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

Si los unit tests solo importan módulos minúsculos de forma aislada, pueden pasar por alto ciclos que solo aparecen por el entrypoint real de la app. Añade un smoke test que importe el módulo de entrada real.

Tabla de decisión: ¿qué solución usar?

SeñalPrefiere
Modelo/constante compartido por ambos ladosExtraer un tercer módulo
Una sola ruta de llamada necesita el otro móduloLazy import en esa función
El import existe solo para type checkersTYPE_CHECKING
Dos servicios se orquestan entre síInyección de dependencias / callbacks / eventos
Los ciclos vuelven tras cada parcheReglas de capas del paquete + linting en CI

FAQ

Conclusión

Las importaciones circulares son un problema de grafo de dependencias. Python las muestra como errores de inicialización parcial, sobre todo con el mensaje familiar “most likely due to a circular import”. Corrígelas haciendo las dependencias unidireccionales: extrae hojas compartidas, usa lazy import solo donde haga falta, deja las importaciones de anotaciones detrás de TYPE_CHECKING, y cablea colaboradores desde fuera cuando dos servicios se necesitan entre sí.

Olvida las soluciones de folklore: importaciones absolutas, __all__ y renombrar archivos no disuelven un ciclo real. Cuando el grafo queda limpio, las importaciones vuelven a ser aburridas, y eso es exactamente lo que quieres.

Related Guides