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 est le mode d’emploi : quel type choisir, ce qu’il faut savoir indexer, comment le contraindre, et à quel moment une clé mérite de devenir une colonne. Il commence par le seul choix de type qui compte encore aujourd’hui.

text → xml → hstore → json → jsonb

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 ne garde un intérêt que face à un partenaire qui ne parle que XSD, et hstore, premier magasin clé/valeur indexable de PostgreSQL, 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 :

json conserve une copie exacte du texte d’entrée, jsonb le 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 le texte source, à l'octet près jsonb un arbre binaire, clés réordonnées {"b":1, "a":2, "a":3} a → 3 · b → 1 doublons, espaces et ordre conservés dernier doublon gagné, clés réordonnées lectures lectures parse parse parse seek seek seek Écriture rapide, lecture coûteuse aucun opérateur =, donc aucun index sur la colonne Écriture plus lente, lecture directe opérateurs de conteneur, index GIN, @> et jsonpath

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

500 000 documents d’environ 300 octets représentant des événements applicatifs, stockés dans trois tables identiques sur PostgreSQL 18.4 (conteneur Docker, paramètres par défaut). 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 :

  1. 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.
  2. jsonb lit 9,5 fois plus vite. L’écart n’est pas un réglage, c’est une différence de nature : json refait le travail à chaque ligne et à chaque accès, jsonb l’a déjà fait.
  3. text est 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 BY sur 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.

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 exprime ce que l’opérateur -> ne sait pas dire — filtres, comparaisons numériques, parcours de tableaux — et depuis PostgreSQL 17, JSON_TABLE projette un document en table relationnelle :

SELECT count(*) FROM e_jsonb WHERE doc @@ '$.props.amount > 100';

SELECT t.*
FROM e_jsonb e,
     JSON_TABLE(e.doc, '$' COLUMNS (
        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 toute cette série. 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. Le ratio exact dépend de la forme du document, mais l’ordre de grandeur tient : un GIN par défaut se compte en dizaines de pourcents de la table, pas en pourcents.

Ce que chacun sait faire

jsonb_ops, l’opérateur par défaut, indexe chaque clé et chaque valeur séparément. Le document {"user": {"plan": "pro"}} produit une entrée pour user, une pour plan, une pour pro. D’où la taille. En échange, il est le seul à savoir répondre à la question « cette clé existe-t-elle ? », c’est-à-dire aux opérateurs ?, ?| et ?&.

jsonb_path_ops indexe une seule entrée par chemin complet, en pratique un hash de user.plan = "pro". Moitié moins d’entrées, dix fois plus rapide sur un @> sélectif, mais aveugle à ? : un WHERE doc ? 'refund_reason' retombe en Seq Scan.

Le B-tree sur expression, lui, n’indexe qu’une clé et 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 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);

Sur le même corpus il tombe à 6,2 Mo, soit 3 % de la table au lieu de 56 %, et il sert toujours les requêtes exploratoires sur le sous-arbre, à condition d’écrire le prédicat sous la même forme : doc -> 'props' @> '{"page":"/product/42"}'.

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

clés connues et peu nombreuses → B-tree sur expression
clés inconnues, requêtes en @> → GIN jsonb_path_ops
clés inconnues mais regroupées sous un nœud → GIN jsonb_path_ops sur le sous-arbre
test d'existence de clés (?) → GIN jsonb_ops

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.
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.


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 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 erreurs.
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.2 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, et 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 que a_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' renvoie NULL si la clé est absente et si sa valeur est le null JSON. Les deux cas sont indiscernables sans doc ? 'x'.

4.3 Deux coûts à l’écriture

Modifier un document coûte plus cher qu’il n’y paraît, pour deux raisons qui se cumulent. Elles se mesurent plutôt qu’elles ne se racontent, en une ligne chacune.

Un document TOASTé et mis à jour souvent.
Au-delà d’environ 2 Ko après compression, il n’existe pas de mise à jour partielle. Modifier une clé réécrit le document entier et génère plus de donnée dans le WAL pour changer sept caractères. Sous le seuil, ce surcoût n’existe pas.

Le GIN amplifie les écritures.
Modifier une clé réinsère toutes les entrées d’index de la ligne : 12 fois plus lent et 5 fois plus de WAL. C’est le pendant en écriture des 56 %, et la seconde raison d’indexer l’expression plutôt que le document.


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 rejouer ou déboguer.

Les champs personnalisés par client.
Le cas d’école de l’EAV. Trois tables attribute / attribute_value / entity_attribute 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, arborescentes par nature. Aucun des inconvénients ne s’applique.

Les instantanés et l’historisation.
La photo d’une commande au moment de sa validation, la ligne d’audit. Écrits une fois, jamais mis à jour donc jamais de réécriture TOAST, et la forme doit rester celle d’il y a deux ans — ce qu’un schéma qui évolue ne permet pas.

Les zones d’atterrissage.
On ingère brut, on transforme vers des colonnes typées ensuite. Le brut reste disponible pour rejouer la transformation le jour où elle se révèle fausse.

À l’inverse, les signaux qui doivent faire reculer :

  • Le champ est dans le WHERE de vos requêtes principales, ou dans un JOIN 👉 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_set dans 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.

PostgreSQL 18 ajoute les colonnes générées VIRTUAL, calculées à la lecture et sans stockage, mais elles ne sont pas indexables. Pour promouvoir une clé qu’on veut filtrer, c’est donc STORED.


En résumé

  • « Stocker du JSON dans PostgreSQL » signifie jsonb.
    json ne sert que quand l’octet exact compte (signature, preuve). hstore, xml et text sont 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.
  • Si vous savez quelles clés vous interrogez, indexez l’expression, pas le document.
    2 % de la table au lieu de 56 %, aussi rapide, et des statistiques qu’un GIN ne donne pas. Le GIN ne se justifie que quand l’ensemble des clés interrogeables est ouvert — et si elles sont regroupées sous un nœud, indexez le sous-arbre.
  • CHECK, jsonb_typeof et les colonnes générées STORED existent 👉 un document sans contrainte, c’est un choix.
    Mais un CHECK qui traverse une clé absente vaut NULL, donc passe : ancrez avec COALESCE.
  • Ce qui a un contrat prend une colonne, ce qui n’en a pas prend le document.
    Une clé qui devient importante se promeut avec CREATE INDEX CONCURRENTLY et CREATE STATISTICS, sans verrou exclusif.

jsonb n’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

Tous les chiffres 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. De quoi tout rejouer chez vous en deux minutes :

Le jeu de données
docker container run --rm -d --name pgjson -e POSTGRES_PASSWORD=postgres postgres:18.4
docker container exec -it pgjson psql -U postgres
-- 500 000 documents d'environ 300 octets, le même contenu dans les trois types
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;

-- 200 lignes portant une clé optionnelle, pour les tests de présence avec ?
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.1)
CREATE TABLE loose (doc jsonb NOT NULL);

VACUUM ANALYZE e_json;
VACUUM ANALYZE e_jsonb;
VACUUM ANALYZE e_text;

Les trois index de la section 3 se créent tels quels sur e_jsonb, et \di+ donne leur taille. Les temps de la section 1 se relèvent avec \timing et SET max_parallel_workers_per_gather = 0, sur une requête déjà lancée une fois pour chauffer le cache.

Ressources