LangueFR
Français
English
Retour au sommaire du cours

SQL et bases de données

Les données d'un projet ne vivent pas éternellement dans des fichiers CSV posés sur un disque. Dès qu'elles sont mises à jour, partagées, ou dépassent quelques gigaoctets, il faut une base de données. Ce module part du modèle relationnel — ce qu'est une table, une clé, une jointure — puis va jusqu'aux fonctions de fenêtre et aux plans d'exécution, avant d'expliquer ce que les familles NoSQL apportent et ce qu'elles abandonnent en échange.

1. Pourquoi une base de données

Un fichier CSV convient parfaitement à un jeu de données figé qu'une seule personne analyse. Il cesse de convenir dès que l'une de ces situations apparaît :

  • Plusieurs écrivains — deux scripts qui écrivent en même temps corrompent le fichier. Une base gère les accès concurrents.
  • Mises à jour partielles — modifier une ligne au milieu d'un CSV de 10 Go impose de le réécrire entièrement.
  • Recherche sélective — trouver les commandes d'un client demande de lire tout le fichier ; une base indexée y accède directement.
  • Cohérence — rien n'empêche un CSV de contenir deux fois le même identifiant, ou une référence vers un client qui n'existe pas. Une base peut le garantir.
  • Reprise après incident — si le programme meurt au milieu d'une écriture, une base revient à un état cohérent ; un fichier reste à moitié écrit.

SQL est par ailleurs un langage déclaratif : on décrit le résultat voulu, et le moteur choisit l'algorithme — parcours complet ou index, ordre des jointures, méthode de tri. C'est un renversement par rapport à Python, où l'on décrit la marche à suivre, et cela demande un temps d'adaptation : optimiser une requête SQL consiste à donner au moteur les moyens de bien choisir, pas à lui dicter ses étapes.

2. Le modèle relationnel

Une table est un tableau : des colonnes typées, des lignes. Chaque table a une clé primaire, une colonne (ou un groupe) qui identifie chaque ligne de façon unique. Une clé étrangère est une colonne qui pointe vers la clé primaire d'une autre table : c'est ce qui relie les tables entre elles.

SQL — un petit schéma complet
CREATE TABLE clients (
    client_id    INTEGER PRIMARY KEY,          -- identifiant unique
    nom          TEXT NOT NULL,                -- valeur obligatoire
    email        TEXT UNIQUE,                  -- pas deux fois le même
    ville        TEXT,
    cree_le      TIMESTAMP DEFAULT now()
);

CREATE TABLE commandes (
    commande_id  INTEGER PRIMARY KEY,
    client_id    INTEGER NOT NULL REFERENCES clients(client_id),   -- clé étrangère
    date_cmd     DATE NOT NULL,
    montant      NUMERIC(10, 2) CHECK (montant >= 0)               -- contrainte métier
);

CREATE INDEX idx_cmd_client ON commandes(client_id);               -- voir section 8

Deux garanties sont posées ici et méritent d'être soulignées. La clé étrangère interdit d'insérer une commande pour un client inexistant, et empêche de supprimer un client qui a des commandes : c'est l'intégrité référentielle, impossible à obtenir avec des fichiers. La contrainte CHECK refuse un montant négatif au niveau de la base, donc quel que soit le programme qui écrit — bien plus robuste qu'une validation dans un seul script.

La normalisation consiste à ne stocker chaque information qu'à un seul endroit. Si la ville du client était recopiée dans chaque commande, un déménagement obligerait à mettre à jour des milliers de lignes, avec la certitude d'en oublier. La règle pratique : une information qui décrit un client vit dans clients, une information qui décrit une commande vit dans commandes. En analytique, on dénormalise parfois volontairement — on recopie pour éviter des jointures coûteuses — mais c'est une optimisation consciente, pas un point de départ.

3. Les bases de SQL

SQL — l'ossature d'une requête
SELECT   ville, montant                 -- 5. colonnes renvoyées
FROM     commandes                      -- 1. table de départ
JOIN     clients USING (client_id)      -- 2. tables associées
WHERE    date_cmd >= DATE '2025-01-01'  -- 3. filtre sur les LIGNES
ORDER BY montant DESC                   -- 6. tri
LIMIT    10;                            -- 7. nombre de lignes renvoyées

L'ordre d'écriture n'est pas l'ordre d'exécution : le moteur commence par FROM et WHERE, agrège ensuite, et n'applique SELECT puis ORDER BY qu'à la fin. C'est ce qui explique une erreur fréquente — on ne peut pas utiliser dans WHERE un alias défini dans SELECT, puisqu'il n'existe pas encore à ce moment-là.

SQL — les opérateurs de filtrage utiles
WHERE montant BETWEEN 100 AND 500          -- bornes incluses
WHERE ville IN ('Grenoble', 'Lyon')        -- appartenance à une liste
WHERE email LIKE '%@exemple.fr'            -- motif ; % = n'importe quelle suite
WHERE email ILIKE '%@Exemple.FR'           -- idem, insensible à la casse (PostgreSQL)
WHERE ville IS NULL                        -- valeur absente : voir section 7
WHERE date_cmd >= now() - INTERVAL '30 days'

-- expressions conditionnelles et conversions
SELECT CASE WHEN montant > 1000 THEN 'grand'
            WHEN montant > 100  THEN 'moyen'
            ELSE 'petit' END AS segment,
       CAST(montant AS INTEGER)  AS montant_entier,
       COALESCE(ville, 'inconnue') AS ville     -- remplace NULL par une valeur
FROM commandes;

4. Jointures

Une jointure associe les lignes de deux tables selon une condition — presque toujours l'égalité d'une clé étrangère et d'une clé primaire. Quatre variantes couvrent tous les cas :

les quatre jointures, illustrées
clients            commandes
id | nom           client_id | montant
1  | Alice         1         | 100
2  | Bob           1         | 250
3  | Chloé         4         | 80        ← client 4 : inexistant dans clients

INNER JOIN  → uniquement les correspondances des DEUX côtés
   Alice|100, Alice|250                        (Bob et Chloé disparaissent)

LEFT JOIN   → toutes les lignes de GAUCHE, complétées par NULL à droite
   Alice|100, Alice|250, Bob|NULL, Chloé|NULL  (utile : "qui n'a rien commandé ?")

FULL OUTER JOIN → l'union des deux, avec NULL des deux côtés
   Alice|100, Alice|250, Bob|NULL, Chloé|NULL, NULL|80

CROSS JOIN  → le produit cartésien : chaque ligne avec chaque ligne
   3 × 3 = 9 lignes   (presque toujours le signe d'une condition oubliée)
SQL — trouver ce qui n'existe PAS (anti-jointure)
-- les clients sans aucune commande
SELECT c.nom
FROM clients c
LEFT JOIN commandes o ON o.client_id = c.client_id
WHERE o.commande_id IS NULL;            -- aucune ligne appariée à droite

-- même résultat, souvent plus lisible et aussi efficace
SELECT nom FROM clients c
WHERE NOT EXISTS (SELECT 1 FROM commandes o WHERE o.client_id = c.client_id);
Le piège n° 1 : l'explosion de cardinalité. Joindre sur une clé qui n'est unique d'aucun côté multiplie les lignes. Si un client a 3 commandes et 4 factures, joindre les deux tables sur client_id produit 12 lignes pour ce client — et toute somme calculée ensuite est fausse d'un facteur 4, sans le moindre message d'erreur. Réflexe : comparer COUNT(*) avant et après chaque jointure, et vérifier l'unicité côté « un » avec un GROUP BY … HAVING COUNT(*) > 1.

5. Agrégations et fonctions de fenêtre

Un GROUP BY écrase les lignes : une ligne par groupe. C'est ce qu'on veut pour un total par client, pas pour comparer chaque commande à la moyenne de son client.

SQL — agrégation classique
SELECT client_id,
       COUNT(*)        AS n_commandes,
       SUM(montant)    AS ca_total,
       AVG(montant)    AS panier_moyen,
       MAX(date_cmd)   AS derniere_commande
FROM commandes
WHERE date_cmd >= DATE '2025-01-01'     -- filtre AVANT agrégation (sur les lignes)
GROUP BY client_id
HAVING SUM(montant) > 1000              -- filtre APRÈS agrégation (sur les groupes)
ORDER BY ca_total DESC;

Une fonction de fenêtre calcule une agrégation sans écraser : chaque ligne conserve son identité tout en « voyant » son groupe. C'est l'outil décisif pour le feature engineering temporel (module 12), car il permet de calculer moyennes mobiles, rangs et décalages directement dans la base, sur des volumes qui ne tiendraient pas en mémoire.

SQL — anatomie d'une fonction de fenêtre
SELECT
    client_id,
    date_cmd,
    montant,

    -- rang de la commande dans l'historique du client
    ROW_NUMBER() OVER (PARTITION BY client_id ORDER BY date_cmd) AS n_commande,
    --           └─ OVER : "voici la fenêtre"
    --              └─ PARTITION BY : découpe en groupes (comme GROUP BY, sans écraser)
    --                 └─ ORDER BY : ordre À L'INTÉRIEUR de la fenêtre

    -- montant de la commande précédente du même client : feature de récence
    LAG(montant) OVER (PARTITION BY client_id ORDER BY date_cmd) AS montant_precedent,

    -- écart en jours depuis la commande précédente
    date_cmd - LAG(date_cmd) OVER (PARTITION BY client_id ORDER BY date_cmd) AS delai,

    -- moyenne glissante sur les 3 dernières commandes, celle-ci incluse
    AVG(montant) OVER (
        PARTITION BY client_id ORDER BY date_cmd
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moyenne_3,

    -- part de cette commande dans le total du client
    montant / SUM(montant) OVER (PARTITION BY client_id) AS part
FROM commandes;

La clause ROWS BETWEEN … AND CURRENT ROW n'est pas cosmétique : elle garantit qu'aucune information future n'entre dans la feature. C'est la version SQL de la protection contre la fuite temporelle (module 12) — sans elle, la fenêtre par défaut inclurait toutes les lignes du groupe, y compris celles qui viennent après.

les fonctions de fenêtre à connaître
ROW_NUMBER()    numérote 1, 2, 3… sans ex æquo
RANK()          ex æquo au même rang, puis saut (1, 2, 2, 4)
DENSE_RANK()    ex æquo au même rang, sans saut (1, 2, 2, 3)
LAG(x, n)       valeur n lignes avant (défaut n=1)
LEAD(x, n)      valeur n lignes après  → à n'utiliser qu'en analyse, jamais en feature
FIRST_VALUE(x)  première valeur de la fenêtre
NTILE(4)        découpe en 4 groupes de taille égale (quartiles)
SUM/AVG/COUNT   les agrégats classiques, en version fenêtrée

6. CTE : structurer une requête

Une Common Table Expression (WITH) nomme une étape intermédiaire. Une requête de 80 lignes avec quatre sous-requêtes imbriquées devient quatre blocs lisibles, testables séparément — il suffit de remplacer la requête finale par un SELECT * FROM etape_2 pour inspecter un résultat intermédiaire.

SQL
WITH commandes_2025 AS (
    SELECT * FROM commandes WHERE date_cmd >= DATE '2025-01-01'
),
agregats_client AS (
    SELECT client_id,
           COUNT(*)           AS n_commandes,
           SUM(montant)       AS ca_total,
           MAX(date_cmd)      AS derniere_commande
    FROM commandes_2025
    GROUP BY client_id
),
segments AS (
    SELECT *,
           NTILE(4) OVER (ORDER BY ca_total) AS quartile_ca
    FROM agregats_client
)
SELECT c.nom, s.n_commandes, s.ca_total, s.quartile_ca,
       CURRENT_DATE - s.derniere_commande AS jours_depuis_achat
FROM segments s
JOIN clients c USING (client_id)
ORDER BY s.ca_total DESC;

7. Le piège des NULL

NULL ne signifie ni « zéro », ni « chaîne vide » : il signifie inconnu. Et toute comparaison avec un inconnu donne un inconnu, traité comme faux.

SQL — les conséquences, à connaître par cœur
WHERE montant != 100        -- EXCLUT les lignes où montant est NULL (rarement voulu)
WHERE montant = NULL        -- ne renvoie JAMAIS rien : il faut IS NULL

SELECT COUNT(*),            -- compte TOUTES les lignes
       COUNT(montant),      -- compte seulement les montants NON NULL
       AVG(montant),        -- moyenne des NON NULL
       SUM(montant) / COUNT(*)   -- ≠ AVG(montant) s'il y a des NULL !
FROM commandes;

-- NULL se propage dans les calculs
SELECT 100 + NULL;          -- NULL, pas 100
SELECT COALESCE(montant, 0) + 100;   -- la bonne façon : remplacer d'abord

-- après un LEFT JOIN, les colonnes de droite peuvent être NULL : penser à COALESCE
SELECT c.nom, COALESCE(SUM(o.montant), 0) AS ca
FROM clients c LEFT JOIN commandes o USING (client_id)
GROUP BY c.nom;

8. Index et plans d'exécution

Sans index, répondre à WHERE client_id = 42 oblige le moteur à lire toute la table, ligne par ligne. Un index est une structure annexe — le plus souvent un arbre équilibré, un B-tree — qui range les valeurs d'une colonne dans un ordre permettant la recherche dichotomique : on passe d'un coût proportionnel au nombre de lignes à un coût proportionnel à son logarithme. Sur dix millions de lignes, c'est la différence entre lire dix millions d'enregistrements et en lire vingt-trois.

L'index n'est pas gratuit : il occupe de l'espace disque et ralentit les écritures, puisque chaque insertion doit le mettre à jour. On indexe donc les colonnes de jointure, celles des filtres sélectifs et celles des tris fréquents — pas toutes les colonnes.

SQL — mesurer plutôt que supposer
EXPLAIN ANALYZE
SELECT * FROM commandes WHERE client_id = 42;

-- AVANT l'index :
-- Seq Scan on commandes  (cost=0.00..18334.00 rows=1 width=44) (actual time=42.1 ms)
--   → "Seq Scan" = parcours complet : 18 000 pages lues pour trouver 1 ligne

CREATE INDEX idx_cmd_client ON commandes(client_id);

-- APRÈS :
-- Index Scan using idx_cmd_client  (cost=0.42..8.44 rows=1) (actual time=0.03 ms)
--   → mille fois plus rapide
Ce qui rend un index inutilisable. Appliquer une fonction à la colonne filtrée empêche le moteur de s'en servir : WHERE YEAR(date_cmd) = 2025 l'oblige à calculer YEAR sur chaque ligne. Il faut réécrire en WHERE date_cmd >= '2025-01-01' AND date_cmd < '2026-01-01'. Même chose pour WHERE UPPER(ville) = 'LYON' (créer plutôt un index sur l'expression) et pour un LIKE '%lyon%' commençant par un joker, qui ne peut pas exploiter l'ordre de l'index.

Deux notions complémentaires : un index composite (client_id, date_cmd) sert les filtres sur client_id seul ou sur les deux, mais pas sur date_cmd seul — l'ordre des colonnes compte, comme dans un annuaire trié par nom puis prénom. Et ANALYZE met à jour les statistiques que le planificateur utilise pour choisir : sur une table qui vient d'être massivement chargée, des statistiques périmées produisent des plans catastrophiques.

9. Transactions et ACID

Une transaction regroupe plusieurs opérations en une unité indivisible : soit tout réussit, soit rien ne s'applique. L'exemple canonique est un virement — débiter un compte et créditer l'autre — où s'arrêter au milieu serait catastrophique. En data science, le cas courant est l'ingestion : charger dix mille lignes et mettre à jour la table de suivi doit être tout ou rien, sinon on ne sait plus ce qui a été chargé.

SQL
BEGIN;                                        -- début de la transaction
  INSERT INTO annonces SELECT * FROM import_temporaire;
  UPDATE suivi_ingestion SET derniere_date = CURRENT_DATE;
COMMIT;                                       -- tout est appliqué d'un coup
-- en cas de problème : ROLLBACK;             -- rien n'a eu lieu

Les quatre garanties résumées par l'acronyme ACID : Atomicité (tout ou rien), Cohérence (les contraintes restent respectées), Isolation (une transaction en cours n'est pas perturbée par les autres), Durabilité (une fois validée, elle survit à une coupure de courant). Ce sont ces garanties que l'on perd en travaillant avec des fichiers — et, en partie, avec certaines bases NoSQL (section 11).

10. OLTP, OLAP et DuckDB

deux mondes, deux optimisations
                 OLTP (transactionnel)        OLAP (analytique)
Exemples         PostgreSQL, MySQL, SQLite    DuckDB, ClickHouse, BigQuery, Snowflake
Requêtes         nombreuses, très courtes     peu nombreuses, très lourdes
Accès typique    quelques lignes entières     2 colonnes sur 50, des millions de lignes
Stockage         par LIGNES                   par COLONNES
Optimisé pour    écriture, cohérence, ACID    lecture, compression, agrégation
Rôle en data     la source de vérité          l'analyse et l'entraînement

Le stockage en colonnes explique l'essentiel. Dans un stockage par lignes, les valeurs d'une même ligne sont contiguës sur le disque : parfait pour lire ou écrire un enregistrement complet. Dans un stockage par colonnes, ce sont les valeurs d'une même colonne qui sont contiguës : un SELECT AVG(montant) ne lit alors que cette colonne, en ignorant les 49 autres. S'ajoute un effet de compression considérable, puisque des valeurs voisines d'une même colonne se ressemblent (mêmes catégories répétées, dates proches, ordres de grandeur similaires).

DuckDB est l'outil à connaître pour un usage local : pas de serveur à installer, un simple fichier (ou même rien du tout, en mémoire), et il interroge directement des fichiers Parquet ou des DataFrames.

Python — DuckDB, SQL sur des fichiers
import duckdb

# SQL directement sur des fichiers Parquet, sans import préalable
df = duckdb.sql("""
    SELECT client_id, SUM(montant) AS ca
    FROM 'data/commandes_*.parquet'          -- le joker lit tous les fichiers
    WHERE date_cmd >= '2025-01-01'
    GROUP BY client_id
    ORDER BY ca DESC
    LIMIT 10
""").df()                                    # renvoie un DataFrame pandas

# on peut aussi interroger un DataFrame existant comme une table
con = duckdb.connect("analyse.duckdb")       # base persistante dans un fichier
con.execute("CREATE TABLE annonces AS SELECT * FROM df_polars")
con.execute("COPY annonces TO 'export.parquet' (FORMAT PARQUET)")

11. NoSQL et bases vectorielles

NoSQL ne veut pas dire « sans SQL » mais « pas seulement relationnel ». Chaque famille abandonne quelque chose — le plus souvent les jointures et la cohérence forte — pour gagner autre chose : du débit, de la souplesse de schéma, de la distribution.

quatre familles, et ce qu'elles font bien
Clé-valeur    Redis, DynamoDB     accès O(1) par clé. Cache, sessions, features
                                  servies en ligne. Aucune requête complexe.
Document      MongoDB, Firestore  documents JSON imbriqués, schéma souple. Pratique
                                  quand la structure varie d'un enregistrement
                                  à l'autre ; jointures pauvres.
Colonnes      Cassandra, HBase    écriture massive distribuée, séries temporelles,
                                  télémétrie. Modélisation dictée par les requêtes.
Graphe        Neo4j               les relations sont l'objet premier : détection de
                                  fraude, réseaux, recommandation (module 18).

Le cadre théorique est le théorème CAP : dans un système distribué, une partition réseau finit toujours par se produire (des machines ne se voient plus). Il faut alors choisir entre la cohérence — refuser de répondre plutôt que renvoyer une donnée potentiellement périmée — et la disponibilité — répondre quand même, quitte à être en retard. PostgreSQL privilégie la première, Cassandra la seconde avec une cohérence « à terme ».

Une base vectorielle indexe des embeddings — des vecteurs de quelques centaines de dimensions représentant un texte ou une image — et répond à « quels sont les k vecteurs les plus proches de celui-ci ? ». Les options : pgvector (extension PostgreSQL, à privilégier tant qu'une seule base suffit), FAISS (bibliothèque locale), Qdrant ou Milvus (services dédiés). Le fonctionnement des index approchés et le pipeline RAG complet sont traités au module 22.

Position par défaut raisonnable

Commencer en PostgreSQL. Il fait du relationnel, du JSON (type jsonb avec index), du plein texte, du vectoriel via pgvector, du géospatial via PostGIS — et il donne des garanties ACID. On ne migre une brique vers une base spécialisée que lorsqu'un besoin précis le justifie : latence d'un cache, volumétrie d'écriture, schéma réellement instable. L'inverse — choisir une base exotique d'abord, découvrir ensuite qu'on a besoin de jointures — coûte beaucoup plus cher.

12. Interroger une base depuis Python

Python — trois niveaux, du plus simple au plus complet
# 1. SQLite : inclus dans Python, zéro installation, un seul fichier
import sqlite3
with sqlite3.connect("projet.db") as con:
    con.execute("CREATE TABLE IF NOT EXISTS annonces (id INTEGER PRIMARY KEY, prix REAL)")
    con.executemany("INSERT INTO annonces VALUES (?, ?)", lignes)   # insertion par lot
    for ligne in con.execute("SELECT * FROM annonces WHERE prix > ?", (200_000,)):
        print(ligne)

# 2. SQLAlchemy : une interface unique pour PostgreSQL, MySQL, SQLite…
from sqlalchemy import create_engine, text
moteur = create_engine("postgresql+psycopg://user:motdepasse@localhost:5432/ma_base")
with moteur.begin() as con:                      # begin() = transaction automatique
    con.execute(text("UPDATE annonces SET prix = prix * 1.02 WHERE ville = :v"),
                {"v": "Grenoble"})

# 3. Charger directement dans un DataFrame
import polars as pl, pandas as pd
df = pl.read_database_uri("SELECT * FROM annonces", uri="postgresql://…")
df = pd.read_sql("SELECT * FROM annonces WHERE ville = %(v)s", moteur, params={"v": "Lyon"})
Toujours des requêtes paramétrées. Construire une requête par concaténation de chaînes — f"SELECT * FROM clients WHERE nom = '{nom}'" — ouvre la porte à l'injection SQL : une valeur contenant '; DROP TABLE clients; -- exécute ce qu'elle veut. Les points d'interrogation (SQLite), les :nom (SQLAlchemy) ou les %(nom)s (psycopg) transmettent la valeur séparément de la requête, ce qui rend l'injection impossible. C'est valable même pour un script interne : la même faille transforme un caractère inattendu en erreur difficile à diagnostiquer.

Ce qu'il faut retenir du module

  • Une base apporte ce qu'un fichier ne peut pas : accès concurrents, mises à jour partielles, recherche indexée, contraintes d'intégrité, reprise après incident.
  • Clés primaires et étrangères garantissent la cohérence au niveau de la base, donc pour tous les programmes.
  • Vérifier la cardinalité avant et après chaque jointure ; connaître l'anti-jointure.
  • GROUP BY écrase, une fonction de fenêtre non : c'est l'outil des features temporelles sans fuite.
  • NULL signifie inconnu et se propage : IS NULL pour tester, COALESCE pour remplacer.
  • Indexer les colonnes de jointure et de filtre ; vérifier avec EXPLAIN ANALYZE ; ne pas appliquer de fonction à une colonne filtrée.
  • Une transaction est une unité tout ou rien, avec les garanties ACID.
  • OLTP pour la source de vérité, OLAP (DuckDB en local) pour l'analyse.
  • PostgreSQL par défaut ; NoSQL seulement pour un besoin précis. Requêtes toujours paramétrées.

Ce module a montré comment interroger des données déjà en place. Le module 7 traite la question d'avant : comment elles y arrivent, sous quel format les stocker, comment organiser un entrepôt pour l'analyse, et comment faire tourner tout cela chaque nuit sans créer de doublons.


SQLPostgreSQLIndexFenêtresDuckDBNoSQL