Aller au contenu
Développement medium

Générateurs et yield : l'itération paresseuse en Python

45 min de lecture

logo python

Un générateur, c'est une fonction qui produit des valeurs une par une au lieu de tout calculer d'un coup. Résultat concret : vous pouvez parcourir un fichier de logs de 10 Go avec seulement quelques kilo-octets de RAM. Le mot-clé yield rend ça possible en mettant la fonction en pause entre chaque valeur. Ce guide vous accompagne pas à pas, en partant de ce que vous connaissez déjà (les listes et les boucles for) jusqu'aux cas réels.

Prérequis : savoir écrire une fonction Python avec return (voir le guide sur les fonctions).

  • Créer une fonction génératrice avec yield et comprendre le mécanisme de pause.
  • Mesurer le gain mémoire d'un générateur face à une liste.
  • Écrire des expressions génératrices et les chaîner en pipeline.
  • Déléguer l'itération avec yield from, y compris en récursion.
  • Appliquer les générateurs à des cas réels : gros fichiers, flux infini, pagination.
  • Aller plus loin avec send(), close() et les context managers.

Vous savez déjà créer une liste et boucler dessus :

nombres = [0, 1, 2, 3, 4]
for n in nombres:
print(n)

La boucle for parcourt la liste élément par élément. Ça fonctionne très bien pour 5 éléments. Mais que se passe-t-il si vous en avez un million ?

On peut créer une liste en une seule ligne grâce à une compréhension de liste (list comprehension). La syntaxe [expression for variable in itérable] applique l'expression à chaque élément et range le résultat dans une liste :

# Forme longue (boucle classique)
carres = []
for x in range(5):
carres.append(x ** 2)
# carres = [0, 1, 4, 9, 16]
# Forme courte (compréhension de liste), même résultat
carres = [x ** 2 for x in range(5)]
# carres = [0, 1, 4, 9, 16]

C'est une écriture compacte très courante en Python (voir le guide sur les listes pour plus de détails). Maintenant, que se passe-t-il si on l'utilise avec un million d'éléments ?

carres = [x ** 2 for x in range(1_000_000)]

Python construit toute la liste en mémoire avant que la boucle for ne commence. Mesurons la taille avec sys.getsizeof(), une fonction du module sys qui renvoie la taille en octets d'un objet en mémoire :

import sys
carres_liste = [x ** 2 for x in range(1_000_000)]
print(sys.getsizeof(carres_liste))
# 8448728 → environ 8 Mo en RAM

Pour un million d'éléments, 8 Mo ça passe. Mais pour 100 millions ? 800 Mo. Pour un milliard ? 8 Go. Et si vous lisez un fichier de logs de 10 Go avec readlines(), c'est 10 Go de RAM consommés d'un coup.

La question est : est-ce qu'on a vraiment besoin de TOUS les éléments en même temps ? En général non : la boucle for ne traite qu'un seul élément à la fois. C'est exactement ce problème que les générateurs résolvent.

Voici deux fonctions qui font presque la même chose. La première utilise return, la seconde utilise yield :

def carres_liste(n):
"""Calcule tous les carrés et renvoie une liste."""
resultat = []
for i in range(n):
resultat.append(i ** 2)
return resultat # Renvoie TOUT d'un coup
# Utilisation
for carre in carres_liste(5):
print(carre)
# 0, 1, 4, 9, 16

Le résultat est le même. Mais la mécanique interne est radicalement différente.

Mesurons la mémoire occupée par chacun avec sys.getsizeof() (qui renvoie la taille en octets). On affiche le résultat avec une f-string, une chaîne préfixée par f qui permet d'insérer des variables entre accolades {} (voir le guide sur le formatage de chaînes pour approfondir) :

import sys
# La version liste (définie plus haut)
resultat_liste = carres_liste(1_000_000)
# La version générateur (définie plus haut)
resultat_gen = carres_generateur(1_000_000)
taille_liste = sys.getsizeof(resultat_liste)
taille_gen = sys.getsizeof(resultat_gen)
print(f"Liste : {taille_liste} octets") # → 8448728 (~8 Mo)
print(f"Générateur : {taille_gen} octets") # → 200 octets

La liste pèse 8 Mo, le générateur pèse 200 octets. C'est plus de 40 000 fois moins. Et cette taille reste constante, que vous génériez 1 000 ou 1 milliard d'éléments.

def compteur(maximum):
print("--- DÉMARRAGE ---")
n = 0
while n < maximum:
print(f" Je vais produire {n}")
yield n
print(f" Je reprends après {n}")
n += 1
print("--- FIN ---")
gen = compteur(3)
print(type(gen))
# <class 'generator'>
# (Aucun print de la fonction n'apparaît !)

Quand vous appelez compteur(3), Python ne lance pas le code. Il crée un objet générateur : une structure qui garde une référence vers la fonction et l'état de son exécution, initialement figé avant la première ligne. Le code ne commencera que quand vous demanderez la première valeur.

Étape 2 : next() lance l'exécution jusqu'au prochain yield

Section intitulée « Étape 2 : next() lance l'exécution jusqu'au prochain yield »
valeur = next(gen)
# --- DÉMARRAGE --- ← le code démarre enfin
# Je vais produire 0 ← s'exécute normalement
# ← PAUSE sur yield
print(valeur)
# 0 ← la valeur produite par yield

La fonction s'exécute depuis le début jusqu'au premier yield. Elle renvoie la valeur 0 et se fige. Toutes ses variables (n = 0, maximum = 3) restent intactes en mémoire.

Étape 3 : next() reprend exactement au bon endroit

Section intitulée « Étape 3 : next() reprend exactement au bon endroit »

Le deuxième next() ne redémarre pas la fonction du début : il reprend juste après le yield où l'exécution s'était figée. Observez l'ordre des messages : le Je reprends après 0 sort avant le Je vais produire 1. C'est cette reprise en place, avec toutes les variables locales conservées, qui distingue un générateur d'une fonction ordinaire.

valeur = next(gen)
# Je reprends après 0 ← reprend APRÈS le yield précédent
# Je vais produire 1 ← continue la boucle
# ← PAUSE sur yield
print(valeur)
# 1
next(gen)
# Je reprends après 1
# Je vais produire 2
# → renvoie 2
next(gen)
# Je reprends après 2
# --- FIN --- ← la boucle while est terminée
# StopIteration ← EXCEPTION ! Plus de valeurs.

Quand la fonction atteint sa fin (ou un return sans valeur), Python lève StopIteration. C'est le signal qui dit "c'est terminé, il n'y a plus rien à produire".

Vous n'avez pas besoin d'appeler next() manuellement. La boucle for le fait et gère StopIteration pour vous :

for valeur in compteur(3):
print(f"Reçu : {valeur}")

C'est équivalent à ce code (que vous n'avez jamais besoin d'écrire) :

gen = compteur(3)
while True:
try:
valeur = next(gen)
except StopIteration:
break
print(f"Reçu : {valeur}")
Critèrereturnyield
Ce qui se passeLa fonction s'exécute entièrement, puis renvoie le résultatLa fonction se met en pause à chaque yield et renvoie une valeur
MémoireTout le résultat est en mémoire (liste complète)Une seule valeur à la fois (taille constante)
Ce que vous récupérezLa valeur directement (liste, dict, string...)Un objet generator sur lequel vous itérez
Réutilisable ?Oui, le résultat est une liste classiqueNon, le générateur est à usage unique
Quand l'utiliserPetit résultat, besoin d'index ou de len()Gros volume, traitement séquentiel, flux infini

La suite de Fibonacci (0, 1, 1, 2, 3, 5, 8, 13, ...) est un cas d'école parfait : c'est une séquence potentiellement infinie où chaque valeur dépend des précédentes.

def fibonacci_liste(n):
"""Renvoie les n premiers nombres de Fibonacci."""
fib = []
a, b = 0, 1
for _ in range(n):
fib.append(a)
a, b = b, a + b
return fib # Toute la liste d'un coup
print(fibonacci_liste(10))
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

La version liste doit savoir à l'avance combien d'éléments produire. Le générateur s'en fiche : il produit des valeurs à l'infini, et c'est l'appelant qui décide quand s'arrêter. Et quand il s'arrête, plus aucune mémoire n'est gaspillée.

Vous connaissez les list comprehensions ([... for ... in ...]). Les expressions génératrices utilisent la même syntaxe, mais avec des parenthèses au lieu de crochets :

# List comprehension → construit toute la liste en mémoire
carres_liste = [x ** 2 for x in range(1_000_000)]
# Generator expression → produit les valeurs une par une
carres_gen = (x ** 2 for x in range(1_000_000))

La seule différence visible, c'est [] vs (). Mais côté mémoire, c'est le jour et la nuit (rappelez-vous : 8 Mo vs 200 octets).

Chaîner des expressions génératrices (pipeline)

Section intitulée « Chaîner des expressions génératrices (pipeline) »

L'intérêt principal des expressions génératrices, c'est qu'elles se chaînent pour former un pipeline de traitement :

# Pipeline : lire → nettoyer → filtrer → afficher
with open("/var/log/syslog") as f:
lignes = (l.strip() for l in f) # 1. Nettoyer
erreurs = (l for l in lignes if "ERROR" in l) # 2. Filtrer
recentes = (l for l in erreurs if "2026-02" in l) # 3. Affiner
for erreur in recentes: # 4. Traiter
print(erreur)

Ici, aucune liste intermédiaire n'est créée. Le fichier est lu ligne par ligne, chaque ligne passe dans la chaîne, et seules les lignes qui correspondent sont affichées. Même un fichier de 50 Go est traité sans problème.

Quand un générateur doit produire les valeurs d'un autre itérable, vous pouvez écrire une boucle for + yield. Mais yield from est plus direct :

def tous_les_nombres():
for n in range(3):
yield n
for n in range(10, 13):
yield n
print(list(tous_les_nombres()))
# [0, 1, 2, 10, 11, 12]

yield from iterable dit : "produis toutes les valeurs de cet itérable, une par une, comme si je les avais yield moi-même". C'est du sucre syntaxique, mais ça rend le code plus lisible.

Cas pratique : parcourir une arborescence de fichiers

Section intitulée « Cas pratique : parcourir une arborescence de fichiers »
from pathlib import Path
def parcourir(dossier):
"""Parcourt récursivement un dossier et produit chaque fichier."""
chemin = Path(dossier)
for item in sorted(chemin.iterdir()):
if item.is_file():
yield item
elif item.is_dir():
yield from parcourir(item) # Récursion via yield from
# Utilisation
for fichier in parcourir("/etc/nginx"):
print(fichier)

Ici yield from parcourir(item) délègue la récursion au sous-dossier. Chaque fichier remonte directement à l'appelant, sans liste intermédiaire.

def lire_fichier(chemin, encodage="utf-8"):
"""Lit un fichier ligne par ligne sans tout charger en mémoire."""
with open(chemin, encoding=encodage) as f:
for numero, ligne in enumerate(f, 1):
yield numero, ligne.rstrip("\n")
# Chercher les erreurs dans un fichier de 10 Go
for num, ligne in lire_fichier("/var/log/syslog"):
if "ERROR" in ligne:
print(f"L{num}: {ligne}")

Le fichier n'est jamais entièrement chargé. Chaque ligne est lue, traitée, puis oubliée. Vous pourriez scanner un fichier de 100 Go avec 10 Mo de RAM.

Un pipeline de générateurs connecte plusieurs étapes de traitement, chacune consommant les valeurs produites par la précédente et transmettant les siennes à la suivante :

def lire_lignes(chemin):
"""Étape 1 : lire le fichier ligne par ligne."""
with open(chemin) as f:
yield from (l.rstrip("\n") for l in f)
def filtrer(lignes, mot_cle):
"""Étape 2 : ne garder que les lignes contenant le mot-clé."""
for ligne in lignes:
if mot_cle in ligne:
yield ligne
def extraire_ip(lignes):
"""Étape 3 : extraire l'adresse IP de chaque ligne."""
import re
pattern = re.compile(r"\d{1,3}(?:\.\d{1,3}){3}")
for ligne in lignes:
match = pattern.search(ligne)
if match:
yield match.group()
# Assembler le pipeline (rien ne s'exécute encore !)
lignes = lire_lignes("/var/log/auth.log")
echecs = filtrer(lignes, "Failed password")
ips = extraire_ip(echecs)
# C'est seulement ici que tout se déclenche :
from collections import Counter
top_ips = Counter(ips).most_common(10)
for ip, count in top_ips:
print(f"{ip:>15} : {count} tentatives")

Ce pipeline lit le fichier, filtre les erreurs d'authentification, et extrait les IP, tout ça ligne par ligne, sans jamais charger le fichier entier.

import time
def tail_follow(chemin):
"""Suit un fichier comme la commande tail -f."""
with open(chemin) as f:
f.seek(0, 2) # Se positionner à la fin du fichier
while True:
ligne = f.readline()
if ligne:
yield ligne.rstrip("\n")
else:
time.sleep(0.1) # Attendre de nouvelles lignes
# Surveiller les connexions SSH en temps réel
for ligne in tail_follow("/var/log/auth.log"):
if "Accepted" in ligne:
print(f"Connexion : {ligne}")

Ce générateur ne s'arrête jamais : il attend les nouvelles lignes et les produit au fur et à mesure. C'est un flux infini, et seul un générateur peut le modéliser proprement.

def paginer(iterable, taille=10):
"""Découpe un itérable en pages (lots) de N éléments."""
page = []
for item in iterable:
page.append(item)
if len(page) == taille:
yield page
page = []
if page:
yield page # Dernière page (éventuellement incomplète)
# Déployer des serveurs par lots de 5
serveurs = ["web-1", "web-2", "db-1", "db-2", "cache-1",
"web-3", "db-3", "cache-2", "monitor-1", "lb-1"]
for i, lot in enumerate(paginer(serveurs, 5), 1):
print(f"Lot {i} : {lot}")
# Lot 1 : ['web-1', 'web-2', 'db-1', 'db-2', 'cache-1']
# Lot 2 : ['web-3', 'db-3', 'cache-2', 'monitor-1', 'lb-1']

Vous pouvez utiliser yield pour créer des context managers (les blocs with) grâce au décorateur @contextmanager :

from contextlib import contextmanager
import time
@contextmanager
def chronometre(label):
"""Mesure le temps d'exécution d'un bloc de code."""
debut = time.perf_counter()
print(f" {label} - début")
yield # ← LE CODE DU BLOC with S'EXÉCUTE ICI
duree = time.perf_counter() - debut
print(f" {label} - {duree:.3f}s")
# Utilisation
with chronometre("Traitement des logs"):
time.sleep(0.5)
print(" Analyse en cours...")
# Traitement des logs - début
# Analyse en cours...
# Traitement des logs - 0.502s

Le yield sépare le code d'entrée (avant) du code de sortie (après). Tout ce qui est dans le bloc with s'exécute "à la place" du yield.

Si vous avez lu le guide sur Textual, vous avez vu yield dans la méthode compose() :

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class MonApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Static("Contenu principal")
yield Footer()

compose() est un générateur : Textual appelle next() dessus pour récupérer chaque widget un par un, dans l'ordre. Chaque yield correspond à un widget qui apparaît à l'écran. C'est plus lisible qu'un return [Header(), Static(...), Footer()] parce que les widgets sont déclarés dans l'ordre d'apparition, comme un plan de l'interface.

Piloter un générateur : send(), throw(), close()

Section intitulée « Piloter un générateur : send(), throw(), close() »

yield peut aussi recevoir une valeur depuis l'extérieur :

def accumulateur():
"""Additionne les valeurs envoyées via send()."""
total = 0
while True:
valeur = yield total # ← produit total ET reçoit la prochaine valeur
if valeur is None:
break
total += valeur
gen = accumulateur()
next(gen) # Amorcer le générateur → produit 0
gen.send(10) # → 10 (total = 0 + 10)
gen.send(25) # → 35 (total = 10 + 25)
gen.send(5) # → 40 (total = 35 + 5)

Le yield joue un double rôle : il envoie total vers l'extérieur, et reçoit la valeur passée par send().

def flux_infini():
n = 0
try:
while True:
yield n
n += 1
except GeneratorExit:
print("Générateur fermé proprement")
gen = flux_infini()
print(next(gen)) # 0
print(next(gen)) # 1
gen.close() # Générateur fermé proprement

close() lève GeneratorExit à l'intérieur du générateur, ce qui permet d'exécuter du code de nettoyage dans un bloc try/except.

throw() lève une exception à l'endroit exact où le générateur est en pause, c'est-à-dire sur la ligne yield. Le générateur peut l'attraper dans un try/except et poursuivre, ou la laisser remonter. Cette mécanique sert surtout à signaler une condition d'erreur à une coroutine sans l'interrompre brutalement.

def generateur_robust():
while True:
try:
valeur = yield
print(f"Reçu : {valeur}")
except ValueError as e:
print(f"Erreur gérée : {e}")
gen = generateur_robust()
next(gen) # Amorcer
gen.send("ok") # Reçu : ok
gen.throw(ValueError("donnée invalide")) # Erreur gérée : donnée invalide
gen.send("reprise") # Reçu : reprise

Ce tableau tranche les cas d'usage réels. Lisez-le à l'envers pour éviter le piège le plus courant : un générateur ne se laisse ni indexer ni mesurer avec len(). Dès que votre code a besoin de resultat[42], de connaître le nombre d'éléments à l'avance, ou de reparcourir les données plusieurs fois, un return classique renvoyant une liste reste le bon choix.

SituationGénérateur ?Pourquoi
Fichier volumineux (> 100 Mo)OuiÉvite l'explosion mémoire
Flux de données infini (logs en temps réel)OuiImpossible de tout stocker, le flux ne s'arrête jamais
Pipeline de transformations (lire → filtrer → transformer)OuiMémoire stable, code lisible et composable
Petit résultat (< 1 000 éléments)Nonreturn est plus simple et le résultat est réutilisable
Besoin d'accès par index (resultat[42])NonUn générateur ne supporte pas l'indexation
Besoin de len() sur le résultatNonlen() ne fonctionne pas sur un générateur

Presque toutes les surprises avec les générateurs viennent de deux oublis : ne pas itérer, et croire qu'on peut reparcourir. Si votre print affiche <generator object ...>, vous avez oublié la boucle for ou le list(). Si la deuxième itération semble vide, c'est que le générateur était déjà épuisé. Gardez ces deux réflexes et la plupart des messages ci-dessous disparaissent.

ErreurCe qui se passeComment corriger
Le résultat est <generator object ...> au lieu des valeursVous avez oublié d'itérer sur le générateurUtilisez une boucle for, ou list(gen)
Le générateur est vide au 2e parcoursUn générateur s'épuise après un seul parcoursRecréez le générateur : gen = ma_fonction()
StopIteration inattenduenext() appelé après épuisementUtilisez une boucle for (elle gère ça automatiquement)
TypeError: 'generator' object is not subscriptableTentative d'indexation gen[0]Convertissez d'abord en liste : list(gen)[0]
SyntaxError avec yield dans une lambdayield n'est pas supporté dans les lambdasUtilisez une fonction def classique

Ces symptômes reviennent régulièrement dès qu'un générateur touche à des fichiers ou à yield from. Le plus insidieux est le fichier « vide » : il vient presque toujours d'une itération tentée après la fermeture du bloc with, quand le fichier n'est plus ouvert. Partez de la colonne de gauche, identifiez le symptôme, puis appliquez la solution.

SymptômeCause probableSolution
Le générateur ne produit aucune valeurOubli d'itérer (boucle for ou next())for x in mon_generateur(): ...
Le fichier n'est pas luLe bloc with est fermé avant l'itérationItérez à l'intérieur du with, pas après
RuntimeError: generator raised StopIterationStopIteration levée dans un yield from (Python 3.7+)Utilisez return au lieu de raise StopIteration
Performance plus lente qu'une listeOverhead des appels next() sur un petit volumePour les petites données, une liste est plus rapide, c'est normal

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

6 questions
6 min.
70% requis

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

  • yield met la fonction en pause et produit une valeur. Au prochain next(), elle reprend exactement là où elle s'était arrêtée, avec toutes ses variables locales conservées.
  • Un générateur ne stocke rien en mémoire : il calcule la prochaine valeur à la demande. C'est ce qu'on appelle l'évaluation paresseuse (lazy evaluation).
  • Les expressions génératrices (x for x in ...) sont la version compacte des fonctions génératrices. Elles se chaînent pour former des pipelines sans consommer de mémoire.
  • yield from délègue l'itération à un autre itérable ou générateur, idéal pour les parcours récursifs.
  • Un générateur est à usage unique : après un parcours complet, il est épuisé. Il faut en recréer un.
  • En pratique, utilisez la boucle for pour consommer un générateur, elle gère next() et StopIteration automatiquement.
  • @contextmanager + yield permet de créer des context managers (with) en quelques lignes.
  • Textual, asyncio et d'autres frameworks s'appuient sur yield pour structurer l'exécution.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn