Aller au contenu
Développement medium

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

27 min de lecture

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 : tu peux 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 t’accompagne pas à pas, en partant de ce que tu connais 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).

Tu sais 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 tu en as 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 tu lis 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.

L’analogie du marque-page : imagine que tu lis un livre. Avec return, tu photocopies le livre entier et tu donnes toutes les pages d’un coup. Avec yield, tu lis une page, tu poses un marque-page, tu donnes la page au lecteur, et tu attends qu’il te dise “page suivante”. Quand il le demande, tu reprends la lecture exactement là où tu t’étais arrêté.

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 tu génères 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 tu appelles compteur(3), Python ne lance pas le code. Il crée un objet générateur — une sorte de télécommande vers la fonction. Le code ne commencera que quand tu demanderas 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 »
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”.

Tu n’as pas besoin d’appeler next() manuellement. La boucle for le fait et gère StopIteration pour toi :

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

C’est équivalent à ce code (que tu n’as 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 tu récupèresLa valeur directement (liste, dict, string…)Un objet generator sur lequel tu itères
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.

Tu connais 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 (rappelle-toi : 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, tu peux é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. Tu pourrais scanner un fichier de 100 Go avec 10 Mo de RAM.

Un pipeline de générateurs connecte des étapes de traitement comme des tuyaux :

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']

Tu peux 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 tu as lu le guide sur Textual, tu as 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.

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.

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
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
ErreurCe qui se passeComment corriger
Le résultat est <generator object ...> au lieu des valeursTu as oublié d’itérer sur le générateurUtilise 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ée le générateur : gen = ma_fonction()
StopIteration inattenduenext() appelé après épuisementUtilise une boucle for (elle gère ça automatiquement)
TypeError: 'generator' object is not subscriptableTentative d’indexation gen[0]Convertis d’abord en liste : list(gen)[0]
SyntaxError avec yield dans une lambdayield n’est pas supporté dans les lambdasUtilise une fonction def classique
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ère à l’intérieur du with, pas après
RuntimeError: generator raised StopIterationStopIteration levée dans un yield from (Python 3.7+)Utilise 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
  • yield met la fonction en pause et produit une valeur. Au prochain next(), elle reprend exactement là où elle s’était arrêtée — c’est le mécanisme du marque-page.
  • 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, utilise 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 tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.