Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Le mot-clé lazy de Python 3.15 : importer un module seulement quand on s'en sert

Ou : Comment payer ses imports à la livraison plutôt qu’à la commande

Python 3.15, dont la version finale était programmée pour le 9 octobre 2026, apporte un mot-clé que beaucoup attendaient sans le savoir : lazy. Écrit devant une instruction d’import, il diffère le chargement du module jusqu’au premier usage de son nom. C’est la PEP 810, et son effet se mesure en millisecondes : sur un petit outil en ligne de commande, la commande qui n’a besoin de rien démarre désormais aussi vite qu’un interpréteur vide, 12 millisecondes au lieu de 71. Le prix est discret mais réel : une erreur d’import ne se manifeste plus au démarrage, elle attend patiemment le premier utilisateur qui empruntera le mauvais chemin.

Imports paresseux en Python 3.15 : la syntaxe lazy import et ce qu’elle change

Le problème que tout outil en ligne de commande finit par avoir

En Python, un import en tête de fichier s’exécute au démarrage, que tu t’en serves ou non. Ton outil a dix sous-commandes, l’une parle HTTP, une autre lance une boucle asyncio, une troisième fait des calculs décimaux, et mon-outil --version charge les trois avant d’afficher un numéro. Le contournement habituel consiste à déplacer l’import dans la fonction qui en a besoin. Ça marche, ça agace les linters, et la PEP 8 demande précisément l’inverse : les imports en tête de fichier.

Le sujet n’est pas neuf. Une première proposition, la PEP 690, rendait les imports paresseux de façon implicite et globale ; elle a été rejetée, en partie parce qu’elle changeait en douce la sémantique des imports pour tout le monde1. La PEP 810 prend le chemin inverse : rien ne change si tu n’écris pas le mot lazy. Elle a été acceptée le 3 novembre 2025 pour Python 3.152.

Une ligne, un mot

1
2
3
4
5
import sys
lazy import asyncio
lazy import json
lazy from urllib.request import urlopen
lazy from decimal import Decimal

Tant que le nom n’est pas utilisé, il désigne un objet d’attente, pas un module. Le premier accès au nom, comme variable globale ou comme attribut, déclenche le vrai import et remplace l’objet d’attente par le module. Un petit script le montre sans ambiguïté :

1
2
3
4
5
6
7
8
import sys
lazy import json

print("json in sys.modules:", "json" in sys.modules)       # False
print("globals() entry:", type(globals()["json"]).__name__)  # lazy_import
print(json.dumps({"ok": True}))
print("json in sys.modules:", "json" in sys.modules)       # True
print("globals() entry:", type(globals()["json"]).__name__)  # module

Le module n’apparaît dans sys.modules qu’après son premier usage, et lire globals() ne suffit pas à le réveiller. Le type de l’objet d’attente est exposé sous le nom types.LazyImportType. Détail rassurant pour le code existant : lazy est un mot-clé contextuel, et une variable nommée lazy fonctionne toujours.

Ce que j’ai mesuré

J’ai testé avec la troisième version candidate, Python 3.15.0rc3, sur un petit outil en ligne de commande à quatre sous-commandes, écrit deux fois : imports classiques, puis imports paresseux. Chaque variante a tourné soixante fois, sur Linux, après cinq exécutions d’échauffement.

CommandeMédiane
Interpréteur vide, python -c pass11,8 ms
Imports classiques, sous-commande version71,0 ms
Imports paresseux, sous-commande version12,2 ms
Imports classiques lancés avec -X lazy_imports=all12,2 ms
Imports classiques, sous-commande serve94,4 ms
Imports paresseux, sous-commande serve76,1 ms

La sous-commande version paresseuse coûte le prix d’un interpréteur vide : les 59 millisecondes d’écart étaient entièrement des imports inutiles. La sous-commande serve, elle, a besoin d’asyncio, qui se charge donc quand même ; elle n’économise que les modules dont elle ne se sert pas. Le mot-clé ne supprime aucun coût, il le déplace vers le moment où il devient utile. python -X importtime donne le détail : asyncio pèse à lui seul entre 30 et 40 millisecondes cumulées selon les exécutions, urllib.request entre 14 et 24.

La quatrième ligne mérite un mot. L’option -X lazy_imports=all, ou la variable d’environnement PYTHON_LAZY_IMPORTS=all, rend paresseux tous les imports de niveau module, sans toucher au code. C’est le moyen le plus rapide de savoir ce que ton outil gagnerait, avant de modifier une seule ligne. Même logique que pour les benchmarks en Go : on mesure avant d’optimiser, pas après.

Ce qui est interdit, et pourquoi c’est bien

Le mot-clé ne fonctionne qu’au niveau du module. Python 3.15.0rc3 refuse tout le reste avec des messages limpides : lazy import not allowed inside functions, lazy import not allowed inside classes, lazy import not allowed inside try/except blocks, et lazy from ... import * is not allowed. Les imports __future__ ne peuvent pas être paresseux non plus. Un bloc with, en revanche, est accepté.

L’interdiction dans try est la plus instructive. Le motif classique try: import ujson except ImportError: import json repose sur une erreur levée à l’import. Avec un import paresseux, l’erreur surviendrait plus tard, loin du try, et le repli ne servirait plus à rien. Python préfère te l’interdire que te laisser croire qu’il fonctionne.

La faute de frappe qui attend son heure

C’est la contrepartie à connaître avant d’ajouter lazy partout. Une faute de frappe dans un nom de module ne casse plus le démarrage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
lazy import jsonn  # typo

print("startup ok")


def handler():
    return jsonn.dumps({})


handler()

Le script affiche startup ok, puis échoue à l’appel de handler(). La trace est bien faite : elle chaîne une première erreur, lazy import of 'jsonn' raised an exception during resolution, qui pointe vers la ligne de l’import, et la vraie ModuleNotFoundError, avec la suggestion Did you mean: 'json'?.

DevOps Dave : Notre outil démarre maintenant en 12 millisecondes.

Security Sarah : Et la sous-commande d’export, tu l’as lancée ?

DevOps Dave : Personne ne l’utilise.

Security Sarah : Alors personne ne saura qu’elle importe un module qui n’existe plus. Jusqu’au premier client qui l’utilise, un vendredi.

La parade tient en quelques lignes dans la chaîne d’intégration continue. Un filtre installé avec sys.set_lazy_imports_filter décide, import par import, s’il peut rester paresseux ; s’il répond toujours non, tout redevient immédiat.

1
2
3
4
5
6
7
import runpy
import sys

# Force every import to be eager, so a broken one fails at startup in CI.
sys.set_lazy_imports_filter(lambda importer, name, fromlist: False)
sys.argv = sys.argv[1:]
runpy.run_path(sys.argv[0], run_name="__main__")

Lancé sur le script fautif, ce lanceur échoue dès la ligne 1, avant le moindre startup ok. La PEP décrit aussi un mode none qui produirait le même effet en une option, mais Python 3.15.0rc3 le refuse : -X lazy_imports: invalid value; expected 'all' or 'normal'. Le filtre, lui, fonctionne. C’est le genre de test qui a sa place dans une stratégie de test complète, même pour un projet Python.

Rester compatible avec les versions précédentes

Sous Python 3.14 ou avant, lazy import json est tout simplement une SyntaxError. Une bibliothèque qui doit tourner sur plusieurs versions passe donc par une liste déclarée en tête de module :

1
2
3
4
__lazy_modules__ = ["json", "asyncio"]
import sys
import json
import asyncio

Python 3.15 traite ces imports comme paresseux : ni json ni asyncio n’apparaissent dans sys.modules au démarrage. Python 3.12, lui, ignore la liste et charge tout, comme avant. Le même fichier fonctionne partout et ne gagne du temps que là où c’est possible.

Le reste de la version, en un paragraphe

Python 3.15 apporte aussi frozendict, un dictionnaire immuable, hachable dès que son contenu l’est (PEP 814), et fait de l’UTF-8 l’encodage par défaut des entrées et sorties, quelle que soit la configuration régionale du système (PEP 686, réversible avec PYTHONUTF8=0). Un profileur statistique par échantillonnage, surnommé Tachyon, arrive dans le module profiling.sampling et peut s’attacher à un processus en cours (PEP 799). Le compilateur JIT reste expérimental, avec un gain annoncé de 7 à 8 % en moyenne géométrique sur x86-64 Linux3. Pour un développeur backend, ce sont de bonnes nouvelles. Mais c’est le mot lazy qui changera la façon d’écrire les outils en ligne de commande, les commandes d’administration et tout ce qui démarre souvent pour peu travailler.

Ce mot ne rend pas ton code plus rapide. Il rend ton démarrage honnête : tu ne paies que ce que tu utilises, au moment où tu l’utilises. Et une erreur que tu n’as jamais exercée reste une erreur, simplement plus patiente. Pour preuve, la troisième version candidate a été publiée pour corriger, selon l’annonce officielle, des bogues de dernière minute dans les imports paresseux. Même chez ceux qui l’ont conçu, la paresse demande de la rigueur.



  1. La PEP 690 proposait de rendre paresseux tous les imports d’un programme, sur simple activation globale. Son statut est « Rejected ». La PEP 810 résume la différence en une phrase : elle « takes an explicit, opt-in approach instead of PEP 690’s implicit global approach ». ↩︎

  2. La PEP 810, créée le 2 octobre 2025 et acceptée le 3 novembre 2025, est signée de sept auteurs, dont Pablo Galindo Salgado et Thomas Wouters. Elle affirme que les imports paresseux peuvent « reduce startup time by 50-70% in practice », en s’appuyant sur l’expérience d’organisations aux bases de code très interconnectées. Le chiffre de cet article vient d’un outil de quatre sous-commandes : l’ordre de grandeur concorde, ce qui n’est pas une démonstration. ↩︎

  3. L’annonce de la troisième version candidate fixe la version finale au 9 octobre 2026 et explique la RC supplémentaire par « some last-minute lazy-import release blockers ». Le détail des nouveautés est dans What’s New in Python 3.15. Pour tester avant la sortie, j’ai utilisé une build autonome de CPython 3.15.0rc3 publiée par le projet python-build-standalone, sans rien installer sur le système. ↩︎