DanLevy.net

JSONB : La meilleure façon de ruiner votre base de données

JSONB est puissant, utile, et très facile à mal utiliser quand on laisse un blob devenir son vrai schéma.

PostgreSQL a ajouté JSONB pour vous permettre de stocker des données semi-structurées sans définir de schémas rigides au préalable. L’idée était solide : parfois, vous ne savez vraiment pas à quoi ressembleront les données, ou elles changent trop fréquemment pour que les colonnes traditionnelles aient un sens.

Cela compte parce que JSONB n’est pas une erreur. Dans de nombreux systèmes, c’est la représentation la plus propre de l’espace problème. Si vous stockez des payloads de webhooks tiers, des corps d’événements versionnés, des feature flags ou des objets de configuration LLM où chaque fournisseur et modèle expose un ensemble d’options légèrement différent et en constante évolution, forcer tout dans des colonnes de première classe peut être plus gênant qu’autre chose.

Le problème, c’est que JSONB est aussi la façon la plus simple de reporter les décisions de schéma sans admettre qu’on les reporte. Quelque part entre l’intention et l’implémentation, c’est devenu l’équivalent base de données de « je rangerai ma chambre plus tard ». Cette solution temporaire que vous avez adoptée il y a six mois ? Elle est toujours là, et la production en dépend maintenant.

Je continue de voir le même schéma. Une équipe ajoute une colonne JSONB parce qu’elle n’est pas sûre des exigences. Elle se promet de normaliser une fois que les choses se seront stabilisées. Trois ans plus tard, cette colonne contient quarante versions différentes de ce qui était censé être un profil utilisateur, interrogée par quinze services qui font chacun des hypothèses différentes sur ce qui se trouve à l’intérieur.

La dette technique, ce n’est pas le JSONB lui-même. C’est l’écart entre ce que vous vous êtes dit que vous construisiez et ce que vous avez réellement construit : un schéma-on-read non documenté.

Ce qui se passe généralement

Vous ajoutez une fonctionnalité et vous ne savez pas si les utilisateurs ont besoin d’un twitter_handle ou d’un bluesky_handle ou d’autre chose entièrement. Plutôt que de réfléchir au schéma, vous faites ceci :

CREATE TABLE users (
id SERIAL PRIMARY KEY,
profile JSONB
);

Ça marche. Vous livrez la fonctionnalité, vous passez à la suivante, puis à la suivante encore. La colonne JSONB grossit tranquillement en arrière-plan.

C’est la fourche sur le chemin. Si profile reste un blob opaque récupéré par user.id, vous vous en sortez probablement. Si ça commence à devenir le lieu principal où vivent les données métier, les compromis changent vite.

Le produit demande : « Combien d’utilisateurs sont à New York ? »

Vous écrivez :

SELECT count(*) FROM users WHERE profile->>'location' = 'New York';

PostgreSQL effectue un scan complet de la table. Chaque ligne.

Vous ajoutez donc un index GIN. Peut-être que c’est encore acceptable. Parfois oui. Mais maintenant vous payez un vrai coût en complexité et en stockage parce qu’un champ qui se comporte comme une donnée relationnelle de première classe n’est jamais devenu une colonne de première classe.

Année 1 : Dérive du schéma

Vous avez trois versions de données dans la même colonne.

Votre code applicatif ressemble maintenant à ça :

const city = user.location || user.city || user.address?.city || "Unknown";

Vous n’avez pas supprimé le schéma. Vous avez juste déplacé la validation et la cohérence de la base de données vers du code applicatif dispersé.


Quand utiliser réellement JSONB

JSONB a des cas d’utilisation valides. Souvent, c’est parfaitement correct, et parfois c’est le meilleur choix disponible.

La distinction critique n’est pas « structuré bien, JSON mal ». C’est plutôt ceci :

Cas d’utilisation légitimes de JSONB

  1. Payloads de webhooks : Vous recevez des données de Stripe, Slack ou GitHub. Vous n’avez aucun contrôle sur le schéma. Vous ne les interrogerez peut-être jamais. Vous avez juste besoin de les stocker pour le débogage ou le replay. Parfait pour JSONB.

  2. Logs et flux d’événements : Logs applicatifs, traces d’audit, contextes d’erreur. Ce sont des écritures massives, rarement interrogées par des champs spécifiques, et souvent analysées en bloc ou exportées vers des plateformes d’analytique. JSONB convient ici.

  3. Préférences et paramètres utilisateur : Objets de paramètres où vous avez 100+ booléens, la plupart sont faux, et vous récupérez toujours le blob entier par ID utilisateur. Vous ne faites pas de WHERE preferences->>'theme' = 'dark'. JSONB fonctionne.

  4. Configuration fournisseur / modèle LLM : C’est l’un des exemples modernes les plus clairs. OpenAI, Anthropic, Gemini, modèles open-weight locaux et gateways spécifiques aux fournisseurs exposent tous des paramètres qui se chevauchent mais diffèrent. Même au sein d’un seul fournisseur, les capacités des modèles et les noms d’options évoluent. Un blob de configuration JSONB est souvent bien plus honnête que de prétendre que temperature, top_p, reasoning_effort, json_schema, tool_choice et vingt autres boutons devraient tous être des colonnes universelles. JSONB est souvent la bonne abstraction ici.

  5. Cache de réponses API : Vous mettez en cache des réponses API entières. La base de données n’est qu’un Redis plus rapide. Vous récupérez par clé de cache, jamais par propriétés imbriquées. JSONB est approprié.

  6. Event Sourcing : Vous stockez des payloads d’événements immuables. Vos requêtes sont toujours « donne-moi tous les événements pour l’agrégat X » ordonnés par temps. Vous ne faites jamais de clauses WHERE sur les propriétés d’événement. JSONB convient.

  7. Surfaces d’extensibilité : Intégrations, paramètres de plugins, overrides par locataire, métadonnées de marketplace, capacités de fournisseur ou champs « extras » où vous vous attendez explicitement à ce que la forme varie par sous-type. JSONB peut être le bon contrat, pas un compromis.

Règle générale : si l’application récupère le document par une clé connue et sait comment le valider/versionner, JSONB peut être excellent. Si le business continue de poser des questions relationnelles sur des clés imbriquées, ces champs essaient de devenir des colonnes.

Le meilleur schéma est souvent hybride

Beaucoup de systèmes matures arrivent ici :

CREATE TABLE llm_requests (
id UUID PRIMARY KEY,
provider TEXT NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
config JSONB NOT NULL
);

C’est généralement mieux que les deux extrêmes.

Ce n’est pas « échouer à normaliser ». C’est tracer la ligne au bon endroit.

À grande échelle : le versionnage d’objets > la normalisation

Voilà où ça devient intéressant. À une échelle suffisamment grande, la « bonne » solution n’est pas la normalisation — c’est le versionnage d’objets.

Si vous avez des milliards de lignes et une évolution fréquente du schéma, migrer des colonnes devient coûteux. Des entreprises comme Stripe, GitHub et Netflix ne normalisent pas tout. À la place :

CREATE TABLE entities (
id UUID PRIMARY KEY,
version INT NOT NULL,
data JSONB NOT NULL
);

Votre application sait comment lire version: 1, version: 2, version: 3. Pas de migrations de base de données pour les nouveaux champs. Le code gère la compatibilité ascendante.

C’est une décision architecturale, pas de la paresse. Ça échange la complexité de la base de données contre la complexité applicative. Parfois, c’est exactement le bon compromis, surtout quand le document est naturellement versionné et que l’app en est l’interprète canonique.

Le mode d’échec n’est pas « utiliser JSONB ». Le mode d’échec, c’est utiliser JSONB sans versionnage, sans validation, sans règles de promotion, sans frontière claire entre données document et données relationnelles.

Les questions qui comptent vraiment

Avant d’ajouter une colonne JSONB, demandez-vous :

  1. Interrogerons-nous des champs imbriqués dans WHERE, JOIN, GROUP BY ou ORDER BY régulièrement ?
  2. Contrôlons-nous ce schéma, ou est-il défini en externe et volatile ?
  3. La forme est-elle intentionnellement hétérogène entre les enregistrements ?
  4. Avons-nous une validation et un versionnage au niveau de l’application ?
  5. Quels champs sont susceptibles de devenir des dimensions opérationnelles plus tard ?

Si la réponse à la question 1 est « oui, constamment », c’est un signal fort pour des colonnes.

Si les réponses aux questions 2 et 3 sont « oui », JSONB fait probablement du vrai travail pour vous.


Sortir du piège

Si vous êtes déjà dans ce trou, arrêtez de creuser.

  1. Audit : Exécutez jsonb_object_keys et inspectez la dérive réelle des formes, pas la forme que vous supposez exister.
  2. Promotion : Identifiez les champs que vous filtrez, rejoignez, triez ou rapportez le plus souvent. Faites-en de vraies colonnes.
  3. Validation : Ajoutez une validation au niveau applicatif ou base de données pour ce qui reste dans JSONB.
  4. Versionnage : Si le blob est une vraie donnée de domaine, versionnez-le explicitement.
  5. Nettoyage : Supprimez les clés dupliquées du blob une fois les colonnes promues établies.

Ne vous dites pas que chaque blob doit être normalisé. Ne vous dites pas non plus qu’un blob avec une sémantique métier permanente est « temporaire ».

JSONB est excellent quand le document a vraiment une forme de document. Il est dangereux quand c’est un schéma relationnel avec une fausse moustache.

Ressources