Il y a un moment, dans la vie d’un schéma, où quelqu’un propose d’ajouter une colonne metadata jsonb.
L’argument est toujours le même, et il est bon : le métier n’est pas stabilisé, chaque client veut ses trois champs à lui, le fournisseur d’API renvoie un payload dont on ne maîtrise ni la forme ni le rythme des évolutions. Une colonne JSON évite six migrations et trois réunions.
Personne ne dit non.
Six mois plus tard, la colonne metadata contient quarante clés dont douze sont utilisées, trois sont écrites avec deux orthographes différentes, et le rapport hebdomadaire met quatre minutes à sortir.
Ce n’est pas une critique de JSON dans PostgreSQL. J’en mets, régulièrement, et souvent volontiers.
C’est une critique de la façon dont on y arrive 👉 par défaut, sans décider, parce que c’était le chemin qui ne demandait rien.
Cet article mesure ce que le document coûte vraiment dans PostgreSQL : les octets, les index, et surtout la chose dont on ne parle jamais, les statistiques. Il commence par le seul choix de type qui compte encore aujourd’hui.
1. Les types disponibles
PostgreSQL a accumulé les façons de stocker du semi-structuré au fil de vingt ans. Elles coexistent toutes, ce qui n’aide pas à choisir.
| Type | Apparu | Ce qu’il stocke | Le problème |
|---|---|---|---|
text |
toujours | La chaîne, telle quelle | La base ne sait pas que c’est du JSON |
xml |
8.3 (2008) | Un document XML validé | Écosystème XPath, personne ne produit du XML neuf |
hstore |
contrib, 8.2 | Clé → valeur, plat, valeurs texte | Pas d’imbrication, pas de types |
json |
9.2 (2012) | Le texte source, validé à l’écriture | Reparsé à chaque accès |
jsonb |
9.4 (2014) | Un arbre binaire décomposé | Réordonne les clés, écriture plus lente |
Les trois premiers sont des réponses historiques.
xml reste pertinent si vous échangez avec un partenaire qui parle XSD et rien d’autre. hstore a été le premier magasin clé/valeur indexable de PostgreSQL et il tourne encore dans beaucoup de bases, mais il ne sait représenter qu’un dictionnaire plat de chaînes : ni imbrication, ni tableau, ni nombre, ni booléen. Tout ce qu’il fait, jsonb le fait mieux.
Reste le vrai choix, celui que la documentation résume en une phrase souvent lue trop vite :
jsonconserve une copie exacte du texte d’entrée,jsonble stocke sous forme décomposée.
Cette phrase a des conséquences bien plus grandes qu’il n’y paraît.
La différence, en une image
json est un text avec un contrôle de syntaxe à l’écriture. Rien de plus.
Chaque doc->'user'->>'plan' reparse le document entier, du premier au dernier octet, pour aller chercher une clé.
jsonb parse une fois, à l’écriture, et range le résultat dans un format binaire où les clés d’un objet sont triées et adressables. Lire une clé devient une recherche dichotomique dans un en-tête, pas une analyse lexicale.
La mesure
Un protocole reproductible : 500 000 documents d’environ 300 octets.
On va simuler l’enregistrement de documents représentant des événements applicatifs avec :
- un type,
- une date,
- une chaîne représentant une session,
- un objet
userimbriqué, - un objet
props,
Ces documents seront stockés dans trois tables identiques sur PostgreSQL 18.4 (conteneur Docker, paramètres par défaut).
docker container run --rm -d --name pgjson -e POSTGRES_PASSWORD=postgres postgres:18.4
docker container exec -it pgjson psql -U postgres
CREATE TABLE e_json (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, doc json NOT NULL);
CREATE TABLE e_jsonb (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, doc jsonb NOT NULL);
CREATE TABLE e_text (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, doc text NOT NULL);
CREATE VIEW gen AS
SELECT json_build_object(
'event_type', (ARRAY['page_view','add_to_cart','checkout','signup'])[1 + g % 4],
'occurred_at', (TIMESTAMP '2026-01-01 00:00:00' + (g % 200000) * INTERVAL '1 minute'),
'user', json_build_object(
'id', g % 50000,
'plan', (ARRAY['free','pro','enterprise'])[1 + g % 3],
'country', (ARRAY['FR','DE','US','ES'])[1 + g % 4]),
'session', md5(g::text),
'props', json_build_object(
'page', '/product/' || (g % 1000),
'referrer', 'https://example.com/landing/' || (g % 50),
'ab_test', (ARRAY['A','B'])[1 + g % 2],
'amount', round((g % 997)::numeric / 7, 2))
) AS doc
FROM generate_series(1, 500000) g;
INSERT INTO e_json (doc) SELECT doc FROM gen;
INSERT INTO e_jsonb (doc) SELECT doc::jsonb FROM gen;
INSERT INTO e_text (doc) SELECT doc::text FROM gen;
Puis un count(*) filtré sur une clé imbriquée, sur cache chaud, parallélisme désactivé :
SELECT count(*) FROM e_jsonb WHERE doc -> 'user' ->> 'plan' = 'pro';
| Type | Insertion 500k | Taille table | Octets / doc | Filtre sur clé imbriquée |
|---|---|---|---|---|
json |
1,46 s | 163 Mo | 299 o | 314 ms |
jsonb |
2,02 s | 178 Mo | 326 o | 33 ms |
text |
1,50 s | 163 Mo | 299 o | 485 ms |
Trois choses à retenir :
jsonbécrit 38 % plus lentement et occupe 9 % de plus. C’est le coût du parsing à l’écriture et de l’en-tête binaire qui rend les clés adressables. Réel, et modeste.jsonblit 9,5 fois plus vite. L’écart n’est pas un réglage, c’est une différence de nature :jsonrefait le travail à chaque ligne et à chaque accès,jsonbl’a déjà fait.textest le pire des deux mondes. Pas de validation à l’écriture, et 485 ms à la lecture parce qu’il faut caster avant de pouvoir déréférencer.
L’argument qui clôt le débat
Ce n’est pas la performance, c’est celui-ci :
SELECT '{"b":1,"a":2}'::json = '{"a":2,"b":1}'::json;
ERROR: operator does not exist: json = json
Le type json n’a pas d’opérateur d’égalité.
Ce qui veut dire, en cascade :
- pas de
DISTINCT, - pas de
GROUP BYsur la colonne, - pas d’index B-tree, pas de contrainte
UNIQUE, - pas de jointure.
Le seul index possible sur une colonne json porte sur une expression qui en extrait du texte.
jsonb, lui, est totalement ordonné et comparable :
SELECT '{"b":1, "a":2, "a":3}'::json; -- {"b":1, "a":2, "a":3}
SELECT '{"b":1, "a":2, "a":3}'::jsonb; -- {"a": 3, "b": 1}
jsonb normalise : espaces supprimés, clés réordonnées, doublons résolus (le dernier gagne).
C’est exactement ce que fait un moteur JSON conforme quand il désérialise, et c’est ce qui rend l’égalité, le tri et l’indexation possibles.
L'ordre des clés n'est pas alphabétique
L'exemple ci-dessus le laisse croire, parce que a et b font un caractère.
L'ordre réel est longueur d'abord, puis ordre binaire :
'{"zz":1,"a":2}'::jsonb → {"a": 2, "zz": 1}, on dirait de l'alphabétique.
'{"b":1,"aa":2}'::jsonb → {"b": 1, "aa": 2}, et là, non.
C'est un choix de performance : comparer les longueurs avant les octets rend la dichotomie dans l'en-tête plus rapide.
Aucune importance tant qu'on interroge le document par ses clés ; piège garanti le jour où quelqu'un compare
deux jsonb::text sérialisés en supposant un ordre lexicographique.
Le seul cas où json est le bon type
Quand vous devez restituer le document octet pour octet tel qu'il est arrivé :
- signature cryptographique à revérifier,
- preuve d'audit,
- rejeu d'un webhook signé.
La normalisation de jsonb casse la signature, et l'ordre des clés fait partie du contrat.
Dans ce cas, stockez le payload brut en text ou json à côté d'une colonne jsonb normalisée qui, elle, sera interrogée et indexée.
L'un sert de preuve, l'autre de donnée.
En pratique 👉 « stocker du JSON dans PostgreSQL » veut dire jsonb.
2. Ce que jsonb apporte vraiment
Trois choses, et elles valent le détour.
L’absorption de l’inconnu
Un schéma relationnel exige de savoir.
jsonb permet de stocker d’abord et de décider ensuite :
- la réponse d’une API tierce,
- le payload d’un webhook,
- les champs personnalisés d’un client,
- la sortie d’un modèle de langage.
Vous encaissez et vous observez ce qui est réellement utilisé afin de promouvoir en colonnes ce qui mérite de l’être.
C’est un droit de tirage sur une décision de modélisation future, et il a une vraie valeur quand le métier bouge encore.
L’opérateur de conteneur
@>teste si un document en contient un autre, à n’importe quelle profondeur :
SELECT count(*) FROM e_jsonb WHERE doc @> '{"user": {"plan": "pro"}}';
Une seule expression pour une condition imbriquée, sans jointure, sans savoir à l’avance quelle profondeur on interroge.
C’est cet opérateur qui est indexable par GIN, et c’est lui qui fait tout l’intérêt de jsonb par rapport à un hstore ou à un text.
SQL/JSON, le langage de chemin
Depuis PostgreSQL 12, jsonpath permet des interrogations que -> ne sait pas exprimer :
- filtres,
- comparaisons numériques,
- parcours de tableaux.
SELECT count(*)
FROM e_jsonb
WHERE doc @@ '$.props.amount > 100';
SELECT jsonb_path_query(doc, '$.props.** ? (@ starts with "/product")')
FROM e_jsonb LIMIT 5;
Et depuis PostgreSQL 17, JSON_TABLE projette un document en table relationnelle, ce qui permet de traiter le semi-structuré avec du SQL ordinaire :
SELECT t.*
FROM e_jsonb e,
JSON_TABLE(e.doc, '$' COLUMNS (
event text PATH '$.event_type',
plan text PATH '$.user.plan',
amount numeric PATH '$.props.amount'
)) AS t
WHERE t.plan = 'pro';
C’est là que la promesse tient : le document et la table cohabitent dans le même moteur, la même transaction, le même JOIN, la même sauvegarde 👉 vous ne payez pas une seconde base de données pour stocker trois champs variables.
3. Indexer du jsonb
C’est le cœur du sujet, et l’endroit où la plupart des projets se trompent soit en ne mettant aucun index, soit en mettant un GIN sur toute la colonne par réflexe.
Trois stratégies existent. Elles ne couvrent pas les mêmes requêtes et ne coûtent pas du tout la même chose.
CREATE INDEX idx_gin_ops ON e_jsonb USING gin (doc); -- jsonb_ops (défaut)
CREATE INDEX idx_gin_path ON e_jsonb USING gin (doc jsonb_path_ops); -- jsonb_path_ops
CREATE INDEX idx_btree_exp ON e_jsonb ((doc -> 'user' ->> 'plan')); -- B-tree sur expression
Sur les mêmes 500 000 documents (table de 178 Mo) :
| Index | Taille | Construction | Requête @> sélective (500 lignes) |
|---|---|---|---|
GIN jsonb_ops |
99 Mo (56 % de la table) | 2,07 s | 1,60 ms |
GIN jsonb_path_ops |
56 Mo (31 %) | 0,92 s | 0,16 ms |
| B-tree sur expression | 3,4 Mo (2 %) | 0,09 s | 0,14 ms |
L’écart de taille est le chiffre le plus parlant de tout cet article. Un GIN par défaut sur une colonne jsonb, c’est plus de la moitié de la taille de la table. Sur une table de 200 Go, cela se remarque.
Ce 56 % n’est pas une constante universelle, il dépend de la forme du document. Le corpus ci-dessus contient une clé session en md5(), soit 500 000 valeurs uniques que jsonb_ops indexe une par une. Un document à faible cardinalité fait s’effondrer le ratio ; un document qui porte plusieurs champs libres le fait grimper. L’ordre de grandeur, lui, est robuste : un GIN par défaut se compte en dizaines de pourcents de la table, pas en pourcents.
Pourquoi cet écart
jsonb_ops, l’opérateur par défaut, indexe chaque clé et chaque valeur séparément, comme des entrées distinctes. Le document {"user": {"plan": "pro"}} produit des entrées pour user, pour plan et pour pro. D’où la taille, et d’où le coût à l’exécution : chercher {"user":{"plan":"pro"}} implique de croiser plusieurs listes d’identifiants puis de revérifier chaque ligne candidate.
jsonb_path_ops indexe une seule entrée par chemin complet comme un hash de user.plan = "pro".
Moitié moins d’entrées, listes bien plus courtes, croisement inutile. C’est ce qui explique les 0,16 ms contre 1,60 ms.
Le B-tree sur expression, lui, n’indexe qu’une clé. Il ne sait rien du reste du document.
C’est précisément pourquoi il fait 3,4 Mo et pourquoi il est imbattable sur la requête qu’il couvre.
Le prix de jsonb_path_ops
Il ne sait pas répondre à la question « cette clé existe-t-elle ? ». Les opérateurs ?, ?| et ?& ne sont supportés que par jsonb_ops.
Sur 200 documents portant une clé refund_reason parmi 500 200 :
SELECT count(*) FROM e_jsonb WHERE doc ? 'refund_reason';
| Index disponible | Plan | Temps |
|---|---|---|
GIN jsonb_path_ops seul |
Seq Scan sur 500 200 lignes | 26,0 ms |
GIN jsonb_ops |
Bitmap Index Scan | 0,16 ms |
162 fois plus rapide, et le rapport grandit avec la table puisque l’un est linéaire et l’autre pas.
Si votre application teste la présence de clés, cas classique des attributs optionnels par client, jsonb_path_ops ne vous servira à rien.
Le GIN sur sous-arbre, l’option qu’on oublie
Un GIN n’est pas obligé de porter sur toute la colonne. Il peut porter sur une expression qui en extrait un sous-arbre :
CREATE INDEX idx_gin_props ON e_jsonb USING gin ((doc -> 'props') jsonb_path_ops);
C’est l’esprit du B-tree sur expression appliqué au GIN : on garde les clés ouvertes du sous-document props, on cesse d’indexer tout le reste.
| Index | Taille | Part de la table |
|---|---|---|
GIN jsonb_ops sur doc |
99 Mo | 56 % |
GIN jsonb_path_ops sur doc |
56 Mo | 31 % |
GIN jsonb_path_ops sur doc -> 'props' |
6,2 Mo | 3 % |
Un ordre de grandeur gagné, et l’index sert toujours les requêtes exploratoires sur le sous-arbre, à condition d’écrire le prédicat sous la même forme :
SELECT count(*) FROM e_jsonb WHERE doc -> 'props' @> '{"page":"/product/42"}';
-- Bitmap Index Scan on idx_gin_props → 0,8 ms
C’est la bonne réponse quand les clés inconnues sont regroupées sous un nœud dédié, ce qui est presque toujours le cas dès qu’on a un peu conçu le document (props, custom_fields, metadata). Pris à l’envers, ça donne une règle de modélisation : ranger les champs libres sous une clé plutôt qu’à la racine, c’est ce qui rend l’index abordable deux ans plus tard.
Choisir
La règle qui résume : si vous savez quelles clés vous interrogez, un B-tree sur expression est presque toujours le bon choix.
Il coûte 2 % de la table au lieu de 56 %, il est aussi rapide, il sert le tri et les inégalités, et il donne au planificateur des statistiques qu’un GIN ne donne pas.
Et si ce sont les statistiques que vous voulez et pas l’accès, il existe moins cher que l’index (voir la section 4.1).
Le GIN n’est justifié que quand l’ensemble des clés interrogeables est ouvert : chaque client a ses champs, chaque intégration a ses propriétés, et vous ne pouvez pas créer un index par cas.
Index partiel : le meilleur des deux mondes ❓
Quand la clé n'existe que sur une fraction des lignes, l'index partiel évite d'indexer le reste :
CREATE INDEX ON events ((doc ->> 'refund_reason')) WHERE doc ? 'refund_reason';
Sur 200 lignes concernées parmi 500 200, l'index mesuré fait 16 Ko au lieu des dizaines de mégaoctets d'un GIN, et sert un Index Scan direct. Le planificateur ne l'utilisera que si la requête porte la même clause WHERE, ce qui est une contrainte à assumer côté applicatif.
4. Le prix à payer
Voici la partie que les articles enthousiastes oublient. Aucun de ces points n’est rédhibitoire mais tous méritent d’être connus avant de choisir.
4.1 Le planificateur est aveugle là où ça compte
C’est de loin le problème le plus coûteux, et le moins visible.
PostgreSQL collecte des statistiques par colonne :
- fréquence des valeurs les plus communes,
- nombre de valeurs distinctes,
- histogramme.
Sur une colonne jsonb, il collecte des statistiques sur le document entier vu comme une valeur opaque, pas sur ses clés.
SELECT tablename, attname, n_distinct,
array_length(most_common_vals, 1) AS n_mcv,
array_length(histogram_bounds, 1) AS n_hist
FROM pg_stats WHERE tablename IN ('e_jsonb', 'e_norm');
| Table | Colonne | n_distinct |
Valeurs communes | Histogramme |
|---|---|---|---|---|
e_jsonb |
doc (jsonb) |
-1 (tout unique) | aucune | 101 documents entiers |
e_norm |
plan (text) |
3 | 3 | aucun |
e_norm |
page (text) |
1000 | 7 | 101 valeurs |
Chaque valeur commune de e_norm est une information exploitable : « plan = 'pro' représente 33 % des lignes ». Les 101 bornes de e_jsonb sont des documents complets, dont aucun ne renseigne sur la distribution d’une clé particulière.
Conséquence directe sur les estimations. Même prédicat, même sélectivité réelle, deux modélisations :
| Prédicat | Lignes estimées | Lignes réelles | Erreur |
|---|---|---|---|
page = '/product/42' (colonne) |
498 | 500 | 0,4 % |
doc @> '{"props":{"page":"/product/42"}}' |
50 | 500 | 10× |
page = '/product/42' AND plan = 'pro' (colonnes) |
168 | 167 | 0,6 % |
doc @> '{"props":{...},"user":{"plan":"pro"}}' |
50 | 167 | 3,3× |
D’où sort ce 50
Il mérite d’être nommé, parce que l’explication paresseuse (« PostgreSQL applique une sélectivité par défaut ») est fausse, et qu’un lecteur qui reproduit tomberait sur autre chose.
L’opérateur @> a pour estimateur matchingsel, dont la sélectivité par défaut est de 1 %. Sur 500 000 lignes, ça donnerait 5 000. Pas 50.
Ce qui se passe est plus retors. ANALYZE construit bien un histogramme sur la colonne doc : 101 bornes, des documents entiers, puisque jsonb est un type ordonné donc échantillonnable. Le planificateur évalue le prédicat @> contre ces 101 documents, puis extrapole. Son estimation dépend donc entièrement de la sélectivité réelle :
Prédicat @> |
Lignes estimées | Lignes réelles | Erreur |
|---|---|---|---|
{"user":{"plan":"pro"}} |
181 818 | 166 667 | 9 % |
{"event_type":"page_view"} |
136 364 | 125 000 | 9 % |
{"props":{"page":"/product/42"}} |
50 | 500 | 10× |
{"user":{"plan":"nonexistent"}} |
50 | 0 | — |
Quand le prédicat retient un tiers de la table, plusieurs des 101 documents échantillonnés correspondent, et l’estimation est bonne. Le planificateur n’est donc pas aveugle au sens strict.
Il l’est là où ça compte. Dès que le prédicat devient sélectif, aucun des 101 documents ne correspond, la sélectivité calculée tombe à zéro, et PostgreSQL applique alors un plancher codé en dur : il refuse de croire une sélectivité inférieure à 0.0001. D’où :
0,0001 × 500 000 = 50
Ce même plancher s’applique aux deux prédicats sélectifs, ce qui explique qu’ils soient estimés à l’identique alors que l’un ramène 500 lignes et l’autre zéro. Le mot « constante » était donc mal choisi : c’est un plancher de sélectivité, et le chiffre grandit avec la table. L’erreur relative, elle, ne s’améliore jamais.
Et c’est exactement l’inverse de ce qu’on voudrait : le planificateur estime bien les prédicats qui ne servent à rien, et mal ceux sur lesquels on construit des plans.
Sur un count(*) isolé, aucune importance : le plan est le même. Le jour où ce prédicat est le côté d’une jointure, c’est autre chose.
Estimer 50 lignes quand il y en a 500 000 fait choisir une Nested Loop là où il fallait un Hash Join, et la requête qui prenait 200 ms en prend 40 secondes. C’est la forme la plus courante de « ça marchait bien en recette ».
Ce qui répare le problème
Deux outils, et le moins connu est le moins cher.
L’index B-tree sur expression rend deux services pour le prix d’un. Il accélère la lecture, et ANALYZE collecte au passage de vraies statistiques sur l’expression indexée : le prédicat doc -> 'user' ->> 'plan' = 'pro' passe d’un facteur 3 d’erreur à 1,5 %. C’est la raison la plus solide de le préférer à un GIN quand les clés sont connues.
Les statistiques étendues sur expression, depuis PostgreSQL 14, donnent la seconde moitié sans payer la première :
CREATE STATISTICS events_plan_stats ON (payload -> 'user' ->> 'plan') FROM events;
ANALYZE events;
Sur doc -> 'user' ->> 'plan' = 'pro' |
Lignes estimées | Lignes réelles | Erreur |
|---|---|---|---|
| Rien | 2 500 | 166 667 | 67× |
CREATE STATISTICS seul |
165 683 | 166 667 | 0,6 % |
Aucun index, aucune écriture ralentie, quelques kilo-octets de catalogue.
Cela reformule la règle de la section 3. Le B-tree sur expression reste le bon choix quand on veut aussi l’accès indexé. Mais quand le prédicat n’est qu’un côté de jointure et que le scan restera séquentiel de toute façon, les statistiques étendues suffisent, et on s’épargne un index à maintenir.
Elles vont même plus loin. Déclarées sur plusieurs expressions, elles capturent les dépendances entre clés, par exemple le fait que plan = 'enterprise' implique presque toujours country = 'US'. Aucun index n’apporte cette information.
CREATE STATISTICS events_multi (dependencies, ndistinct)
ON (payload -> 'user' ->> 'plan'), (payload -> 'user' ->> 'country')
FROM events;
Sur le corpus de test, le prédicat combiné plan = 'pro' AND country = 'FR' est alors estimé à 41 872 lignes pour 41 667 réelles, soit 0,5 % d’erreur sur deux clés imbriquées d’un document, sans un seul index.
Le réflexe à prendre
Devant une requête lente sur dujsonb, on cherche par réflexe quel index ajouter.Commencez plutôt par comparer
rows= et actual rows= dans un EXPLAIN ANALYZE : si l'écart est d'un ordre de grandeur, aucun index ne réparera le plan. C'est une estimation qu'il faut corriger, pas un chemin d'accès.
4.2 Aucune contrainte, par défaut
Une colonne jsonb accepte tout ce qui est du JSON valide. Y compris tout ce que vous ne vouliez pas :
INSERT INTO loose VALUES ('{"user":{"plan":"pro"}}'),
('{"user":{"plann":"pro"}}'), -- faute de frappe
('{"user":{"plan":42}}'), -- mauvais type
('{"user":{"plan":null}}'), -- null JSON, pas NULL SQL
('{"usr":{"plan":"pro"}}'); -- autre faute de frappe
SELECT count(*) FROM loose WHERE doc @> '{"user":{"plan":"pro"}}';
-- 1 sur 5
Cinq lignes insérées, cinq lignes acceptées, une seule retrouvée. Aucune erreur, aucun avertissement. La faute de frappe qu’une colonne text NOT NULL aurait rendue impossible devient ici une ligne silencieusement invisible.
Deux pièges de typage s’ajoutent, spécifiques au document :
SELECT '{"a": 1e2}'::jsonb; -- {"a": 100} -- réécrit
SELECT ('{"a":"12"}'::jsonb ->> 'a') > '9'; -- false -- comparaison de texte
->> renvoie du text. '12' > '9' est faux en ordre lexicographique.
Il faut caster explicitement, à chaque fois, et un cast oublié ne lève aucune erreur 👉 il donne juste un mauvais résultat.
Deux autres pièges, cette fois à l’écriture, et ce sont de loin ceux qu’on retrouve le plus souvent en production :
SELECT jsonb_set('{"a":1,"b":2}', '{a}', NULL);
-- NULL
jsonb_set est une fonction stricte : un argument NULL et c’est le document entier qui devient NULL, pas la clé. Un UPDATE ... SET doc = jsonb_set(doc, '{a}', <valeur>) où la valeur vient d’une sous-requête qui ne ramène rien efface la colonne. Sans erreur. La parade est jsonb_set(doc, '{a}', COALESCE(<valeur>, 'null'::jsonb)), en distinguant bien le null JSON du NULL SQL.
SELECT '{"user":{"plan":"pro","country":"FR"}}'::jsonb
|| '{"user":{"plan":"free"}}'::jsonb;
-- {"user": {"plan": "free"}}
L’opérateur || est une fusion superficielle : il fusionne à la racine, et remplace les niveaux imbriqués au lieu de les fusionner. Ici country a disparu. Pour une mise à jour en profondeur, c’est jsonb_set avec le chemin complet qu’il faut, pas ||.
La bonne nouvelle : PostgreSQL sait contraindre le document, à condition qu’on le lui demande.
CREATE TABLE events (
doc jsonb NOT NULL,
CONSTRAINT doc_is_object CHECK (jsonb_typeof(doc) = 'object'),
CONSTRAINT has_plan CHECK (doc -> 'user' ? 'plan'),
CONSTRAINT plan_allowed CHECK (doc -> 'user' ->> 'plan' IN ('free', 'pro', 'enterprise'))
);
INSERT INTO events VALUES ('{"user":{"plan":"premium"}}');
ERROR: new row for relation "events" violates check constraint "plan_allowed"
Ça fonctionne, c’est vérifié à chaque écriture, et presque personne ne le fait 🤦♂️.
Sauf que ces CHECK ne contraignent pas ce qu’ils ont l’air de contraindre
INSERT INTO events VALUES ('{}'); -- passe sans broncher
La règle SQL est qu’une contrainte CHECK qui s’évalue à NULL est satisfaite. Seul FALSE rejette la ligne.
Or sur un document, l’absence est le cas nominal. doc -> 'user' vaut NULL quand la clé user manque, donc NULL ? 'plan' vaut NULL, donc has_plan est satisfaite. Idem pour plan_allowed : NULL IN ('free', 'pro', 'enterprise') vaut NULL, pas FALSE.
Ce qui rend le piège vicieux, c’est qu’il ne se déclenche que si la clé parente manque. Ici has_plan rattrape bien '{"user":{}}', parce que '{}'::jsonb ? 'plan' vaut FALSE et non NULL. La contrainte a donc l’air de marcher, jusqu’au document qui n’a pas de clé user du tout.
Il faut ancrer explicitement la valeur manquante :
CONSTRAINT plan_allowed CHECK (
COALESCE(doc -> 'user' ->> 'plan', '') IN ('free', 'pro', 'enterprise')
)
INSERT INTO events VALUES ('{}');
ERROR: new row for relation "events" violates check constraint "plan_allowed"
La règle à retenir : sur du jsonb, toute contrainte qui traverse une clé doit décider ce que « clé absente » veut dire. Un COALESCE, un IS NOT NULL explicite, ou un jsonb_typeof(...) = ... qui échoue franchement sur NULL. Sinon la contrainte ne protège que les lignes déjà bien formées.
4.3 TOAST : le coût caché des gros documents
Quand une ligne dépasse environ 2 Ko, PostgreSQL la fait rentrer dans une page en deux temps, et l’ordre des deux compte :
- il compresse d’abord les plus gros attributs, sur place ;
- si le tuple dépasse toujours le seuil, il sort les plus gros de la ligne et les range dans une table annexe dite TOAST.
Un document de 3 Ko qui se comprime à 1,5 Ko reste donc dans la table, compressé, sans TOAST. C’est la taille après compression qui décide.
Quand le déplacement a bien lieu, le document n’est plus dans la ligne : il est référencé par elle.
C’est efficace pour le stockage mais c’est brutal en écriture, car il n’existe pas de mise à jour partielle.
Modifier une clé oblige à décompresser le document, le reconstruire entier, le recompresser et réécrire toute la chaîne TOAST.
La mesure, sur 2 000 documents d’environ 21 Ko (≈ 15,5 Ko une fois compressés) :
UPDATE big SET status = 'done'; -- colonne texte à côté
UPDATE big SET doc = jsonb_set(doc, '{0,v}', '"patched"'); -- une clé dans le document
| Opération | Durée | WAL généré |
|---|---|---|
Mise à jour d’une colonne text voisine |
2,4 ms | 461 Ko |
| Mise à jour d’une clé dans le document TOASTé | 276 ms | 33 Mo |
115 fois plus lent, 73 fois plus de WAL pour modifier une chaîne de sept caractères. Et ce WAL se paie une deuxième fois sur les réplicas et une troisième dans les sauvegardes.
Ces deux ratios ne valent pas partout. Ils dépendent de default_toast_compression (pglz par défaut, lz4 disponible depuis PostgreSQL 14 et nettement plus rapide) et du moment du test dans le cycle de checkpoint, puisque les full-page writes qui suivent un checkpoint gonflent mécaniquement le WAL. Refaites la mesure chez vous avant de citer ces chiffres en réunion. L’ordre de grandeur, lui, tient : modifier une clé d’un document TOASTé réécrit le document entier.
À relativiser sur les petits documents
Le même test rejoué sur la table de la section 1, soit 500 000 documents de 300 octets restés sous le seuil TOAST, donne 45 Mo de WAL pour la mise à jour d'une clé jsonb contre 46 Mo pour la mise à jour d'une colonne équivalente 👉 résultat identique.
C'est normal, le MVCC de PostgreSQL réécrit la ligne entière dans les deux cas.
Le surcoût dejsonb en écriture n'est donc pas structurel : il apparaît au franchissement du seuil TOAST.Ce seuil, autour de 2 Ko, est la vraie ligne rouge des documents mis à jour fréquemment.
4.4 L’amplification en écriture du GIN
Le 56 % de la section 3 a un pendant en écriture, et c’est lui qui tue les tables jsonb chaudes.
Un GIN n’a pas de notion de mise à jour partielle lui non plus. Modifier une clé du document réinsère toutes les entrées d’index de la ligne, pas seulement celle qui a changé : du point de vue de l’index, la ligne est une nouvelle version, dont il faut réindexer chaque clé et chaque valeur.
Sur 20 000 lignes mises à jour dans une table de 200 000 documents, une seule clé imbriquée modifiée :
Index sur doc |
Durée | WAL généré |
|---|---|---|
| Aucun | 42 ms | 18 Mo |
GIN jsonb_ops, fastupdate = off |
515 ms | 92 Mo |
GIN jsonb_ops, fastupdate = on (défaut) |
188 ms | 56 Mo |
12 fois plus lent et 5 fois plus de WAL pour le même UPDATE, du seul fait de l’index.
fastupdate, actif par défaut, se contente de différer la facture. Les nouvelles entrées s’accumulent dans une pending list non triée, que la lecture doit parcourir en entier et qu’un VACUUM ou une insertion malchanceuse finit par vider d’un coup. On troque un surcoût régulier contre une latence imprévisible, et des temps de réponse qui se dégradent en silence entre deux vidages. Personnellement, je préfère le surcoût régulier : au moins il se voit dans les graphes.
C’est le même raisonnement que pour la taille, vu du côté des écritures : indexez le sous-arbre ou l’expression, pas le document. Un B-tree sur expression ne réindexe qu’une clé, et ne bouge même pas quand cette clé n’a pas changé.
4.5 Ce que vous perdez au passage
- Les clés étrangères.
doc -> 'user' ->> 'id'ne référence rien.
Le jour où un utilisateur est supprimé, les documents qui le mentionnent restent, et personne ne le sait. - Les noms de clés, répétés à chaque ligne.
Un nom de clé coûte un octet par caractère, dans chaque document. Deux tables de 500 000 lignes portant les mêmes valeurs, l’une avec les clésevent_type/session_identifier, l’autre avece/s: 61 Mo contre 45 Mo. La compression TOAST n’intervient qu’au-delà de 2 Ko par valeur, donc jamais sur ce genre de document. - La lisibilité.
doc -> 'a' -> 'b' ->> 'c'se relit moins bien quea_b_c, et se refactorise beaucoup moins bien. Aucun outil ne vous dira qu’une clé a été renommée. Le compilateur ne voit rien, le linter SQL non plus. - La
NULL-ité.doc ->> 'x'renvoieNULLsi la clé est absente et si sa valeur est lenullJSON. Les deux cas sont indiscernables sansdoc ? 'x'.
5. Quand jsonb est le bon outil
Les payloads externes que vous ne contrôlez pas.
Réponses d’API tierces, webhooks, événements d’un bus. La forme change sans préavis, elle n’est pas votre modèle, et vous voulez le brut pour pouvoir rejouer ou déboguer. Une colonne jsonb est exactement le bon récipient.
Les champs personnalisés par client.
Le cas d’école de l’EAV. Trois tables attribute / attribute_value / entity_attribute avec leurs jointures et leur absence de typage font moins bien qu’une colonne custom_fields jsonb avec un GIN dessus, et se lisent nettement moins bien.
La configuration et les préférences.
Peu de lignes, lues souvent, écrites rarement, structure arborescente par nature.
Aucun des inconvénients ne s’applique : pas de volumétrie, pas de jointure, pas de statistiques à calculer.
Les instantanés et l’historisation.
La photo d’une commande au moment où elle a été validée, la version N d’un document, la ligne d’audit. On les écrit une fois, on les relit tels quels, on ne les met jamais à jour donc jamais de réécriture TOAST.
Et la forme du document doit rester celle d’il y a deux ans, ce qu’un schéma relationnel qui évolue ne permet pas.
Les zones d’atterrissage.
On ingère brut dans une colonne jsonb, on transforme vers des colonnes typées dans un second temps.
Le brut reste disponible pour rejouer la transformation quand elle se révèle fausse.
À l’inverse, les signaux qui doivent faire reculer :
- Le champ est dans le
WHEREde vos requêtes principales, ou dans unJOIN👉 colonne. - Le champ est agrégé (
SUM,AVG,GROUP BY) dans un rapport 👉 colonne. - Le champ référence une autre table 👉 colonne, avec sa clé étrangère.
- Le document dépasse 2 Ko et est mis à jour souvent 👉 découpez-le.
- Vous vous surprenez à écrire
jsonb_setdans une boucle applicative 👉 c’était une table.
6. Le modèle hybride, celui qui marche
La bonne question n’a jamais été « relationnel ou document ».
C’est « quelle partie de cette donnée a un contrat, et quelle partie n’en a pas ».
Ce qui a un contrat (ce qu’on filtre, agrège, référence, contraint) prend une colonne. Ce qui n’en a pas prend le document.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id bigint NOT NULL REFERENCES accounts (id),
event_type text NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
CONSTRAINT payload_is_object CHECK (jsonb_typeof(payload) = 'object')
);
CREATE INDEX ON events (account_id, occurred_at DESC);
CREATE INDEX ON events (event_type);
CREATE INDEX ON events USING gin (payload jsonb_path_ops);
Le stable est typé, contraint, indexé en B-tree, et le planificateur a des statistiques dessus.
Le variable est dans payload, avec un GIN pour les requêtes exploratoires.
Promouvoir une clé sans casser le code
Quand une clé du document devient importante, PostgreSQL permet de la remonter en colonne sans changer une ligne du code d’écriture :
ALTER TABLE events
ADD COLUMN plan text GENERATED ALWAYS AS (payload -> 'user' ->> 'plan') STORED;
CREATE INDEX ON events (plan);
L’application continue d’écrire son payload complet. La colonne se calcule toute seule, s’indexe, et surtout ANALYZE collecte de vraies statistiques dessus. C’est le chemin de migration progressif dont on parlait en section 2.
Sauf que ce n’est pas gratuit. ADD COLUMN ... GENERATED ALWAYS AS (...) STORED doit calculer la valeur pour chaque ligne existante : c’est une réécriture complète de la table sous ACCESS EXCLUSIVE. Personne ne lit, personne n’écrit, jusqu’à la fin. Sur la table de 200 Go évoquée plus haut, ce n’est pas un ALTER TABLE, c’est une opération de maintenance à planifier.
L’alternative donne l’essentiel du bénéfice sans verrou exclusif :
CREATE INDEX CONCURRENTLY idx_events_plan ON events ((payload -> 'user' ->> 'plan'));
CREATE STATISTICS events_plan_stats ON (payload -> 'user' ->> 'plan') FROM events;
ANALYZE events;
L’accès indexé et les statistiques, sans bloquer la production.
Gardez la colonne générée STORED pour ce que l’index ne sait pas faire : porter une clé étrangère, une contrainte NOT NULL, ou être projetée telle quelle par du code qui ne doit pas connaître la forme du document. C’est-à-dire le jour où la clé n’est plus « une clé qu’on interroge » mais un vrai attribut du modèle. Ce jour-là, la fenêtre de maintenance se justifie.
STORED ou VIRTUAL ?
PostgreSQL 18 ajoute les colonnes générées VIRTUAL, calculées à la lecture et qui ne consomment aucun stockage.
Pratique pour de l'exposition, mais elles ne sont pas indexables :
ERROR: indexes on virtual generated columns are not supported
STORED.
7. Le vrai seuil de bascule
« Si un jour ça ne suffit plus, on passera sur MongoDB » est la conclusion réflexe de ce genre d’article. Elle ne tient pas ici, et c’est tout ce qui précède qui la démonte : aucune des trois douleurs mesurées ne se résout en changeant de moteur.
Le planificateur est aveugle parce qu’on lui a caché la structure. Un moteur documentaire n’a pas de meilleures statistiques sur des documents hétérogènes, il a surtout un optimiseur moins ambitieux. La réécriture TOAST, elle, est le prix d’une mise à jour de clé dans un gros document, et MongoDB réécrit lui aussi le document entier. Quant à l’absence de contrainte, elle tient au schéma qu’on a écrit, pas au moteur qui l’exécute.
Ces trois-là sont des problèmes de modélisation. Ils voyagent avec vous.
Les vraies raisons de sortir de PostgreSQL sont ailleurs, et elles sont beaucoup plus rares qu’on ne le dit :
- Le débit d’écriture impose de répartir sur plusieurs nœuds. Quand un primaire ne suffit plus, que le partitionnement déclaratif et les réplicas en lecture ne rattrapent plus, il faut un moteur qui distribue les écritures nativement. C’est un problème de volume, pas de forme de la donnée.
- L’indexation par tenant sur des clés arbitraires. Mille clients, chacun avec ses propres champs interrogeables : vous ne pouvez pas créer mille index, et un GIN unique sur toute la colonne vous ramène aux 56 % et à l’amplification en écriture de la section 4.4. C’est le seul cas où le modèle documentaire a structurellement raison.
- La géodistribution avec écriture multi-région, que PostgreSQL ne fait pas seul.
Notez ce qu’aucune de ces trois lignes ne dit : « on a beaucoup de JSON ». Le volume de document n’a jamais été un critère.
Dit autrement : dans l’immense majorité des cas, PostgreSQL ne vous fait pas gagner du temps avant la migration, il vous l’épargne. Et le jour où elle s’impose vraiment, vous saurez laquelle de ces trois raisons vous concerne, avec les chiffres pour l’établir.
En résumé
- « Stocker du JSON dans PostgreSQL » signifie
jsonb.jsonne sert que quand l’octet exact compte (signature, preuve).hstore,xmlettextsont des réponses historiques. jsonbécrit 38 % plus lentement et pèse 9 % de plus, mais lit 9,5 fois plus vite et il est le seul type comparable, donc indexable.- Un GIN
jsonb_opspar défaut, c’est 56 % de la taille de la table.jsonb_path_opsdivise par deux, le même posé sur un sous-arbre divise par dix, un B-tree sur expression par trente. - Si vous savez quelles clés vous interrogez, indexez l’expression, pas le document.
Il est plus petit, aussi rapide, et il ne réindexe qu’une clé à l’écriture là où un GIN réindexe tout le document : 12 fois plus lent, 5 fois plus de WAL sur le mêmeUPDATE. - Le vrai coût de
jsonbn’est ni les octets ni le CPU : c’est que le planificateur voit bien les prédicats peu sélectifs et pas du tout les autres. Sous un certain seuil il applique un plancher de sélectivité, et se trompe d’un ordre de grandeur. CREATE STATISTICSsur expression répare ça sans index 👉 0,6 % d’erreur au lieu de 67×, pour quelques kilo-octets de catalogue.- Un document TOASTé (> 2 Ko après compression) mis à jour souvent est un piège : 73 fois plus de WAL pour modifier une clé.
CHECK,jsonb_typeofet les colonnes généréesSTOREDexistent 👉 un document sans contrainte, c’est un choix, pas une fatalité.
Mais unCHECKqui traverse une clé absente vautNULL, donc passe : ancrez avecCOALESCE, sinon la contrainte ne protège que les lignes déjà correctes.
jsonbn’est pas une dispense de modélisation.C’est l’endroit où l’on range ce qu’on n’a pas encore modélisé, à condition de savoir qu’il est là, et pourquoi.
Reproduire les mesures
Toutes les mesures de cet article viennent d’un PostgreSQL 18.4 en conteneur, paramètres par défaut, cache chaud, parallélisme désactivé pour les comparaisons de temps. Les tables de la section 1 suffisent pour la majorité ; voici les trois autres, pour que le protocole soit complet.
Le reste du jeu de données
-- e_norm : la même donnée, en colonnes typées (sections 4.1)
CREATE TABLE e_norm AS
SELECT (doc ->> 'event_type') AS event_type,
(doc -> 'user' ->> 'plan') AS plan,
(doc -> 'props' ->> 'page') AS page
FROM e_jsonb;
-- les 200 lignes portant une clé optionnelle, soit 500 200 au total (section 3)
INSERT INTO e_jsonb (doc)
SELECT jsonb_build_object('event_type', 'refund', 'refund_reason', 'damaged')
FROM generate_series(1, 200);
-- loose : le même document, sans aucune contrainte (section 4.2)
CREATE TABLE loose (doc jsonb NOT NULL);
-- big : 2 000 documents d'environ 21 Ko, donc au-delà du seuil TOAST (section 4.3)
CREATE TABLE big (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL DEFAULT 'pending',
doc jsonb NOT NULL
);
INSERT INTO big (doc)
SELECT (SELECT jsonb_agg(jsonb_build_object('k', i, 'v', md5(i::text)))
FROM generate_series(1, 420) i)
FROM generate_series(1, 2000);
VACUUM ANALYZE e_jsonb;
VACUUM ANALYZE e_norm;
VACUUM ANALYZE big;
Le WAL se mesure en encadrant l'opération d'un SELECT pg_current_wal_lsn() et en soustrayant les deux positions. Pensez à un CHECKPOINT avant chaque série : les full-page writes qui suivent un checkpoint faussent la comparaison.
