data / sqlalchemy
Insertions en masse haute performance
Explication
Ce que vous allez apprendre
- Choisir le bon niveau d'optimisation pour une insertion massive selon le volume de données
- Utiliser
insert()de Core comme premier niveau, simple et déjà bien plus rapide que l'ORM naïf - Aller plus loin avec
bulk_insert_mappingsen désactivant certains mécanismes ORM - Atteindre la vitesse maximale avec
COPY(spécifique PostgreSQL) pour des volumes très importants - Committer par petits lots pour éviter de bloquer d'autres opérations concurrentes
Dans quel contexte ?
Une entreprise doit migrer 5 millions de lignes d'un ancien système vers sa nouvelle base PostgreSQL, dans une fenêtre de maintenance de quelques heures. Une insertion ligne par ligne avec l'ORM classique prendrait plusieurs jours à ce volume. Cette dernière leçon du cours présente les trois niveaux d'optimisation, du plus simple (insert() de Core) au plus radical (COPY), pour rendre cette migration réalisable dans le temps imparti.
D'abord, rappel d'une idée déjà vue
La leçon 13 a introduit l'idée que les opérations "bulk" sont plus rapides que l'ORM naïf. Cette dernière leçon pousse cette idée jusqu'au bout, en présentant plusieurs niveaux d'optimisation pour l'insertion massive de données.
| Niveau | Outil | Gain de vitesse | Fonctionnalités ORM |
|---|---|---|---|
| 1 | insert() de Core | modéré | aucune (déjà hors session) |
| 2 | bulk_insert_mappings | élevé | aucune, events désactivés |
| 3 | COPY (PostgreSQL) | 10 à 50x vs INSERT unitaires | aucune, SQL brut |
Étape 1 : Core insert(), le premier niveau
insert() de Core évite déjà la création d'objets Python individuellement trackés par la session — un bon compromis simplicité/performance pour la majorité des cas.
Étape 2 : bulk_insert_mappings, un cran plus loin
bulk_insert_mappings va plus loin en désactivant certains mécanismes ORM, comme les événements vus en leçon 16 ou le RETURNING automatique, pour gagner encore en vitesse.
Étape 3 : COPY, l'option la plus radicale
COPY, spécifique à PostgreSQL, contourne même le protocole SQL classique ligne par ligne en flux continu de données, ce qui explique des gains pouvant atteindre 10 à 50 fois par rapport à des INSERT unitaires.
Le compromis à ne jamais perdre de vue
Chaque gain de vitesse a une contrepartie fonctionnelle : moins de fonctionnalités ORM disponibles, comme la validation @validates ou les événements before_insert, et un code plus proche du SQL brut, donc moins portable entre bases de données.
Prérequis
Ces techniques ne se justifient que pour des volumes réellement importants (dizaines de milliers de lignes ou plus). Pour l'insertion quotidienne d'un formulaire, l'ORM classique avec session.add() reste largement suffisant et bien plus lisible — n'optimisez que quand un vrai besoin de volume l'exige.
Un principe complémentaire à ne pas oublier
Committer par petits lots plutôt qu'en une seule transaction géante évite de retenir des verrous de base de données trop longtemps, ce qui pourrait bloquer d'autres opérations concurrentes pendant tout l'import.
Pour conclure ce parcours
Ces techniques ne se justifient que pour des volumes réellement importants, dizaines de milliers de lignes ou plus, pas pour l'insertion quotidienne d'un formulaire. Elles résument bien l'esprit de ce cours : SQLAlchemy offre un confort ORM par défaut, et la possibilité de descendre progressivement vers du contrôle plus fin quand la performance l'exige réellement.
Commandes & code
Insertions en masse haute performance
import time
from sqlalchemy import insert, text
from sqlalchemy.orm import Session
# --- Niveau 1 : Core insert() par lot -- bon compromis simplicité/performance ---
with SessionLocal() as session:
session.execute(insert(Produit), [{"nom": f"P{i}", "prix": 1.0} for i in range(100_000)])
session.commit()
# --- Niveau 2 : découper en chunks pour limiter la mémoire et la taille des transactions ---
def inserer_par_lots(session: Session, lignes: list[dict], taille_lot: int = 5000):
for i in range(0, len(lignes), taille_lot):
lot = lignes[i : i + taille_lot]
session.execute(insert(Produit), lot)
session.commit() # committer par lot évite une transaction géante et des verrous prolongés
# --- Niveau 3 : bulk_insert_mappings -- contourne encore plus le tracking ORM que Core insert() ---
with SessionLocal() as session:
session.bulk_insert_mappings(
Produit,
[{"nom": f"P{i}", "prix": 1.0} for i in range(100_000)],
)
session.commit()
# Contrepartie : pas de RETURNING automatique, pas d'événements ORM (before_insert...), pas de relations
# --- Niveau 4 : COPY -- le plus rapide sur PostgreSQL, contourne le protocole INSERT ligne par ligne ---
def inserer_via_copy(session: Session, chemin_csv: str):
connexion_brute = session.connection().connection # connexion DBAPI sous-jacente (psycopg)
with connexion_brute.cursor() as curseur:
with open(chemin_csv, "r", encoding="utf-8") as f:
curseur.copy_expert(
"COPY produits (nom, prix) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)", f
)
session.commit()
# COPY peut être 10 à 50x plus rapide que des INSERT unitaires sur de très gros volumes
# --- Désactiver temporairement les triggers pendant un import massif contrôlé ---
def import_massif_optimise(session: Session, lignes: list[dict]):
session.execute(text("ALTER TABLE produits DISABLE TRIGGER ALL")) # PostgreSQL
try:
session.bulk_insert_mappings(Produit, lignes)
session.commit()
finally:
session.execute(text("ALTER TABLE produits ENABLE TRIGGER ALL"))
session.commit()
# --- Benchmark comparatif (ordre de grandeur, dépend fortement du matériel) ---
def comparer_methodes(n: int = 50_000) -> dict[str, float]:
resultats = {}
debut = time.perf_counter()
with SessionLocal() as session:
for i in range(n):
session.add(Produit(nom=f"A{i}", prix=1.0)) # 1 objet ORM tracké individuellement
session.commit()
resultats["orm_naif"] = time.perf_counter() - debut # référence, généralement le plus lent
debut = time.perf_counter()
with SessionLocal() as session:
session.execute(insert(Produit), [{"nom": f"B{i}", "prix": 1.0} for i in range(n)])
session.commit()
resultats["core_insert"] = time.perf_counter() - debut # souvent 5-20x plus rapide
return resultats
# Ordre de grandeur typique, du plus lent au plus rapide :
# ORM naïf (session.add un par un) > bulk_insert_mappings > Core insert() > COPY
# bulk_update_mappings : équivalent en masse pour des mises à jour avec des valeurs différentes par ligne
with SessionLocal() as session:
session.bulk_update_mappings(
Produit,
[{"id": 1, "prix": 12.5}, {"id": 2, "prix": 8.0}, {"id": 3, "prix": 45.0}],
)
session.commit()Résumé
session.execute(insert(Modele), [...])(Core) est déjà bien plus rapide que Nsession.add(), sans perdre la simplicité de l'API.bulk_insert_mappings/bulk_update_mappingscontournent davantage le tracking ORM (pas d'objets Python créés) au prix des événements et duRETURNINGautomatique.COPY(spécifique PostgreSQL) reste la méthode la plus rapide pour de très gros volumes, en passant par la connexion DBAPI brute.- Découper en lots (
chunks) et committer régulièrement évite une transaction géante qui retient ses verrous trop longtemps. - Désactiver temporairement les triggers pendant un import massif contrôlé peut réduire significativement le temps total, à réactiver systématiquement après coup.
Exercices pratiques
Mission : une migration de 5 millions de lignes qui ne tiendra jamais dans la fenêtre de maintenance
Objectif : Diagnostiquer pourquoi un script d'import massif écrit avec session.add() est inutilisable à l'échelle, puis le réécrire avec les bons outils tout en gérant le risque de verrous prolongés.
Contexte
Un développeur a écrit ce script pour migrer 5 millions de lignes vers PostgreSQL avant la fenêtre de maintenance de cette nuit :
with SessionLocal() as session:
for ligne in lire_ancien_systeme():
session.add(Produit(nom=ligne["nom"], prix=ligne["prix"]))
session.commit()Sur un échantillon de 10 000 lignes en local, ça fonctionne. Sur les 5 millions de lignes réelles, le process consomme toute la RAM du serveur avant même que la première requête SQL ne parte, puis plante avec une erreur mémoire. Il te reste quelques heures avant la fenêtre de maintenance pour livrer une version qui tienne la charge, sans bloquer les autres services qui lisent la table produits en journée.