PostgreSQL JSONB vs tables normalisées : décider et migrer
PostgreSQL JSONB vs tables normalisées : un cadre pratique pour choisir lors des prototypes, et un chemin de migration sécurisé quand l'application évolue.

Le vrai problème : avancer vite sans se coincer
Des exigences qui changent chaque semaine sont normales quand vous construisez quelque chose de nouveau. Un client demande un champ de plus. Les ventes veulent un flux différent. Le support a besoin d'une piste d'audit. Votre base de données finit par porter le poids de tous ces changements.
Itérer rapidement ne veut pas seulement dire livrer des écrans plus vite. Cela signifie pouvoir ajouter, renommer et supprimer des champs sans casser les rapports, les intégrations ou les anciens enregistrements. Cela signifie aussi pouvoir répondre à de nouvelles questions ("Combien de commandes avaient des notes de livraison manquantes le mois dernier ?") sans transformer chaque requête en script ad hoc.
C'est pourquoi le choix entre JSONB et des tables normalisées compte tôt. Les deux peuvent fonctionner, et les deux peuvent faire mal si on les utilise au mauvais endroit. JSONB donne l'impression de liberté parce que vous pouvez stocker presque n'importe quoi dès aujourd'hui. Les tables normalisées semblent plus sûres parce qu'elles imposent une structure. Le vrai objectif est d'adapter le modèle de stockage à l'incertitude actuelle de vos données et à la rapidité avec laquelle elles doivent devenir fiables.
Quand les équipes choisissent le mauvais modèle, les symptômes sont généralement évidents :
- Des questions simples deviennent des requêtes lentes et salissantes ou du code personnalisé.
- Deux enregistrements représentent la même chose mais utilisent des noms de champs différents.
- Des champs optionnels deviennent obligatoires plus tard et les anciennes données ne correspondent pas.
- Vous ne pouvez pas appliquer des règles (valeurs uniques, relations requises) sans contournements.
- Les rapports et exports continuent de se casser après de petits changements.
La décision pratique est : où avez-vous besoin de flexibilité (et pouvez tolérer l'incohérence un temps), et où avez-vous besoin de structure (parce que les données pilotent l'argent, les opérations ou la conformité) ?
JSONB et tables normalisées, expliqués simplement
PostgreSQL peut stocker des données en colonnes classiques (texte, nombre, date). Il peut aussi stocker un document JSON entier dans une colonne avec JSONB. La différence n'est pas « nouveau vs ancien ». C'est ce que vous attendez de la base : quelles garanties elle doit fournir.
JSONB stocke des clés, valeurs, tableaux et objets imbriqués. Il n'impose pas automatiquement que chaque ligne ait les mêmes clés, que les valeurs aient toujours le même type, ou qu'un élément référencé existe dans une autre table. Vous pouvez ajouter des contrôles, mais il faut les décider et les implémenter.
Les tables normalisées signifient séparer les données en tables distinctes selon ce que chaque chose est et les relier par des IDs. Un client est dans une table, une commande dans une autre, et chaque commande pointe vers un client. Cela vous protège mieux des contradictions.
Dans le travail quotidien, les compromis sont simples :
- JSONB : flexible par défaut, facile à changer, plus susceptible de dériver.
- Tables normalisées : plus délibérées à modifier, plus faciles à valider, plus simples à interroger de façon cohérente.
Un exemple simple : des champs personnalisés d'un ticket support. Avec JSONB vous pouvez ajouter un champ demain sans migration. Avec des tables normalisées, ajouter un champ est plus intentionnel, mais le reporting et les règles sont plus clairs.
Quand JSONB est l'outil adapté pour itérer vite
JSONB est un bon choix quand le plus grand risque est de construire la mauvaise forme de données, et non d'imposer des règles strictes. Si votre produit cherche encore son flux, forcer tout dans des tables fixes peut vous ralentir avec des migrations constantes.
Un bon signe : les champs changent chaque semaine. Pensez à un formulaire d'onboarding où le marketing ajoute des questions, renomme des étiquettes et supprime des étapes. JSONB permet de stocker chaque soumission telle quelle, même si la version de demain est différente.
JSONB convient aussi pour les « inconnus » : des données que vous ne comprenez pas encore complètement ou que vous ne contrôlez pas. Si vous ingérez des payloads webhook de partenaires, sauvegarder le payload brut en JSONB vous permet de supporter de nouveaux champs immédiatement et de décider plus tard ce qui doit devenir colonne de première classe.
Usages courants en phase early-stage : formulaires qui évoluent vite, capture d'événements et journaux d'audit, paramètres par client, feature flags et expérimentations. C'est particulièrement utile quand vous écrivez surtout les données, les relisez globalement, et que la forme bouge encore.
Une précaution simple aide plus que prévu : gardez une note courte et partagée des clés utilisées pour éviter d'avoir cinq orthographes différentes du même champ dans les lignes.
Quand les tables normalisées sont le choix plus sûr à long terme
Les tables normalisées gagnent quand les données cessent d'être « juste pour cette fonctionnalité » et deviennent partagées, interrogées et dignes de confiance. Si les gens vont segmenter et filtrer les enregistrements de nombreuses façons (statut, propriétaire, région, période), des colonnes et des relations rendent le comportement prévisible et plus facile à optimiser.
La normalisation compte aussi lorsque les règles doivent être appliquées par la base, pas par du code applicatif « au mieux ». JSONB peut stocker n'importe quoi, ce qui devient problématique quand vous avez besoin de garanties fortes.
Signes qu'il faut normaliser maintenant
Il est généralement temps d'abandonner un modèle centré sur JSON quand plusieurs de ces conditions sont vraies :
- Vous avez besoin de reporting et tableaux de bord cohérents.
- Vous avez besoin de contraintes comme des champs requis, valeurs uniques ou relations avec d'autres enregistrements.
- Plus d'un service ou d'une équipe lit et écrit les mêmes données.
- Les requêtes commencent à scanner beaucoup de lignes parce qu'elles ne peuvent pas utiliser des index simples.
- Vous êtes dans un environnement réglementé ou audité et les règles doivent être prouvables.
La performance est un point d'inflexion courant. Avec JSONB, filtrer signifie souvent extraire des valeurs à chaque fois. Vous pouvez indexer des chemins JSON, mais les besoins ont tendance à évoluer vers un patchwork d'index difficile à maintenir.
Un exemple concret
Un prototype stocke des « demandes client » en JSONB parce que chaque type de demande a des champs différents. Plus tard, les opérations ont besoin d'une file filtrée par priorité et SLA. La finance a besoin de totaux par département. Le support doit garantir que chaque demande a un customer_id et un statut. Là, les tables normalisées brillent : colonnes claires pour les champs communs, clés étrangères vers clients et équipes, et contraintes qui empêchent les mauvaises données d'entrer.
Un cadre de décision simple à utiliser en 30 minutes
Vous n'avez pas besoin d'un grand débat sur la théorie des bases. Vous avez besoin d'une réponse écrite rapide à une question : où la flexibilité vaut-elle plus que la structure stricte ?
Faites-le avec les personnes qui construisent et utilisent le système (builder, ops, support, et peut-être finance). L'objectif n'est pas de choisir un vainqueur unique. C'est de choisir le bon ajustement pour chaque partie du produit.
La checklist en 5 étapes
-
Listez vos 10 écrans les plus importants et les questions exactes qui les sous-tendent. Exemples : « ouvrir une fiche client », « trouver les commandes en retard », « exporter les paiements du mois dernier ». Si vous ne pouvez pas nommer la question, vous ne pouvez pas la concevoir.
-
Mettez en évidence les champs qui doivent être corrects à chaque fois. Ce sont des règles fortes : statut, montants, dates, propriété, permissions. Si une valeur erronée coûterait de l'argent ou déclencherait un incendie support, elle appartient généralement à des colonnes normales avec contraintes.
-
Marquez ce qui change souvent vs rarement. Les changements hebdomadaires (nouvelles questions de formulaire, détails spécifiques aux partenaires) sont de bons candidats pour JSONB. Les champs « cœur » qui changent rarement penchent vers la normalisation.
-
Décidez ce qui doit être recherchable, filtrable ou triable dans l'interface. Si les utilisateurs filtrent dessus constamment, mieux vaut en faire une colonne de première classe (ou un chemin JSONB soigneusement indexé).
-
Choisissez un modèle par zone. Une séparation courante : tables normalisées pour les entités et workflows centraux, JSONB pour les extras et métadonnées à évolution rapide.
Bases de performance sans se perdre dans les détails
La rapidité vient généralement d'une chose : rendre vos questions les plus fréquentes peu coûteuses à répondre. C'est plus important que l'idéologie.
Si vous utilisez JSONB, gardez-le petit et prévisible. Quelques champs supplémentaires passent. Un blob géant et en constante évolution est difficile à indexer et facile à mal utiliser. Si vous savez qu'une clé existera (comme "priority" ou "source"), gardez le nom de clé et le type de valeur cohérents.
Les index ne sont pas magiques. Ils échangent des lectures plus rapides contre des écritures plus lentes et plus d'espace disque. Indexez uniquement ce sur quoi vous filtrez ou joignez souvent, et seulement dans la forme que vous interrogez réellement.
Règles d'or pour l'indexation
- Mettez des index btree sur les filtres courants comme status, owner_id, created_at, updated_at.
- Utilisez un index GIN sur une colonne JSONB lorsque vous recherchez souvent à l'intérieur.
- Préférez des index d'expression pour un ou deux champs JSON chauds (par exemple (meta->>'priority')) plutôt que d'indexer tout le JSONB.
- Utilisez des index partiels quand seule une tranche importe (par exemple, uniquement les lignes où status = 'open').
Évitez de stocker des nombres et des dates sous forme de chaînes dans JSONB. "10" est trié avant "2", et les calculs de date deviennent pénibles. Utilisez de vrais types numériques et timestamp en colonnes, ou au moins stockez les nombres JSON comme nombres.
Un modèle hybride gagne souvent : champs cœur en colonnes, extras flexibles en JSONB. Exemple : une table operations avec id, status, owner_id, created_at comme colonnes, plus meta JSONB pour les réponses optionnelles.
Erreurs courantes qui créent de la douleur plus tard
JSONB peut donner l'illusion de liberté au départ. La douleur apparaît généralement des mois plus tard, quand plus de personnes touchent les données et que le « ça marche comme ça » devient « on ne peut rien changer sans casser quelque chose ». Ces patterns causent la plupart du travail de nettoyage :
- Traiter JSONB comme une poubelle. Si chaque équipe stocke des formes légèrement différentes, vous finissez par écrire de la logique de parsing personnalisée partout. Fixez des conventions de base : noms de clés cohérents, formats de date clairs et un petit champ de version dans le JSON.
- Cacher des entités cœur dans JSONB. Stocker des clients, commandes ou permissions uniquement en blobs semble simple au début, puis les jointures deviennent maladroites, les contraintes difficiles à appliquer et des doublons apparaissent. Gardez le qui/quoi/quand en colonnes et mettez les détails optionnels en JSONB.
- Attendre la migration jusqu'à l'urgence. Si vous ne suivez pas quelles clés existent, comment elles ont changé et lesquelles sont « officielles », votre première vraie migration devient risquée.
- Partir du principe que JSONB signifie automatiquement flexibilité et rapidité. La flexibilité sans règles n'est que l'incohérence. La vitesse dépend des patterns d'accès et des index.
- Casser l'analytique en changeant les clés au fil du temps. Renommer status en state, transformer des nombres en chaînes ou mélanger les fuseaux horaires ruineront silencieusement les rapports.
Exemple concret : une équipe démarre avec une table tickets et un champ details en JSONB pour les réponses de formulaire. Plus tard, la finance veut des découpages hebdomadaires par catégorie, les opérations veulent le suivi des SLA et le support veut des dashboards « ouverts par équipe ». Si catégories et timestamps dérivent entre clés et formats, chaque rapport devient une requête ad hoc.
Un plan de migration quand le prototype devient critique
Quand un prototype commence à piloter la paie, l'inventaire ou le support client, « on réparera les données plus tard » n'est plus acceptable. Le chemin le plus sûr est de migrer en petites étapes, en laissant les données JSONB anciennes fonctionner pendant que la nouvelle structure fait ses preuves.
Une approche phasée évite une réécriture risquée en une fois :
- Concevez d'abord la destination. Écrivez les tables cibles, les clés primaires et les règles de nommage. Décidez ce qui est une vraie entité (Customer, Ticket, Order) et ce qui reste flexible (notes, attributs optionnels).
- Construisez des tables à côté des anciennes données. Conservez la colonne JSONB, ajoutez des tables normalisées et des index en parallèle.
- Backfillez par lots et validez. Copiez les champs JSONB dans les nouvelles tables par tranches. Validez avec des comptes de lignes, des champs requis non nuls, et des vérifications ponctuelles.
- Basculez les lectures avant les écritures. Mettez à jour les requêtes et rapports pour lire d'abord les nouvelles tables. Quand les sorties correspondent, commencez à écrire les changements dans les tables normalisées.
- Verrouillez. Arrêtez d'écrire dans le JSONB, puis supprimez ou figez les anciens champs. Ajoutez des contraintes (clés étrangères, règles uniques) pour empêcher de mauvaises données de réapparaître.
Avant la bascule finale :
- Faites fonctionner les deux voies pendant une semaine (ancien vs nouveau) et comparez les sorties.
- Surveillez les requêtes lentes et ajoutez des index si nécessaire.
- Préparez un plan de retour en arrière (feature flag ou switch de config).
- Communiquez l'heure exacte du basculement des écritures à l'équipe.
Vérifications rapides avant de vous engager
Avant de verrouiller votre approche, faites un check reality. Ces questions attrapent la plupart des problèmes futurs tant que le changement reste bon marché.
Cinq questions qui décident la plupart des résultats
- Avons‑nous besoin d'unicité, de champs requis ou de types stricts maintenant (ou dans la prochaine release) ?
- Quels champs doivent être filtrables et triables pour les utilisateurs (recherche, statut, propriétaire, dates) ?
- Aurons‑nous bientôt besoin de tableaux de bord, d'exports ou de rapports « envoyer à la finance/ops » ?
- Peut‑on expliquer le modèle de données à un nouveau collègue en 10 minutes, sans ambiguïté ?
- Quel est notre plan de rollback si une migration casse un flux ?
Si vous répondez « oui » aux trois premières, vous penchez déjà vers des tables normalisées (ou au moins un hybride : champs cœur normalisés, attributs long tail en JSONB). Si le seul « oui » est le dernier, votre problème est plutôt process que schéma.
Règle simple
Utilisez JSONB quand la forme des données est encore incertaine, mais que vous pouvez nommer un petit ensemble de champs stables dont vous aurez toujours besoin (comme id, owner, status, created_at). Dès que des personnes dépendent de filtres cohérents, d'exports fiables ou d'une validation stricte, le coût de la « flexibilité » augmente vite.
Exemple : d'un formulaire flexible à un système ops fiable
Imaginez un formulaire d'entrée support qui change chaque semaine. Une semaine vous ajoutez « device model », la semaine suivante « refund reason », puis vous renommez « priority » en « urgency ». Au début, mettre le payload du formulaire dans une seule colonne JSONB paraît parfait. Vous pouvez livrer sans migration et personne ne se plaint.
Trois mois plus tard, les managers veulent des filtres comme « urgency = high and device model starts with iPhone », des SLA basés sur le tier client, et un rapport hebdomadaire qui doit correspondre aux chiffres de la semaine précédente.
Le mode d'échec est prévisible : quelqu'un demande « Où est passé ce champ ? » Les anciens enregistrements utilisaient un nom de clé différent, le type de valeur a changé ("3" vs 3), ou le champ n'existait pas pour la moitié des tickets. Les rapports deviennent un patchwork de cas particuliers.
Un compromis pratique est un design hybride : gardez les champs stables et critiques en colonnes réelles (created_at, customer_id, status, urgency, sla_due_at), et conservez une zone JSONB pour les champs nouveaux ou rares qui changent encore.
Un calendrier à faible perturbation qui fonctionne bien :
- Semaine 1 : Choisissez 5 à 10 champs qui doivent être filtrables et reportables. Ajoutez des colonnes.
- Semaine 2 : Backfillez ces colonnes depuis le JSONB pour les enregistrements récents d'abord, puis les plus anciens.
- Semaine 3 : Mettez à jour les écritures pour que les nouveaux enregistrements remplissent à la fois les colonnes et le JSONB (double-write temporaire).
- Semaine 4 : Basculez les lectures et rapports vers les colonnes. Gardez le JSONB uniquement pour les extras.
Prochaines étapes : décider, documenter et continuer à livrer
Si vous ne faites rien, la décision se fera toute seule. Le prototype grossit, les bords se durcissent et chaque changement devient risqué. Mieux vaut prendre une petite décision écrite maintenant, puis continuer à construire.
Listez les 5 à 10 questions que votre app doit pouvoir répondre rapidement ("Afficher toutes les commandes ouvertes pour ce client", "Trouver les utilisateurs par email", "Reporter le chiffre d'affaires par mois"). À côté de chacune, écrivez les contraintes que vous ne pouvez pas casser (email unique, statut requis, totaux valides). Puis tracez une frontière claire : conservez en JSONB les champs qui changent souvent et sont rarement filtrés ou joints, et promouvez en colonnes/tables tout ce que vous recherchez, triez, joignez ou devez valider systématiquement.
Si vous utilisez une plateforme no-code qui génère de vraies applications, cette séparation peut être plus facile à gérer au fil du temps. Par exemple, AppMaster (appmaster.io) vous permet de modéliser visuellement des tables PostgreSQL et de régénérer le backend et les applications sous-jacentes au fur et à mesure que les exigences évoluent, ce qui rend les changements de schéma itératifs et les migrations planifiées moins pénibles.
FAQ
Utilisez JSONB quand la forme des données change souvent et que vous faites principalement du stockage et de la restitution du payload, comme pour des formulaires qui évoluent vite, des webhooks partenaires, des feature flags ou des paramètres par client. Conservez un petit ensemble de champs stables en colonnes normales pour pouvoir filtrer et reporter de manière fiable.
Normalisez lorsque les données sont partagées, interrogées de nombreuses façons ou doivent être fiables par défaut. Si vous avez besoin de champs obligatoires, de valeurs uniques, de clés étrangères ou de tableaux de bord/exports cohérents, des tables avec des colonnes claires et des contraintes vous feront gagner du temps à long terme.
Oui — un hybride est souvent la meilleure option par défaut : mettez les champs critiques métier en colonnes et relations, et conservez les attributs optionnels ou changeants rapidement dans une colonne JSONB « meta ». Cela stabilise le reporting et les règles tout en vous laissant itérer sur le long tail.
Demandez-vous sur quoi les utilisateurs doivent filtrer, trier et exporter dans l'interface, et ce qui doit être correct à chaque fois (argent, statut, propriété, permissions, dates). Si un champ est souvent utilisé dans des listes, tableaux de bord ou jointures, promouvez-le en colonne réelle ; conservez en JSONB les extras peu utilisés.
Les principaux risques sont des noms de clés incohérents, des types de valeurs mélangés et des changements silencieux au fil du temps qui cassent l'analytique. Prévenez cela en imposant des noms de clés cohérents, en gardant JSONB réduit, en stockant nombres/dates comme types appropriés (ou nombres JSON), et en ajoutant un petit champ de version dans le JSON.
Oui, mais cela demande un travail supplémentaire. JSONB n'impose pas de structure par défaut, vous devrez donc ajouter des contrôles explicites, indexer soigneusement les chemins que vous interrogez et suivre des conventions strictes. Les schémas normalisés rendent ces garanties plus simples et plus visibles.
Indexez uniquement ce que vous interrogez réellement. Utilisez des index btree classiques pour les colonnes courantes comme status et timestamps ; pour le JSONB, privilégiez des index d'expression sur des clés chaudes (par exemple extraire un seul champ) plutôt que d'indexer le document entier, sauf si vous recherchez vraiment sur de nombreuses clés.
Recherchez des requêtes lentes ou salissantes, des scans complets fréquents et un ensemble croissant de scripts ad hoc juste pour répondre à des questions simples. D'autres signaux : plusieurs équipes écrivent les mêmes clés JSON différemment, ou le besoin de contraintes strictes et d'exports stables augmente.
Concevez d'abord les tables cibles, puis faites-les tourner en parallèle avec les données JSONB. Backfillez par lots, validez les sorties, basculez les lectures vers les nouvelles tables, puis les écritures, et enfin verrouillez le schéma avec des contraintes pour empêcher le retour de mauvaises données.
Modélisez vos entités principales (clients, commandes, tickets) comme des tables avec des colonnes claires pour les champs que les utilisateurs filtrent et reportent, puis ajoutez une colonne JSONB pour les extras flexibles. Des outils comme AppMaster (appmaster.io) peuvent faciliter l'itération en vous permettant de mettre à jour visuellement le modèle PostgreSQL et de régénérer le backend et les apps lorsque les exigences changent.


