Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wuXP7-000mF7-0o for pgsql-bugs@arkaria.postgresql.org; Thu, 13 Aug 2026 15:31:37 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wuXP5-00FCic-0W for pgsql-bugs@arkaria.postgresql.org; Thu, 13 Aug 2026 15:31:36 +0000 Received: from makus.postgresql.org ([2001:4800:3e1:1::229]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wuIi0-00AP8Z-2r for pgsql-bugs@lists.postgresql.org; Wed, 12 Aug 2026 23:50:10 +0000 Received: from mahout.postgresql.org ([2001:4800:3e1:1::227]) by makus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.98.2) (envelope-from ) id 1wuIhy-00000000Lh6-0zeE for pgsql-bugs@lists.postgresql.org; Wed, 12 Aug 2026 23:50:08 +0000 DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=postgresql.org; s=20171124; h=Message-ID:Date:Reply-To:Cc:From:To:Subject: Content-Transfer-Encoding:MIME-Version:Content-Type:Sender:Content-ID: Content-Description:In-Reply-To:References; bh=yDZuJ6I4wLVza9TKB6xLfD6d4/UqzcQH/ejfiGzwz78=; b=DCB+9BRoS5y3Q6/eGYUDh9wTFh XZ2QLNDwormWav4TBadGkMD0w2jrcZ232CzMcp4fltTs8YTU+KJ+VwyDWdeMUTQqISflSW21OTKdW l2yOyLuRomDqRHd1kXs2PpiWgjKG0ch5Yc8977efmrO+dBGmNnLUDTVl6dFymvUH/PKoyjBOGzYrN 5YcryEjYnE4W5O4diJmZ+dXyy2hZiU3kUTzU3XSTY7o58QPyCABTKYPG/PbmXdKw1aX1sC7ph2tuW 8W5veEQIp/cvi7EQFsCqosIHztrFN9+YKkJG6mrDMDRsTjJs268jfFrgjELv1U1111r7/tmFZdOYP E+vQ5jfw==; Received: from wrigleys.postgresql.org ([2a02:16a8:dc51::60]) by mahout.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wuIhx-0011wG-2T for pgsql-bugs@lists.postgresql.org; Wed, 12 Aug 2026 23:50:05 +0000 Received: from localhost ([127.0.0.1] helo=wrigleys.postgresql.org) by wrigleys.postgresql.org with esmtp (Exim 4.98.2) (envelope-from ) id 1wuIhv-00000003AoQ-3jGW for pgsql-bugs@lists.postgresql.org; Wed, 12 Aug 2026 23:50:03 +0000 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Subject: BUG #19617: Hash node can report incorrect actual rows number To: pgsql-bugs@lists.postgresql.org From: PG Bug reporting form Cc: malis@pgrust.com Reply-To: malis@pgrust.com, pgsql-bugs@lists.postgresql.org Date: Wed, 12 Aug 2026 23:49:52 +0000 Message-ID: <19617-10756e8d10b8af53@postgresql.org> X-Auto-Response-Suppress: All Auto-Submitted: auto-generated List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk The following bug has been logged on the website: Bug reference: 19617 Logged by: Michael Malis Email address: malis@pgrust.com PostgreSQL version: 18.3 Operating system: MacOS Description: =20 ExecBuildHash32Expr compiles a multi-key hash into a chain of EEOP_HASHDATUM_FIRST[_STRICT] / NEXT32[_STRICT] steps. Only the LAST step writes the ExprState's resvalue/resnull (what the caller reads); every earlier step writes a shared intermediate NullableDatum. The _STRICT variants abort on a NULL key by storing NULL into their own output cell and jumping to DONE = =E2=80=94 but for a non-final key that cell is the intermediate, and the jump skips the final step, so the ExprState result keeps whatever the previous row's evaluation left in it. In hash-join build this makes a NULL non-final-key tuple counted/bucketed according to the prior row. Consequence is misleading instrumentation (order-dependent Hash "actual rows") and a small bucket/memory overcount; query RESULTS are unaffected (the rechecked join qual still rejects NULL keys). Reproducer ---------- The SAME four rows in a DIFFERENT order report different Hash "actual rows" (3 vs 2); the correct count is 2 in both (two un-matchable NULL-keyed rows). SET enable_mergejoin =3D off; SET enable_nestloop =3D off; SET max_parallel_workers_per_gather =3D 0; CREATE TEMP TABLE probe (a int, b int); INSERT INTO probe SELECT 1, 1 FROM generate_series(1, 1000); ANALYZE probe; CREATE TEMP TABLE build1 (a int, b int); INSERT INTO build1 VALUES (1,1), (NULL,1), (2,NULL), (3,3); ANALYZE build1; EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT count(*) FROM probe JOIN build1 USING (a, b); -- -> Hash (actual rows=3D3.00 ...) (NULL,1) wrongly admitted CREATE TEMP TABLE build2 (a int, b int); INSERT INTO build2 VALUES (1,1), (2,NULL), (NULL,1), (3,3); ANALYZE build2; EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT count(*) FROM probe JOIN build2 USING (a, b); -- -> Hash (actual rows=3D2.00 ...) right, only because the preceding -- (2,NULL) row left NULL in the cell Both queries return count =3D 1000. The offending code (verbatim, PostgreSQL 18.3) ---------------------------------------------- src/backend/executor/execExprInterp.c =E2=80=94 the _STRICT abort stores in= to the step's OWN cell (*op->resnull/*op->resvalue =3D the intermediate, for a non-final key) and jumps straight to DONE, past the step that writes the ExprState result: 1825 EEO_CASE(EEOP_HASHDATUM_FIRST_STRICT) 1826 { 1827 FunctionCallInfo fcinfo =3D op->d.hashdatum.fcinfo_data; 1828 1829 if (fcinfo->args[0].isnull) 1830 { ... 1836 *op->resnull =3D true; /* <-- intermediate cell */ 1837 *op->resvalue =3D (Datum) 0; 1838 EEO_JUMP(op->d.hashdatum.jumpdone); /* <-- skips final step */ 1839 } 1873 EEO_CASE(EEOP_HASHDATUM_NEXT32_STRICT) 1874 { 1875 FunctionCallInfo fcinfo =3D op->d.hashdatum.fcinfo_data; 1876 1877 if (fcinfo->args[0].isnull) 1878 { ... 1884 *op->resnull =3D true; /* <-- intermediate cell */ 1885 *op->resvalue =3D (Datum) 0; 1886 EEO_JUMP(op->d.hashdatum.jumpdone); /* <-- skips final step */ 1887 } src/backend/executor/execExpr.c, ExecBuildHash32Expr =E2=80=94 only the fin= al key's step targets the ExprState result; jumpdone points at the trailing DONE: 4390 if (i =3D=3D num_exprs - 1) 4391 { 4392 /* the result for hashing the final expr is stored in the state */ 4393 scratch.resvalue =3D &state->resvalue; 4394 scratch.resnull =3D &state->resnull; 4395 } 4396 else 4397 { ... 4400 /* intermediate values are stored in an intermediate result */ 4401 scratch.resvalue =3D &iresult->value; 4402 scratch.resnull =3D &iresult->isnull; 4403 } ... 4442 as->d.hashdatum.jumpdone =3D state->steps_len; /* every strict jumps to DONE */ The caller reads the (stale) ExprState result =E2=80=94 src/backend/executor/nodeHash.c, MultiExecPrivateHash (function at :138): 173 hashdatum =3D ExecEvalExprSwitchContext(node->hash_expr, econtext, 174 &isnull); 175 176 if (!isnull) /* stale isnull */ ... 194 hashtable->totalTuples +=3D 1; /* =3D EXPLAIN's actual rows */