Mont de Marsan, le 10 juillet 2026
Version améliorée et enrichie de l’article initialement publié le 25 juillet 2025.
Dans un précédent
article, je vous
illustrais à quel point la justesse des statistiques sur les données était
critique pour la performance de vos requêtes. Intéressons-nous aujourd’hui au
paramètre default_statistics_target et regardons comment il peut nous aider à
améliorer les performances de nos requêtes grâce à des statistiques plus
précises.

Le principe
Pour obtenir les meilleurs plans d’exécution possibles, PostgreSQL a besoin d’estimer, à chaque étape d’une requête, le nombre de lignes qui seront manipulées.
PostgreSQL a aussi besoin d’estimer la taille moyenne de chaque colonne, par exemple pour déterminer si un tri pourra se faire entièrement en mémoire.
L’échantillonnage
Pour ce faire, nul besoin de mesurer chacune des lignes de chacune des tables. Un échantillon assez grand pris aléatoirement est suffisamment représentatif de l’ensemble. PostgreSQL se base sur cette publication de Surajit Chaudhuri, Rajeev Motwani et Vivek Narasayya “Random sampling for histogram construction: how much is enough?” de 1998 pour déterminer combien de lignes suffisent à construire cet échantillon.
Les développeurs se sont appuyés sur cette étude pour retenir un échantillon de
300 × k lignes, où k est la « cible statistique » de chaque colonne,
définie globalement par le paramètre default_statistics_target. Le facteur
300 est codé en dur dans le code source de
analyze.c :
std_typanalyze(VacAttrStats *stats)
{
…
stats->minrows = 300 * stats->attstattarget;
…
}
On comprend que la taille de l’échantillon est donc de 300 fois la valeur de la cible statistique. PostgreSQL utilise donc par défaut un échantillon de 30 000 lignes pour calculer ses statistiques :
postgres=# SHOW default_statistics_target; -- nous avons bien la valeur par défaut
default_statistics_target
---------------------------
100
(1 ligne)
postgres=# ANALYZE VERBOSE matable; -- confirmation de la taille de l'échantillon
INFO: analyzing "public.matable" -- "30000 rows in sample" ci-dessous
INFO: "matable": scanned 7280 of 7280 pages, containing 990000 live rows and 0 dead rows; 30000 rows in sample, 990000 estimated total rows
ANALYZE
Temps : 376,310 ms
La résolution
Pour chaque colonne, PostgreSQL calcule un certain nombre de statistiques. Elles
peuvent être consultées dans la vue
pg_stats. Deux
d’entre elles, la liste des most common values (MCV) et l’histogramme
des valeurs sont particulièrement importantes.
Par exemple, une liste MCV pour une colonne prix d’une table produit
pourrait avoir
cette forme :
postgres=# SELECT most_common_vals, most_common_freqs
FROM pg_stats
WHERE attname='prix' \gx
-[ RECORD 1 ]-----+----------------------------------
most_common_vals | {4.99,9.99,19.99,29.99,49.99}
most_common_freqs | {0.3,0.1,0.05,0.047,0.03}
Ces cinq prix sont les plus fréquents dans la colonne prix. 30 % des valeurs
sont égales à 4,99 et 3 % égales à 49,99.
Pour illustrer la notion d’histogramme, on peut considérer une colonne poids
d’une table produit :
postgres=# SELECT histogram_bounds FROM pg_stats WHERE attname='poids' \gx
-[ RECORD 1 ]----+---------------------------
histogram_bounds | {0,30,200,600,1000,10000}
Avec ces 6 bornes, notre histogramme dispose de 5 intervalles. L’écart entre les bornes est ajusté afin que chaque intervalle corresponde sensiblement au même nombre de lignes. Il y a donc environ autant de produits dont le poids est compris entre 0 et 30 g que de produits compris entre 1 et 10 kg.
Mais quelle est la bonne quantité de valeurs fréquentes à retenir pour la liste
MCV ? Et combien d’intervalles doit comporter notre histogramme calculé pour les
valeurs scalaires ? En regardant dans les fonctions compute_distinct_stats()
et compute_scalar_stats(), nous observons ces initialisations :
/* dans compute_distinct_stats() */
int num_mcv = stats->attstattarget;
/* dans compute_scalar_stats() */
int num_mcv = stats->attstattarget;
int num_bins = stats->attstattarget;
Nous constatons que c’est de nouveau la cible statistique de la colonne
(attstattarget, égale à default_statistics_target par défaut) qui est
utilisée. Par défaut, pour chaque colonne de chaque table, la liste MCV
comportera donc jusqu’à 100 valeurs, et l’histogramme jusqu’à 100 intervalles.
La cible statistique sert aussi et surtout à indiquer une résolution pour les statistiques, suffisamment élevée pour qu’elles soient précises et utiles, sans trop l’être pour ne pas pénaliser le temps de planification des requêtes.
Un paramètre, deux fonctions
Nous voyons donc que la cible statistique a deux fonctions distinctes
dans la collecte des statistiques par ANALYZE, une pour la taille de
l’échantillon, une autre pour la résolution de certaines statistiques, listes
MCV et histogrammes.
L’étude citée nous a montré qu’augmenter la taille de l’échantillon n’apporte que peu d’information supplémentaire à partir d’un certain seuil. En revanche, la résolution des statistiques peut parfois être insuffisante, surtout pour l’histogramme, quand on a des valeurs avec une distribution peu régulière, voire erratique.
Grâce à un histogramme de meilleure résolution, l’optimiseur pourra se rendre compte que certains filtres sont plus sélectifs et ajuster le plan d’exécution en conséquence.
Exemple
Considérons la table mesure suivante :
CREATE TABLE mesure (
id SERIAL PRIMARY KEY,
valeur_a NUMERIC,
valeur_b NUMERIC
);
CREATE INDEX idx_valeura ON mesure(valeur_a);
CREATE INDEX idx_valeurb ON mesure(valeur_b);
puis insérons quelques données :
-- un million de mesures : 80 % concentrées autour des dizaines
-- (bandes continues [0,1), [10,11), … [990,991)),
-- 20 % de fond uniforme entre 0 et 1000
INSERT INTO mesure (valeur_a, valeur_b)
SELECT v, v
FROM (
SELECT CASE WHEN random() < 0.8
THEN floor(random()*100)*10 + random()
ELSE random()*1000
END AS v
FROM generate_series(1, 1000000)
) t;
Ici, les valeurs sont concentrées autour des dizaines, et plus clairsemées entre les bandes :
postgres=# SELECT count(*) FROM mesure WHERE floor(valeur_a)%10 = 0;
count
--------
819812
(1 ligne)
postgres=# SELECT count(*) FROM mesure WHERE floor(valeur_a)%10 <> 0;
count
--------
180188
(1 ligne)

Nous définissons une cible statistique de 100 pour la colonne valeur_a et de
1000 pour valeur_b. Ceci nous permettra de voir l’impact de la résolution de
l’histogramme entre 100 et 1000 intervalles.
ALTER TABLE mesure ALTER COLUMN valeur_a SET STATISTICS 100;
ALTER TABLE mesure ALTER COLUMN valeur_b SET STATISTICS 1000;
À noter que la taille d’échantillon sera de 300 000 lignes dans les deux cas :
postgres=# ANALYZE VERBOSE mesure;
INFO: analyzing "public.mesure"
INFO: "mesure": scanned 7353 of 7353 pages, containing 1000000 live rows and 0 dead rows; 300000 rows in sample, 1000000 estimated total rows
ANALYZE
Nous pouvons vérifier la taille des histogrammes :
postgres=# SELECT array_length(histogram_bounds,1) FROM pg_stats WHERE tablename='mesure' AND attname='valeur_a';
SELECT array_length(histogram_bounds,1) FROM pg_stats WHERE tablename='mesure' AND attname='valeur_b';
array_length
--------------
101
(1 ligne)
array_length
--------------
1001
(1 ligne)
Le premier comporte bien 100 intervalles (101 bornes), le second 1000 (1001 bornes), soit la résolution maximale de chaque cible.
Les illustrations suivantes montrent ce que le planificateur estime de la
répartition des données pour chaque colonne grâce aux histogrammes. Nous
remarquons à quel point l’histogramme sur valeur_b est plus proche de la
répartition réelle, rappelée en filigrane.


Cherchons maintenant, dans la zone clairsemée entre deux bandes (en orange ci-dessus), les 100 premières lignes dans l’ordre de la clé primaire (une pagination classique) :
EXPLAIN ANALYZE SELECT * FROM mesure
WHERE valeur_a BETWEEN 504 AND 507 ORDER BY id LIMIT 100;
EXPLAIN ANALYZE SELECT * FROM mesure
WHERE valeur_b BETWEEN 504 AND 507 ORDER BY id LIMIT 100;
QUERY PLAN
----------------------------------------------------------------------------------
Limit (cost=0.42..1277.87 rows=100 width=26)
(actual time=0.043..15.608 rows=100 loops=1)
-> Index Scan using mesure_pkey on mesure
(cost=0.42..38336.43 rows=3001 width=26)
(actual time=0.043..15.603 rows=100 loops=1)
Filter: ((valeur_a >= '504'::numeric) AND (valeur_a <= '507'::numeric))
Rows Removed by Filter: 176253
Planning Time: 0.018 ms
Execution Time: 15.615 ms
(6 lignes)
QUERY PLAN
----------------------------------------------------------------------------------
Limit (cost=2015.41..2015.66 rows=100 width=26)
(actual time=0.488..0.493 rows=100 loops=1)
-> Sort (cost=2015.41..2017.04 rows=655 width=26)
(actual time=0.488..0.492 rows=100 loops=1)
Sort Key: id
Sort Method: top-N heapsort Memory: 32kB
-> Bitmap Heap Scan on mesure (cost=19.14..1990.37 rows=655 width=26)
(actual time=0.098..0.455 rows=615 loops=1)
Recheck Cond: ((valeur_b >= '504'::numeric) AND (valeur_b <= '507'::numeric))
Heap Blocks: exact=584
-> Bitmap Index Scan on idx_valeurb (cost=0.00..18.98 rows=655 width=0)
(actual time=0.053..0.053 rows=615 loops=1)
Index Cond: ((valeur_b >= '504'::numeric) AND (valeur_b <= '507'::numeric))
Planning Time: 0.095 ms
Execution Time: 0.502 ms
(11 lignes)
Les deux colonnes contiennent les mêmes données : l’intervalle renferme en
réalité 615 lignes (visibles dans le second plan). L’histogramme à haute
résolution de valeur_b fournit une estimation de 655, très proche du réel,
là où l’histogramme à basse résolution de valeur_a sur-estime à 3001.
Certains intervalles de l’histogramme à basse résolution couvrent à la fois des
bandes denses et des zones clairsemées. Le planificateur suppose que les lignes
sont uniformément réparties sur la largeur de l’intervalle, ce qui n’est pas le
cas et le conduit à choisir un plan sous-optimal.
Pour produire un résultat trié par id, le planificateur hésite donc entre deux
stratégies : parcourir la table dans l’ordre de la clé primaire en filtrant au
passage, quitte à s’arrêter dès la centième ligne trouvée, ou bien récupérer
toutes les lignes de l’intervalle via l’index sur la valeur, puis les trier. Le
coût de la première stratégie dépend directement de l’estimation : plus
l’intervalle semble peuplé, plus le planificateur pense s’arrêter tôt. Avec
une estimation à 3001 lignes, il espère trouver ses 100 lignes après avoir parcouru et filtré une faible fraction de l’index sur clé primaire.
Inversement, le bitmap index scan suivi d’un tri lui paraît plus coûteux qu’il ne l’est en réalité, car il estime plus de lecture hors cache. Sur
valeur_b, avec une meilleure information, il choisira le meilleur plan.
Le premier plan balaye et rejette plus de 176 000 lignes avant de réunir ses 100 lignes : 15,6 ms, contre 0,5 ms pour le second, une trentaine de fois plus rapide (hors cache, l’écart se resserre autour d’un facteur 2, les deux plans ayant alors un coût I/O non négligeable). Même requête, mêmes données, même taille d’échantillon : seule la résolution de l’histogramme diffère.
À retenir
Augmenter la cible statistique permet d’obtenir des statistiques de meilleure
qualité, et donc de meilleures performances, au prix d’un ANALYZE/autoanalyze
plus coûteux et d’un temps de planification légèrement plus élevé. Il est donc
généralement préférable d’augmenter cette cible colonne par colonne, plutôt que
globalement avec default_statistics_target.
Pour aller plus loin, je vous invite à revoir la conférence Voyage au centre des statistiques de Louise Leinweber à PGDay FR 2025. Nos formations DEVPG et PERF1 incluent aussi depuis cette année un nouveau module dédié à la compréhension des statistiques utilisées par le planificateur de PostgreSQL.