Python 3.15: lazy imports, frozendict y lo que cambia, probado en Docker
Índice de contenidos
- Puntos clave
- Qué trae Python 3.15 y cuándo sale
- Cómo probé Python 3.15 en Docker
- Cómo funcionan los lazy imports
- Qué pasa cuando un lazy import falla
- El modo global rompe los imports con efectos secundarios
- Cuánto arranque ahorran los lazy imports
- frozendict: un diccionario inmutable y hashable
- sentinel: valores centinela con nombre
- Desempaquetado con * y ** en comprensiones
- Cómo perfilar con Tachyon sin tocar el código
- Qué rompe UTF-8 como codificación por defecto
- Frame pointers y JIT: lo que cambia por debajo
- Qué se elimina y qué te encuentras al actualizar
- Ruedas binarias para cp315
- Cómo actualizar una imagen Docker a Python 3.15
- Preguntas frecuentes
- ¿Cuándo sale Python 3.15 y hasta cuándo tiene soporte?
- ¿Tengo que reescribir mis imports para usar los lazy imports?
- ¿Python 3.15 es más rápido que 3.14 sin cambiar nada?
- Conclusión
- Fuentes
Python 3.15 trae imports perezosos con la palabra clave lazy, los tipos integrados frozendict y sentinel, UTF-8 como codificación por defecto, desempaquetado en comprensiones y Tachyon, un perfilador por muestreo. En Docker, marcar como lazy seis imports bajó el arranque de una CLI de 73,5 ms a 9,8 ms y de 342 a 62 módulos cargados.
*Python 3.15 es la versión de CPython que añade imports perezosos con la palabra clave lazy, los tipos integrados frozendict y sentinel, UTF-8 como codificación por defecto, desempaquetado con `y` en comprensiones y Tachyon, un perfilador por muestreo. El calendario oficial fija la versión final para el 9 de octubre de 2026, una semana más tarde de lo previsto. Aquí está cada novedad ejecutada en Docker, con una medición del arranque con y sin lazy y las roturas que me encontré al actualizar. Tienes también la versión en inglés de este análisis de Python 3.15.
Puntos clave
- En una herramienta de línea de comandos (CLI) que importa requests, rich y PyYAML, marcar seis imports como
lazybajó el arranque de--versionde 73,5 ms a 9,8 ms y los módulos cargados de 342 a 62. PYTHON_LAZY_IMPORTS=allhace perezosos todos los imports sin tocar el código, pero rompe los módulos que se registran al importarse: mi registro de plugins quedó vacío.frozendictes un diccionario inmutable y hashable que no hereda dedict, así queisinstance(x, dict)devuelveFalse.- Con un locale Latin-1, un
open()sinencodingque en 3.14.7 leíaMuñozlanzaUnicodeDecodeErroren 3.15;PYTHONUTF8=0recupera el comportamiento anterior. - Tachyon (
python -m profiling.sampling) perfila un script o un proceso en marcha a 1 000 muestras por segundo por defecto y genera flamegraphs y mapas de calor en HTML. - Desaparecen
sre_compile, el modo--cgidehttp.servery los argumentos posicionales desqlite3.connect()más allá del primero.
Qué trae Python 3.15 y cuándo sale
Python 3.15 añade ocho cambios de lenguaje e intérprete que se notan en el código del día a día. Los recoge la página What’s New de Python 3.15[1], que edita Hugo van Kemenade, también gestor de esta versión.
El PEP 790, el calendario de Python 3.15[2], cerró la entrada de funciones nuevas con la beta 1, el 7 de mayo de 2026. La final estaba fijada para el jueves 1 de octubre, pero una tercera candidata, publicada el 2 de octubre, la movió al viernes 9 de octubre de 2026. La 3.15 recibe correcciones cada dos meses durante dos años y parches de seguridad hasta octubre de 2031, aproximadamente.
Esta tabla resume las novedades que probé y lo que tienes que vigilar con cada una:
| Novedad | PEP | Qué hace | Qué vigilar |
|---|---|---|---|
lazy import |
810 | Retrasa la carga del módulo hasta el primer uso | Los errores de import salen al usar el nombre, no al arrancar |
frozendict |
814 | Diccionario inmutable y hashable | No es subclase de dict |
sentinel |
661 | Valores centinela con nombre y repr legible |
Solo se serializa con pickle si es importable por módulo y nombre |
| UTF-8 por defecto | 686 | open() sin encoding usa UTF-8 |
Rompe ficheros en la codificación del locale |
* y ** en comprensiones |
798 | [*l for l in listas] aplana listas |
Es sintaxis nueva: 3.14 da SyntaxError |
profiling y Tachyon |
799 | Perfilador por muestreo sin modificar el código | profile queda obsoleto y se elimina en 3.17 |
| Frame pointers | 831 | Pilas nativas fiables para perf y eBPF | Las extensiones con su propio sistema de compilación deben conservar los flags |
| ABI estable free-threaded | 803 | Extensiones abi3t válidas para builds sin GIL |
Las herramientas de compilación aún no generan abi3t |
Cómo probé Python 3.15 en Docker
Todas las pruebas corrieron el 27 de septiembre de 2026 con la imagen oficial python:3.15.0rc2-slim, la segunda de las tres candidatas, sobre Debian 13 (trixie). La máquina es un Linux aarch64 con 18 núcleos, sin GPU, dentro de una máquina virtual de OrbStack. La página de la 3.15.0rc2 en python.org[3] lo deja claro: "only reviewed code changes which are clear bug fixes are allowed between this release candidate and the final release". Por eso lo que cuento aquí vale para la 3.15.0.
docker run --rm python:3.15.0rc2-slim python -VV
Python 3.15.0rc2 (main, Sep 19 2026, 01:05:41) [GCC 14.2.0]
Para comparar usé python:3.14.7-slim-trixie. Tras la publicación de la final, las etiquetas python:3.15 y python:3.15-slim del repositorio oficial de Python en Docker Hub[4] apuntan a la 3.15.0; en tus Dockerfile cambia 3.15.0rc2-slim por 3.15-slim. La imagen oficial se compila con --enable-optimizations y --with-lto, pero sin JIT: sys._jit.is_available() devuelve False.
Cómo funcionan los lazy imports
Un lazy import crea un objeto proxy en lugar de cargar el módulo, y la carga real ocurre la primera vez que usas el nombre. El PEP 810 de imports perezosos explícitos[5] lo firman Pablo Galindo Salgado, Germán Méndez Bravo, Thomas Wouters, Dino Viehland, Brittany Reynoso, Noah Kim y Tim Stumbaugh. lazy es una palabra clave blanda (soft keyword): solo tiene ese significado delante de import o from, y lazy = "algo" sigue siendo una asignación válida.
Esta es la cabecera de la CLI de prueba, un pequeño generador de informes que lee YAML y pinta una tabla con rich:
import argparse
lazy import json
lazy import sqlite3
lazy import requests
lazy import yaml
lazy from rich.console import Console
lazy from rich.table import Table
Con --version el programa imprime la versión y sale, así que ninguno de esos seis imports llega a cargar su módulo. El proxy se ve si inspeccionas globals(): antes del primer uso, type(globals()["json"]) devuelve <class 'lazy_import'>, y después, <class 'module'>. El tipo está disponible como types.LazyImportType.
Qué pasa cuando un lazy import falla
Si el módulo no existe, el programa arranca sin error y la excepción aparece en la línea que usa el nombre. El traceback encadena las dos ubicaciones, la del import y la del uso:
arranca sin error
Traceback (most recent call last):
File "/w/fallo.py", line 1, in <module>
lazy import modulo_que_no_existe
ImportError: lazy import of 'modulo_que_no_existe' raised an exception
during resolution
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/w/fallo.py", line 6, in <module>
modulo_que_no_existe.hacer()
ModuleNotFoundError: No module named 'modulo_que_no_existe'
lazy solo se admite en el nivel del módulo. Dentro de una función, Python 3.15 responde SyntaxError: lazy import not allowed inside functions, y tampoco lo acepta en clases, bloques try ni con import *. Si tu código tiene que seguir funcionando en 3.14, declara __lazy_modules__ = ["json"] al principio del módulo. La 3.15 trata entonces import json como perezoso, y la 3.14 ignora la variable y lo carga al momento; lo comprobé con las dos imágenes.
El modo global rompe los imports con efectos secundarios
La variable PYTHON_LAZY_IMPORTS=all (o -X lazy_imports=all) vuelve perezosos todos los imports sin cambiar una línea. El riesgo está en los módulos que hacen algo al importarse. En mi prueba, plugin_csv.py se añade a una lista de plugins cuando se importa, y app.py lo importa sin usarlo:
$ docker run --rm -v "$PWD":/w -w /w python:3.15.0rc2-slim python app.py
plugins: ['csv']
$ docker run --rm -e PYTHON_LAZY_IMPORTS=all -v "$PWD":/w -w /w \
python:3.15.0rc2-slim python app.py
plugins: []
Con el modo global, plugin_csv nunca se ejecuta porque nadie usa su nombre, y el registro queda vacío sin ningún error. Si quieres el modo global, limita su alcance con sys.set_lazy_imports_filter(), que recibe el módulo que importa y el importado y decide cuáles pueden ser perezosos.
Cuánto arranque ahorran los lazy imports
Marcar como lazy los seis imports redujo el arranque de --version un 87 %, de 73,5 ms a 9,8 ms de mediana. Medí cada caso 50 veces con un script que lanza el intérprete con subprocess y cronometra con time.perf_counter(), dentro del mismo contenedor.
Solo acepté tandas con la carga media del último minuto por debajo de 3 al empezar y al terminar. En las cuatro que publico se movió entre 1,09 y 1,15. Anoto también el tiempo de CPU del proceso hijo, que en estas condiciones coincide con el tiempo real.
La diferencia sale de los módulos que no se cargan. -X importtime escribe una línea por módulo importado más una cabecera, así que la versión normal cargó 342 módulos y la perezosa, 62:
python -X importtime informe_eager.py --version 2>imp_eager.txt
grep -c "import time:" imp_eager.txt
343
python -X importtime informe_lazy.py --version 2>imp_lazy.txt
grep -c "import time:" imp_lazy.txt
63
El import más caro era requests, con 37,0 ms acumulados en esa medición. Incluso al generar el informe, cuando yaml y rich sí se cargan, la versión perezosa tardó 29,2 ms frente a 71,3 ms, porque requests y sqlite3 nunca llegan a usarse.
El PEP 810 anticipa este efecto en herramientas de línea de comandos: "This can reduce startup time by 50-70% in practice". Mi 87 % supera esa horquilla porque --version no usa ninguno de los módulos; en un comando real ahorras lo que tu ruta no toca.
Con imports normales, la 3.15.0rc2 arrancó algo más lenta que la 3.14.7: 73,5 ms frente a 66,4 ms. En una segunda tanda con el orden invertido salió 70,4 ms frente a 65,6 ms. Incluso el intérprete vacío (python -c pass) tardó 5,0 ms frente a 4,4 ms.
frozendict: un diccionario inmutable y hashable
frozendict es un tipo integrado que no admite cambios después de crearse y que sirve como clave de diccionario si sus claves y valores son hashables. El PEP 814 de frozendict[6] lo implementaron Victor Stinner y Donghee Na. Este script prueba lo que más te va a importar:
import json, copy
cfg = frozendict(host="db", port=5432)
try:
cfg["port"] = 6432
except TypeError as e:
print("TypeError:", e)
otro = frozendict(port=5432, host="db")
print(cfg == otro, hash(cfg) == hash(otro))
print({cfg: "principal"}[otro])
print(isinstance(cfg, dict), json.dumps(cfg))
print(copy.copy(cfg) is cfg)
print(cfg | {"port": 6432})
La salida en 3.15.0rc2 fue esta:
TypeError: 'frozendict' object does not support item assignment
True True
principal
False {"host": "db", "port": 5432}
True
frozendict({'host': 'db', 'port': 6432})
La igualdad ignora el orden de inserción, así que dos frozendict con las mismas claves en distinto orden tienen el mismo hash. El operador | devuelve un frozendict nuevo sin tocar el original. json, copy, pickle y pprint lo aceptan, pero el False de isinstance(cfg, dict) es la dificultad: si tu código valida con isinstance(x, dict), cámbialo por isinstance(x, collections.abc.Mapping).
sentinel: valores centinela con nombre
sentinel crea un objeto único con un repr legible, para distinguir "no me han pasado nada" de None. Es el PEP 661 de valores centinela[7], escrito por Tal Einat e implementado por Jelle Zijlstra. Hasta ahora lo habitual era MISSING = object(), que se imprime como <object object at 0x…>.
import copy, pickle
MISSING = sentinel("MISSING")
def get(d, k, default=MISSING):
v = d.get(k, default)
if v is MISSING:
raise KeyError(k)
return v
print(MISSING, copy.deepcopy(MISSING) is MISSING)
print(pickle.loads(pickle.dumps(MISSING)) is MISSING)
print(int | MISSING)
print(get({"a": None}, "a"))
El script imprimió MISSING True, True, int | MISSING y None. La copia profunda y pickle conservan la identidad, así que is MISSING sigue funcionando tras serializar, y int | MISSING vale como anotación de tipo.
Desempaquetado con * y ** en comprensiones
Las comprensiones de listas, conjuntos y diccionarios aceptan ahora * y **, lo que sustituye a los bucles anidados y a itertools.chain(). El PEP 798 de desempaquetado en comprensiones[8] extiende la sintaxis del PEP 448 a este caso:
listas = [[1, 2], [3, 4], [5]]
print([*l for l in listas])
print({*s for s in [{1, 2}, {2, 3}]})
defaults = {"retries": 3, "timeout": 10}
env = {"timeout": 30}
print({**d for d in (defaults, env)})
La 3.15.0rc2 imprimió [1, 2, 3, 4, 5], {1, 2, 3} y {'retries': 3, 'timeout': 30}: en diccionarios gana el último valor. El mismo fichero en 3.14.7 falla antes de ejecutarse con SyntaxError: iterable unpacking cannot be used in comprehension, así que no lo uses en librerías que aún soporten versiones anteriores.
Cómo perfilar con Tachyon sin tocar el código
Tachyon es el perfilador por muestreo del nuevo paquete profiling: en lugar de instrumentar cada llamada, lee la pila del proceso a intervalos. La documentación de profiling.sampling[9] fija la frecuencia por defecto en 1 kHz y admite modos por tiempo real, CPU, GIL o excepciones. cProfile sigue existiendo como alias de profiling.tracing, y el módulo profile queda obsoleto hasta su eliminación en 3.17.
Lo probé con un script que serializa 20 000 filas a JSON y calcula su hash SHA-256, 40 veces:
python -m profiling.sampling run --pstats lento.py
python -m profiling.sampling run --flamegraph -o flame.html lento.py
python -m profiling.sampling run --heatmap -o heat lento.py
La primera orden capturó 1 109 muestras en 1,11 s, a 997,64 muestras por segundo. Esta es la salida recortada a tres columnas:
nsamples sample% filename:lineno(function)
382/382 40.9 encoder.py:263(JSONEncoder.iterencode)
166/166 17.8 lento.py:7(resumir)
144/757 15.4 lento.py:4(serializar)
El 40,9 % de las muestras cayó dentro del codificador JSON y el 17,8 % en el cálculo del hash. El modo --heatmap lleva ese dato a cada línea del código fuente (la captura es de otra ejecución del mismo script):

Para un proceso que ya está en marcha, python -m profiling.sampling attach -d 2 --pstats PID muestrea durante dos segundos y dump PID imprime la pila actual, útil con procesos colgados. En mi contenedor los dos funcionaron incluso como usuario sin privilegios, pero porque este host tiene ptrace_scope a 0. La documentación avisa de que en Linux necesitas ser root, tener CAP_SYS_PTRACE o relajar ese ajuste, así que en Docker añade --cap-add SYS_PTRACE si falla. El perfilador y el proceso deben usar la misma versión menor de Python.
Qué rompe UTF-8 como codificación por defecto
Python 3.15 usa UTF-8 en open() y en el resto de E/S de texto cuando no pasas encoding, sin mirar el locale del sistema. Es el PEP 686, que hace UTF-8 el modo por defecto[10], escrito por Inada Naoki. En la imagen oficial de Docker no notarás nada: no define locale, y con el locale C la 3.14.7 ya activaba el modo UTF-8 (sys.flags.utf8_mode valía 1). El cambio muerde en sistemas con locales heredados.
Para reproducirlo, generé el locale es_ES ISO-8859-1 en las dos imágenes y leí un CSV exportado en Latin-1 con open("clientes.csv"), sin encoding:
3.14.7 utf8_mode: 0 locale: ISO-8859-1
'id;nombre\n1;Mu\xf1oz\n'
3.15.0rc2 utf8_mode: 1 locale: ISO-8859-1
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 14:
invalid continuation byte
El arreglo correcto es declarar la codificación: con open("clientes.csv", encoding="latin-1") la 3.15 leyó Muñoz otra vez. Como parche temporal, PYTHONUTF8=0 devolvió el comportamiento de 3.14. Para encontrar estas llamadas antes de migrar, ejecuta tus pruebas en 3.14 con -X warn_default_encoding: en mi caso marcó la línea exacta con un EncodingWarning.
Frame pointers y JIT: lo que cambia por debajo
CPython 3.15 se compila por defecto con -fno-omit-frame-pointer y -mno-omit-leaf-frame-pointer, según el PEP 831 de frame pointers[11]. En la imagen oficial comprobé que los dos flags están en sysconfig.get_config_var("CFLAGS"). Con ellos, herramientas como perf o los perfiladores basados en eBPF reconstruyen la pila nativa sin tablas de desenrollado, un problema que ya tratamos en profiling continuo con eBPF en producción.
El JIT experimental también mejora. La página What’s New cita un 7-8 % de mejora media geométrica en pyperformance sobre el intérprete estándar en Linux x86-64, y un 11-12 % en macOS AArch64. No lo he medido: la imagen oficial de Docker no lo incluye y habría que compilar CPython con LLVM 21. El GIL sigue activo en la build estándar; la opción sin GIL es la que explicamos en Python 3.13 con GIL opcional, y la 3.15 le añade el ABI estable abi3t para extensiones.
Qué se elimina y qué te encuentras al actualizar
La 3.15 retira APIs que 3.13 y 3.14 ya marcaban como obsoletas. Ejecuté el mismo script en las dos versiones; en 3.14.7 cada caso era un DeprecationWarning y en 3.15.0rc2 fue esto:
sre_compile: ModuleNotFoundError: No module named 'sre_compile'
sqlite3 posicional: TypeError: connect() takes at most 1 positional
arguments (2 given)
strptime %d sin año: ValueError: Day of month directive '%d' may not be
used without a year directive.
import profile: DeprecationWarning The profile module is deprecated and
will be removed in Python 3.17.
python -m http.server --cgi también falla ahora con unrecognized arguments: --cgi, porque se eliminó CGIHTTPRequestHandler. Otras eliminaciones de la lista oficial son typing.no_type_check_decorator, codeobject.co_lnotab y el método load_module() de los cargadores de importlib. Además, re.prefixmatch() llega como nombre explícito de re.match(), que no se retira.
Los mensajes de error siguen mejorando, en la línea de lo que contamos en Python 3.12 y sus errores que por fin ayudan. [1, 2].push(3) responde ahora Did you mean '.append'? y 'hola'.toUpperCase(), Did you mean '.upper'?; en 3.14.7 no había sugerencia.
Ruedas binarias para cp315
El 27 de septiembre de 2026 comprobé con pip download --only-binary=:all: qué paquetes compilados tenían ruedas cp315 para aarch64. Las encontré para estos:
- numpy 2.5.3 y pandas 3.0.6
- pydantic-core 2.49.0 y orjson 3.12.0
- lxml 6.1.3, pillow 12.3.0 y psycopg-binary 3.3.6
- cryptography 50.0.1, con una rueda
abi3que sirve para cualquier CPython desde la 3.11
Según python.org, las ruedas compiladas contra las candidatas "will work with future versions of Python 3.15", así que no necesitan recompilarse para la final.
Cómo actualizar una imagen Docker a Python 3.15
La actualización se reduce a cambiar la etiqueta base y ejecutar las pruebas con las advertencias convertidas en errores. Este es el Dockerfile que usé para las pruebas con dependencias:
FROM python:3.15.0rc2-slim
RUN pip install --no-cache-dir --root-user-action=ignore \
requests rich pyyaml
Antes de cambiar la etiqueta a python:3.15-slim en producción, sigue estos pasos en orden:
- Ejecuta las pruebas en 3.14 con
-W error::DeprecationWarning -X warn_default_encodingpara ver lo que la 3.15 eliminará y losopen()sin codificación. - Añade
encoding=explícito a cadaopen()que lea ficheros que no genera tu aplicación. - Comprueba con
pip download --only-binary=:all:que tus dependencias compiladas tienen ruedas cp315. - Cambia la etiqueta base y repite las pruebas; si usas
isinstance(x, dict)para validar configuración, revisa si te llegaránfrozendict. - Mide el arranque con
python -X importtimey marca comolazylos imports que tu ruta más frecuente no usa.
Preguntas frecuentes
¿Cuándo sale Python 3.15 y hasta cuándo tiene soporte?
El PEP 790 fija la 3.15.0 final para el 9 de octubre de 2026; la fecha inicial era el 1 de octubre. Recibe correcciones cada dos meses durante dos años y parches de seguridad hasta octubre de 2031, aproximadamente.
¿Tengo que reescribir mis imports para usar los lazy imports?
No. Puedes marcar solo los imports caros con lazy, usar __lazy_modules__ si mantienes compatibilidad con 3.14, o activar el modo global con PYTHON_LAZY_IMPORTS=all. El modo global omite los módulos que se registran al importarse, así que pruébalo antes en tu código.
¿Python 3.15 es más rápido que 3.14 sin cambiar nada?
No en mi prueba. Con imports normales, la 3.15.0rc2 arrancó mi CLI algo más lenta que la 3.14.7: 73,5 ms frente a 66,4 ms de mediana. La ganancia grande llega al marcar imports como lazy. El JIT promete un 7-8 % en x86-64, pero la imagen oficial de Docker no lo trae.
Conclusión
Python 3.15 se nota sobre todo en el arranque y en la ergonomía. lazy convierte en una palabra lo que antes eran imports escondidos dentro de funciones, y frozendict y sentinel sustituyen patrones caseros. Las roturas reales son pocas y predecibles: ficheros leídos sin encoding, módulos eliminados tras dos versiones de aviso y parámetros de sqlite3 que pasan a ser solo por nombre. Empieza por ejecutar tus pruebas en 3.14 con -X warn_default_encoding y después cambia la etiqueta de tu imagen a python:3.15-slim.
Fuentes: [1] What’s New de Python 3.15[1], [2] PEP 790: calendario de Python 3.15[2], [3] PEP 810: imports perezosos explícitos[5], [4] PEP 814: el tipo integrado frozendict[6], [5] PEP 661: valores centinela[7], [6] Documentación de profiling.sampling (Tachyon)[9], [7] PEP 686: UTF-8 como modo por defecto[10], [8] PEP 798: desempaquetado en comprensiones[8], [9] PEP 831: frame pointers por defecto[11], [10] Página de Python 3.15.0rc2 en python.org[3], [11] Imagen oficial de Python en Docker Hub[4], [12] Página de Python 3.15.0rc3 en python.org[12].
Fuentes
- página What’s New de Python 3.15
- PEP 790, el calendario de Python 3.15
- página de la 3.15.0rc2 en python.org
- repositorio oficial de Python en Docker Hub
- PEP 810 de imports perezosos explícitos
- PEP 814 de frozendict
- PEP 661 de valores centinela
- PEP 798 de desempaquetado en comprensiones
- documentación de profiling.sampling
- PEP 686, que hace UTF-8 el modo por defecto
- PEP 831 de frame pointers
- Página de Python 3.15.0rc3 en python.org
Código fuente
Accede a todo el código fuente de este artículo en GitHub.
Ver en GitHub