9.1 — Bases relationnelles (SQL)
🎯 Objectif : écrire du SQL idiomatique en 2026, indexer correctement, lire un plan d’exécution. PostgreSQL est le défaut industriel — c’est notre référence.
À l'issue de cet axe, tu sauras :
- Concevoir un schéma SQL avec contraintes, types, index appropriés
- Maîtriser SELECT avec jointures, sous-requêtes, CTE, fenêtrage
- Comprendre les transactions, ACID, niveaux d'isolation
- Indexer correctement (B-tree, GIN, partiels, couvrants)
- Lire un EXPLAIN ANALYZE et identifier un goulot
- Migrer un schéma proprement (Prisma Migrate, Alembic, Doctrine, Knex)
Confirmé
Pourquoi PostgreSQL en 2026 ?
Section intitulée « Pourquoi PostgreSQL en 2026 ? »| Critère | PostgreSQL 17 | MySQL 8.4 | SQLite |
|---|---|---|---|
| Type-safety, contraintes | 🟢 Excellent | 🟡 Moyen | 🟡 Faible (typing par valeur) |
| JSONB + indexes GIN | 🟢 Natif | 🟡 JSON limité | 🟡 |
| Full-text search | 🟢 Natif (tsvector) | 🟡 OK | 🟡 FTS5 |
| CTEs récursives | 🟢 | 🟢 (8.0+) | 🟢 |
| Window functions | 🟢 Très complet | 🟢 | 🟢 |
| Réplication / scale | 🟢 logical + physical | 🟢 | 🔴 |
| Hébergement managé | Supabase, Neon, Render, AWS RDS | RDS, PlanetScale | embarqué |
Verdict 2026 : PostgreSQL est le défaut universel. MySQL reste valable (notamment pour WordPress, vieux projets). SQLite excellent pour dev, test, edge, et apps single-user.
PostgreSQL 17 (sortie septembre 2024) apporte :
- Performances des
WITHnon-matérialisées améliorées. - Logical replication plus mature pour la HA.
- Améliorations VACUUM (mémoire).
Modélisation — un rappel
Section intitulée « Modélisation — un rappel »Voir 3.5 — Modélisation de données pour MCD/MLD/MPD et la normalisation 1NF/2NF/3NF. Ici, on plonge dans le SQL concret.
DDL — créer un schéma
Section intitulée « DDL — créer un schéma »-- Types et contraintesCREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');
CREATE TABLE customers ( id SERIAL PRIMARY KEY, email CITEXT NOT NULL UNIQUE, -- case-insensitive text (extension) name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE products ( id SERIAL PRIMARY KEY, sku VARCHAR(32) NOT NULL UNIQUE, name TEXT NOT NULL, price_cents INTEGER NOT NULL CHECK (price_cents >= 0), -- argent en centimes ! stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE orders ( id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, status order_status NOT NULL DEFAULT 'pending', total_cents INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE order_lines ( id SERIAL PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT, quantity INTEGER NOT NULL CHECK (quantity > 0), unit_price_cents INTEGER NOT NULL, -- snapshot prix au moment de la commande UNIQUE (order_id, product_id));Bonnes pratiques
Section intitulée « Bonnes pratiques »id SERIAL PRIMARY KEYouid BIGSERIALsi tu prévois > 2 milliards de lignes.TIMESTAMPTZ(avec timezone) plutôt queTIMESTAMP— toujours.CITEXTpour les emails (recherche insensible à la casse). ExtensionCREATE EXTENSION citext.- Argent en centimes (INTEGER), JAMAIS en
FLOATouDOUBLE. ON DELETE RESTRICTpar défaut (sécurité),CASCADEquand le cycle de vie est lié.CHECKpour les invariants (stock ≥ 0, quantité > 0).- Index unique sur les colonnes naturelles (
email,sku).
DML — manipuler les données
Section intitulée « DML — manipuler les données »INSERT INTO products (sku, name, price_cents, stock)VALUES ('SKU-001', 'Carnet', 990, 100)RETURNING id; -- récupère l'id généréUPDATE productsSET stock = stock - 1, updated_at = now()WHERE id = 42 AND stock > 0RETURNING stock; -- 0 ligne renvoyée si stock était à 0 → indicateur de ruptureDELETE FROM order_lines WHERE order_id = 99;UPSERT — INSERT ... ON CONFLICT
Section intitulée « UPSERT — INSERT ... ON CONFLICT »INSERT INTO products (sku, name, price_cents, stock)VALUES ('SKU-001', 'Carnet', 990, 50)ON CONFLICT (sku) DO UPDATE SET stock = products.stock + EXCLUDED.stock, name = EXCLUDED.name;Crée la ligne si le sku n’existe pas, sinon incrémente le stock. Atomique, idéal pour des imports.
SELECT — l’arsenal complet
Section intitulée « SELECT — l’arsenal complet »Jointures
Section intitulée « Jointures »SELECT o.id, c.name AS customer, o.status, COUNT(ol.id) AS line_count, SUM(ol.quantity * ol.unit_price_cents) / 100.0 AS total_eurFROM orders oJOIN customers c ON c.id = o.customer_id -- INNER JOIN (défaut)LEFT JOIN order_lines ol ON ol.order_id = o.id -- LEFT : garde les commandes sans ligneWHERE o.created_at >= now() - INTERVAL '30 days'GROUP BY o.id, c.name, o.statusHAVING COUNT(ol.id) > 0ORDER BY total_eur DESCLIMIT 50;Sous-requêtes
Section intitulée « Sous-requêtes »-- INSELECT * FROM products WHERE id IN ( SELECT product_id FROM order_lines GROUP BY product_id HAVING SUM(quantity) > 100);
-- EXISTS (souvent meilleur que IN)SELECT * FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid');CTE — Common Table Expressions
Section intitulée « CTE — Common Table Expressions »Décompose une requête complexe en étapes nommées, sans imbriquer de sous-requêtes.
WITH paid_orders AS ( SELECT customer_id, total_cents FROM orders WHERE status = 'paid'),customer_totals AS ( SELECT customer_id, SUM(total_cents) AS lifetime_cents FROM paid_orders GROUP BY customer_id)SELECT c.name, ct.lifetime_cents / 100.0 AS lifetime_eurFROM customer_totals ctJOIN customers c ON c.id = ct.customer_idORDER BY ct.lifetime_cents DESCLIMIT 20;Window functions — l’outil ultime
Section intitulée « Window functions — l’outil ultime »Calculs par groupe sans agréger les lignes (contrairement à GROUP BY).
-- Top 3 produits PAR catégorie en termes de ventesSELECT category, name, total_soldFROM ( SELECT p.category, p.name, SUM(ol.quantity) AS total_sold, ROW_NUMBER() OVER (PARTITION BY p.category ORDER BY SUM(ol.quantity) DESC) AS rank FROM products p JOIN order_lines ol ON ol.product_id = p.id GROUP BY p.category, p.id) rankedWHERE rank <= 3;Autres fenêtres utiles :
ROW_NUMBER()— numéro uniqueRANK(),DENSE_RANK()— classement avec ex æquoLAG(col, 1),LEAD(col, 1)— valeur précédente / suivanteSUM(col) OVER (PARTITION BY x ORDER BY y)— somme cumulative
Transactions et ACID
Section intitulée « Transactions et ACID »ACID :
| Lettre | Sens |
|---|---|
| Atomicity | Tout ou rien |
| Consistency | Les contraintes restent valides |
| Isolation | Les transactions concurrentes se voient comme isolées |
| Durability | Une fois commit, rien ne se perd |
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Si l'une des 2 updates échoue ou si on fait ROLLBACK, l'autre est annuléeCOMMIT;Niveaux d’isolation
Section intitulée « Niveaux d’isolation »| Niveau | Phénomènes possibles | PostgreSQL |
|---|---|---|
| Read uncommitted | Dirty read | (n’existe pas, comme Read committed) |
| Read committed | Non-repeatable read | Défaut PG |
| Repeatable read | Phantom read | Disponible |
| Serializable | Aucun (le plus strict) | Disponible |
Pour la majorité des apps : Read committed (défaut) suffit. Pour des traitements financiers : Serializable.
BEGIN ISOLATION LEVEL SERIALIZABLE;-- ...COMMIT;Indexes — la clé de la performance
Section intitulée « Indexes — la clé de la performance »Sans index, une requête WHERE email = '...' fait un scan séquentiel de toute la table. Avec un index B-tree, c’est O(log n).
Types d’index PostgreSQL
Section intitulée « Types d’index PostgreSQL »| Type | Usage |
|---|---|
| B-tree | Défaut, comparaisons d’égalité et d’ordre (=, <, BETWEEN) |
| GIN | Indexer JSONB, tableaux, full-text search (tsvector) |
| GiST | Données géographiques (PostGIS), full-text |
| BRIN | Très grandes tables où les valeurs sont triées (logs, time-series) |
| Hash | Égalité stricte uniquement |
Index simple
Section intitulée « Index simple »CREATE INDEX orders_customer_id_idx ON orders(customer_id);CREATE INDEX orders_created_at_idx ON orders(created_at);Index composé
Section intitulée « Index composé »-- Pour "WHERE customer_id = X ORDER BY created_at DESC"CREATE INDEX orders_customer_created_idx ON orders(customer_id, created_at DESC);L’ordre des colonnes compte : utilisé pour WHERE customer_id = X, mais pas pour WHERE created_at = X seul.
Index partiel
Section intitulée « Index partiel »Index uniquement les lignes pertinentes :
-- Pour "WHERE status = 'pending'" qui est ~1 % des commandesCREATE INDEX orders_pending_idx ON orders(created_at) WHERE status = 'pending';L’index est minuscule, ultra-rapide à scanner, et la requête vole.
Index sur expressions
Section intitulée « Index sur expressions »-- Recherche insensible à la casse sans CITEXTCREATE INDEX users_email_lower_idx ON users(LOWER(email));
-- ... puisSELECT * FROM users WHERE LOWER(email) = LOWER('Alice@Example.com');Index couvrant (covering index)
Section intitulée « Index couvrant (covering index) »-- Sert à éviter d'aller relire la tableCREATE INDEX orders_idx ON orders(customer_id) INCLUDE (status, total_cents);L’index contient déjà status et total_cents → la requête est satisfaite uniquement avec l’index (index-only scan).
Index GIN sur JSONB
Section intitulée « Index GIN sur JSONB »CREATE INDEX products_metadata_idx ON products USING GIN (metadata);
-- Maintenant on peut chercher dans le JSON rapidementSELECT * FROM products WHERE metadata @> '{"color": "red"}';Piège réel rencontré — Foreign Key sans index → DELETE/UPDATE catastrophiques Performance
🩹 Symptôme : DELETE FROM orders WHERE id = 42 prend 8 secondes au lieu de 5 ms.
🔍 Cause : Postgres n’indexe pas automatiquement les colonnes FK côté enfant. Pour vérifier qu’aucun enfant ne référence un parent à supprimer, il scanne toute la table enfant.
🩺 Fix : toujours indexer les colonnes FK.
CREATE INDEX order_lines_order_id_idx ON order_lines(order_id);Audit script — liste les FK sans index :
SELECT c.conname, c.conrelid::regclass AS table, a.attname AS columnFROM pg_constraint cJOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)WHERE c.contype = 'f' AND NOT EXISTS ( SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey) );🧠 Leçon : Postgres indexe la PK automatiquement, mais pas les FK côté enfant. Toujours créer ces index. Sur une grosse table avec CASCADE, l’oubli peut transformer un DELETE en panne de 30 secondes en prod.
Piège réel rencontré — JSONB sans index GIN → seq scan systématique Performance
🩹 Symptôme :
SELECT * FROM products WHERE metadata @> '{"color": "red"}';-- EXPLAIN : Seq Scan, 800 ms sur 100K rows🔍 Cause : sans index GIN, Postgres ne peut pas accéder rapidement aux clés/valeurs JSONB. Il fait un Seq Scan qui parse chaque metadata row par row.
🩺 Fix : créer un index GIN.
CREATE INDEX products_metadata_idx ON products USING GIN (metadata);Pour un filtre sur une seule clé souvent utilisée, un index expression est plus petit :
CREATE INDEX products_color_idx ON products ((metadata->>'color'));🧠 Leçon : si tu utilises JSONB en colonne, toujours un index GIN (ou expression) sur ce qui est filtré. Sinon tu as choisi JSONB pour la flexibilité mais tu paies le prix de la lenteur.
EXPLAIN — comprendre ce que fait Postgres
Section intitulée « EXPLAIN — comprendre ce que fait Postgres »EXPLAIN ANALYZESELECT * FROM orders WHERE customer_id = 42 AND created_at >= '2026-01-01';Sortie typique :
Index Scan using orders_customer_created_idx on orders (cost=0.42..8.45 rows=12 width=24) (actual time=0.038..0.052 rows=12 loops=1) Index Cond: (customer_id = 42 AND created_at >= '2026-01-01')Planning Time: 0.124 msExecution Time: 0.073 ms| Mot-clé | Sens |
|---|---|
| Seq Scan | Scan séquentiel — pas d’index, lent sur grosse table |
| Index Scan | Utilise un index — bon |
| Bitmap Heap Scan | Combine plusieurs index — souvent OK |
| Nested Loop | Jointure par boucle imbriquée — bon pour petits ensembles |
| Hash Join | Bon pour gros ensembles |
| Merge Join | Quand les deux côtés sont déjà triés |
Site indispensable : explain.dalibo.com — colle le plan, il te le visualise et te dit où ça coince.
Migrations — versionner le schéma
Section intitulée « Migrations — versionner le schéma »| Outil | Langage | Style |
|---|---|---|
| Prisma Migrate | TS (Node) | Schema-first, génère le SQL |
| Drizzle Kit | TS | Schema-first ou SQL pur |
| Knex | TS/JS | API JS pour DDL |
| Alembic | Python | SQLAlchemy-friendly |
| Doctrine Migrations | PHP | Symfony |
| Laravel Migrations | PHP | DSL Eloquent |
| Flyway | Multi (Java, JVM) | SQL pur versionné |
| Sqitch | Multi | SQL pur, target-based |
Le pattern est toujours le même : un fichier par migration, ordonné, idempotent, versionné dans Git.
migrations/├── 20260115_create_customers.sql├── 20260120_add_customer_email_unique.sql└── 20260201_add_orders_status_index.sqlRègle d’or : jamais modifier une migration déjà appliquée en prod. Tu crées une nouvelle migration qui annule/corrige la précédente.
Auto-évaluation
Section intitulée « Auto-évaluation »Pour aller plus loin
Section intitulée « Pour aller plus loin »- PostgreSQL Documentation — postgresql.org/docs
- Use The Index, Luke! — use-the-index-luke.com — la référence indexation
- PostgreSQL Up & Running — Regina Obe & Leo Hsu
- Explain Visualizer — explain.dalibo.com — colle ton plan, visualise
- PG Mustard — pgmustard.com — analyse automatique de plans
Suite : 9.2 — NoSQL — quand le relationnel ne suffit pas.