J’ai hérité d’assez de schémas cassés pour avoir des avis tranchés. Les pires incidents que j’ai dû gérer ne venaient presque jamais d’un index manquant ou d’un disque lent. Ils venaient d’un modèle de données qui mentait. Une colonne qui voulait dire trois choses différentes selon la ligne. Un champ « statut » qui servait en secret de dépotoir à texte libre. Une clé étrangère qui existait dans la tête de quelqu’un mais jamais dans la base. Une bonne conception est l’assurance la moins chère que vous achèterez jamais, et vous l’achetez avant d’écrire la moindre requête.
Cet article explique comment j’aborde réellement la normalisation, ce que les formes normales apportent en pratique, et la poignée de situations où je dénormalise délibérément. Si vous voulez le volet collecte des besoins, je l’ai traité à part dans mon guide sur la méthodologie de modélisation des données. Ici, on parle du schéma lui-même.
Contre quoi la normalisation vous protège vraiment
On parle de la normalisation comme d’un exercice académique. Elle ne l’est pas. Chaque forme normale existe pour empêcher une classe précise de bugs qui finira par vous réveiller à 3h du matin. Retirez le jargon formel et l’objectif est simple. Stocker chaque fait une seule fois, à l’endroit où il appartient, pour qu’il soit impossible que deux copies du même fait se contredisent.
Quand la même donnée vit à deux endroits, ces deux endroits finiront par diverger. Pas peut-être. Ils divergeront. Quelqu’un met à jour l’e-mail du client dans une table et oublie l’autre. Un batch touche la moitié des lignes. Vous avez maintenant deux vérités et aucun moyen de savoir laquelle est correcte. La normalisation supprime la seconde copie pour que la contradiction devienne impossible plutôt que simplement improbable.
Les formes normales, à ma façon
Je ne récite pas les définitions formelles, mais je garde leur intention en tête quand j’esquisse des tables.
- La première forme normale signifie pas de groupes répétés ni de colonnes multivaluées. Si vous nommez des colonnes telephone1, telephone2, telephone3, une table séparée attend de naître. Une liste séparée par des virgules dans un varchar est le même crime déguisé.
- La deuxième forme normale signifie que chaque colonne non-clé dépend de toute la clé primaire, pas seulement d’une partie. Cela ne mord qu’avec des clés composites, mais quand ça mord, ça laisse une marque.
- La troisième forme normale signifie que les colonnes non-clés dépendent de la clé et de rien d’autre que la clé. Si une colonne dépend d’une autre colonne non-clé, elle a sa propre table. L’exemple classique est de stocker une ville et son code postal ensemble alors que l’un détermine l’autre.
Au quotidien, si j’atteins la troisième forme normale je suis généralement en bonne posture. Boyce-Codd et les formes supérieures comptent pour des cas précis de clés qui se chevauchent, mais la troisième forme normale attrape la grande majorité des vraies erreurs de modélisation que je vois en revue de code.
Un exemple concret
Disons que nous stockons des commandes. La version naïve entasse tout dans une seule table large, en répétant le nom et l’e-mail du client sur chaque ligne de commande. Voici la version normalisée que je livrerais réellement.
-- Les clients possèdent leurs propres faits, une seule fois
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email CITEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Les commandes référencent le client, elles ne le copient pas
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status TEXT NOT NULL
CHECK (status IN ('pending','paid','shipped','cancelled')),
placed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Les lignes ont leur propre granularité : une ligne par produit par commande
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(12,2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
Remarquez quelques choix qui ne relèvent pas strictement des formes normales mais qui accompagnent une bonne conception. La colonne status a une contrainte CHECK pour que la base elle-même impose les valeurs autorisées. Le unit_price vit sur la ligne, pas sur le produit, parce que le prix au moment de la vente est un fait différent du prix actuel. Cette distinction est exactement le genre de chose que la normalisation vous oblige à voir. Est-ce la valeur actuelle ou la valeur telle qu’elle était ? Ce ne sont pas le même fait et ils n’appartiennent pas à la même colonne.
Les contraintes font partie de la conception, pas de la décoration
Un schéma sans contraintes est une suggestion. Je pousse autant d’invariants que raisonnablement possible dans la base, parce que le code applicatif est le mauvais endroit pour garantir l’intégrité des données. Il y aura toujours un second écrivain un jour. Un script de migration, un outil d’admin, un collègue qui fouille dans une session psql. La base est la seule couche qu’ils partagent tous.
J’utilise donc NOT NULL agressivement, des clés étrangères sans m’excuser, des contraintes UNIQUE sur tout ce qui doit être unique, et des contraintes CHECK pour les plages de valeurs et les énumérations. Si vous ne retenez qu’une habitude de cet article, prenez celle-là. La plupart des « données mystérieusement corrompues » que j’ai déboguées auraient été impossibles avec une contrainte qui prend trente secondes à écrire. J’approfondis ce point dans mes notes sur les bonnes pratiques de schéma.
Quand je dénormalise volontairement
Maintenant l’hérésie. Je dénormalise régulièrement, et je ne culpabilise pas, parce que la dénormalisation faite avec intention est une optimisation, pas une erreur. L’astuce est que vous ne dénormalisez qu’après avoir compris le motif d’accès, jamais avant. Une dénormalisation prématurée n’est qu’un modèle de données avec des bugs en plus.
Voici les cas où j’y ai recours.
- Agrégats à forte lecture. Si un tableau de bord lit le total d’une commande mille fois pour chaque fois où la commande change, recalculer ce total à chaque lecture est du gaspillage. Je stockerai une colonne total mise en cache et je la maintiendrai à jour avec un trigger ou dans la même transaction que l’écriture.
- Tables de reporting et d’analyse. La normalisation transactionnelle et les motifs de requêtes analytiques tirent dans des sens opposés. Une table large dénormalisée ou un schéma en étoile peut transformer une jointure brutale à huit tables en un seul parcours. Je les garde séparées de la source de vérité et je les reconstruis à partir d’elle.
- Jointures coûteuses sur le chemin chaud. Parfois une jointure est réellement le goulot même après indexation. Copier une colonne souvent lue pour éviter une jointure peut en valoir la peine, tant que vous maîtrisez le chemin de mise à jour.
La règle non négociable dans chacun de ces cas est que la version normalisée reste la source de vérité. La copie dénormalisée est dérivée, jetable et reconstructible. Dès l’instant où vous avez deux sources de vérité indépendantes, vous revenez au péché originel que la normalisation servait à empêcher.
Comment je garde la dénormalisation sûre
Si je stocke une valeur dérivée, je rends la dérivation explicite et automatique. Une colonne en cache est mise à jour dans la même transaction que sa source, ou par un trigger, jamais par un commentaire optimiste disant « penser à mettre à jour ceci ». Une vue matérialisée a un calendrier de rafraîchissement documenté. Une table de reporting est reconstruite par un job que je peux lancer à la demande et vérifier contre la source.
J’écris aussi un contrôle, même lent et exécuté la nuit, qui compare la valeur dérivée à un calcul frais et hurle s’ils diffèrent. La dérive est le mode de défaillance de la dénormalisation, et la seule défense est de la détecter tôt. Une fois que je peux prouver que la copie correspond à la source, le gain de performance arrive avec la conscience tranquille. Quand les copies commencent à diverger, c’est presque toujours parce qu’un plan de requête a changé ou qu’un index a disparu, exactement le terrain que je couvre dans mon guide de l’indexation.
L’ordre dans lequel je procède
Ma séquence par défaut n’a pas changé depuis des années. Normaliser d’abord, jusqu’à la troisième forme normale, avec de vraies contraintes. Rendre le modèle correct et le laisser correct. Puis mesurer. Ce n’est que lorsqu’un motif d’accès précis et mesuré l’exige que j’introduis une copie dénormalisée, et uniquement comme un artefact dérivé avec un chemin de mise à jour imposé. La correction d’abord, puis la vitesse, et jamais une vitesse achetée au prix d’un mensonge dans les données.
Cet ordre compte parce qu’il est bien plus facile de dénormaliser un modèle propre que de nettoyer un modèle vaseux dès la naissance. Commencez strict. Relâchez délibérément. Votre futur vous, réveillé à 3h du matin, vous remerciera.

