Retour au cours

data / sqlalchemy

Insertions en masse haute performance

Leçon 201 exercice

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_mappings en 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.

NiveauOutilGain de vitesseFonctionnalités ORM
1insert() de Coremodéréaucune (déjà hors session)
2bulk_insert_mappingsélevéaucune, events désactivés
3COPY (PostgreSQL)10 à 50x vs INSERT unitairesaucune, 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

python
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 N session.add(), sans perdre la simplicité de l'API.
  • bulk_insert_mappings/bulk_update_mappings contournent davantage le tracking ORM (pas d'objets Python créés) au prix des événements et du RETURNING automatique.
  • 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

1 disponible
1

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 :

python
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.

Résoudre l’exercice →