BUG #19821: pg_restore_attribute_stats() accepts an unsorted scalar histogram_bounds, producing badly wrong row

Started by PG Bug reporting form3 days ago3 messagesbugs
Jump to latest
#1PG Bug reporting form
noreply@postgresql.org

The following bug has been logged on the website:

Bug reference: 19821
Logged by: chunling qin
Email address: 303677365@qq.com
PostgreSQL version: 18.6
Operating system: Linux (TencentOS 4.4, x86_64)
Description:

Hello,

pg_restore_attribute_stats() validates that range_bounds_histogram is
sorted in ascending order (stat_utils.c:842, commit df293aed46e "Reject
incorrect range_bounds_histograms in stats import functions"). The very
same function does not apply any ordering check to the scalar
histogram_bounds, even though the consumer, histogram_selectivity()
(src/backend/utils/adt/selfuncs.c), binary-searches the histogram and
therefore assumes ascending order. So an unsorted (e.g. descending)
histogram_bounds is accepted -- the function returns true -- and every
row estimate made from it afterwards is arbitrary.

Reproducer (self-contained):

CREATE TABLE h1 (a int) WITH (autovacuum_enabled = false);
INSERT INTO h1 SELECT g FROM generate_series(1, 100) g;
ANALYZE h1;

-- baseline: sorted histogram built by ANALYZE
EXPLAIN SELECT * FROM h1 WHERE a < 50;
-- Seq Scan ... rows=48 (true value 49)

-- import a DESCENDING histogram: accepted, returns true
SELECT pg_restore_attribute_stats(
'schemaname', 'public', 'relname', 'h1',
'attname', 'a', 'inherited', false,
'histogram_bounds', '{100,90,80,70,60,50,40,30,20,10,1}');
-- t

EXPLAIN SELECT * FROM h1 WHERE a < 50; -- rows=1 (true value 49)
EXPLAIN SELECT * FROM h1 WHERE a < 95; -- rows=100 (true value 94)
EXPLAIN SELECT * FROM h1 WHERE a > 95; -- rows=1 (true value 5)

For comparison, in the very same function and session, the range
variant of the same mistake is properly rejected:

SELECT pg_restore_attribute_stats(
'schemaname', 'public', 'relname', 'h1',
'attname', 'a', 'inherited', false,
'range_bounds_histogram', '{"[90,93)","[1,4)","[50,53)"}');
-- WARNING: "range_bounds_histogram" must have its lower and upper
-- bounds sorted in ascending order

Expected: histogram_bounds should get the same ordering check that
range_bounds_histogram already has (an unsorted input should produce a
WARNING and be skipped, not silently accepted). The sibling slots
most_common_elems and elem_count_histogram also carry ordering/shape
contracts from ANALYZE that the import side does not verify, but the
scalar histogram is the one with a directly measurable effect on row
estimates shown above.

Actual: the function returns true and the planner's estimates become
arbitrary (49 -> 1, 94 -> 100, 5 -> 1).

Reproduced on:
- PostgreSQL 20devel (snapshot of 2026-10-27, x86_64-pc-linux-gnu)
- PostgreSQL 18.6 (REL_18_STABLE, self-built)

A draft fix location would be the STATISTIC_KIND_HISTOGRAM branch of
pg_restore_attribute_stats() (src/backend/statistics/attribute_stats.c,
around lines 425-440): apply the same ascending-order check as
statatt_check_bounds_histogram() (src/backend/statistics/stat_utils.c).

Regards,
Chunling Qin

#2mingjiegao
mingjie.gmj@gmail.com
In reply to: PG Bug reporting form (#1)
Re: BUG #19821: pg_restore_attribute_stats() accepts an unsorted scalar histogram_bounds, producing badly wrong row

Hi,

Expected: histogram_bounds should get the same ordering check that
range_bounds_histogram already has

I think the missing ordering check for scalar histograms may be
intentional. This came up during development of the stats import
functions. Corey specifically mentioned that earlier versions checked
whether histograms were monotonically nondecreasing, but those checks
were removed to support planner experiments and fuzzing:

/messages/by-id/CADkLM=frNuS6xP-PPgAis-62pzL6cF6jve9-H967003voSd6DA@mail.gmail.com

Tom also explained that direct calls should allow injecting counterfactual
stats, as long as they don't crash the backend:

/messages/by-id/1183619.1712291852@sss.pgh.pa.us

Given that discussion, getting bad row estimates from an unsorted
histogram doesn't seem sufficient by itself to call this a bug.

The difference with range_bounds_histogram is worth looking into, though.
Was that check added to prevent a crash or query failure? If so, that
would explain why the two cases are handled differently.

Regards,
JackGao

On Fri, 30 Oct 2026 07:01:30 +0000, PG Bug reporting form
<noreply@postgresql.org> wrote:

Show quoted text

The following bug has been logged on the website:

Bug reference: 19821
Logged by: chunling qin
Email address: 303677365@qq.com
PostgreSQL version: 18.6
Operating system: Linux (TencentOS 4.4, x86_64)
Description:

Hello,

pg_restore_attribute_stats() validates that range_bounds_histogram is
sorted in ascending order (stat_utils.c:842, commit df293aed46e "Reject
incorrect range_bounds_histograms in stats import functions"). The very
same function does not apply any ordering check to the scalar
histogram_bounds, even though the consumer, histogram_selectivity()
(src/backend/utils/adt/selfuncs.c), binary-searches the histogram and
therefore assumes ascending order. So an unsorted (e.g. descending)
histogram_bounds is accepted -- the function returns true -- and every
row estimate made from it afterwards is arbitrary.

Reproducer (self-contained):

CREATE TABLE h1 (a int) WITH (autovacuum_enabled = false);
INSERT INTO h1 SELECT g FROM generate_series(1, 100) g;
ANALYZE h1;

-- baseline: sorted histogram built by ANALYZE
EXPLAIN SELECT * FROM h1 WHERE a < 50;
-- Seq Scan ... rows=48 (true value 49)

-- import a DESCENDING histogram: accepted, returns true
SELECT pg_restore_attribute_stats(
'schemaname', 'public', 'relname', 'h1',
'attname', 'a', 'inherited', false,
'histogram_bounds', '{100,90,80,70,60,50,40,30,20,10,1}');
-- t

EXPLAIN SELECT * FROM h1 WHERE a < 50; -- rows=1 (true value 49)
EXPLAIN SELECT * FROM h1 WHERE a < 95; -- rows=100 (true value 94)
EXPLAIN SELECT * FROM h1 WHERE a > 95; -- rows=1 (true value 5)

For comparison, in the very same function and session, the range
variant of the same mistake is properly rejected:

SELECT pg_restore_attribute_stats(
'schemaname', 'public', 'relname', 'h1',
'attname', 'a', 'inherited', false,
'range_bounds_histogram', '{"[90,93)","[1,4)","[50,53)"}');
-- WARNING: "range_bounds_histogram" must have its lower and upper
-- bounds sorted in ascending order

Expected: histogram_bounds should get the same ordering check that
range_bounds_histogram already has (an unsorted input should produce a
WARNING and be skipped, not silently accepted). The sibling slots
most_common_elems and elem_count_histogram also carry ordering/shape
contracts from ANALYZE that the import side does not verify, but the
scalar histogram is the one with a directly measurable effect on row
estimates shown above.

Actual: the function returns true and the planner's estimates become
arbitrary (49 -> 1, 94 -> 100, 5 -> 1).

Reproduced on:
- PostgreSQL 20devel (snapshot of 2026-10-27, x86_64-pc-linux-gnu)
- PostgreSQL 18.6 (REL_18_STABLE, self-built)

A draft fix location would be the STATISTIC_KIND_HISTOGRAM branch of
pg_restore_attribute_stats() (src/backend/statistics/attribute_stats.c,
around lines 425-440): apply the same ascending-order check as
statatt_check_bounds_histogram() (src/backend/statistics/stat_utils.c).

Regards,
Chunling Qin

#3Michael Paquier
michael@paquier.xyz
In reply to: mingjiegao (#2)
Re: BUG #19821: pg_restore_attribute_stats() accepts an unsorted scalar histogram_bounds, producing badly wrong row

On Fri, Oct 30, 2026 at 02:50:28AM -0700, mingjie gao wrote:

Given that discussion, getting bad row estimates from an unsorted
histogram doesn't seem sufficient by itself to call this a bug.

In short, if the restore functions lead to one of the two following
problems, this may be something that should be adjusted:
- Crash of the backend, due to an incorrect loading of data.
- Assertion failure, where we may want to switch that to an
elog(ERROR) instead.

If no problem of this class happens and the only user-facing behavior
is that the planner goes crazy, there is nothing to do here. If any
problem in the lines of the previous two points can happen, please
prove that it is the case. range_bounds_histogram was leading to
a weird state of things when loading the data in line with these two
previous points. If this is also the case here, please show an
example of crash and/or assertion failure and/or elog() failure.

A elog() may not be worth tackling, the other two ones would be to me.
The original point that stats injection is a feature and not a bug
still stands, up to the point where the backend can hand its own and
not take down the whole server.
--
Michael