2 puntos por GN⁺ 2025-10-04 | 1 comentarios | Compartir por WhatsApp
  • En Python, la convención habitual es declarar todas las importaciones a nivel de módulo
  • Sin embargo, al ejecutar un programa también se cargan de inmediato módulos de dependencias innecesarias, lo que provoca problemas de velocidad de arranque y uso de memoria
  • Hasta ahora se usaban mucho las importaciones diferidas manuales, por ejemplo dentro de funciones, pero esto tiene la desventaja de dificultar el mantenimiento y la gestión de dependencias
  • Este PEP 810 introduce una sintaxis de importaciones diferidas explícitas con la nueva palabra clave lazy, de forma local, explícita, controlada y granular
  • Con esta función, los módulos se cargan solo cuando realmente se necesitan, mejorando la latencia de inicio y el desperdicio de memoria al mismo tiempo que se mantiene la transparencia en la estructura del código

Situación actual y problemas de las importaciones en Python

  • En Python, la práctica más extendida es escribir las sentencias import al inicio del módulo
  • Este enfoque reduce duplicación, permite ver de un vistazo la estructura de dependencias de importación e importar una sola vez para minimizar la sobrecarga en tiempo de ejecución
  • Sin embargo, cuando se carga el primer módulo (main) al ejecutar el programa, es fácil que se produzcan importaciones en cadena que terminan cargando de inmediato muchos módulos de dependencia que en realidad no se usan
  • En particular, en las herramientas CLI, incluso invocar solo la ayuda puede precargar decenas de módulos, generando sobrecarga innecesaria para cada subcomando

Alternativas existentes y sus problemas

  • Con frecuencia se retrasa manualmente el momento de importar, por ejemplo moviendo las importaciones dentro de funciones
  • Pero este enfoque tiene desventajas importantes, como pérdida de consistencia y mantenibilidad, además de dificultar la comprensión de todas las dependencias
  • Según el análisis de la biblioteca estándar, en código sensible al rendimiento ya se usa diferimiento de importaciones con fines de optimización en alrededor del 17% de todas las importaciones, colocándolas dentro de funciones o métodos
  • Existen herramientas relacionadas con el diferimiento de importaciones, como importlib.util.LazyLoader y el paquete de terceros lazy_loader, pero no cubren todos los casos o no existe un estándar único

PEP 810: introducción de importaciones diferidas explícitas

  • Se introduce la nueva palabra clave suave lazy (solo tiene significado en contextos específicos y también puede usarse como nombre de variable)

  • lazy solo puede usarse antes de una sentencia import, y no puede usarse en ámbitos de función/clase/with/try ni con star import

  • Se aplica de forma clara por cada sentencia de importación, retrasando la carga del módulo hasta el momento de uso

    lazy import nombre_del_módulo
    lazy from nombre_del_módulo import nombre
    

Forma de implementación y reglas sintácticas de las importaciones diferidas explícitas

  • Casos que producen error de sintaxis:

    • No se permite dentro de funciones, dentro de clases, en try/with, ni con star import (*)
  • Ejemplo de uso:

    import sys
    lazy import json
    print('json' in sys.modules)  # False (todavía no se carga)
    result = json.dumps({"hello": "world"})  # Se carga en el primer uso
    print('json' in sys.modules)  # True (carga diferida del módulo completada)
    
  • También se puede indicar el objetivo lazy en el atributo __lazy_modules__ a nivel de módulo, como lista de cadenas

    __lazy_modules__ = ["json"]
    import json  # se procesa como lazy
    

Control del comportamiento mediante bandera global y filtros

  • Es posible controlar si se aplica lazy por módulo o de forma global usando una bandera global o una función de filtrado

  • También se puede usar una función de filtrado para aplicar excepciones de importación eager solo a módulos específicos

    def my_filter(importer, name, fromlist):
        if name in {'problematic_module'}:
            return False  # eager import
        return True  # lazy import
    sys.set_lazy_imports_filter(my_filter)
    

Comportamiento en runtime y manejo de errores

  • Cuando se usa lazy import, la importación real ocurre no en la sentencia de importación, sino en el primer acceso al nombre

  • Si la importación falla, se muestra claramente tanto el lugar donde se definió como donde ocurrió mediante encadenamiento de excepciones (traceback chaining)

    lazy from json import dumsp  # error tipográfico
    result = dumsp({"key": "value"})  # ImportError en el momento del acceso real
    

Beneficios de memoria y rendimiento

  • Los módulos diferidos aparecen solo en el conjunto sys.lazy_modules y no se registran en sys.modules antes de su uso real
  • Después de usarse, se reemplazan por objetos de módulo normales y pueden usarse sin penalización adicional de rendimiento
  • En cargas de trabajo reales, se observa una reducción de 50 a 70% en la latencia de arranque y un ahorro de 30 a 40% de memoria

Resumen del funcionamiento

  • En el primer acceso a un objeto lazy ocurre la reificación (reification), es decir, la importación real y el reemplazo
  • Si código externo accede al __dict__ del módulo, todos los objetos lazy se cargan de forma forzada (reificación)
  • Al extraer el diccionario con globals(), el proxy lazy se mantiene y requiere acceso directo

Anotaciones de tipo y optimización de TYPE_CHECKING

  • Con lazy from módulo import nombre, las importaciones usadas solo para tipos garantizan costo de runtime CERO
  • Esto permite reemplazar condicionales existentes con from typing import TYPE_CHECKING, haciendo el código más conciso y claro

Diferencias frente a PEP 690 y características de implementación

  • PEP 810 adopta una estructura explícita, por importación individual y opt-in, basada en objetos proxy simples
  • En cambio, PEP 690 proponía una estructura de importaciones diferidas global e implícita

Puntos de atención e interacción entre módulos

  • star import (*) no tiene soporte lazy (siempre es eager)
  • Los import hook y loader personalizados siguen funcionando normalmente en el momento de la reificación
  • Incluso en entornos multihilo, se garantiza que la importación se realice una sola vez de forma thread-safe y con enlace seguro
  • Si se usa el mismo módulo en modo lazy y eager al mismo tiempo, el eager siempre tiene prioridad

Guía de aplicación del código y migración

  • Al aplicar esto en código existente, se recomienda convertir a lazy solo las importaciones necesarias mediante profiling y hacerlo de forma gradual
  • Si se usa __lazy_modules__, hay compatibilidad incluso con versiones anteriores a Python 3.15

Otros puntos importantes de preguntas y respuestas

  • Los efectos secundarios en tiempo de importación (por ejemplo, patrones de registro) se retrasan hasta el primer acceso. Si el side effect es indispensable, se recomienda un patrón con función de inicialización explícita
  • El problema de circular import no se resuelve por completo con lazy import (solo puede mitigarse si el acceso también se retrasa)
  • El rendimiento en rutas críticas elimina por completo la verificación lazy después del primer uso mediante optimización automática (bytecode adaptive specialization)
  • El módulo real solo se registra en sys.modules después de la reificación (primer uso)
  • A diferencia de importlib.util.LazyLoader, no requiere configuración adicional, mantiene el rendimiento y ofrece claridad mediante sintaxis estándar

Conclusión

  • PEP 810 agrega la palabra clave lazy a las sentencias import de Python, permitiendo optimizar de forma simple y predecible los problemas de rendimiento causados por la carga innecesaria de módulos en áreas como CLI con subcomandos, aplicaciones grandes y anotaciones de tipo
  • La nueva palabra clave permite especificar con precisión el momento y el objetivo de adopción, por lo que resulta adecuada para introducción gradual y ajuste de rendimiento en servicios reales
  • Como una evolución práctica del sistema de importación de Python, satisface al mismo tiempo las tres necesidades de visibilidad, mantenibilidad y rendimiento

1 comentarios

 
GN⁺ 2025-10-04
Comentarios en Hacker News
  • Mi herramienta CLI de llm.datasette.io soporta plugins, pero hubo muchas quejas de que incluso comandos como "llm --help" tenían un arranque demasiado lento. Revisando, resultó que plugins populares importaban por defecto paquetes pesados como pytorch, bloqueando todo el inicio. Por eso en la documentación para autores de plugins indico que importen dependencias solo dentro de las funciones y solo cuando hagan falta (enlace a la documentación relacionada), pero creo que sería mucho mejor que Python soportara esto a nivel del lenguaje.

    • Hoy mismo se puede implementar esto en herramientas (enlace con explicación), pero este enfoque se aplica globalmente a todo el proceso, así que si importas numpy de forma diferida, también se diferirán todos los imports de sus submódulos. Al final, si no se necesita todo numpy, puede que ni siquiera llegue a importarse, pero el fenómeno de importar módulos parcialmente en el momento en que se necesiten puede quedar repartido de manera impredecible durante la ejecución. En pruebas adicionales, si haces import foo.bar.baz, foo y foo.bar siguen cargándose de inmediato y solo foo.bar.baz queda diferido. Quizá esa sea una de las razones por las que el PEP usa la palabra "mostly". Si mejorara más mi implementación, tal vez podría resolver eso.

    • Recomiendo parsear primero la línea de comandos para manejar opciones como "--help" sin importar nada. Ejecuta los imports solo cuando realmente sean necesarios; o, dicho de otra forma, diseña el programa para importar solo cuando ya se hayan procesado las opciones sencillas y todavía quede trabajo por hacer.

  • La propuesta de lazy import ya existía desde antes, y la más reciente fue rechazada en 2022 (enlace a la discusión relacionada). Según recuerdo, lazy import ya está en Cinder, la variante de CPython de Meta, y esta vez también están impulsando el PEP personas que trabajaron en Cinder. La discusión se centró en cosas como: "¿opt-in u opt-out?", "¿hasta dónde se aplica?", "¿debería ir como build flag de CPython?". Al final, dicen que el Steering Council lo rechazó por la complejidad de dividir el comportamiento de los imports en dos. Ojalá esta propuesta sí pase; realmente quiero usar esta función.

    • Me gusta especialmente que sea opt-in, con aplicación granular por niveles e incluso un interruptor global para desactivarlo. Es una especificación muy bien armada dentro de muchas restricciones.

    • Yo también espero que esta propuesta pase, pero no soy optimista. Esto va a romper muchísimo código y va a desatar problemas inesperados. La sentencia import tiene efectos secundarios por naturaleza, y si cambia el momento en que se aplican, vamos a sufrir durante mucho tiempo con bugs cuya causa no será obvia. No es alarmismo, es una preocupación real y con fundamento. Hay una razón por la que lazy import solo estaba en Meta: hace falta tener recursos al nivel de Meta para lidiar con algo así. Mucha gente solo ve "pandas, numpy o mi weird module enredado son demasiado lentos, ojalá fueran más rápidos", pero creo que muy poca gente entiende siquiera cómo funciona realmente el sistema de imports de Python. Incluso hay muchas opiniones a favor sin saber cómo se implementaría lazy import. Si miras PEP 690, hay varias desventajas. Por ejemplo, se rompe código que usa decoradores para registrar funciones en un registry central. Un caso típico es la librería Dash, que conecta interfaces basadas en JavaScript con callbacks de Python mediante decoradores al momento del import; si el import se vuelve lazy, ese frontend simplemente dejaría de funcionar. Hasta servicios con muchísimos usuarios podrían romperse al instante. Y dicen: “como es opt-in, si no te sirve, apagas lazy import”. ¿Pero qué pasa si los imports son transitivos? ¿Qué pasa si necesitas arrancar un proceso importante solo después de que el frontend quede completamente inicializado? ¿Quién sabe qué efectos puede tener eso en un ecosistema donde se mezclan librerías y código de muchas personas? A diferencia de los type hints, esto sí cambia de verdad el comportamiento en runtime. La sentencia import está en prácticamente todo código Python real, así que si se introduce lazy import, cambia de forma fundamental la manera de ejecutar. Y además hay más casos raros de los que habla el PEP. Es un problema mucho más difícil de lo que parece.

    • Sería buenísimo poder hacer imports con versión explícita como import torch==2.6.0+cu124, import numpy>=1.2.6, y además poder instalar/importar varias versiones de un mismo paquete al mismo tiempo dentro del mismo entorno de Python. Ya quisiera que se acabara este infierno de conda/virtualenv/docker/bazel.

  • No me desagrada, pero tampoco me entusiasma demasiado. Si esto sigue así, siento que vamos a terminar poniendo lazy delante de casi todos los import, salvo unos pocos casos que de verdad necesiten eager import. Eso ensucia el código, y como tampoco parece que vaya a convertirse en el comportamiento por defecto, esa verbosidad se quedará para siempre. Yo habría preferido un sistema donde el módulo declarara opt-in para lazy loading, sin cambiar la sintaxis de import. Así solo las librerías grandes tendrían que preocuparse por la pereza. Claro, eso implicaría que el intérprete tenga que inspeccionar el sistema de archivos al momento de importar, entre otras desventajas.

    • Si todo el mundo pudiera usar lazy import ampliamente sin mayores problemas, entonces lazy debería haber sido el valor por defecto y <i>eager</i> debería ser la keyword opcional. Este tipo de cambio de paradigma no sería el primero en Python: varias construcciones que en v2 creaban listas eager pasaron a ser generators en v3 sin causar mayores problemas.

    • Si hubiera una bandera de línea de comandos para volver lazy todos los imports de módulos de Python, yo la usaría sin dudar. En la práctica, fuera de scripts o código realmente simple, generar side effects al cargar un módulo es un patrón que de verdad debería evitarse.

    • No creo que tenga sentido que el módulo decida si usar lazy loading. Solo quien llama sabe si necesita lazy load, así que lo razonable es que la opción esté del lado del código que hace el import. Cualquier módulo podría cargarse de forma lazy, y aunque tenga side effects, quien lo importa podría querer diferir también eso.

    • Ojalá se pudiera especificar una opción de lazy loading por regex en pyproject.toml.

    • En el pasado la gente expresó preocupaciones parecidas cada vez que salieron funciones nuevas como type hints, walrus, asyncio, dataclasses, etc., pero en la práctica no fue como que muchísima gente empezara a usarlas todas de golpe ni que todos los patrones existentes cambiaran. Muchos usuarios siguen usando básicamente un Python modernizado al nivel de 2.4, y aun así son suficientemente productivos. Lleva 20 años funcionando bien, así que no creo que haya un problema tan grande.

  • Si te interesa, presento lazyimp, que implementa lazy import de forma muy cómoda usando un context manager. Normalmente basta con envolver la sentencia import en un bloque with, así que se lleva bien con las herramientas existentes, y si hace falta depurar, se puede volver fácilmente a eager import. Usa una cext para cambiar f_builtins del frame y ser más potente que un hook de importlib. No es perfecto, pero también tiene una versión thread-safe y una versión con handler global. Al principio era cauteloso, pero ahora ya migré casi toda mi base de código a esto y no he tenido ningún problema real (salvo no haber cuidado el registro por módulo), además de que la mejora de velocidad se siente muchísimo, así que estoy muy satisfecho.

  • Es realmente molesto que los linters de Python fuercen a poner los imports al principio del archivo. Cada vez que uno usa la forma obvia de implementar lazy import, aparecen errores de lint. Este problema va mucho más allá de un simple tema de rendimiento. Por ejemplo, si necesitas una librería específica de una plataforma y quieres importarla solo en esa plataforma, forzar el import al principio puede hacer que el import falle por completo.

    • En ese caso, creo que no queda otra que arreglar el linter.

    • La mayoría de los linters se pueden ignorar con un comentario como #noqa E402.

  • Se comenta que hasta cierto punto ya es posible hacer lazy import automático mediante la clase LazyLoader. Aun así, como el enfoque aprovecha internals del sistema de imports de Python de una forma poco clara, incluso en Stack Overflow la explicación no se ve muy bien (Q&A relacionada). Por eso implementé yo mismo una prueba de concepto para volver lazy todos los imports sin sintaxis explícita del programador.

import sys
import threading  # necesario en Python 3.13, al menos en el REPL
from importlib.util import LazyLoader  # ¡esto sí debe importarse de inmediato!
class LazyPathFinder(sys.meta_path[-1]):  # hereda de _frozen_importlib_external.PathFinder
  @classmethod
  def find_spec(cls, fullname, path=None, target=None):
    base = super().find_spec(fullname, path, target)
    base.loader = LazyLoader(base.loader)
    return base
sys.meta_path[-1] = LazyPathFinder

Con esto reemplazas el meta path finder por un wrapper, de modo que el loader se sustituye por LazyLoader. Cuando se ejecuta el import, el nombre del módulo en realidad queda enlazado como <class 'importlib.util._LazyModule'>, y el módulo real se carga al acceder a sus atributos. Código de prueba:

import this  # no muestra ningún resultado
print(type(this))  # <class 'importlib.util._LazyModule'>
rot13 = this.s  # se imprime Zen, aquí se carga el módulo
print(type(this))  # <class 'module'>

Eso sí, no tengo claro qué significa exactamente "mostly" en el PEP.

  • Me parece que se están subestimando los riesgos de thread safety en los lazy imports. No se puede predecir en absoluto cuándo se ejecutará un import, en qué hilo, ni qué locks tendrá tomados, aparte del importer lock. Antes, aunque se ejecutara código riesgoso al importar un módulo, casi siempre ocurría solo durante la inicialización monohilo, así que no era tan problemático. Si todo pasa a lazy, los errores podrían aparecer de formas realmente impredecibles, tipo Heisenbug. Los imports a nivel de función también pueden tener ese problema, pero al menos tienen la previsibilidad de ejecutarse al principio de código explícito.

  • Se siente como una buena función. Es fácil de explicar, tiene casos de uso reales y su alcance (global, o con una keyword simple) parece razonable. Me gusta.

    • Entre los PEP recientes, siento que este es de los más limpios desde la perspectiva del usuario. Tengo curiosidad por ver el resultado final después del tradicional proceso de syntax bikeshedding.

    • Me parece un PEP cuidadosamente preparado: validación con casos de uso reales y edge cases, compromisos razonables, un enfoque no exagerado y varias rondas de refinamiento. Sobre todo porque tocar un sistema tan central como este en un lenguaje grande con comunidades de todo tipo en el mundo puede ser muy peligroso. Considerando esa dificultad, me parece especialmente impresionante.

    • Espero que hayan aprendido bien por qué se rechazó PEP-690. En nuestra base de código también intentamos implementar algo así por cuenta propia, pero nunca logramos que funcionara bien a un nivel realmente utilizable.

  • El peligro de lazy import es que puede producir errores inesperados en runtime en servicios de larga duración. Parece una ventaja por el arranque rápido, pero a cambio te llevas la posibilidad de que la ejecución se detenga a la mitad por un fallo de import. Además, pueden aparecer edge cases donde ya no sea posible garantizar qué cosas se importarán al arrancar el programa.

    • Aun así, este es un problema real que sí o sí hay que resolver. No es solo por la velocidad de arranque; cuando Python arranca con dependencias grandes, puede volverse ridículamente lento. Los proyectos grandes tampoco pueden simplemente empaquetar todas las librerías pesadas que no todos los usuarios van a usar, así que los desarrolladores ya están recurriendo a soluciones todavía más raras, lo cual añade problemas absurdos. Ya sería un gran avance con solo dejar de duplicar, esconder o disimular imports a nivel de función. Y además, esto se está proponiendo únicamente como una feature opcional del lenguaje.

    • Con pruebas automatizadas se puede mitigar bastante el riesgo, y vale la pena a cambio de un arranque rápido. El startup time nunca es solo un problema "cosmético". Yo lo sufrí en un monolito de Django: por culpa de apenas unas cuantas librerías pesadas, había que esperar entre 10 y 15 segundos en cada management command, test o recarga de contenedor. Diferir esos imports con lazy import hizo una diferencia enorme.

  • Nosotros preferimos imports explícitos al principio del archivo, porque así los problemas de dependencias salen a la luz en cuanto arranca el programa. Si usas lazy import, aparece la incomodidad de descubrir el problema solo cuando se ejecuta cierta ruta de código, quizá horas o días después.

    • Por otro lado, si todos los imports se diferieran automáticamente, la velocidad de ejecución de pip en tareas cortas mejoraría de inmediato.
$ time pip install --disable-pip-version-check
ERROR: You must give at least one requirement to install (see "pip help install")

real  0m0.399s
user  0m0.360s
sys   0m0.041s

La mayor parte del tiempo en realidad se va importando y descargando módulos vendor que ni siquiera se usan (por ejemplo, casi 100 módulos solo relacionados con Requests). Al investigarlo, vi que en total se importan innecesariamente más de 500 módulos.

  • Tampoco sé por qué los generadores de código están produciendo cada vez más código que mete imports locales dentro de funciones en vez de ponerlos arriba. No me gustaría recomendar ese patrón, porque vuelve más difícil entender las dependencias del módulo y aumenta el riesgo de que después aparezcan dependencias cíclicas.

  • Aún no leo todo el PEP, pero pienso que estaría bien si hubiera alguna forma de hacer validación de dependencias mediante una bandera de línea de comandos o una herramienta externa, algo parecido a las herramientas para type hints.

  • Me da curiosidad a quién se refiere exactamente "nosotros".

  • ¿No sería esto algo que debería cubrirse con tests?