Komeet Technologies

Nicolas Auvray / 26/10/2026

 

Python 3.15 est sorti le 9 octobre 2026. Comme chaque automne, c’est la nouvelle version annuelle, et la quasi-totalité du code existant tourne sans modification. Pourtant, elle fait partie des plus riches de ces dernières années, car même si c’est une amélioration mineure les apports sont eux important tant sur les nouvelles fonctionnalités que sur les améliorations de performance.

Pour mieux comprendre ce que ces évolutions apportent, en mesurer l’impact par nous-mêmes et apprendre à les manipuler, nous avons fait tourner les mêmes scripts sous Python 3.14 et 3.15, sur la même machine (détails en fin d’article). Pour chaque nouveauté, vous trouverez le code avant et après, nos mesures quand elles ont du sens, les cas où elle vaut vraiment le coup et, le cas échéant, les librairies connues qu’elle touche ou pourrait toucher.

En bref

  • lazy import : un module n’est chargé qu’au moment où le code s’en sert.
  • Unpacking dans les list/dict comprehensions : [*L for L in listes], plus court et deux fois plus rapide que les alternatives.
  • frozendict etsentinel : un dictionnaire immuable et une vraie valeur « absent ».
  • Messages d’erreur plus malins et UTF-8 par défaut, y compris sous Windows.
  • base64, binascii, csv : de 1,1 à 168 fois plus rapides, sans changer une ligne.
  • bytearray.take_bytes() : sortir des octets d’un tampon sans copie.
  • Tachyon : un profileur qui s’attache à un programme déjà lancé.
  • JIT : 32 % de temps en moins que 3.14 sur six benchmarks de calcul.

lazy import

En effet, avec un import classique, Python charge le module tout de suite, même si le programme ne s’en sert jamais. Avec lazy import (PEP 810), il ne le charge qu’au premier usage dans le déroulé du code.

Avant (3.14)

import json
import asyncio
import sqlite3   # chargés au lancement, utilisés ou non

Après (3.15)

lazy import json
lazy import asyncio
lazy import sqlite3   # chargés au premier usage

print("Démarrage...")              # aucun module chargé
data = json.loads('{"ok": true}')  # json se charge ici

Toutefois, faire un test de performance ici n’aurait pas vraiment de sens. lazy import ne rend pas l’import plus rapide, il évite de charger ce qui ne sert pas. Prenons-le plutôt comme une nouvelle façon d’importer, qui fait gagner du temps au démarrage si on l’utilise à bon escient.

À noter que l’option -X lazy_imports=all (ou la variable d’environnement PYTHON_LAZY_IMPORTS=all) rend tous les imports « lazy » sans toucher au code.

Est-ce la fin de import ?

Il ne faut pas non plus prendre lazy comme un remplaçant de l’import classique. Il ne fonctionne qu’au niveau du module (pas dans une fonction ni dans un try), il charge toujours le module entier, et décale une erreur d’import au premier usage plutôt qu’au lancement. On le réserve donc aux dépendances lourdes qu’on n’utilise pas à chaque fois. Pour rester compatible avec les anciennes versions, on peut déclarer __lazy_modules__ = ["json", "pathlib"] en tête de fichier. Attention aussi à l’utilisation massive de lazy_imports=all, qui peut casser du code important un module uniquement pour ses effets de bord (enregistrement de tâches Celery, de modèles SQLAlchemy…). En développement, l’import classique reste préférable pour détecter les erreurs tôt, lazy import vient ensuite pour optimiser.

Les cas d’usage

On pourra retrouver un apport dulazy import , si il est utilisé à bon escient, dans tout ce qui va fréquemment se lancer :

  • Outils en ligne de commande : seule la sous-commande appelée charge ses dépendances, et une commande comme --help devient bien plus réactive, ce qui est plus agréable pour l’utilisateur.
  • API : tout dépend du déploiement bien sûr. Pour un serveur tournant en continu, et qui ne démarre qu’une fois, le gain est négligeable. Mais pour une API en serverless ou en conteneur démarrant à la demande (FastAPI, Flask ou Django sur AWS Lambda, Cloud Run, Kubernetes), chaque démarrage à froid peut peser lourd sur la latence du premier utilisateur, et sur la facture avec Lambda.
  • Data : le scheduler d’Airflow relit régulièrement les fichiers de pipelines pour détecter les changements. Avec lazy import, il ne charge plus pandas ou scikit-learn, dont il n’a pas besoin pour lire la structure d’un pipeline.
  • Tests avec pytest : pytest importe toute l’application pour collecter les tests, même quand on n’en lance qu’un seul. C’est du temps gagné à chaque cycle de développement.

Impact sur les librairies

Les librairies scientifiques qui gèrent déjà le chargement à la demande à la main (SciPy, scikit-image via lazy_loader) disposent désormais d’un mécanisme natif. Par ailleurs, lazy est un nouveau mot-clé, comme l’unpacking présenté juste après est une nouvelle syntaxe. Il faut donc des versions de Ruff, Black, mypy ou Pyright qui les reconnaissent.

Unpacking dans les list comprehensions et dict comprehensions

Le * qu’on utilise déjà dans print(*liste) fonctionne désormais dans les list comprehensions, mais aussi dans les set et dict comprehensions (PEP 798).

Avant (3.14)

plat = [x for L in listes for x in L]
plat = list(itertools.chain.from_iterable(listes))

Après (3.15)

plat = [*L for L in listes]          # aplatir une liste de listes
tags = {*t for t in tags_par_doc}    # réunir des ensembles
fusion = {**d for d in dicts}        # fusionner des dictionnaires

Mesure sur 2 000 listes de 10 entiers :

Méthode 3.14 3.15
[x for L in listes for x in L] 931 µs 603 µs
list(chain.from_iterable(listes)) 420 µs 382 µs
[*L for L in listes] n’existe pas 194 µs

Pour fusionner 2 000 petits dictionnaires, {**d for d in dicts} prend 947 µs, contre 1 989 µs pour {k: v for d in dicts for k, v in d.items()} en 3.14.

Les cas d’usage

Les cas d’usage les plus courant seront bien sûr en data et ou en scraping, où l’unpacking est une opération de tous les jours. Chaque page scrapée ou chaque appel d’API paginée renvoie une liste d’éléments qu’il faut mettre bout à bout avant de créer un DataFrame ou d’écrire en base (lignes = [*extraire(page) for page in pages]). Même chose en data engineering pour regrouper des lots ou fusionner des métadonnées venues de plusieurs sources ({**m for m in metas}). Une fois les données dans pandas ou Polars, leurs propres fonctions (concat, explode) restent plus adaptées. Mais au-delà de la vitesse, le vrai gain reste la clarté du code !

frozendict et sentinel

Frozendict

Un dict ne peut pas servir de clé de cache, car il est modifiable. En 3.14, il fallait le convertir à chaque appel. frozendict (PEP 814) vient combler ce manque avec un dictionnaire figé et hashable, c’est-à-dire utilisable comme clé de dictionnaire.

Avant (3.14)

cle = frozenset(config.items())   # recalculé à chaque appel
cache[cle] = resultat

Après (3.15)

config = frozendict(host="db", port=5432)
config["port"] = 1        # TypeError
cache[config] = resultat  # utilisable directement comme clé
Opération (1 000 clés) Temps
3.14 : hash(frozenset(d.items())) 112 µs
3.14 : hash(tuple(sorted(d.items()))) 191 µs
3.15 : hash(frozendict) 0,08 µs
3.15 : créer un dict / un frozendict 6,7 µs / 7,4 µs
3.15 : lire une clé, dict / frozendict 0,05 µs / 0,05 µs

Création et lecture vont aussi vite qu’avec un dict. En effet, le gain est ailleurs, avec une clé de cache prête à l’emploi (functools.cache, pools de connexions), une configuration partagée que personne ne peut écraser par erreur, ou une valeur par défaut sûre (def f(options=frozendict())). Ce dictionnaire est figé en surface seulement, une liste rangée à l’intérieur restera modifiable. Ce n’est pas non plus un coffre-fort pour les secrets. On ne peut pas le modifier, mais on peut toujours le lire.

Impact sur les librairies

Les paquets PyPI frozendict et immutabledict, qui comblaient ce manque, deviennent inutiles à partir de 3.15. Attention toutefois au nom, car tant que le paquet est installé, from frozendict import frozendict masque le type natif.

Sentinel

Quand None est une valeur valide, on utilisait object() pour dire « rien n’a été fourni », avec un affichage illisible (PEP 661). Python 3.15 ajoute le type sentinel, qui permet de créer une valeur dédiée pour distinguer une valeur volontairement laissée vide d’une valeur qui n’a pas été remontée.

Si l’on prend l’exemple d’une API qui remonte les données d’un client, dont son numéro de téléphone, le résultat doit être différent selon que le numéro a été volontairement laissé vide ou qu’il n’a pas été remonté.

Avant (3.14)

MANQUANT = object()   # l'astuce habituelle : un objet unique

def lire_telephone(client):
    return client.get("telephone", MANQUANT)

# Cas 1 : le client n'a volontairement pas renseigné son numéro
lire_telephone({"nom": "Durand", "telephone": None})   # None

# Cas 2 : l'API n'a pas remonté le champ
tel = lire_telephone({"nom": "Durand"})
print(tel)            # <object object at 0x7f3a...> : illisible dans les logs
tel is MANQUANT       # True... mais False après une copie ou un pickle (sauvegarde d'objet),
                      # le cas 2 se confond alors avec une vraie valeur

Après (3.15)

MANQUANT = sentinel("MANQUANT")

def lire_telephone(client):
    return client.get("telephone", MANQUANT)

# Cas 1 : le client n'a volontairement pas renseigné son numéro
lire_telephone({"nom": "Durand", "telephone": None})   # None

# Cas 2 : l'API n'a pas remonté le champ
tel = lire_telephone({"nom": "Durand"})
print(tel)            # MANQUANT : lisible dans les logs
tel is MANQUANT       # True, même après une copie ou un pickle

Par exemple, la sentinelle prend tout son sens lors d’une mise à jour. Elle permet de savoir si un champ a été volontairement vidé, et doit donc être supprimé, ou simplement pas envoyé.

# Mise à jour : seul ce qui est envoyé est modifié
def modifier_client(id, telephone: str | None | MANQUANT = MANQUANT):
    if telephone is MANQUANT:
        return                     # champ non envoyé : on ne touche à rien
    enregistrer(id, telephone)     # None : on supprime le numéro ; sinon : on le remplace

modifier_client(42)                   # le numéro reste inchangé
modifier_client(42, telephone=None)   # le numéro est supprimé

La sentinelle se définit une seule fois, en haut du module, puis se réutilise partout. Deux appels à sentinel("MANQUANT") créent en effet deux valeurs différentes !

Impact sur les librairies

Beaucoup de librairies ont leur sentinelle maison (attrs.NOTHING, PydanticUndefined chez Pydantic, ou dataclasses.MISSING dans la librairie standard). Le type standard leur offre une base commune, et typing_extensions.Sentinel le rend disponible sur les versions précédentes.

Messages d’erreur et UTF-8

Des suggestions plus précises

Python 3.15 suggère le bon chemin quand l’attribut existe dans un objet imbriqué, et reconnaît les réflexes venus d’autres langages :

Code Python 3.14 Python 3.15
conteneur.radius pas de suggestion Did you mean '.inner.radius' instead of '.radius'?
math.sqroot(4) Did you mean: 'sqrt'? Did you mean '.sqrt' instead of '.sqroot'?
"hello".toUpperCase() pas de suggestion Did you mean '.upper'?

De quoi faire gagner du temps aux débutants, aux équipes qui arrivent de JavaScript ou de Java, et à quiconque lit des logs.

UTF-8 partout (même sous Windows)

Jusqu’ici, open("fichier.txt") sans encodage utilisait celui du système, UTF-8 sous Linux et macOS mais souvent cp1252 sous Windows, d’où le classique « ça marche chez moi » avec les accents. Avec la PEP 686, Python 3.15 passe à UTF-8 partout. Sur notre machine Windows :

>>> import locale; locale.getpreferredencoding()
'cp1252'   # Python 3.14
'utf-8'    # Python 3.15

Préciser encoding= reste une bonne pratique.

Impact sur les librairies

Tout ce qui ouvre un fichier sans préciser l’encodage change de comportement sous Windows, y compris dans la librairie standard (logging.FileHandler, configparser, et surtout subprocess en mode texte). Sur notre machine, subprocess.run(["cmd", "/c", "echo café"], capture_output=True, text=True) affichait caf� en 3.14 et lève une UnicodeDecodeError en 3.15, car les commandes Windows écrivent dans l’encodage de la console. Il faut alors préciser encoding="oem" pour ces commandes.

Traitement des octets

bytearray.take_bytes()

Un bytearray est un tampon, une zone d’octets modifiable qu’on remplit au fur et à mesure (lecture réseau, fichier…). bytes(tampon) en recopie tout le contenu, alors que take_bytes() le cède sans le copier.

Avant (3.14)

donnees = bytes(tampon)   # copie les 10 Mo
tampon.clear()

Après (3.15)

donnees = tampon.take_bytes()   # aucune copie

Sur 10 Mo, bytes() prend 1,7 ms et take_bytes() 0,6 µs. Le gain grandit avec la taille, ce qui est utile pour les lectures réseau, les protocoles binaires ou les gros fichiers lus par blocs. Sur quelques Ko par contre, l’apport est invisible.

Impact sur les librairies

Les librairies qui accumulent des octets reçus dans un bytearray avant de les transmettre, comme aiohttp ou websockets, pourront l’adopter pour économiser une copie à chaque message.

base64, binascii, csv

Par ailleurs, les encodeurs ont été réécrits et optimisé. Il suffit de changer de version de Python. Voici les mesures sur 1 Mo de données aléatoires :

Fonction 3.14 3.15 Gain
base64.b64encode 6 920 µs 1 925 µs × 3,6
base64.b64decode 5 432 µs 1 306 µs × 4,2
base64.b32encode 275 500 µs 1 637 µs × 168
base64.b85encode 219 723 µs 2 501 µs × 88
binascii.hexlify 3 142 µs 2 336 µs × 1,3
binascii.unhexlify 3 416 µs 2 984 µs × 1,1

base64 est partout, dans les pièces jointes d’e-mails, les jetons JWT ou les images envoyées aux API de modèles d’IA. Autre bonne surprise, le module csv lit notre fichier de test (diamonds.csv, 53 940 lignes) deux fois plus vite, 72 ms contre 154 ms, et csv.DictReader passe de 308 à 187 ms.

Impact sur les librairies

Les librairies qui s’appuient sur le module standard en profitent automatiquement, comme PyJWT pour les jetons, le module email pour les pièces jointes, ou le code qui encode des images avant de les envoyer aux SDK OpenAI ou Anthropic.

Tachyon

Historiquement, cProfile permettait d’observer chaque appel de fonction. Précis, mais il a tendance à ralentir le programme et à fausser ses propres mesures. Python 3.15 ajoute le paquet profiling (PEP 799) et un profileur par échantillonnage (sampling profiler), Tachyon, qui regarde de l’extérieur, des milliers de fois par seconde, où en est le programme. Son impact sur les mesures devient négligeable.

Avant (3.14)

python -m cProfile -s cumtime mon_script.py

Après (3.15)

python -m profiling.sampling run -r 1khz mon_script.py
python -m profiling.sampling attach 12345   # sur un processus en cours

Son principal intérêt est de pouvoir se brancher sur un service qui ralentit en production, sans le redémarrer ni modifier son code. Il donne une vue statistique, idéale pour trouver les goulets d’étranglement, moins pour compter exactement les appels. cProfile reste disponible (aussi sous le nom profiling.tracing). Le module profile en pur Python est lui déprécié.

Impact sur les librairies

Tachyon fait désormais en standard ce qui demandait jusqu’ici un outil externe comme py-spy ou Scalene.

JIT

Le JIT traduit en code machine les parties les plus sollicitées d’un programme pendant son exécution. Expérimental depuis 3.13, il couvre en 3.15 beaucoup plus d’opérations. On l’active sans changer une ligne de code :

PYTHON_JIT=1 python mon_script.py

Six benchmarks de pyperformance (de petits programmes de calcul qui servent de référence à la communauté Python), temps moyen en millisecondes :

Benchmark 3.14 3.14 + JIT 3.15 3.15 + JIT
fannkuch 626 393 435 344
float 91 80 73 64
go 133 115 119 92
nbody 120 104 109 91
richards 53 42 43 39
spectral_norm 117 108 92 77

En moyenne sur les six benchmarks, 3.15 sans JIT met 18 % de temps en moins que 3.14. De plus, le JIT retire encore 17 %, soit 32 % de moins que 3.14 sans JIT.

Des gains relatifs

Ces six benchmarks sont des boucles de calcul, le terrain favori d’un JIT. Sur l’ensemble de la suite, l’annonce officielle donne des gains plus modestes, de 7 à 8 % sur Linux x86-64 et de 11 à 12 % sur macOS ARM. Cependant, le JIT reste expérimental, et il n’est pas nécessairement synonyme de gain de temps. Son avenir reste d’ailleurs incertain. Cet été, le comité qui prend les grandes décisions sur le langage (le Steering Council) a mis son développement en pause. L’équipe du JIT avait proposé un plan pour le rendre officiel d’ici Python 3.17 (PEP 836), mais elle l’a retiré le 19 octobre 2026, faute de temps pour le mener à bien.

Même si cela paraît niche, ce n’est pas vraiment le cas. Le JIT accélère tout ce qui s’exécute en Python pur, comme les boucles sur des lignes ou des dictionnaires, le nettoyage de données écrit à la main, les transformations ETL ligne à ligne, les règles métier ou les parsers. Il ne fait rien en revanche pour le code déjà compilé, ni pour un programme qui attend le réseau ou une base de données. Si votre traitement de données est une boucle for sur des millions de lignes, le JIT peut aider. S’il est vectorisé avec pandas, NumPy ou Polars, qui calculent en C ou en Rust, il ne change rien.

Impact sur les librairies

Les librairies écrites en Python pur peuvent en profiter sans modification, comme openpyxl (fichiers Excel), NetworkX (graphes) ou SymPy (calcul symbolique). Nous ne l’avons pas mesuré ici.

Et face à C, Rust ou Go ?

Pour situer Python, nous avons repris un calcul classique, la simulation « n-body » (cinq astres qui s’attirent, 500 000 pas).

Version Temps (meilleur de 3)
Python 3.14 2,94 s
Python 3.15 2,53 s
Python 3.15 + JIT 2,54 s

Python 3.15 gagne 14 %. Le JIT, lui, ne change rien sur ce script, alors qu’il accélère le nbody de pyperformance. Selon le code, son impact peut donc être important ou quasi nul.

Sur ce type de calcul, C, Rust et Go jouent dans une tout autre catégorie, avec des écarts qui se comptent en ordres de grandeur. Ces langages sont bien moins utilisés que Python pour l’analyse de données ou les scripts d’automatisation, et plus exigeants à écrire, mais nettement plus efficaces pour le calcul intensif, les outils système ou les services très sollicités. C’est d’ailleurs souvent en C ou en Rust que sont écrites les librairies que Python orchestre (NumPy, Polars, DuckDB), et dans un vrai projet, l’écart se réduit beaucoup.

En conclusion

Pour une version mineure, la 3.15 apporte des changements plus importants qu’il n’y paraît. Elle rend le code plus lisible (unpacking dans les comprehensions, sentinel, messages d’erreur), parfois plus rapide (JIT, base64, csv), et intègre dans Python ce que l’on allait jusqu’ici chercher sur PyPI ou que certaines librairies avaient dû construire elles-mêmes. frozendict remplace les paquets frozendict et immutabledict, sentinel les sentinelles maison d’attrs ou de Pydantic, lazy import le chargement à la demande que SciPy gérait avec lazy_loader, et Tachyon des profileurs externes comme py-spy. Seul le JIT reste à surveiller, son avenir n’étant pas encore tranché.

Et après ?

La première alpha de Python 3.16 est sortie le 13 octobre 2026, pour une version finale prévue le 5 octobre 2027 (PEP 826). Au programme pour l’instant, des opérations d’ensembles et la syntaxe \p{...} dans re, la famille sinpi dans math, et la suppression de fichiers dans une archive zipfile. Rien n’est figé avant la bêta de mai 2027.

Et si les sujets portant sur Python vous intéressent, sachez que la PyConFR 2026 se tient à Biarritz du 29 octobre au 1er novembre. N’hésitez pas à consulter le programme et à y participer, l’entrée est gratuite sur inscription.

Méthode de mesure

  • Machine : ordinateur portable, Intel Core i7-1185G7 (4 cœurs, 8 threads), 16 Go de RAM, Windows 11 Pro.
  • Interpréteurs : Python 3.14.8 et 3.15.0, builds python-build-standalone installés avec uv, JIT inclus (activé par PYTHON_JIT=1).
  • Méthode : meilleur de 7 répétitions pour les micro-tests, pyperformance 1.14.0 pour le JIT.

Sources

Laisser une réponse

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

footer shape