Hi Shihao,

On Tue, Oct 13, 2026 at 6:00 PM Álvaro Herrera <[email protected]>
wrote:
> I suspect we don't really know what other
> things this misfeature can break.

One more: with constraint_exclusion = on (not the default), a query
on the parent returns wrong results, on 18.6 too:

  create table p (a int,
    v int generated always as (nullif(a, 0)) virtual not null)
    partition by list (a);
  create table p1 partition of p
    (v generated always as (a + 1) virtual) for values in (0);
  insert into p values (0);
  set constraint_exclusion = on;
  select count(*) from p where v is null; -- 0, should be 1

The plan is "One-Time Filter: false": the parent's NOT NULL refutes
the qual, but the row read through p has v = NULL. A CHECK (v > 0)
on the parent does the same: count(*) where v < 0 gives 0, not 1.
Both on 18.6, 19beta4, 19rc1 and master (dfd7ede529f), so
d8444163d22 does not change it; with the default setting all give 1.
repro-ce-on.sql.txt is attached.

On Wed, Oct 14, 2026 at 12:39 AM shihao zhong <[email protected]>
wrote:
> With the patch, a virtual generated column of a partition must use the
> same expression as the parent. CREATE TABLE PARTITION OF and ATTACH
> PARTITION check this, and SET EXPRESSION can only change the whole
> partition tree. Stored columns and plain inheritance are unchanged.

I ran v1 on master and REL_19_STABLE, and the 18 version on
REL_18_STABLE, against 13 statements that create a divergent
expression: PARTITION OF (LIST, RANGE, HASH, DEFAULT), ATTACH, SET
EXPRESSION on a partition or on ONLY the parent, four multi-level
cases and two foreign-table partitions (paths.sql.txt). All 13 fail
on the three branches. The controls with the parent's expression,
STORED and INHERITS still work. Your query finds 7 mismatches
unpatched and 0 patched. make check passes on the three, and the
pg_upgrade TAP tests on master. Without the ATTACH check, 3 of the
13 get through, so the script catches that. On REL_19_STABLE v1
needs "git am -3" (check_for_pg_role_prefix is still in check.c).

> For existing databases, the attached query lists the partition columns
> that differ, and pg_upgrade on master and 19 runs the same check. Users
> can fix them with SET EXPRESSION on the parent, unless existing rows
> break NOT NULL under the parent's expression.

pg_upgrade --check from an 18.6 cluster to master and to 19 (both
patched) lists all four divergent columns I created. On patched 18
the existing ones keep the wrong result above until they are fixed.

One case where the fix fails on 18: SET EXPRESSION on a virtual
column errors out if the table has any CHECK constraint, even one
that does not involve the column, and also after the rows are fixed:

  ERROR:  ALTER TABLE / SET EXPRESSION is not supported for virtual
  generated columns in tables with check constraints

f80bedd52b1 lifted this in 19; 18 still has it. So the pg_upgrade
hint fails for such tables in an 18 cluster. What worked on patched
18, in one transaction: drop the CHECK, SET EXPRESSION on the parent,
add the CHECK back. After that your query is empty and pg_upgrade
--check passes (legacy.sh.txt).

All of this is on one machine, cassert builds; 17 and older not run,
no check-world. Details are in measurements.txt.

Regards,
Manu
-- Wrong result with constraint_exclusion = on (not the default) when a
-- partition has its own VIRTUAL generation expression.  The partition
-- enforces NOT NULL / CHECK with its own expression, a read through the
-- parent computes the parent's, and constraint exclusion trusts the
-- parent's constraints.  The correct answer is 1 for each count.
-- Run with: psql -X -f repro-ce-on.sql.txt
-- With the v1 patch, the two CREATE TABLE ... PARTITION OF fail.
\pset null '(null)'
select current_setting('server_version') as server_version;

create table p (a int,
  v int generated always as (nullif(a, 0)) virtual not null)
  partition by list (a);
create table p1 partition of p
  (v generated always as (a + 1) virtual) for values in (0);
insert into p values (0);

create table c (a int,
  v int generated always as (-a) virtual check (v > 0))
  partition by list (a);
create table c1 partition of c
  (v generated always as (a) virtual) for values in (5);
insert into c values (5);

select a, v from p;                         -- v reads NULL through p

set constraint_exclusion = partition;       -- the default
select count(*) as p_null from p where v is null;   -- 1
select count(*) as c_neg from c where v < 0;        -- 1
set constraint_exclusion = on;
select count(*) as p_null from p where v is null;   -- 0, should be 1
select count(*) as c_neg from c where v < 0;        -- 0, should be 1
explain (costs off) select count(*) from p where v is null;
reset constraint_exclusion;
-- Every DDL path that can give a partition a VIRTUAL generation expression
-- different from its parent's, each with a control that uses the parent's
-- expression and must keep working.  Run with ON_ERROR_STOP off; the
-- expected outcome is in the comment of each statement:
--   [div]  divergent: accepted on unpatched builds, should fail with the patch
--   [same] same expression: should succeed on every build
--   [keep] outside the patch's scope: should behave as before
-- At the end, the find-mismatches query lists what got through.
\pset null '(null)'
select current_setting('server_version') as server_version;

-- P1. CREATE TABLE ... PARTITION OF, LIST
create table l (a int, v int generated always as (a * 2) virtual)
  partition by list (a);
create table l1 partition of l
  (v generated always as (a * 3) virtual) for values in (1);      -- [div]
create table l2 partition of l
  (v generated always as (a * 2) virtual) for values in (2);      -- [same]
create table l3 partition of l for values in (3);                 -- [same]

-- P2. RANGE and HASH
create table r (a int, v int generated always as (a * 2) virtual)
  partition by range (a);
create table r1 partition of r
  (v generated always as (a * 3) virtual) for values from (0) to (10); -- [div]
create table h (a int, v int generated always as (a * 2) virtual)
  partition by hash (a);
create table h1 partition of h
  (v generated always as (a * 3) virtual)
  for values with (modulus 2, remainder 0);                       -- [div]

-- P3. ATTACH PARTITION, also with a different column order
create table l4 (a int, v int generated always as (a * 3) virtual);
alter table l attach partition l4 for values in (4);              -- [div]
create table l5 (v int generated always as (a * 2) virtual, a int);
alter table l attach partition l5 for values in (5);              -- [same]

-- P4. DEFAULT partition
create table l6 partition of l
  (v generated always as (a * 3) virtual) default;                -- [div]

-- P5. SET EXPRESSION
alter table l2 alter column v set expression as (a * 5);          -- [div]
alter table only l alter column v set expression as (a * 5);      -- [div]
alter table l alter column v set expression as (a * 7);           -- [same]
select c.relname, pg_get_expr(d.adbin, d.adrelid) as expr
  from pg_class c join pg_attrdef d on d.adrelid = c.oid
 where c.oid = 'l'::regclass or c.oid in
       (select inhrelid from pg_inherits where inhparent = 'l'::regclass)
 order by 1;

-- P6. Multi-level: a partitioned partition
create table m (a int, b int, v int generated always as (a * 2) virtual)
  partition by list (a);
create table m1 partition of m
  (v generated always as (a * 3) virtual)
  for values in (1) partition by list (b);                        -- [div]
create table m2 partition of m for values in (2)
  partition by list (b);                                          -- [same]
create table m2a partition of m2
  (v generated always as (a * 3) virtual) for values in (1);      -- [div]
create table m2b partition of m2 for values in (2);               -- [same]
alter table m2 alter column v set expression as (a * 9);          -- [div]
-- a standalone partitioned table whose own leaf matches it, attached
-- to a parent with a different expression
create table m3 (a int, b int, v int generated always as (a * 3) virtual)
  partition by list (b);
create table m3a partition of m3 for values in (1);               -- [same]
alter table m attach partition m3 for values in (3);              -- [div]

-- P7. Foreign table as a partition (no handler needed for DDL)
create foreign data wrapper vgd_fdw;
create server vgd_srv foreign data wrapper vgd_fdw;
create foreign table f1 partition of l
  (v generated always as (a * 3) virtual)
  for values in (7) server vgd_srv;                               -- [div]
create foreign table f2 (a int, v int generated always as (a * 3) virtual)
  server vgd_srv;
alter table l attach partition f2 for values in (8);              -- [div]

-- P8. Unchanged by design: STORED and plain inheritance
create table s (a int, v int generated always as (a * 2) stored)
  partition by list (a);
create table s1 partition of s
  (v generated always as (a * 3) stored) for values in (1);       -- [keep]
create table i (a int, v int generated always as (a * 2) virtual);
create table i1 (a int, v int generated always as (a * 3) virtual)
  inherits (i);                                                   -- [keep]
alter table i1 alter column v set expression as (a * 4);          -- [keep]

-- What got through: the patch's own find-mismatches query
select c.oid::pg_catalog.regclass as partition,
       i.inhparent::pg_catalog.regclass as parent,
       a.attname as column,
       pg_catalog.pg_get_expr(dc.adbin, dc.adrelid) as partition_expr,
       pg_catalog.pg_get_expr(dp.adbin, dp.adrelid) as parent_expr
from pg_catalog.pg_class c
     join pg_catalog.pg_inherits i on i.inhrelid = c.oid
     join pg_catalog.pg_attribute a on a.attrelid = c.oid
     join pg_catalog.pg_attribute pa on pa.attrelid = i.inhparent
          and pa.attname = a.attname
     join pg_catalog.pg_attrdef dc on dc.adrelid = a.attrelid
          and dc.adnum = a.attnum
     join pg_catalog.pg_attrdef dp on dp.adrelid = pa.attrelid
          and dp.adnum = pa.attnum
where c.relispartition and a.attgenerated = 'v'
      and pg_catalog.pg_get_expr(dc.adbin, dc.adrelid) <>
          pg_catalog.pg_get_expr(dp.adbin, dp.adrelid)
order by 1, 3;
#!/usr/bin/env bash
# Divergent partitions that already exist when the patch arrives.
# Usage: bash legacy.sh.txt OLD18=<bindir> NEW18=<bindir> [UP_LABEL=<bindir> 
...]
#   OLD18  unpatched 18.x: creates the divergent partitions
#   NEW18  18.x with the REL_18 patch: same data directory, minor update
#   UP_*   newer major versions with the patch: pg_upgrade --check targets
# Output goes to stdout; every cluster lives in a temp dir removed at exit.
set -uo pipefail
old=${1#*=}; new18=${2#*=}; shift 2
tmp=$(mktemp -d "${TMPDIR:-/tmp}/vgdl.XXXXXX")
trap 'for d in "$tmp"/*/; do [ -f "$d/postmaster.pid" ] && \
  "$new18/pg_ctl" -D "$d" -m immediate -w stop >/dev/null 2>&1; done; rm -rf 
"$tmp"' EXIT
port=${PORT_BASE:-61600}
cat >"$tmp/legacy.sql" <<'EOF'
-- Divergent partitions created on an unpatched server (the state that
-- already exists in deployed databases).
create table p (a int,
  v int generated always as (nullif(a, 0)) virtual not null)
  partition by list (a);
create table p1 partition of p
  (v generated always as (a + 1) virtual) for values in (0);
insert into p values (0);
create table c (a int,
  v int generated always as (-a) virtual check (v > 0))
  partition by list (a);
create table c1 partition of c
  (v generated always as (a) virtual) for values in (5);
insert into c values (5);
-- divergent, but no constraint is violated through the parent
create table b (a int, v int generated always as (a * 2) virtual)
  partition by list (a);
create table b1 partition of b
  (v generated always as (a * 3) virtual) for values in (1);
insert into b values (1);
-- divergent, with a CHECK constraint that does not involve v
create table k (a int check (a > 0), v int generated always as (a * 2) virtual)
  partition by list (a);
create table k1 partition of k
  (v generated always as (a * 3) virtual) for values in (1);
insert into k values (1);
EOF
cat >"$tmp/legacy-check.sql" <<'EOF'
\pset null '(null)'
select current_setting('server_version') as server_version;
-- mismatches as the patch's query reports them
select c.oid::pg_catalog.regclass as partition,
       a.attname as column,
       pg_catalog.pg_get_expr(dc.adbin, dc.adrelid) as partition_expr,
       pg_catalog.pg_get_expr(dp.adbin, dp.adrelid) as parent_expr
from pg_catalog.pg_class c
     join pg_catalog.pg_inherits i on i.inhrelid = c.oid
     join pg_catalog.pg_attribute a on a.attrelid = c.oid
     join pg_catalog.pg_attribute pa on pa.attrelid = i.inhparent
          and pa.attname = a.attname
     join pg_catalog.pg_attrdef dc on dc.adrelid = a.attrelid
          and dc.adnum = a.attnum
     join pg_catalog.pg_attrdef dp on dp.adrelid = pa.attrelid
          and dp.adnum = pa.attnum
where c.relispartition and a.attgenerated = 'v'
      and pg_catalog.pg_get_expr(dc.adbin, dc.adrelid) <>
          pg_catalog.pg_get_expr(dp.adbin, dp.adrelid)
order by 1, 2;
-- the wrong result: correct answer is 1 for both
set constraint_exclusion = partition;
select 'ce=partition' as setting, (select count(*) from p where v is null) as 
p_null,
       (select count(*) from c where v < 0) as c_neg;
set constraint_exclusion = on;
select 'ce=on' as setting, (select count(*) from p where v is null) as p_null,
       (select count(*) from c where v < 0) as c_neg;
reset constraint_exclusion;
EOF
start() { "$1/pg_ctl" -D "$2" -l "$2.log" -w \
            -o "-p $port -k $tmp -c listen_addresses=''" start >/dev/null; }
stop() { "$1/pg_ctl" -D "$2" -m fast -w stop >/dev/null; }
sql() { local bin=$1 db=$2; shift 2
        "$bin/psql" -X -h "$tmp" -p "$port" -U postgres -d "$db" "$@" 2>&1; }
say() { printf '\n### %s\n' "$*"; }

say "1. unpatched $("$old/postgres" --version): create divergent partitions"
"$old/initdb" -D "$tmp/old" -U postgres -A trust >/dev/null
start "$old" "$tmp/old"
"$old/createdb" -h "$tmp" -p "$port" -U postgres legacy
sql "$old" legacy -q -f "$tmp/legacy.sql"
sql "$old" legacy -f "$tmp/legacy-check.sql"

say "2. pg_dump of the unpatched server: how the partitions are emitted"
"$old/pg_dump" -h "$tmp" -p "$port" -U postgres legacy >"$tmp/dump-old.sql"
grep -nE 'CREATE TABLE|ATTACH PARTITION|GENERATED ALWAYS' "$tmp/dump-old.sql" \
  | sed 's/^/  /'
stop "$old" "$tmp/old"

for spec in "$@"; do
  label=${spec%%=*}; bin=${spec#*=}
  say "3. pg_upgrade --check, old cluster as created in 1 -> $label"
  "$bin/initdb" -D "$tmp/new-$label" -U postgres -A trust >/dev/null
  ( cd "$tmp" && "$bin/pg_upgrade" --check -U postgres -b "$old" -B "$bin" \
      -d "$tmp/old" -D "$tmp/new-$label" -p $((port + 1)) -P $((port + 2)) \
      -s "$tmp" ) 2>&1 | grep -vE '^\s*$' | sed 's/^/  /'
  f=$(find "$tmp/new-$label" -name partition_virtual_generated_columns.txt | 
head -1)
  [ -n "$f" ] && { echo "  report file:"; sed 's/^/    /' "$f"; }
done

say "4. same data directory started with $("$new18/postgres" --version) + patch"
start "$new18" "$tmp/old"
sql "$new18" legacy -f "$tmp/legacy-check.sql"

say "5. restore the unpatched dump (2) into the patched server"
"$new18/createdb" -h "$tmp" -p "$port" -U postgres restored_old
sql "$new18" restored_old -q -f "$tmp/dump-old.sql" | grep -E 'ERROR|DETAIL' \
  | sed 's/^/  /'
sql "$new18" restored_old -f "$tmp/legacy-check.sql"

say "6. dump with the patched pg_dump, restore into the patched server"
"$new18/pg_dump" -h "$tmp" -p "$port" -U postgres legacy >"$tmp/dump-new.sql"
"$new18/createdb" -h "$tmp" -p "$port" -U postgres restored_new
sql "$new18" restored_new -q -f "$tmp/dump-new.sql" | grep -E 'ERROR|DETAIL' \
  | sed 's/^/  /'

say "7. remedy suggested by the patch: SET EXPRESSION on the parent"
for t in "b:(a * 2)" "p:(nullif(a, 0))" "c:(-a)" "k:(a * 2)"; do
  echo "  $t"
  sql "$new18" legacy -c "alter table ${t%%:*} alter column v set expression as 
${t#*:}" \
    | sed 's/^/    /'
done
say "7b. after deleting the rows that violate the parent's constraints"
sql "$new18" legacy -c 'delete from p1' -c 'delete from c1' | sed 's/^/  /'
for t in "p:(nullif(a, 0))" "c:(-a)"; do
  echo "  $t"
  sql "$new18" legacy -c "alter table ${t%%:*} alter column v set expression as 
${t#*:}" \
    | sed 's/^/    /'
done
say "7c. drop the CHECK, SET EXPRESSION, add the CHECK back"
sql "$new18" legacy -c 'begin' \
  -c 'alter table c drop constraint c_v_check' \
  -c 'alter table c alter column v set expression as (-a)' \
  -c 'alter table c add constraint c_v_check check (v > 0)' \
  -c 'alter table k drop constraint k_a_check' \
  -c 'alter table k alter column v set expression as (a * 2)' \
  -c 'alter table k add constraint k_a_check check (a > 0)' \
  -c 'commit' | sed 's/^/  /'
sql "$new18" legacy -f "$tmp/legacy-check.sql"
stop "$new18" "$tmp/old"

for spec in "$@"; do
  label=${spec%%=*}; bin=${spec#*=}
  say "8. pg_upgrade --check after 7c -> $label"
  rm -rf "$tmp/new-$label"
  "$bin/initdb" -D "$tmp/new-$label" -U postgres -A trust >/dev/null
  ( cd "$tmp" && "$bin/pg_upgrade" --check -U postgres -b "$new18" -B "$bin" \
      -d "$tmp/old" -D "$tmp/new-$label" -p $((port + 1)) -P $((port + 2)) \
      -s "$tmp" ) 2>&1 | grep -iE 'virtual|clusters are compatible|fatal' \
      | sed 's/^/  /'
done
Partition with its own VIRTUAL generation expression: wrong result with
constraint_exclusion = on, and shihao's v1 against every DDL path
=========================================================================

Correctness only, no timings.  One machine (x86_64, Linux), gcc,
--enable-cassert --enable-debug, CFLAGS=-O2.  17 and older not run
(no virtual generated columns before 18).

Builds
  18.6      REL_18_6 release
  19beta4   REL_19_BETA4 (before d8444163d22)
  19rc1     REL_19_RC1 (contains d8444163d22)
  master    dfd7ede529f (2026-10-25)
  m-v1      master + v1-0001 (applies cleanly)
  19-v1     REL_19_STABLE 2b491ff1cd2 + v1-0001; plain "git am" fails on
            the src/bin/pg_upgrade/check.c prototype context
            (check_for_pg_role_prefix is still there in 19);
            "git am -3" applies it without conflicts
  18-v1     REL_18_STABLE 1735ad557d3 + nocfbot-v1-REL_18-0001
  mutant    m-v1 with the CheckPartitionVirtualGeneratedExprs() call in
            MergeAttributesIntoExisting() removed (ATTACH unchecked)

1. repro-ce-on.sql.txt (correct answer: 1 in every cell)
-------------------------------------------------------
                                  18.6  19beta4  19rc1  master
  NOT NULL: count(*) WHERE v IS NULL
    constraint_exclusion=partition   1        1      1       1
    constraint_exclusion=on          0        0      0       0
  CHECK (v > 0): count(*) WHERE v < 0
    constraint_exclusion=partition   1        1      1       1
    constraint_exclusion=on          0        0      0       0
  Plan with "on": Result, One-Time Filter: false, on all four.
  m-v1, 19-v1, 18-v1: both CREATE TABLE ... PARTITION OF are rejected,
  so the case cannot be built.

2. paths.sql.txt: DDL paths that create a divergent expression
--------------------------------------------------------------
  A = accepted, R = rejected          18.6/master   m-v1/19-v1/18-v1
  P1 CREATE TABLE ... PARTITION OF, LIST        A        R
  P2 same, RANGE and HASH                       A        R
  P3 ATTACH PARTITION                           A        R
  P4 DEFAULT partition                          A        R
  P5 SET EXPRESSION on a partition              A        R
     SET EXPRESSION on ONLY parent              A        R
  P6 partitioned partition, own expression      A        R
     leaf under it, own expression              A        R
     SET EXPRESSION on the middle level         A        R
     ATTACH of a partitioned table (its leaf
       matches it, it differs from the parent)  A        R
  P7 CREATE FOREIGN TABLE ... PARTITION OF      A        R
     ATTACH of a foreign table                  A        R
  13 divergent statements: 13 errors on each patched build, the same
  13 statements, and 0 on 18.6/master.  Every control with the
  parent's expression is accepted on all builds: CREATE ... PARTITION
  OF with the same or no expression, ATTACH with a different column
  order, recursive SET EXPRESSION on the parent, a leaf under a
  partitioned partition.  P8 (STORED partitions, INHERITS children,
  SET EXPRESSION on an INHERITS child) is accepted on all builds.
  Final query (the one from nocfbot-find-mismatched-virtual-exprs.sql):
  7 rows on 18.6/master, 0 rows on each patched build.
  mutant: the 3 ATTACH statements are accepted again (10 errors), and
  the final query lists the 2 that survive to the end (m3, f2).

3. legacy.sh.txt: divergent partitions that already exist
---------------------------------------------------------
  Created on 18.6: p1 (NOT NULL broken through the parent), c1 (CHECK
  on v broken), b1 (no constraint), k1 (CHECK (a > 0), not on v).
  1  18.6: the query lists 4; constraint_exclusion=on gives 0 and 0.
  3  pg_upgrade --check 18.6 -> m-v1 and -> 19-v1: fatal, the report
     file lists the 4 columns, on both.
  4  same data directory started with 18-v1: the query lists 4;
     constraint_exclusion=on still gives 0 and 0 (the patch prevents
     new ones; existing ones stay until fixed).
  5  18.6 pg_dump restored into 18-v1: each of the 4 ATTACH PARTITION
     fails, as the v1 mail says.
  7  ALTER TABLE <parent> ALTER COLUMN v SET EXPRESSION (on 18-v1):
       b  ok
       p  ERROR: column "v" of relation "p1" contains null values
       c  ERROR: ALTER TABLE / SET EXPRESSION is not supported for
          virtual generated columns in tables with check constraints
       k  same ERROR as c, although its CHECK does not involve v
     7b after deleting the violating rows: p ok, c same ERROR.
     The restriction is in REL_18_STABLE ATExecSetExpression()
     (num_check > 0 on the relation, any CHECK); f80bedd52b1 lifted
     it in 19 and it was not back-patched.
     7c in one transaction: DROP CONSTRAINT, SET EXPRESSION on the
        parent, ADD CONSTRAINT again, for c and k: ok; the query
        lists 0.
  8  pg_upgrade --check after 7c -> m-v1 and -> 19-v1: compatible.

4. Tests on the patched builds
------------------------------
  make check: m-v1 239/239, 19-v1 239/239, 18-v1 231/231.
  src/bin/pg_upgrade TAP on m-v1 (--enable-tap-tests, PROVE_FLAGS=-j1):
  8 files, 160 tests, PASS.
  Not run: check-world, meson/clang, cross-version pg_upgrade TAP.

How to repeat
  psql -X -f repro-ce-on.sql.txt          (any database)
  psql -X -f paths.sql.txt                (fresh database)
  bash legacy.sh.txt OLD18=<18.x bindir> NEW18=<patched 18 bindir> \
       <label>=<patched 19 or master bindir> ...

Reply via email to