Jour 44 Day 44 · jeudi 8 octobre 2026 Thursday 8 October 2026 Data Intermédiaire

ORM, migrations & N+1 ORMs, migrations & N+1

Ce qu'un ORM apporte, ce qu'il cache (le fameux N+1), et comment faire évoluer un schéma en production sans rien casser — un trio de questions quasi garanti en entretien backend. What an ORM gives you, what it hides (the infamous N+1), and how to evolve a schema in production without breaking anything — a trio of near-guaranteed backend interview questions.

L’essentiel

Un ORM (Object-Relational Mapper — Prisma, SQLAlchemy, Hibernate, Entity Framework…) fait le pont entre les objets de votre langage et les tables relationnelles. Il apporte trois choses : le mapping (une ligne ↔ un objet, une clé étrangère ↔ une propriété), des requêtes typées (l’IDE autocomplète, le compilateur attrape la faute de frappe avant la prod) et des migrations qui versionnent le schéma comme du code.

Le coût est symétrique : chaque accès innocent à une propriété (author.posts) peut déclencher une requête SQL que vous n’avez jamais écrite. Un ORM est une abstraction qui fuit : dès que la performance compte, il faut lire le SQL généré. Le symptôme le plus courant — et la question d’entretien la plus posée sur le sujet — s’appelle le N+1 : une requête pour charger une liste, puis une requête par élément dans la boucle qui suit.

À retenir avant tout le reste : l’ORM ne dispense pas de savoir SQL. Il l’écrit à votre place, et c’est vous qui relisez.

Comment ça marche

Le N+1, chiffré. 100 auteurs, chacun avec ses articles. La version naïve semble propre — et déclenche 101 requêtes :

// ❌ N+1 : 1 requête pour la liste, puis 1 PAR auteur
const authors = await prisma.author.findMany();     // 1 requête
for (const author of authors) {
  const posts = await prisma.post.findMany({
    where: { authorId: author.id },                 // ×100 !
  });
  console.log(author.name, posts.length);
}
// 101 requêtes × ~5 ms de round-trip réseau ≈ 500 ms

// ✅ Eager loading : tout en 2 requêtes
const authors = await prisma.author.findMany({
  include: { posts: true },  // JOIN ou WHERE authorId IN (...)
});
// 2 requêtes ≈ 10 ms — ~50× plus rapide, même logique métier

Le coût n’est pas le volume de données : c’est le round-trip réseau vers la base, payé N fois. Le N+1 passe inaperçu en dev (base locale, 10 lignes de seed) et explose en prod (latence réseau réelle, 10 000 lignes).

Eager vs lazy loading — deux stratégies pour charger une relation :

Lazy loadingEager loading
ChargementÀ l’accès, requête à la demandeDès la requête initiale (JOIN/IN)
Nombre de requêtes1 + N (risque N+1 en boucle)1 à 2, constant
MémoireMinimale si la relation est ignoréeCharge tout, même l’inutile
Bon usageRelation rarement consultéeListes, boucles, réponses d’API

Hibernate ou SQLAlchemy sont lazy par défaut : le N+1 se cache derrière un simple accès de propriété. Prisma ne charge une relation que si on la demande (include/select) : le N+1 y est au moins visible — c’est la requête dans la boucle. Dans tous les cas, la détection est la même : compter les requêtes.

💡 Réflexe à montrer — activer le log SQL en dev (log: ['query'] chez Prisma, echo=True chez SQLAlchemy) et regarder ce qu’un seul écran déclenche. Si le nombre de requêtes croît avec le nombre d’éléments affichés, c’est un N+1.

Concepts clés à maîtriser

  • Quand écrire du SQL brut : rapports et agrégations complexes, window functions, bulk updates, et le hot path où le SQL généré est mauvais. Tous les ORM ont une trappe de sortie ($queryRaw, text(), native queries) — l’utiliser n’est pas un échec, c’est prévu pour. L’ORM garde les 90 % de CRUD répétitif.
  • Migrations versionnées : chaque changement de schéma est un fichier horodaté, commité dans le repo, avec un up (appliquer) et idéalement un down (annuler). La base tient la liste de celles déjà appliquées : même schéma garanti du poste du stagiaire à la prod.
  • Ne jamais éditer une migration appliquée : elle a déjà tourné ailleurs (chez les collègues, en CI, en prod). L’éditer fait diverger l’historique — les outils le détectent (checksum chez Prisma) et refusent d’avancer. Une erreur se corrige en avant, par une nouvelle migration.
  • Expand/contract (ou parallel change) : la recette pour changer un schéma sans casser le code encore en ligne. Jamais de rename direct — trois phases, trois déploiements :
Renommer name → full_name sans downtime

  EXPAND            MIGRATE            CONTRACT
  ajouter           double écriture    supprimer
  full_name         + backfill des     name
  (nullable)        anciennes lignes
──────────────────────────────────────────────▶ temps
  déploiement 1     déploiement 2      déploiement 3
  l'ancien code     le code lit        plus personne
  marche encore     full_name          ne lit name
  • Seeds & environnements : les données initiales (comptes de test, référentiels) sont scriptées et versionnées, par environnement. Le dev veut des données réalistes, le CI un jeu minimal et déterministe, la prod — presque rien.

🎤 En entretien — si on vous demande de faire évoluer un schéma en production, dérouler expand/contract au tableau (ajouter → migrer → retirer, un déploiement par phase) montre plus de maturité que n’importe quel buzzword. C’est exactement ce qu’un senior veut entendre d’un stagiaire.

En entretien

« C’est quoi le problème N+1 et comment le corriger ? » — Une requête pour la liste, puis une par élément dans la boucle : 100 auteurs = 101 requêtes, dominées par les round-trips réseau. Correction : eager loading (include, JOIN, WHERE IN) pour ramener le tout en 1-2 requêtes. Détection : logs SQL en dev, APM en prod. Donner le chiffre (101 → 2) fait toute la différence.

« Eager ou lazy loading par défaut ? » — Lazy évite de charger l’inutile mais transforme chaque boucle en N+1 ; eager garantit un nombre de requêtes constant mais peut sur-charger. Réponse mûre : lazy pour les relations rarement lues, eager explicite partout où on itère — et connaître le défaut de son ORM.

« Quand écrirais-tu du SQL à la main ? » — Rapports complexes, window functions, bulk operations, hot paths où le SQL généré est inefficace. La trappe de sortie ($queryRaw, text()) est assumée : l’ORM pour le CRUD, le SQL pour le reste.

« Comment déployer un changement de schéma sans downtime ? » — Expand/contract : ajouter le nouveau (colonne nullable, double écriture), migrer données et code, puis seulement retirer l’ancien. Trois déploiements. Pendant un déploiement, ancien et nouveau code cohabitent : le schéma doit satisfaire les deux.

« Pourquoi ne pas modifier une migration déjà mergée ? » — Elle a déjà été appliquée sur d’autres bases ; la modifier fait diverger l’historique (checksum invalide, environnements incohérents). On corrige en avant, avec une nouvelle migration.

Pièges & idées reçues

⚠️ La migration destructive du vendredi — DROP COLUMN déployé à 17 h : le vieux code encore en ligne lit toujours la colonne, tout crashe pendant le week-end, et le down d’un DROP ne restaure pas les données. Une migration destructive part en début de semaine, en phase contract (plus aucun lecteur), après un backup vérifié.

  • « L’ORM m’évite d’apprendre SQL » — c’est l’inverse : il faut savoir SQL pour relire ce que l’ORM génère. Le jour où l’endpoint rame, la réponse est dans EXPLAIN, pas dans la doc de l’ORM.
  • Faire confiance aux migrations auto-générées — l’outil diffe le schéma, mais un rename devient souvent DROP + ADD, donc perte de données. Toujours relire le SQL généré avant de merger.
  • Le down comme filet de sécurité — un down qui annule un DROP COLUMN recrée la colonne… vide. Le vrai filet, c’est backup + expand/contract.
  • Seeder la prod avec les seeds de dev — un db seed lancé sur la prod avec les comptes de test finit en incident, parfois en fuite de données. Les seeds sont par environnement, et la prod n’en a presque jamais.

Pour aller plus loin

The essentials

An ORM (Object-Relational Mapper — Prisma, SQLAlchemy, Hibernate, Entity Framework…) bridges your language’s objects and relational tables. It brings three things: mapping (a row ↔ an object, a foreign key ↔ a property), typed queries (the IDE autocompletes, the compiler catches the typo before production) and migrations that version the schema like code.

The cost is symmetric: every innocent property access (author.posts) can fire a SQL query you never wrote. An ORM is a leaky abstraction: as soon as performance matters, you have to read the generated SQL. The most common symptom — and the most asked interview question on the topic — is called N+1: one query to load a list, then one query per element in the loop that follows.

Remember this before anything else: an ORM does not exempt you from knowing SQL. It writes it for you, and you are the reviewer.

How it works

N+1, with numbers. 100 authors, each with their posts. The naive version looks clean — and fires 101 queries:

// ❌ N+1: 1 query for the list, then 1 PER author
const authors = await prisma.author.findMany();     // 1 query
for (const author of authors) {
  const posts = await prisma.post.findMany({
    where: { authorId: author.id },                 // ×100!
  });
  console.log(author.name, posts.length);
}
// 101 queries × ~5 ms network round-trip ≈ 500 ms

// ✅ Eager loading: everything in 2 queries
const authors = await prisma.author.findMany({
  include: { posts: true },  // JOIN or WHERE authorId IN (...)
});
// 2 queries ≈ 10 ms — ~50× faster, same business logic

The cost isn’t the data volume: it’s the network round-trip to the database, paid N times. N+1 goes unnoticed in dev (local database, 10 seeded rows) and explodes in production (real network latency, 10,000 rows).

Eager vs lazy loading — two strategies to load a relation:

Lazy loadingEager loading
LoadingOn access, query on demandWith the initial query (JOIN/IN)
Query count1 + N (N+1 risk in loops)1 to 2, constant
MemoryMinimal if the relation is unusedLoads everything, even the useless
Good fitRarely-read relationLists, loops, API responses

Hibernate and SQLAlchemy are lazy by default: the N+1 hides behind a simple property access. Prisma only loads a relation when asked (include/select): its N+1 is at least visible — it’s the query inside the loop. Either way, detection is the same: count the queries.

💡 Reflex to show — turn on SQL logging in dev (log: ['query'] in Prisma, echo=True in SQLAlchemy) and watch what a single screen triggers. If the query count grows with the number of displayed items, it’s an N+1.

Key concepts to master

  • When to write raw SQL: complex reports and aggregations, window functions, bulk updates, and hot paths where the generated SQL is bad. Every ORM has an escape hatch ($queryRaw, text(), native queries) — using it isn’t a failure, it’s by design. The ORM keeps the 90% of repetitive CRUD.
  • Versioned migrations: every schema change is a timestamped file, committed to the repo, with an up (apply) and ideally a down (undo). The database keeps the list of migrations already applied: the same schema guaranteed from the intern’s laptop to production.
  • Never edit an applied migration: it has already run elsewhere (on colleagues’ machines, in CI, in production). Editing it makes the history diverge — tools detect it (Prisma’s checksums) and refuse to proceed. Mistakes get fixed forward, with a new migration.
  • Expand/contract (a.k.a. parallel change): the recipe for changing a schema without breaking code still running. Never a direct rename — three phases, three deployments:
Renaming name → full_name with zero downtime

  EXPAND            MIGRATE            CONTRACT
  add               dual writes        drop
  full_name         + backfill of      name
  (nullable)        existing rows
──────────────────────────────────────────────▶ time
  deployment 1      deployment 2       deployment 3
  old code still    code reads         nobody reads
  works             full_name          name anymore
  • Seeds & environments: initial data (test accounts, reference tables) is scripted and versioned, per environment. Dev wants realistic data, CI a minimal deterministic set, production — almost nothing.

🎤 In an interview — if asked to evolve a schema in production, walking through expand/contract on the whiteboard (add → migrate → remove, one deployment per phase) shows more maturity than any buzzword. It’s exactly what a senior wants to hear from an intern.

In an interview

“What is the N+1 problem and how do you fix it?” — One query for the list, then one per element in the loop: 100 authors = 101 queries, dominated by network round-trips. Fix: eager loading (include, JOIN, WHERE IN) to bring it all back in 1-2 queries. Detection: SQL logs in dev, APM in production. Giving the numbers (101 → 2) makes all the difference.

“Eager or lazy loading by default?” — Lazy avoids loading the useless but turns every loop into an N+1; eager guarantees a constant query count but can over-fetch. Mature answer: lazy for rarely-read relations, explicit eager everywhere you iterate — and know what your ORM does by default.

“When would you write SQL by hand?” — Complex reports, window functions, bulk operations, hot paths where the generated SQL is inefficient. The escape hatch ($queryRaw, text()) is intentional: ORM for CRUD, SQL for the rest.

“How do you deploy a schema change with zero downtime?” — Expand/contract: add the new (nullable column, dual writes), migrate data and code, only then remove the old. Three deployments. During a rollout, old and new code coexist: the schema must satisfy both.

“Why never modify an already-merged migration?” — It has already been applied to other databases; modifying it makes the history diverge (invalid checksum, inconsistent environments). You fix forward, with a new migration.

Pitfalls & misconceptions

⚠️ The Friday destructive migration — DROP COLUMN deployed at 5 pm: the old code still live keeps reading the column, everything crashes over the weekend, and the down of a DROP does not restore the data. A destructive migration ships early in the week, in the contract phase (no readers left), after a verified backup.

  • “The ORM saves me from learning SQL” — it’s the opposite: you need SQL to review what the ORM generates. The day the endpoint is slow, the answer is in EXPLAIN, not in the ORM’s docs.
  • Trusting auto-generated migrations — the tool diffs the schema, but a rename often becomes DROP + ADD, i.e. data loss. Always read the generated SQL before merging.
  • The down migration as a safety net — a down that undoes a DROP COLUMN recreates the column… empty. The real safety net is backup + expand/contract.
  • Seeding production with dev seeds — a db seed run against production with test accounts ends in an incident, sometimes a data leak. Seeds are per environment, and production almost never has any.

Going further

S'entraîner sur ce sujet → Practice this topic →