Python 3.15 trae lazy import y lo he medido en un CLI

Publicado 10 min de lectura
Ver imagen del artículo
Guido van Rossum con camiseta de Python y acreditación de la PyCon US 2024

Mi primer lazy import en Python 3.15 acabó en SyntaxError. Lo puse donde siempre van los imports opcionales, dentro de un try, y eso es justo lo que la palabra nueva no admite. Buen comienzo para algo que promete arrancar más rápido.

Python 3.15.0 salió el 9 de octubre, una semana tarde. El motivo lo dio el equipo de publicación en la rc3 del día 2: «some last-minute lazy-import release blockers». La novedad que retrasó la versión es la que he querido medir antes de tocar un solo guion mío.

Gráfico de barras: el mismo programa tarda 103,8 ms en arrancar con imports normales y 26,6 ms con lazy import
El mismo CLI de prueba, con y sin lazy import, en Python 3.15.0. Esquema propio con las medidas del 12 de octubre.

Qué he probado y dónde

Lo Probado lo ejecuté el 12 de octubre de 2026, entre las 19:05 y las 19:20 UTC, en un Ubuntu 24.04 x86-64 con 8 hilos y otros procesos trabajando de fondo. Lo que sale de las notas, las PEP o la documentación va como Revisado, como explico en cómo pruebo y reviso las guías.

No he tocado el Python del sistema. Compilé el 3.15.0 desde el código fuente en un directorio de trabajo, con altinstall para que no cree un python3 que tape al de Ubuntu:

compilar-315.sh
BASH
# La huella tiene que coincidir con la de la pagina de la version
curl -sSfLO https://www.python.org/ftp/python/3.15.0/Python-3.15.0.tar.xz
sha256sum Python-3.15.0.tar.xz
# ba4bed1ba346b916890b76d9e320451420aa69f6408997d33c66482eeae3d575

# Todo dentro de $HOME/py315; altinstall solo crea python3.15
tar xf Python-3.15.0.tar.xz && cd Python-3.15.0
./configure --prefix="$HOME/py315"
nice make -j3 && make altinstall

"$HOME/py315/bin/python3.15" -c 'import sys; print(sys.version.split()[0], sys.flags.utf8_mode, sys.get_lazy_imports())'
# 3.15.0 1 normal

Sin --enable-optimizations, así que no es tan rápido como el binario de una distribución. Faltaron _decimal, _dbm, _gdbm y _tkinter por no tener sus librerías de desarrollo; para esto no hacen falta. El detalle inútil: en cada línea del compilador asoma -fno-omit-frame-pointer, que ahora va activado por defecto (PEP 831).

[WARN] Un import perezoso no falla al arrancar: falla la primera vez que se usa. Un módulo mal escrito o que no está instalado pasa todas las pruebas que no lleguen a esa línea y revienta después, quizá en una tarea de cron a las tres de la mañana.

La palabra nueva y dónde no cabe

La PEP 810 añade lazy delante de import o de from. El nombre queda ligado a un objeto intermedio y el módulo de verdad se carga la primera vez que se toca. Hasta entonces no está ni en sys.modules. Esto lo he leído; lo que sigue lo he ejecutado:

  • Dentro de try/except: SyntaxError: lazy import not allowed inside try/except blocks. El patrón del import opcional se queda como estaba.
  • Dentro de una función: SyntaxError: lazy import not allowed inside functions. Solo vale a nivel de módulo.
  • Dentro de un with: funciona. La PEP estudió prohibirlo y lo descartó.
  • lazy from x import * y lazy from __future__ tampoco valen, según la documentación.

La propia biblioteca estándar ya la usa. El site.py que se ejecuta al arrancar cualquier intérprete tiene lazy import locale, pkgutil, traceback y warnings en sus líneas 51 a 54. Conté 27 ficheros de la biblioteca, sin contar las pruebas, con algún lazy a nivel de módulo.

Un CLI de prueba, medido

Escribí un informe.py con la cabecera típica de una herramienta de administración: diez módulos que solo hacen falta en algunas órdenes. Luego saqué una copia con lazy delante de esos diez, dejando normales argparse y sys, que se usan siempre:

informe_lazy.py
PYTHON
# Lo que se usa en todas las ordenes se importa como siempre
import argparse
import sys
# Lo que solo usan algunas ordenes espera a que se toque
lazy import csv
lazy import json
lazy import sqlite3
lazy import tarfile
lazy import zipfile
lazy import subprocess
lazy import urllib.request
lazy import logging.handlers
lazy import email.utils
lazy import asyncio

Medí cada variante 200 veces, intercaladas, con un bucle que lanza el proceso y cronometra hasta que termina. --version pasó de 103,8 a 26,6 ms de mediana, y de 221 a 62 módulos cargados. La orden exportar, que sí usa sqlite3 y json, bajó de 103,2 a 30,6 ms: el resto sigue sin cargarse.

Una segunda tanda, con la máquina más cargada, dio 140,2 y 36,9 ms. Los milisegundos dependen de lo que haga la máquina en ese momento; la proporción, casi cuatro veces, se mantuvo. Para saber de dónde salían, -X importtime:

importtime.sh
BASH
# Tiempo acumulado de cada import, en microsegundos (sale por stderr)
python3.15 -X importtime informe_eager.py --version 2>&1 >/dev/null \
  | sort -t'|' -k2 -n -r | head -5

# import time:      1126 |      21200 | urllib.request
# import time:       241 |      16898 | asyncio
# import time:       664 |       6843 | logging.handlers
# import time:      1085 |       6816 | argparse
# import time:      1035 |       5961 | tarfile

Solo urllib.request y asyncio suman 38 ms para un programa que, en esa orden, imprime un número de versión. Es el caso que la PEP 810 describe: dependencias que se cargan en cada ejecución aunque esa ejecución no las use.

Sin tocar el código: -X lazy_imports=all

Con -X lazy_imports=all (o PYTHON_LAZY_IMPORTS=all) todos los imports de nivel de módulo se vuelven perezosos. Sobre la versión sin tocar dio 25,4 ms y 55 módulos. Tentador, y con trampa: hay imports que se hacen por lo que el módulo hace al cargarse, no por un nombre que se vaya a usar.

app.py
PYTHON
# plugin_csv.py se registra al importarse: plugins.REGISTRO.append("csv")
import plugins
import plugin_csv  # solo por su efecto al cargarse
print("formatos:", plugins.REGISTRO)

# $ python3.15 app.py
# formatos: ['csv']
# $ python3.15 -X lazy_imports=all app.py
# formatos: []

Ni error ni aviso: el plugin no se ha cargado porque nadie toca el nombre plugin_csv. Para esto existe sys.set_lazy_imports_filter(), que decide módulo a módulo. Yo el modo global lo dejaría para medir, no para producción.

Dónde muerde

Cambié json por jsno en la versión perezosa, a propósito. --version salió bien, con código de salida 0. exportar falló al llegar a la línea que lo usa:

errata.txt
SALIDA
# Salida recortada: sin rutas completas ni la cabecera Traceback
$ python3.15 informe_errata.py --version
informe 1.0
$ python3.15 informe_errata.py exportar
  File "informe_errata.py", line 5, in <module>
    lazy import jsno
ImportError: lazy import of 'jsno' raised an exception during resolution

The above exception was the direct cause of the following exception:
  File "informe_errata.py", line 29, in main
    print(jsno.dumps({"filas": ...}))
ModuleNotFoundError: No module named 'jsno'

Lo bueno: el error señala las dos líneas, la del import y la del uso. Lo malo: llega tarde. Si un guion tiene órdenes que casi nunca se ejecutan, sus imports rotos pueden dormir meses. La prueba mínima pasa a ser ejecutar cada orden, no solo --help.

Y el mismo fichero en el Python 3.12 de Ubuntu 24.04 no arranca: lazy import csv es un SyntaxError. Para código que tenga que vivir en las dos versiones, la documentación propone una lista con los nombres de los módulos:

informe_compat.py
PYTHON
# Una variable normal: 3.12 la ignora y 3.15 vuelve perezosos esos imports
__lazy_modules__ = ["csv", "json", "sqlite3", "tarfile", "zipfile", "subprocess",
                    "urllib.request", "logging.handlers", "email.utils", "asyncio"]
import argparse
import sys
import csv
import json
# ...el resto, sin cambios

# python3   (3.12.3): 208 modulos, funciona igual que antes
# python3.15 (3.15.0):  62 modulos

El otro cambio: UTF-8 por defecto

Con la PEP 686, un open() sin encoding usa UTF-8 aunque el sistema diga otra cosa. En un Linux con locale UTF-8 no cambia nada. Con el entorno vacío de una tarea de cron, tampoco: comprobé con env -i que el 3.12 ya activa el modo UTF-8 cuando el locale es C.

Cambia donde el locale no es UTF-8. En Linux es raro, pero existe: es_ES a secas es ISO-8859-1. Creé un CSV de dos líneas en Latin-1 con una «ñ» y lo leí sin decir la codificación:

leer.py
PYTHON
import sys
print("utf8_mode:", sys.flags.utf8_mode)
with open("viejo.csv") as f:   # sin encoding: manda la version de Python
    print(ascii(f.read().splitlines()[1]))

# $ LC_ALL=es_ES python3 leer.py                 (3.12.3)
# utf8_mode: 0
# 'nuria;A Coru\xf1a'
# $ LC_ALL=es_ES python3.15 leer.py              (3.15.0)
# utf8_mode: 1
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 27
# $ LC_ALL=es_ES PYTHONUTF8=0 python3.15 leer.py
# utf8_mode: 0
# 'nuria;A Coru\xf1a'

Al escribir pasa lo contrario, y sin error: con el mismo locale, el 3.12 guardó la «ñ» de «Coruña» en un byte (f1) y el 3.15, en dos (c3 b1). Un programa que espere Latin-1 verá basura. La PEP 686 lo dice sin rodeos: «this change mostly affects Windows users». Ahí no lo he probado.

Lo que sí se puede hacer hoy, con el Python que ya tienes (existe desde la 3.10), es pedirle que avise de cada open() sin codificación:

avisos.sh
BASH
# Con el Python actual, antes de cambiar de version
python3 -X warn_default_encoding leer.py
# leer.py:3: EncodingWarning: 'encoding' argument not specified

# El arreglo es decir lo que el fichero es, no lo que el sistema supone
#   open("viejo.csv", encoding="latin-1")

Vuelta atrás

  • Un lazy que molesta: se borra la palabra y el import vuelve a ser normal. Para descartar que el problema sea la pereza sin editar nada, un filtro que devuelva False fuerza la carga inmediata incluso de los lazy explícitos. Lo probé: el CLI volvió a cargar asyncio y urllib.request al arrancar.
  • El modo global: -X lazy_imports solo acepta all o normal. Con none el intérprete ni arranca: «invalid value; expected ‘all’ or ‘normal’».
  • UTF-8: PYTHONUTF8=0 o -X utf8=0 devuelven el comportamiento anterior, comprobado arriba. Es un parche; lo bueno es poner encoding.
  • La compilación: todo vive en un directorio; borrarlo deja la máquina como estaba.

Qué no he probado

  • Un binario con PGO, el de una distribución o el de python.org. Los milisegundos serán otros.
  • Librerías de terceros grandes. Mi CLI solo usa la biblioteca estándar.
  • Windows y macOS, ni el cambio de UTF-8 donde más se va a notar.
  • La compilación sin GIL, el JIT y el perfilador nuevo (PEP 799).
  • Nada en ARM.

[INFO] Actualizado el 28/10/2026: el JIT ya lo he medido, con el Python 3.15.2 que instala uv, ahora que va a salir de la rama principal de CPython. Lo cuento en Python quitará su JIT: lo he encendido y medido en la 3.15.

Me queda una duda: cuánto se gana en un programa cuyo tiempo no se va en importar. Sospecho que casi nada. Hace unas semanas escribí sobre Python Workers en Cloudflare, donde el arranque sí pesa; hay más notas de desarrollo en el hub de desarrollo web.

Compartir esta entrada WhatsApp X / Twitter LinkedIn Reddit Telegram Bluesky Email
Siguiente comando: volver al archivo o pulsa Ctrl + K
Hilo abierto

La conversación empieza aquí

Escribir un comentario

Nadie ha escrito todavía. Si lo has probado en tu máquina, cuenta qué te salió: otra versión u otro resultado también ayudan.

Escribe tu comentario

Si planteas un problema técnico, di sistema, versión, qué esperabas y qué has probado. Respeto y cero spam: tu correo no se publica.

0 caracteresCtrl + Enter para enviar

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.