
La première technique d'optimisation, c'est la mesure : tant que vous n'avez pas chiffré où le programme passe son temps, chaque changement est une supposition. Ce guide part de ce principe et parcourt les leviers qui rapportent vraiment en Python : choisir une structure de données adaptée, préférer les compréhensions, économiser la mémoire avec des générateurs, mémoïser avec lru_cache, alléger les objets avec __slots__, et construire les chaînes avec str.join. Il s'adresse aux développeurs à l'aise avec le langage qui veulent des gains réels et non des micro-astuces. Pour chaque technique, le gain est mesuré avec timeit, chiffres avant/après à l'appui. Les exemples ont été exécutés avec Python 3.12.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Mesurer un fragment de code avec
timeitavant de le toucher. - Choisir entre
list,setetdictselon l'opération dominante. - Réduire l'empreinte mémoire avec les générateurs et
__slots__. - Éliminer les recalculs avec la mémoïsation
lru_cache. - Construire des chaînes et parcourir des collections sans gaspillage.
- Éviter l'optimisation prématurée qui complique sans accélérer.
Mesurer d'abord avec timeit
Section intitulée « Mesurer d'abord avec timeit »Optimiser sans mesurer, c'est deviner. Le coupable réel d'une lenteur est presque toujours ailleurs que là où l'intuition le place : une requête répétée, une structure mal choisie, une copie cachée. Le module timeit exécute un fragment un grand nombre de fois et renvoie un temps stable, à l'abri des variations d'une exécution unique. C'est l'outil de base pour comparer deux versions d'un même code.
L'idée est de fournir un setup (le décor, exécuté une fois) et un stmt (le code mesuré, répété number fois). Le temps total divisé par number donne le coût par appel.
import timeit
setup = "data = list(range(10000))"stmt = "sum(x * x for x in data)"
total = timeit.timeit(stmt, setup=setup, number=1000)print(f"{total / 1000 * 1e6:.1f} us par appel")1043.7 us par appelCe chiffre n'a de sens que comparé à une alternative. Toute la suite du guide applique ce protocole : une version de référence, une version optimisée, et l'écart mesuré entre les deux. Retenez la règle : mesurez, changez, remesurez. Sans le troisième temps, vous ne saurez jamais si le changement a aidé.
Choisir la bonne structure de données
Section intitulée « Choisir la bonne structure de données »C'est le levier le plus rentable de tout Python, et de loin. Tester l'appartenance d'un élément (x in collection) coûte un temps proportionnel à la taille dans une list (parcours élément par élément, complexité O(n)), mais un temps quasi constant dans un set ou un dict (accès par table de hachage, complexité O(1)). Sur des milliers de recherches, l'écart devient colossal.
import timeit
setup = """data_list = list(range(10000))data_set = set(data_list)cible = 9999"""
t_list = timeit.timeit("cible in data_list", setup=setup, number=100000)t_set = timeit.timeit("cible in data_set", setup=setup, number=100000)print(f"list : {t_list / 100000 * 1e6:.3f} us par test")print(f"set : {t_set / 100000 * 1e6:.3f} us par test")list : 42.729 us par testset : 0.013 us par testPlus de 3000 fois plus rapide pour la recherche, sur une collection de 10 000 éléments. Et l'écart grandit avec la taille : la list ralentit à mesure qu'elle grossit, le set reste plat. Dès que votre code fait des tests d'appartenance répétés (dédoublonnage, filtrage, vérification d'existence), convertissez la liste en set une fois, puis interrogez le set. Le dict offre la même vitesse quand vous avez besoin d'associer une valeur à chaque clé.
Préférer les compréhensions aux boucles
Section intitulée « Préférer les compréhensions aux boucles »Construire une liste avec une compréhension ([i * i for i in ...]) est plus rapide qu'une boucle for qui appelle append à chaque tour. La raison est interne : la compréhension exécute la construction dans une boucle optimisée au niveau du bytecode, sans repayer la résolution de la méthode append ni l'appel de fonction à chaque itération. Le gain reste modéré, mais il est gratuit et le code est plus lisible.
import timeit
stmt_boucle = """res = []for i in range(1000): res.append(i * i)"""stmt_comp = "[i * i for i in range(1000)]"
t_boucle = timeit.timeit(stmt_boucle, number=10000)t_comp = timeit.timeit(stmt_comp, number=10000)print(f"boucle append : {t_boucle / 10000 * 1e6:.1f} us")print(f"comprehension : {t_comp / 10000 * 1e6:.1f} us")boucle append : 19.5 uscomprehension : 16.9 usEnviron 13% de temps en moins ici, sans autre effort que réécrire la boucle. Le point important : la compréhension gagne quand elle construit réellement une liste. Si votre boucle fait un travail complexe à chaque tour ou déclenche des effets de bord, ne la tordez pas en compréhension illisible pour grappiller ces pourcents. La lisibilité prime sur un gain marginal.
Économiser la mémoire avec les générateurs
Section intitulée « Économiser la mémoire avec les générateurs »Quand vous n'avez pas besoin de tous les éléments en même temps, un générateur remplace une liste et supprime son coût mémoire. Une liste stocke chaque valeur en RAM ; un générateur ne garde que l'état nécessaire pour produire la valeur suivante à la demande. Sur de grands volumes, la différence n'est pas un pourcentage, c'est un changement d'ordre de grandeur.
import sys
liste = [i for i in range(1000000)]generateur = (i for i in range(1000000))
print(f"liste : {sys.getsizeof(liste)} octets")print(f"generateur : {sys.getsizeof(generateur)} octets")liste : 8448728 octetsgenerateur : 192 octetsLa liste occupe plus de 8 Mo, le générateur 192 octets, quel que soit le nombre d'éléments à venir. Cet écart change ce qui est possible : un fichier de plusieurs gigaoctets se traite ligne par ligne avec un générateur alors qu'il ferait exploser la mémoire chargé d'un bloc. Le compromis est réel : un générateur se parcourt une seule fois et n'autorise pas l'accès par indice. Utilisez-le pour un flux que vous consommez une fois, gardez la liste quand vous devez relire ou indexer.
Mémoïser les calculs répétés avec lru_cache
Section intitulée « Mémoïser les calculs répétés avec lru_cache »Si une fonction pure (même entrée, même sortie, sans effet de bord) est appelée plusieurs fois avec les mêmes arguments, recalculer est du gaspillage. Le décorateur functools.lru_cache mémorise les résultats déjà obtenus et les restitue instantanément. Sur un calcul récursif qui explore les mêmes sous-problèmes, l'effet est spectaculaire.
import timeit
setup = """from functools import lru_cache
def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
@lru_cache(maxsize=None)def fib_cache(n): return n if n < 2 else fib_cache(n - 1) + fib_cache(n - 2)"""
t_sans = timeit.timeit("fib(30)", setup=setup, number=100)t_avec = timeit.timeit("fib_cache(30)", setup=setup, number=100)print(f"sans cache : {t_sans / 100 * 1e3:.2f} ms par appel")print(f"avec cache : {t_avec / 100 * 1e6:.3f} us par appel")sans cache : 63.16 ms par appelavec cache : 0.095 us par appelLa version naïve recalcule les mêmes valeurs des millions de fois ; la version mémoïsée les stocke et devient quasi gratuite après le premier appel. Deux garde-fous s'imposent. Le cache ne vaut que pour une fonction déterministe : ne jamais mémoïser une fonction qui lit une base, un fichier ou l'heure. Et maxsize=None garde tout en mémoire sans limite ; sur un domaine d'entrées vaste, fixez une taille (lru_cache(maxsize=1024)) pour borner la consommation.
Alléger les objets avec slots
Section intitulée « Alléger les objets avec slots »Par défaut, chaque instance Python range ses attributs dans un dictionnaire interne (__dict__), souple mais coûteux en mémoire. Déclarer __slots__ remplace ce dictionnaire par un stockage fixe et compact : les attributs sont figés, mais l'empreinte fond. Sur des centaines de milliers d'objets, ce gain devient décisif.
import tracemalloc
class Point: def __init__(self, x, y): self.x = x self.y = y
class PointSlots: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y
tracemalloc.start()objets = [Point(i, i) for i in range(100000)]courant, _ = tracemalloc.get_traced_memory()print(f"sans slots : {courant / 1e6:.2f} Mo")tracemalloc.stop()
tracemalloc.start()objets2 = [PointSlots(i, i) for i in range(100000)]courant2, _ = tracemalloc.get_traced_memory()print(f"avec slots : {courant2 / 1e6:.2f} Mo")tracemalloc.stop()sans slots : 12.00 Moavec slots : 8.79 MoEnviron 27% de mémoire en moins pour 100 000 objets, et un accès aux attributs légèrement plus rapide en prime. La contrepartie est la rigidité : avec __slots__, on ne peut plus ajouter d'attribut non déclaré à l'instance, et l'héritage multiple demande de la prudence. Réservez ce levier aux classes nombreuses et stables, typiquement des points, des enregistrements ou des noeuds que vous créez en masse.
Résoudre les noms en local dans les boucles chaudes
Section intitulée « Résoudre les noms en local dans les boucles chaudes »Python cherche un nom d'abord dans l'espace local, puis dans le global, puis dans les builtins. Cette cascade a un coût minuscule, mais dans une boucle très chaude qui appelle des millions de fois la même fonction globale, copier la référence dans une variable locale avant la boucle fait gagner quelques pourcents. C'est un levier de dernier recours, à ne sortir que sur un point chaud identifié par la mesure.
import timeit
setup = """import math
def globale(): total = 0.0 for i in range(10000): total += math.sqrt(i) + math.sin(i) return total
def locale(): sqrt = math.sqrt sin = math.sin total = 0.0 for i in range(10000): total += sqrt(i) + sin(i) return total"""
t_glob = timeit.timeit("globale()", setup=setup, number=2000)t_loc = timeit.timeit("locale()", setup=setup, number=2000)print(f"acces global : {t_glob:.3f} s")print(f"acces local : {t_loc:.3f} s")acces global : 1.190 sacces local : 1.132 sDe l'ordre de 5 à 10% ici, selon la charge de la boucle. C'est réel, mais faible : n'appliquez cette réécriture que sur un noyau de calcul avéré, jamais partout par réflexe. En Python 3.12, l'interpréteur adaptatif optimise déjà une partie de ces accès, et l'astuce peut même n'apporter presque rien selon le cas. Autrement dit : mesurez avant de compliquer votre code pour ça.
Construire les chaînes avec str.join
Section intitulée « Construire les chaînes avec str.join »Assembler une chaîne en concaténant dans une boucle (s += morceau) est un piège classique. Les chaînes Python sont immuables : chaque += peut recréer une nouvelle chaîne et recopier tout l'accumulé. La bonne méthode collecte les morceaux puis les assemble en une seule passe avec str.join. L'écart est net dès quelques milliers de fragments.
import timeit
setup = "mots = [str(i) for i in range(10000)]"
stmt_plus = """s = ""for m in mots: s += m"""stmt_join = "''.join(mots)"
t_plus = timeit.timeit(stmt_plus, setup=setup, number=2000)t_join = timeit.timeit(stmt_join, setup=setup, number=2000)print(f"concatenation += : {t_plus:.3f} s")print(f"str.join : {t_join:.3f} s")concatenation += : 0.317 sstr.join : 0.048 sPlus de 6 fois plus rapide pour assembler 10 000 fragments, et le code est plus clair. La règle pratique : dès que vous accumulez des morceaux de texte dans une boucle, mettez-les dans une liste et faites "".join(liste) à la fin. Réservez l'opérateur + aux assemblages ponctuels de deux ou trois chaînes, où il reste parfaitement lisible.
Éviter les copies inutiles
Section intitulée « Éviter les copies inutiles »Beaucoup de lenteurs viennent de données recopiées sans raison. Le cas le plus courant est le slice complet (data[:]) utilisé pour itérer : il duplique toute la collection en mémoire alors qu'un simple parcours direct suffit. Le surcoût est double, en temps et en RAM.
import timeit
setup = "data = list(range(10000))"
stmt_copie = """total = 0for x in data[:]: total += x"""stmt_direct = """total = 0for x in data: total += x"""
t_copie = timeit.timeit(stmt_copie, setup=setup, number=10000)t_direct = timeit.timeit(stmt_direct, setup=setup, number=10000)print(f"data[:] (copie) : {t_copie / 10000 * 1e6:.1f} us")print(f"data (direct) : {t_direct / 10000 * 1e6:.1f} us")data[:] (copie) : 135.2 usdata (direct) : 123.9 usEnviron 9% de temps en moins, plus une copie complète de la liste évitée à chaque tour. Le slice n'est justifié que si vous modifiez la collection pendant le parcours et voulez itérer sur un instantané figé. En dehors de ce cas, itérez directement. Même logique pour les fonctions : passer une grande structure en argument ne la copie pas en Python (un appel transmet des références aux objets, jamais des copies), inutile donc de la dupliquer par prudence.
Fuir l'optimisation prématurée
Section intitulée « Fuir l'optimisation prématurée »La technique la plus importante de tout ce guide est de savoir quand ne pas optimiser. La très grande majorité d'un programme n'est pas un goulot d'étranglement : y appliquer des micro-astuces ne fait que compliquer le code sans effet mesurable sur le temps total. Un code plus tordu est plus long à lire, plus dur à corriger, et cache mieux ses bugs. Le vrai coût d'une optimisation inutile est là.
La bonne démarche tient en trois temps. D'abord, écrire un code clair qui marche. Ensuite, mesurer pour trouver les 20% de code qui consomment 80% du temps. Enfin, optimiser uniquement ces points chauds, en remesurant après chaque changement. Un profileur identifie ces zones avec précision ; timeit chiffre ensuite chaque candidat de remplacement. Optimiser au jugé, partout, sans données, est la façon la plus sûre de perdre du temps et de la clarté à la fois.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Mesurez avant tout :
timeitcompare deux versions, un profileur trouve les points chauds. Sans chiffres, vous devinez. - La structure de données est le premier levier :
set/dicten O(1) contrelisten O(n), mesuré à plus de 3000 fois plus rapide pour l'appartenance. - Les compréhensions battent la boucle
appendd'environ 13%, à lisibilité égale ou meilleure. - Un générateur remplace une liste par 192 octets au lieu de 8 Mo quand vous consommez un flux une seule fois.
lru_cacherend quasi gratuite une fonction pure rappelée avec les mêmes arguments ; à réserver au déterministe et à borner en taille.__slots__réduit d'environ 27% l'empreinte d'objets créés en masse, au prix de la rigidité.str.joinest plus de 6 fois plus rapide que la concaténation+=en boucle.- Évitez les copies inutiles et surtout l'optimisation prématurée : un code clair d'abord, l'accélération ensuite, sur les seuls points chauds mesurés.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Générateurs et itérateurs : le mécanisme complet du
yieldet de l'évaluation paresseuse pour traiter de grands flux sans saturer la mémoire. - Structures de données et collections :
deque,Counter,defaultdictet les autres outils du modulecollectionspour choisir la bonne structure selon l'opération. - itertools et functools : les briques de la bibliothèque standard pour composer des traitements efficaces sans réécrire de boucles.
- Décorateurs en Python : comprendre le mécanisme derrière
lru_cachepour écrire vos propres décorateurs de mise en cache. - Cython : le cran d'après quand les leviers du Python pur ne suffisent plus, en compilant la boucle chaude en C.