Le premier article posait la règle : « stocker du JSON dans PostgreSQL » veut dire jsonb, on indexe l’expression plutôt que le document, et on contraint ce qui doit l’être. De quoi travailler.
Celui-ci est la facture, avec les chiffres.
Pourquoi un GIN par défaut pèse la moitié de sa table. Pourquoi le planificateur annonce 50 lignes quand il y en a 500, et ce que ça casse le jour où ce prédicat est le côté d’une jointure. Ce que coûte réellement la modification d’une clé dans un document de 20 Ko. Tout est mesuré sur un PostgreSQL 18.4 en conteneur, et le protocole est en tête d’article pour que vous puissiez refaire les mesures plutôt que de me croire.
De quoi décider en connaissance de cause plutôt qu’au jugé : quel index poser et lequel éviter, quand une clé mérite des statistiques, et à partir de quelle taille un document devient un problème d’écriture.
1. Le protocole
500 000 documents d’environ 300 octets.
On simule l’enregistrement d’événements applicatifs avec :
- un type,
- une date,
- une chaîne représentant une session,
- un objet
userimbriqué, - un objet
props,
Ces documents sont 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;
Les comparaisons de temps sont faites sur cache chaud, parallélisme désactivé. Le reste du jeu de données, les quatre tables annexes qu’utilisent les sections suivantes, est en fin d’article.
Le résultat de base, celui qui justifie de ne plus jamais écrire json ni text :
| 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 |
jsonb écrit 38 % plus lentement, occupe 9 % de plus, et lit 9,5 fois plus vite. Tout le reste de cet article part de la table e_jsonb : 500 000 documents, 178 Mo.
Ces 9 % de surcoût ne sont pas la seule chose que le document paie en octets. Les noms de clés sont répétés à chaque ligne, un octet par caractère, et la compression TOAST n’intervient qu’au-delà de 2 Ko par valeur, donc jamais sur ce corpus. Deux tables de 500 000 lignes portant exactement les mêmes valeurs, l’une avec les clés event_type / session_identifier, l’autre avec e / s : 61 Mo contre 45 Mo.
L'ordre des clés n'est pas alphabétique
'{"b":1,"a":2}'::jsonb → {"a": 2, "b": 1} le laisse croire, parce que a et b font un caractère.
L'ordre réel est longueur d'abord, puis ordre binaire :
'{"b":1,"aa":2}'::jsonb → {"b": 1, "aa": 2}.
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.
2. Le coût des index
Trois stratégies d’indexation existent sur du jsonb. 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
| 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.
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.
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.
3. 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
L’opérateur @> a pour estimateur matchingsel, comme les six autres opérateurs jsonb (<@, ?, ?|, ?&, @@, @?) — le catalogue le confirme :
SELECT oprname, oprrest FROM pg_operator
WHERE oprname = '@>' AND oprleft = 'jsonb'::regtype;
-- @> | matchingsel
Sa sélectivité par défaut est de 1 % : DEFAULT_MATCHING_SEL vaut 0.010 dans src/include/utils/selfuncs.h, soit le double de DEFAULT_EQ_SEL, ce que la documentation formule par « twice the default estimate used in eqsel ». Sur 500 000 lignes, ça donnerait 5 000. Pas 50.
Ce qui se passe est plus compliqué.
ANALYZE construit bien un histogramme sur la colonne doc avec 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. matchingsel délègue à generic_restriction_selectivity, dans src/backend/utils/adt/selfuncs.c, qui se termine par :
/* In any case, don't believe extremely small or large estimates. */
if (selec < 0.0001)
selec = 0.0001;
else if (selec > 0.9999)
selec = 0.9999;
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.
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 2. 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. 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 sont que des grandeurs de mes tests, ils ne se valent donc pas partout.
Par ailleurs, on peut améliorer la vitesse de compression en fonction de l’algorithme choisis qui dépend de default_toast_compression.
pglz par défaut, lz4 disponible depuis PostgreSQL 14 est plus rapide.
Ce qui est sûre c’est que : 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.
5. L’amplification en écriture du GIN
Le 56 % de la section 2 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, change l’endroit où les nouvelles entrées atterrissent. Au lieu de les insérer une par une dans la structure GIN, PostgreSQL les accumule dans une liste d’attente non triée, puis les y déplace en bloc, avec la même technique d’insertion massive que la création d’index.
Ce que le mécanisme achète, et ce qu’il coûte :
- les écritures sont nettement plus rapides, et une partie du travail peut être faite par un processus d’arrière-plan plutôt qu’en avant-plan. C’est l’écart entre les deux dernières lignes du tableau, 188 ms contre 515 ;
- les lectures doivent parcourir la liste d’attente en plus de l’index, donc une liste longue ralentit les recherches ;
- l’écriture qui fait déborder la limite déclenche le transfert et le paie elle-même, ce qui la rend bien plus lente que les autres.
La documentation en tire la règle :
si la régularité des temps de réponse compte plus que la vitesse d’écriture, désactivez
fastupdate.
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é.
En résumé
- 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. - En écriture, le même GIN coûte 12 fois plus lent et 5 fois plus de WAL sur un
UPDATEqui ne touche qu’une clé, parce qu’il réindexe le document entier.fastupdateen absorbe une partie (188 ms au lieu de 515) en différant l’insertion dans une liste d’attente, que les lectures doivent ensuite parcourir en plus de l’index. jsonb_path_opsne sait pas répondre à?. Si vous testez la présence de clés, il ne vous sert à rien.- Le vrai coût de
jsonbn’est ni les octets ni le CPU : le planificateur voit correctement les prédicats peu sélectifs et pas du tout les autres. Sous un certain seuil il applique un plancher de sélectivité à0.0001, et se trompe d’un ordre de grandeur au moment précis où l’estimation sert à choisir un plan. CREATE STATISTICSsur expression répare ça sans index 👉 0,6 % d’erreur au lieu de 67×, pour quelques kilo-octets de catalogue. Déclarée sur deux expressions, elle capture même la corrélation entre deux clés.- Un document TOASTé (> 2 Ko après compression) mis à jour souvent est un piège : 73 fois plus de WAL pour modifier sept caractères. Sous le seuil, le surcoût disparaît entièrement.
- Aucune de ces trois douleurs n’est un défaut du moteur : ce sont des problèmes de modélisation, et elles se corrigent dans le schéma et dans les index.
Le document ne coûte pas cher à écrire.
Il coûte cher à interroger le jour où le planificateur doit deviner ce qu’il contient.
Reproduire les mesures
Toutes les mesures 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 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 (section 3)
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 2)
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 (article précédent)
CREATE TABLE loose (doc jsonb NOT NULL);
-- big : 2 000 documents d'environ 21 Ko, donc au-delà du seuil TOAST (section 4)
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.
Ressources
- JSON Types
- JSON Functions and Operators
- GIN Indexes
- GIN Fast Update Technique
- TOAST
- Extended Statistics
- Operator Optimization Information (la doc de
matchingsel) selfuncs.c(le plancher de sélectivité)- Using PostgreSQL for JSON Storage
- Indexing Strategies for JSONB Fields in PostgreSQL: GIN vs BTREE
- Mastering gin_pending_list_limit: How This parameter shapes GIN index insert performance
