Cuando el intérprete carga un módulo, lo ejecuta de arriba a abajo y lo registra en sys.modules antes de terminar esa ejecución. Ahí está la raíz del problema: si el módulo A importa B mientras se está ejecutando, y B intenta importar algo de A, Python encuentra A en sys.modules pero en un estado parcialmente inicializado — los nombres que todavía no se han definido simplemente no existen. El resultado es un ImportError: cannot import name X from partially initialized module.
La clave para entender por qué Python funciona así es que los módulos son objetos que se construyen incrementalmente. No hay una fase de “resolución de dependencias” previa como en algunos lenguajes compilados. El import es ejecución directa, así que si A y B se necesitan mutuamente, alguien tiene que llegar primero a la fiesta a medio montar.
El problema aparece con más frecuencia de lo que parece: un paquete de modelos que importa utilidades, y esas utilidades que importan los modelos para hacer type hints o para alguna lógica compartida. La circularidad suele ser accidental y solo explota cuando agregas ese import inocente de más.
Lo que rompe la lógica no siempre es la circularidad en sí — Python puede tolerar ciclos si los nombres que necesitas ya están definidos en el momento del import. Lo que siempre rompe es intentar usar un nombre que todavía no se ejecutó.
# Estructura del proyecto:
# shop/
# __init__.py
# models.py
# serializers.py
# utils.py
# --- shop/models.py ---
from dataclasses import dataclass
@dataclass
class Order:
id: int
total: float
customer_email: str
def to_dict(self) -> dict:
# Import tardío: solo se ejecuta cuando se llama al método,
# no cuando Python carga el módulo.
from shop.serializers import format_order
return format_order(self)
# --- shop/serializers.py ---
from __future__ import annotations # Postpone evaluation of annotations
from typing import TYPE_CHECKING
# Este bloque NUNCA se ejecuta en runtime; solo lo ven las herramientas
# de análisis estático y mypy. Elimina la dependencia circular en ejecución.
if TYPE_CHECKING:
from shop.models import Order
def format_order(order: Order) -> dict:
return {
"id": order.id,
"total": round(order.total, 2),
"email": order.customer_email,
}
def apply_discount(order: Order, pct: float) -> Order:
# Import tardío dentro de la función: shop.models puede estar
# completamente inicializado para cuando esto se llame.
from shop.models import Order as OrderModel
return OrderModel(
id=order.id,
total=order.total * (1 - pct),
customer_email=order.customer_email,
)
# --- shop/utils.py ---
# Módulo que antes causaba la circularidad importando desde los dos lados.
# Solución estructural: extraer la lógica compartida aquí y que ambos
# módulos importen desde utils, no entre sí.
def round_currency(amount: float, decimals: int = 2) -> float:
return round(amount, decimals)
# --- main.py ---
from shop.models import Order
from shop.serializers import apply_discount
order = Order(id=1, total=99.99, customer_email="user@example.com")
discounted = apply_discount(order, 0.10)
print(order.to_dict())
print(discounted)
Qué hace cada decisión y por qué importa
El import tardío dentro de to_dict es la solución más directa. Cuando Python carga models.py, no ejecuta el cuerpo del método — solo lo define. En el momento en que alguien llama a order.to_dict(), serializers.py ya terminó de cargarse, así que el import funciona sin problema. El coste es mínimo: Python ya tiene el módulo en sys.modules y simplemente hace un lookup.
TYPE_CHECKING es la solución elegante para el caso más frecuente en bases de código modernas: imports que solo existen para anotar tipos. El bloque if TYPE_CHECKING: es literalmente False en runtime — mypy, pyright y los IDEs lo evalúan como True durante el análisis estático, pero el intérprete nunca lo ejecuta. from __future__ import annotations es el complemento necesario: hace que Python trate todas las anotaciones como strings hasta que se evalúen explícitamente, lo que permite referenciar Order en firmas de función sin que Python intente resolverlo en el momento de definir la función.
La extracción a utils.py representa la solución estructural real. Si A importa B y B importa A, a menudo hay lógica que pertenece a un tercer módulo C del que ambos dependen en una sola dirección. Forzarte a encontrar ese módulo mejora el diseño más allá de solo arreglar el error.
La combinación de las tres técnicas cubre el espacio completo: TYPE_CHECKING para anotaciones, import tardío para dependencias funcionales inevitables, y reestructuración cuando la circularidad delata un problema de diseño más profundo.
Errores que debes conocer
Error: poner from __future__ import annotations sin TYPE_CHECKING y asumir que eso solo resuelve el circular import en runtime.
# ❌ Wrong
# serializers.py
from __future__ import annotations
from shop.models import Order # Esto aún se ejecuta; el circular import ocurre igual
def format_order(order: Order) -> dict: ...
# ✅ Right
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from shop.models import Order
def format_order(order: Order) -> dict: ...
from __future__ import annotations solo pospone la evaluación de las anotaciones, no elimina la ejecución del import. Solo TYPE_CHECKING evita que el import ocurra en runtime.
Error: usar import tardío en el nivel de módulo en lugar de dentro de la función, perdiendo el beneficio.
# ❌ Wrong
# models.py — el import se ejecuta al cargar el módulo, igual que antes
def to_dict(self):
pass
from shop.serializers import format_order # Al final del módulo, pero sigue siendo top-level
# ✅ Right
def to_dict(self):
from shop.serializers import format_order # Dentro del cuerpo de la función
return format_order(self)
El import tiene que vivir dentro del cuerpo ejecutable de la función, no en el scope del módulo, para que se difiera hasta el momento de la llamada.
Control de Versión
N° 68