Subject: Re: explain analyze rows=%.0f

From: Andrei Lepikhov <[email-masked]>
Date: Sat, 15 Feb 2025 09:46:13 +0100

On 13/2/2025 21:42, Robert Haas wrote:
> On Thu, Feb 13, 2025 at 4:05 AM Ilia Evdokimov
> <[email-masked]> wrote:
>> 1. Documentation (v9-0001-Clarify-display-of-rows-as-decimal-fractions-DOC.patch)
>>
>> One thing that bothers me is that the documentation explains how to compute total time, but it does not clarify how to compute total rows. Maybe this was obvious to others before, but now that we are displaying rows as a fraction, we should explicitly document how to interpret it alongside total time.
>>
>> I believe it would be helpful to show a non-integer rows value in an example query. However, to achieve this, we need the index scan results to vary across iterations. One way to accomplish this is by using the condition t1.unique2 > t2.unique2. Additionally, I suggest making loops a round number (e.g., 100) for better readability, which can be achieved using t1.thousand < 10. The final query would look like this:
>>
>> EXPLAIN ANALYZE SELECT *
>> FROM tenk1 t1, tenk2 t2
>> WHERE t1.thousand < 10 AND t1.unique2 > t2.unique2;
>>
>> I believe this is an ideal example for the documentation because it not only demonstrates fractional rows, but also keeps the execution plan nearly unchanged. While the estimated and actual average row counts become slightly rounded, I don't see another way to ensure different results for each index scan.
> 
> Anyone else have an opinion on this? I see Ilia's point, but a
> non-equality join is probably an atypical case.
For me, it is ok: it demonstrates the feature and may legally happen. 
But this patch should be the second one, isn't it?
> 
>> 2. Code and tests (v9-0002-Clarify-display-of-rows-as-decimal-fractions.patch)
>>
>> I left the code and tests unchanged since we agreed on a fixed format of two decimal places.
> 
> This still has the HAS_DECIMAL() thing to which I objected.
I don't understand why using math.h and the floor() routine is 
necessary. I personally prefer x%y!=0 expression.

-- 
regards, Andrei Lepikhov

---

From: Ilia Evdokimov <[email-masked]>
Date: Mon, 17 Feb 2025 11:07:54 +0300

On 15.02.2025 11:46, Andrei Lepikhov wrote:
>>> 1. Documentation 
>>> (v9-0001-Clarify-display-of-rows-as-decimal-fractions-DOC.patch)
>>>
>>> One thing that bothers me is that the documentation explains how to 
>>> compute total time, but it does not clarify how to compute total 
>>> rows. Maybe this was obvious to others before, but now that we are 
>>> displaying rows as a fraction, we should explicitly document how to 
>>> interpret it alongside total time.
>>>
>>> I believe it would be helpful to show a non-integer rows value in an 
>>> example query. However, to achieve this, we need the index scan 
>>> results to vary across iterations. One way to accomplish this is by 
>>> using the condition t1.unique2 > t2.unique2. Additionally, I suggest 
>>> making loops a round number (e.g., 100) for better readability, 
>>> which can be achieved using t1.thousand < 10. The final query would 
>>> look like this:
>>>
>>> EXPLAIN ANALYZE SELECT *
>>> FROM tenk1 t1, tenk2 t2
>>> I believe this is an ideal example for the documentation because it 
>>> not only demonstrates fractional rows, but also keeps the execution 
>>> plan nearly unchanged. While the estimated and actual average row 
>>> counts become slightly rounded, I don't see another way to ensure 
>>> different results for each index scan.
>>

I didn't want to introduce a new table instead of tenk1 and tenk2, as 
the entire page already uses them. Moreover, if we use = in the join 
condition, the number of rows will always be the same. So, I couldn't 
find a better option than t1.unique > t2.unique.

Regarding the order of the patches, I made this one first because, as 
Tom mentioned earlier, we need to finalize the documentation first. 
However, in order to check tests in CirrusCI, it should be really the 
second.

>>
>>> 2. Code and tests 
>>> (v9-0002-Clarify-display-of-rows-as-decimal-fractions.patch)
>>>

You're right—floor shouldn't be used since it behaves differently across 
platforms, as Tom also pointed out earlier. I like the idea of using %, 
but since the compiler doesn't allow this operation with double, we need 
to cast it to int64. I checked how nloops and ntuples are modified - 
they are only incremented by 1. So that the casting might be safe. If I 
missed anything or if there's a reason why this casting wouldn't be 
safe, please let me know.

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.
From c1494f8815b0e69479d19e74e10970a54c8e6de6 Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <[email-masked]>
Date: Mon, 17 Feb 2025 11:02:33 +0300
Subject: [PATCH v10 2/2] Clarify display of rows as decimal fractions

When loops > 1, the average rows value is now displayed as a decimal fraction
with two digits after the decimal point in EXPLAIN ANALYZE.

Previously, the average rows value was always rounded to an integer,
which could lead to misleading results when estimating total rows
by multiplying rows by loop. This change ensures that users
get a more accurate representation of the data flow through execution nodes.
---
 doc/src/sgml/perform.sgml | 39 ++++++++++++++++++++-------------------
 1 file changed, 20 insertions(+), 19 deletions(-)

diff --git a/doc/src/sgml/perform.sgml b/doc/src/sgml/perform.sgml
index a502a2aaba..588ee6d5aa 100644
--- a/doc/src/sgml/perform.sgml
+++ b/doc/src/sgml/perform.sgml
@@ -717,26 +717,26 @@ FROM tenk1 t1 WHERE t1.ten = (SELECT (random() * 10)::integer);
 <screen>
 EXPLAIN ANALYZE SELECT *
 FROM tenk1 t1, tenk2 t2
-WHERE t1.unique1 &lt; 10 AND t1.unique2 = t2.unique2;
+WHERE t1.thousand &lt; 10 AND t1.unique2 &gt; t2.unique2;
 
-                                                           QUERY PLAN
--------------------------------------------------------------------&zwsp;--------------------------------------------------------------
- Nested Loop  (cost=4.65..118.50 rows=10 width=488) (actual time=0.017..0.051 rows=10 loops=1)
-   Buffers: shared hit=36 read=6
-   -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.38 rows=10 width=244) (actual time=0.009..0.017 rows=10 loops=1)
-         Recheck Cond: (unique1 &lt; 10)
-         Heap Blocks: exact=10
-         Buffers: shared hit=3 read=5 written=4
-         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0) (actual time=0.004..0.004 rows=10 loops=1)
-               Index Cond: (unique1 &lt; 10)
+                                                                 QUERY PLAN
+-------------------------------------------------------------------&zwsp;-------------------------------------------------------------------------
+ Nested Loop  (cost=5.40..11571.44 rows=356667 width=488) (actual time=0.042..117.205 rows=513832 loops=1)
+   Buffers: shared hit=19377 read=29
+   -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=5.11..233.60 rows=107 width=244) (actual time=0.021..0.103 rows=100 loops=1)
+         Recheck Cond: (thousand &lt; 10)
+         Heap Blocks: exact=90
+         Buffers: shared hit=92
+         -&gt;  Bitmap Index Scan on tenk1_thous_tenthous  (cost=0.00..5.09 rows=107 width=0) (actual time=0.010..0.010 rows=100 loops=1)
+               Index Cond: (thousand &lt; 10)
                Buffers: shared hit=2
-   -&gt;  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.90 rows=1 width=244) (actual time=0.003..0.003 rows=1 loops=10)
-         Index Cond: (unique2 = t1.unique2)
-         Buffers: shared hit=24 read=6
+   -&gt;  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..72.63 rows=3333 width=244) (actual time=0.006..0.410 rows=5138.32 loops=100)
+         Index Cond: (unique2 &lt; t1.unique2)
+         Buffers: shared hit=19285 read=29
  Planning:
-   Buffers: shared hit=15 dirtied=9
- Planning Time: 0.485 ms
- Execution Time: 0.073 ms
+   Buffers: shared hit=258 read=9 dirtied=2
+ Planning Time: 0.519 ms
+ Execution Time: 131.378 ms
 </screen>
 
     Note that the <quote>actual time</quote> values are in milliseconds of
@@ -756,8 +756,9 @@ WHERE t1.unique1 &lt; 10 AND t1.unique2 = t2.unique2;
     values shown are averages per-execution.  This is done to make the numbers
     comparable with the way that the cost estimates are shown.  Multiply by
     the <literal>loops</literal> value to get the total time actually spent in
-    the node.  In the above example, we spent a total of 0.030 milliseconds
-    executing the index scans on <literal>tenk2</literal>.
+    the node, and to get the total rows processed in the node. In the above example,
+    we spent a total of 41 milliseconds executing the index scans,
+    and the node processed a total of 513832 rows on <literal>tenk2</literal>.
    </para>
 
    <para>
-- 
2.34.1

From 0ba7069534afcf54cd39fc4bc21aa44d5bf3e343 Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <[email-masked]>
Date: Mon, 17 Feb 2025 10:59:50 +0300
Subject: [PATCH v10 1/2] Clarify display of rows as decimal fractions

When loops > 1, the average rows value is now displayed as a decimal fraction
with two digits after the decimal point in EXPLAIN ANALYZE.

Previously, the average rows value was always rounded to an integer,
which could lead to misleading results when estimating total rows
by multiplying rows by loop. This change ensures that users
get a more accurate representation of the data flow through execution nodes.
---
 src/backend/commands/explain.c                | 54 +++++++++++++------
 src/test/regress/expected/partition_prune.out | 18 +++----
 src/test/regress/sql/partition_prune.sql      |  2 +-
 3 files changed, 48 insertions(+), 26 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index dc4bef9ab8..50b3b224c2 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -1978,17 +1978,20 @@ ExplainNode(PlanState *planstate, List *ancestors,
 		double		startup_ms = 1000.0 * planstate->instrument->startup / nloops;
 		double		total_ms = 1000.0 * planstate->instrument->total / nloops;
 		double		rows = planstate->instrument->ntuples / nloops;
+		bool		rows_is_fractonal = (int64)planstate->instrument->ntuples %
+										(int64)nloops;
 
 		if (es->format == EXPLAIN_FORMAT_TEXT)
 		{
+			appendStringInfo(es->str, " (actual ");
+
 			if (es->timing)
-				appendStringInfo(es->str,
-								 " (actual time=%.3f..%.3f rows=%.0f loops=%.0f)",
-								 startup_ms, total_ms, rows, nloops);
+				appendStringInfo(es->str, "time=%.3f..%.3f ", startup_ms, total_ms);
+
+			if (nloops > 1 && rows_is_fractonal)
+				appendStringInfo(es->str, "rows=%.2f loops=%.0f)", rows, nloops);
 			else
-				appendStringInfo(es->str,
-								 " (actual rows=%.0f loops=%.0f)",
-								 rows, nloops);
+				appendStringInfo(es->str, "rows=%.0f loops=%.0f)", rows, nloops);
 		}
 		else
 		{
@@ -1999,8 +2002,16 @@ ExplainNode(PlanState *planstate, List *ancestors,
 				ExplainPropertyFloat("Actual Total Time", "ms", total_ms,
 									 3, es);
 			}
-			ExplainPropertyFloat("Actual Rows", NULL, rows, 0, es);
-			ExplainPropertyFloat("Actual Loops", NULL, nloops, 0, es);
+			if (nloops > 1 && rows_is_fractonal)
+			{
+				ExplainPropertyFloat("Actual Rows", NULL, rows, 2, es);
+				ExplainPropertyFloat("Actual Loops", NULL, nloops, 0, es);
+			}
+			else
+			{
+				ExplainPropertyFloat("Actual Rows", NULL, rows, 0, es);
+				ExplainPropertyFloat("Actual Loops", NULL, nloops, 0, es);
+			}
 		}
 	}
 	else if (es->analyze)
@@ -2040,26 +2051,28 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			double		startup_ms;
 			double		total_ms;
 			double		rows;
+			bool		rows_is_fractonal;
 
 			if (nloops <= 0)
 				continue;
 			startup_ms = 1000.0 * instrument->startup / nloops;
 			total_ms = 1000.0 * instrument->total / nloops;
 			rows = instrument->ntuples / nloops;
+			rows_is_fractonal = (int64)instrument->ntuples % (int64)nloops;
 
 			ExplainOpenWorker(n, es);
 
 			if (es->format == EXPLAIN_FORMAT_TEXT)
 			{
 				ExplainIndentText(es);
+				appendStringInfo(es->str, "actual ");
 				if (es->timing)
-					appendStringInfo(es->str,
-									 "actual time=%.3f..%.3f rows=%.0f loops=%.0f\n",
-									 startup_ms, total_ms, rows, nloops);
+					appendStringInfo(es->str, "time=%.3f..%.3f", startup_ms, total_ms);
+
+				if (nloops > 1 && rows_is_fractonal)
+					appendStringInfo(es->str, "rows=%.2f loops=%.0f\n", rows, nloops);
 				else
-					appendStringInfo(es->str,
-									 "actual rows=%.0f loops=%.0f\n",
-									 rows, nloops);
+					appendStringInfo(es->str, "rows=%.0f loops=%.0f\n", rows, nloops);
 			}
 			else
 			{
@@ -2070,8 +2083,17 @@ ExplainNode(PlanState *planstate, List *ancestors,
 					ExplainPropertyFloat("Actual Total Time", "ms",
 										 total_ms, 3, es);
 				}
-				ExplainPropertyFloat("Actual Rows", NULL, rows, 0, es);
-				ExplainPropertyFloat("Actual Loops", NULL, nloops, 0, es);
+
+				if (nloops > 1 && rows_is_fractonal)
+				{
+					ExplainPropertyFloat("Actual Rows", NULL, rows, 2, es);
+					ExplainPropertyFloat("Actual Loops", NULL, nloops, 0, es);
+				}
+				else
+				{
+					ExplainPropertyFloat("Actual Rows", NULL, rows, 0, es);
+					ExplainPropertyFloat("Actual Loops", NULL, nloops, 0, es);
+				}
 			}
 
 			ExplainCloseWorker(n, es);
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index 6f80b62a3b..1e9ceee593 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -2367,7 +2367,7 @@ begin
             $1)
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: N');
-        ln := regexp_replace(ln, 'actual rows=\d+ loops=\d+', 'actual rows=N loops=N');
+        ln := regexp_replace(ln, 'actual rows=\d+(?:\.\d+)? loops=\d+', 'actual rows=N loops=N');
         ln := regexp_replace(ln, 'Rows Removed by Filter: \d+', 'Rows Removed by Filter: N');
         return next ln;
     end loop;
@@ -3049,14 +3049,14 @@ order by tbl1.col1, tprt.col1;
 insert into tbl1 values (1001), (1010), (1011);
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Nested Loop (actual rows=23 loops=1)
    ->  Seq Scan on tbl1 (actual rows=5 loops=1)
-   ->  Append (actual rows=5 loops=5)
+   ->  Append (actual rows=4.60 loops=5)
          ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2 loops=5)
                Index Cond: (col1 < tbl1.col1)
-         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=3 loops=4)
+         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=2.75 loops=4)
                Index Cond: (col1 < tbl1.col1)
          ->  Index Scan using tprt3_idx on tprt_3 (actual rows=1 loops=2)
                Index Cond: (col1 < tbl1.col1)
@@ -3070,16 +3070,16 @@ select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Nested Loop (actual rows=3 loops=1)
    ->  Seq Scan on tbl1 (actual rows=5 loops=1)
-   ->  Append (actual rows=1 loops=5)
+   ->  Append (actual rows=0.60 loops=5)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 = tbl1.col1)
          ->  Index Scan using tprt2_idx on tprt_2 (actual rows=1 loops=2)
                Index Cond: (col1 = tbl1.col1)
-         ->  Index Scan using tprt3_idx on tprt_3 (actual rows=0 loops=3)
+         ->  Index Scan using tprt3_idx on tprt_3 (actual rows=0.33 loops=3)
                Index Cond: (col1 = tbl1.col1)
          ->  Index Scan using tprt4_idx on tprt_4 (never executed)
                Index Cond: (col1 = tbl1.col1)
diff --git a/src/test/regress/sql/partition_prune.sql b/src/test/regress/sql/partition_prune.sql
index 86621dcec0..6aad02156c 100644
--- a/src/test/regress/sql/partition_prune.sql
+++ b/src/test/regress/sql/partition_prune.sql
@@ -586,7 +586,7 @@ begin
             $1)
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: N');
-        ln := regexp_replace(ln, 'actual rows=\d+ loops=\d+', 'actual rows=N loops=N');
+        ln := regexp_replace(ln, 'actual rows=\d+(?:\.\d+)? loops=\d+', 'actual rows=N loops=N');
         ln := regexp_replace(ln, 'Rows Removed by Filter: \d+', 'Rows Removed by Filter: N');
         return next ln;
     end loop;
-- 
2.34.1

---

From: Robert Haas <[email-masked]>
Date: Mon, 17 Feb 2025 09:19:43 -0500

> safe, please let me know.

What I've been advocating for is just:

if (nloops > 1)

Instead of:

if (nloops > 1 && rows_is_fractonal)

I don't think it's really safe to just cast a double back to int64. In
practice, the number of tuples should never be large enough to
overflow int64, but if it did, this result would be nonsense. Also, if
the double ever lost precision, the result would be nonsense. If we
want to have an exact count of tuples, we ought to change ntuples and
ntuples2 to be uint64. But I don't think we should do that in this
patch, because that adds a whole bunch of new problems to worry about
and might cause us to get nothing committed. Instead, I think we
should just always show two decimal digits if there's more than one
loop.

That's simpler than what the patch currently does and avoids this
problem. Perhaps it's objectionable for some other reason, but if so,
can somebody please spell out what that reason is so we can talk about
it?

-- 
Robert Haas
EDB: http://www.enterprisedb.com

---

From: Ilia Evdokimov <[email-masked]>
Date: Mon, 17 Feb 2025 21:53:16 +0300

> can somebody please spell out what that reason is so we can talk about

I have no objections. I agree if we are going to transition from double 
to int64, it should be done properly, but definitely not in current thread.

As for documentation changes, I'll keep t1.unique > t2.unique. If we use 
equality instead, we need to explicitly show rows=1.00, which would 
likely raise too many questions from users what it means.

I attached patches v11 with changes.

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.
From 766e8bf46a415cf3c4e3a4c94a2479ea91d3e93f Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <[email-masked]>
Date: Mon, 17 Feb 2025 21:22:11 +0300
Subject: [PATCH v11 2/2] Clarify display of rows as decimal fractions

 
-                                                           QUERY PLAN

    <para>
-- 
2.34.1

From 7cb3f36f3fb4db0f75a72df1caa5e3218198fa84 Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <[email-masked]>
Date: Mon, 17 Feb 2025 21:20:10 +0300
Subject: [PATCH v11 1/2] Clarify display of rows as decimal fractions

Previously, the average rows value was always rounded to an integer,
which could lead to misleading results when estimating total rows
by multiplying rows by loop. This change ensures that users
get a more accurate representation of the data flow through execution nodes.
---
 src/backend/commands/explain.c                | 50 +++++++----
 src/test/regress/expected/explain.out         |  4 +-
 src/test/regress/expected/memoize.out         | 86 +++++++++----------
 src/test/regress/expected/partition_prune.out | 42 ++++-----
 src/test/regress/expected/select_parallel.out | 24 +++---
 src/test/regress/sql/partition_prune.sql      |  2 +-
 6 files changed, 113 insertions(+), 95 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index dc4bef9ab8..ea4ae60d57 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -1981,14 +1981,15 @@ ExplainNode(PlanState *planstate, List *ancestors,
 
+			if (nloops > 1)
@@ -1999,8 +2000,16 @@ ExplainNode(PlanState *planstate, List *ancestors,
+			if (nloops > 1)
@@ -2052,14 +2061,14 @@ ExplainNode(PlanState *planstate, List *ancestors,
+				if (nloops > 1)
@@ -2070,8 +2079,17 @@ ExplainNode(PlanState *planstate, List *ancestors,
+				if (nloops > 1)
 
 			ExplainCloseWorker(n, es);
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index ee31e41d50..eb187516b3 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -528,7 +528,7 @@ select jsonb_pretty(
                              "Plan Rows": 0,                +
                              "Plan Width": 0,               +
                              "Total Cost": 0.0,             +
-                             "Actual Rows": 0,              +
+                             "Actual Rows": 0.0,            +
                              "Actual Loops": 0,             +
                              "Startup Cost": 0.0,           +
                              "Async Capable": false,        +
@@ -575,7 +575,7 @@ select jsonb_pretty(
                      "Plan Rows": 0,                        +
                      "Plan Width": 0,                       +
                      "Total Cost": 0.0,                     +
-                     "Actual Rows": 0,                      +
+                     "Actual Rows": 0.0,                    +
                      "Actual Loops": 0,                     +
                      "Startup Cost": 0.0,                   +
                      "Async Capable": false,                +
diff --git a/src/test/regress/expected/memoize.out b/src/test/regress/expected/memoize.out
index 5ecf971dad..dbd01066d0 100644
--- a/src/test/regress/expected/memoize.out
+++ b/src/test/regress/expected/memoize.out
@@ -35,18 +35,18 @@ SELECT explain_memoize('
 SELECT COUNT(*),AVG(t1.unique1) FROM tenk1 t1
 INNER JOIN tenk1 t2 ON t1.unique1 = t2.twenty
 WHERE t2.unique1 < 1000;', false);
-                                      explain_memoize                                      
--------------------------------------------------------------------------------------------
+                                       explain_memoize                                        
+----------------------------------------------------------------------------------------------
  Aggregate (actual rows=1 loops=N)
    ->  Nested Loop (actual rows=1000 loops=N)
          ->  Seq Scan on tenk1 t2 (actual rows=1000 loops=N)
                Filter: (unique1 < 1000)
                Rows Removed by Filter: 9000
-         ->  Memoize (actual rows=1 loops=N)
+         ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t2.twenty
                Cache Mode: logical
                Hits: 980  Misses: 20  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-               ->  Index Only Scan using tenk1_unique1 on tenk1 t1 (actual rows=1 loops=N)
+               ->  Index Only Scan using tenk1_unique1 on tenk1 t1 (actual rows=1.00 loops=N)
                      Index Cond: (unique1 = t2.twenty)
                      Heap Fetches: N
 (12 rows)
@@ -66,18 +66,18 @@ SELECT COUNT(*),AVG(t2.unique1) FROM tenk1 t1,
 LATERAL (SELECT t2.unique1 FROM tenk1 t2
          WHERE t1.twenty = t2.unique1 OFFSET 0) t2
 WHERE t1.unique1 < 1000;', false);
-                                      explain_memoize                                      
--------------------------------------------------------------------------------------------
+                                       explain_memoize                                        
+----------------------------------------------------------------------------------------------
  Aggregate (actual rows=1 loops=N)
    ->  Nested Loop (actual rows=1000 loops=N)
          ->  Seq Scan on tenk1 t1 (actual rows=1000 loops=N)
                Filter: (unique1 < 1000)
                Rows Removed by Filter: 9000
-         ->  Memoize (actual rows=1 loops=N)
+         ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t1.twenty
                Cache Mode: binary
                Hits: 980  Misses: 20  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-               ->  Index Only Scan using tenk1_unique1 on tenk1 t2 (actual rows=1 loops=N)
+               ->  Index Only Scan using tenk1_unique1 on tenk1 t2 (actual rows=1.00 loops=N)
                      Index Cond: (unique1 = t1.twenty)
                      Heap Fetches: N
 (12 rows)
@@ -100,20 +100,20 @@ LATERAL (
 ) t2
 ON t1.two = t2.two
 WHERE t1.unique1 < 10;', false);
-                                       explain_memoize                                        
-----------------------------------------------------------------------------------------------
+                                         explain_memoize                                         
+-------------------------------------------------------------------------------------------------
  Aggregate (actual rows=1 loops=N)
    ->  Nested Loop Left Join (actual rows=20 loops=N)
          ->  Index Scan using tenk1_unique1 on tenk1 t1 (actual rows=10 loops=N)
                Index Cond: (unique1 < 10)
-         ->  Memoize (actual rows=2 loops=N)
+         ->  Memoize (actual rows=2.00 loops=N)
                Cache Key: t1.two
                Cache Mode: binary
                Hits: 8  Misses: 2  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-               ->  Subquery Scan on t2 (actual rows=2 loops=N)
+               ->  Subquery Scan on t2 (actual rows=2.00 loops=N)
                      Filter: (t1.two = t2.two)
                      Rows Removed by Filter: 2
-                     ->  Index Scan using tenk1_unique1 on tenk1 t2_1 (actual rows=4 loops=N)
+                     ->  Index Scan using tenk1_unique1 on tenk1 t2_1 (actual rows=4.00 loops=N)
                            Index Cond: (unique1 < 4)
 (13 rows)
 
@@ -134,18 +134,18 @@ SELECT explain_memoize('
 SELECT COUNT(*), AVG(t1.twenty) FROM tenk1 t1 LEFT JOIN
 LATERAL (SELECT t1.two+1 AS c1, t2.unique1 AS c2 FROM tenk1 t2) s ON TRUE
 WHERE s.c1 = s.c2 AND t1.unique1 < 1000;', false);
-                                      explain_memoize                                      
--------------------------------------------------------------------------------------------
+                                       explain_memoize                                        
+----------------------------------------------------------------------------------------------
  Aggregate (actual rows=1 loops=N)
    ->  Nested Loop (actual rows=1000 loops=N)
          ->  Seq Scan on tenk1 t1 (actual rows=1000 loops=N)
                Filter: (unique1 < 1000)
                Rows Removed by Filter: 9000
-         ->  Memoize (actual rows=1 loops=N)
+         ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: (t1.two + 1)
                Cache Mode: binary
                Hits: 998  Misses: 2  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-               ->  Index Only Scan using tenk1_unique1 on tenk1 t2 (actual rows=1 loops=N)
+               ->  Index Only Scan using tenk1_unique1 on tenk1 t2 (actual rows=1.00 loops=N)
                      Filter: ((t1.two + 1) = unique1)
                      Rows Removed by Filter: 9999
                      Heap Fetches: N
@@ -173,11 +173,11 @@ WHERE s.c1 = s.c2 AND t1.unique1 < 1000;', false);
          ->  Seq Scan on tenk1 t1 (actual rows=1000 loops=N)
                Filter: (unique1 < 1000)
                Rows Removed by Filter: 9000
-         ->  Memoize (actual rows=1 loops=N)
+         ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t1.two, t1.twenty
                Cache Mode: binary
                Hits: 980  Misses: 20  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-               ->  Seq Scan on tenk1 t2 (actual rows=1 loops=N)
+               ->  Seq Scan on tenk1 t2 (actual rows=1.00 loops=N)
                      Filter: ((t1.twenty = unique1) AND (t1.two = two))
                      Rows Removed by Filter: 9999
 (12 rows)
@@ -207,15 +207,15 @@ VACUUM ANALYZE expr_key;
 SELECT explain_memoize('
 SELECT * FROM expr_key t1 INNER JOIN expr_key t2
 ON t1.x = t2.t::numeric AND t1.t::numeric = t2.x;', false);
-                                      explain_memoize                                      
--------------------------------------------------------------------------------------------
+                                       explain_memoize                                        
+----------------------------------------------------------------------------------------------
  Nested Loop (actual rows=80 loops=N)
    ->  Seq Scan on expr_key t1 (actual rows=40 loops=N)
-   ->  Memoize (actual rows=2 loops=N)
+   ->  Memoize (actual rows=2.00 loops=N)
          Cache Key: t1.x, (t1.t)::numeric
          Cache Mode: logical
          Hits: 20  Misses: 20  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-         ->  Index Only Scan using expr_key_idx_x_t on expr_key t2 (actual rows=2 loops=N)
+         ->  Index Only Scan using expr_key_idx_x_t on expr_key t2 (actual rows=2.00 loops=N)
                Index Cond: (x = (t1.t)::numeric)
                Filter: (t1.x = (t)::numeric)
                Heap Fetches: N
@@ -232,18 +232,18 @@ SELECT explain_memoize('
 SELECT COUNT(*),AVG(t1.unique1) FROM tenk1 t1
 INNER JOIN tenk1 t2 ON t1.unique1 = t2.thousand
 WHERE t2.unique1 < 1200;', true);
-                                      explain_memoize                                      
--------------------------------------------------------------------------------------------
+                                       explain_memoize                                        
+----------------------------------------------------------------------------------------------
  Aggregate (actual rows=1 loops=N)
    ->  Nested Loop (actual rows=1200 loops=N)
          ->  Seq Scan on tenk1 t2 (actual rows=1200 loops=N)
                Filter: (unique1 < 1200)
                Rows Removed by Filter: 8800
-         ->  Memoize (actual rows=1 loops=N)
+         ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t2.thousand
                Cache Mode: logical
                Hits: N  Misses: N  Evictions: N  Overflows: 0  Memory Usage: NkB
-               ->  Index Only Scan using tenk1_unique1 on tenk1 t1 (actual rows=1 loops=N)
+               ->  Index Only Scan using tenk1_unique1 on tenk1 t1 (actual rows=1.00 loops=N)
                      Index Cond: (unique1 = t2.thousand)
                      Heap Fetches: N
 (12 rows)
@@ -261,7 +261,7 @@ SELECT * FROM flt f1 INNER JOIN flt f2 ON f1.f = f2.f;', false);
  Nested Loop (actual rows=4 loops=N)
    ->  Index Only Scan using flt_f_idx on flt f1 (actual rows=2 loops=N)
          Heap Fetches: N
-   ->  Memoize (actual rows=2 loops=N)
+   ->  Memoize (actual rows=2.00 loops=N)
          Cache Key: f1.f
          Cache Mode: logical
          Hits: 1  Misses: 1  Evictions: Zero  Overflows: 0  Memory Usage: NkB
@@ -273,16 +273,16 @@ SELECT * FROM flt f1 INNER JOIN flt f2 ON f1.f = f2.f;', false);
 -- Ensure memoize operates in binary mode
 SELECT explain_memoize('
 SELECT * FROM flt f1 INNER JOIN flt f2 ON f1.f >= f2.f;', false);
-                                explain_memoize                                
--------------------------------------------------------------------------------
+                                 explain_memoize                                  
+----------------------------------------------------------------------------------
  Nested Loop (actual rows=4 loops=N)
    ->  Index Only Scan using flt_f_idx on flt f1 (actual rows=2 loops=N)
          Heap Fetches: N
-   ->  Memoize (actual rows=2 loops=N)
+   ->  Memoize (actual rows=2.00 loops=N)
          Cache Key: f1.f
          Cache Mode: binary
          Hits: 0  Misses: 2  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-         ->  Index Only Scan using flt_f_idx on flt f2 (actual rows=2 loops=N)
+         ->  Index Only Scan using flt_f_idx on flt f2 (actual rows=2.00 loops=N)
                Index Cond: (f <= f1.f)
                Heap Fetches: N
 (10 rows)
@@ -300,32 +300,32 @@ ANALYZE strtest;
 -- Ensure we get 3 hits and 3 misses
 SELECT explain_memoize('
 SELECT * FROM strtest s1 INNER JOIN strtest s2 ON s1.n >= s2.n;', false);
-                                 explain_memoize                                  
-----------------------------------------------------------------------------------
+                                   explain_memoize                                   
+-------------------------------------------------------------------------------------
  Nested Loop (actual rows=24 loops=N)
    ->  Seq Scan on strtest s1 (actual rows=6 loops=N)
          Disabled: true
-   ->  Memoize (actual rows=4 loops=N)
+   ->  Memoize (actual rows=4.00 loops=N)
          Cache Key: s1.n
          Cache Mode: binary
          Hits: 3  Misses: 3  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-         ->  Index Scan using strtest_n_idx on strtest s2 (actual rows=4 loops=N)
+         ->  Index Scan using strtest_n_idx on strtest s2 (actual rows=4.00 loops=N)
                Index Cond: (n <= s1.n)
 (9 rows)
 
 -- Ensure we get 3 hits and 3 misses
 SELECT explain_memoize('
 SELECT * FROM strtest s1 INNER JOIN strtest s2 ON s1.t >= s2.t;', false);
-                                 explain_memoize                                  
-----------------------------------------------------------------------------------
+                                   explain_memoize                                   
+-------------------------------------------------------------------------------------
  Nested Loop (actual rows=24 loops=N)
    ->  Seq Scan on strtest s1 (actual rows=6 loops=N)
          Disabled: true
-   ->  Memoize (actual rows=4 loops=N)
+   ->  Memoize (actual rows=4.00 loops=N)
          Cache Key: s1.t
          Cache Mode: binary
          Hits: 3  Misses: 3  Evictions: Zero  Overflows: 0  Memory Usage: NkB
-         ->  Index Scan using strtest_t_idx on strtest s2 (actual rows=4 loops=N)
+         ->  Index Scan using strtest_t_idx on strtest s2 (actual rows=4.00 loops=N)
                Index Cond: (t <= s1.t)
 (9 rows)
 
@@ -348,7 +348,7 @@ SELECT * FROM prt t1 INNER JOIN prt t2 ON t1.a = t2.a;', false);
    ->  Nested Loop (actual rows=16 loops=N)
          ->  Index Only Scan using iprt_p1_a on prt_p1 t1_1 (actual rows=4 loops=N)
                Heap Fetches: N
-         ->  Memoize (actual rows=4 loops=N)
+         ->  Memoize (actual rows=4.00 loops=N)
                Cache Key: t1_1.a
                Cache Mode: logical
                Hits: 3  Misses: 1  Evictions: Zero  Overflows: 0  Memory Usage: NkB
@@ -358,7 +358,7 @@ SELECT * FROM prt t1 INNER JOIN prt t2 ON t1.a = t2.a;', false);
    ->  Nested Loop (actual rows=16 loops=N)
          ->  Index Only Scan using iprt_p2_a on prt_p2 t1_2 (actual rows=4 loops=N)
                Heap Fetches: N
-         ->  Memoize (actual rows=4 loops=N)
+         ->  Memoize (actual rows=4.00 loops=N)
                Cache Key: t1_2.a
                Cache Mode: logical
                Hits: 3  Misses: 1  Evictions: Zero  Overflows: 0  Memory Usage: NkB
@@ -378,7 +378,7 @@ ON t1.a = t2.a;', false);
  Nested Loop (actual rows=16 loops=N)
    ->  Index Only Scan using iprt_p1_a on prt_p1 t1 (actual rows=4 loops=N)
          Heap Fetches: N
-   ->  Memoize (actual rows=4 loops=N)
+   ->  Memoize (actual rows=4.00 loops=N)
          Cache Key: t1.a
          Cache Mode: logical
          Hits: 3  Misses: 1  Evictions: Zero  Overflows: 0  Memory Usage: NkB
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index 6f80b62a3b..1dbe6ff54f 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -2367,7 +2367,7 @@ begin
             $1)
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: N');
-        ln := regexp_replace(ln, 'actual rows=\d+ loops=\d+', 'actual rows=N loops=N');
+        ln := regexp_replace(ln, 'actual rows=\d+(?:\.\d+)? loops=\d+', 'actual rows=N loops=N');
         ln := regexp_replace(ln, 'Rows Removed by Filter: \d+', 'Rows Removed by Filter: N');
         return next ln;
     end loop;
@@ -2940,7 +2940,7 @@ update ab_a1 set b = 3 from ab_a2 where ab_a2.b = (select 1);');
                ->  Seq Scan on ab_a1_b1 ab_a1_1 (actual rows=1 loops=1)
                ->  Seq Scan on ab_a1_b2 ab_a1_2 (actual rows=1 loops=1)
                ->  Seq Scan on ab_a1_b3 ab_a1_3 (actual rows=1 loops=1)
-         ->  Materialize (actual rows=1 loops=3)
+         ->  Materialize (actual rows=1.00 loops=3)
                Storage: Memory  Maximum Storage: NkB
                ->  Append (actual rows=1 loops=1)
                      ->  Seq Scan on ab_a2_b1 ab_a2_1 (actual rows=1 loops=1)
@@ -2983,12 +2983,12 @@ set enable_hashjoin = off;
 set enable_mergejoin = off;
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Nested Loop (actual rows=6 loops=1)
    ->  Seq Scan on tbl1 (actual rows=2 loops=1)
-   ->  Append (actual rows=3 loops=2)
-         ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2 loops=2)
+   ->  Append (actual rows=3.00 loops=2)
+         ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2.00 loops=2)
                Index Cond: (col1 < tbl1.col1)
          ->  Index Scan using tprt2_idx on tprt_2 (actual rows=2 loops=1)
                Index Cond: (col1 < tbl1.col1)
@@ -3004,14 +3004,14 @@ select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Nested Loop (actual rows=2 loops=1)
    ->  Seq Scan on tbl1 (actual rows=2 loops=1)
-   ->  Append (actual rows=1 loops=2)
+   ->  Append (actual rows=1.00 loops=2)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 = tbl1.col1)
-         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=1 loops=2)
+         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=1.00 loops=2)
                Index Cond: (col1 = tbl1.col1)
          ->  Index Scan using tprt3_idx on tprt_3 (never executed)
                Index Cond: (col1 = tbl1.col1)
@@ -3049,16 +3049,16 @@ order by tbl1.col1, tprt.col1;
 insert into tbl1 values (1001), (1010), (1011);
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Nested Loop (actual rows=23 loops=1)
    ->  Seq Scan on tbl1 (actual rows=5 loops=1)
-   ->  Append (actual rows=5 loops=5)
-         ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2 loops=5)
+   ->  Append (actual rows=4.60 loops=5)
+         ->  Index Scan using tprt1_idx on tprt_1 (actual rows=2.00 loops=5)
                Index Cond: (col1 < tbl1.col1)
-         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=3 loops=4)
+         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=2.75 loops=4)
                Index Cond: (col1 < tbl1.col1)
-         ->  Index Scan using tprt3_idx on tprt_3 (actual rows=1 loops=2)
+         ->  Index Scan using tprt3_idx on tprt_3 (actual rows=1.00 loops=2)
                Index Cond: (col1 < tbl1.col1)
          ->  Index Scan using tprt4_idx on tprt_4 (never executed)
                Index Cond: (col1 < tbl1.col1)
@@ -3070,16 +3070,16 @@ select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Nested Loop (actual rows=3 loops=1)
    ->  Seq Scan on tbl1 (actual rows=5 loops=1)
-   ->  Append (actual rows=1 loops=5)
+   ->  Append (actual rows=0.60 loops=5)
          ->  Index Scan using tprt1_idx on tprt_1 (never executed)
                Index Cond: (col1 = tbl1.col1)
-         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=1 loops=2)
+         ->  Index Scan using tprt2_idx on tprt_2 (actual rows=1.00 loops=2)
                Index Cond: (col1 = tbl1.col1)
-         ->  Index Scan using tprt3_idx on tprt_3 (actual rows=0 loops=3)
+         ->  Index Scan using tprt3_idx on tprt_3 (actual rows=0.33 loops=3)
                Index Cond: (col1 = tbl1.col1)
          ->  Index Scan using tprt4_idx on tprt_4 (never executed)
                Index Cond: (col1 = tbl1.col1)
diff --git a/src/test/regress/expected/select_parallel.out b/src/test/regress/expected/select_parallel.out
index a809036453..4732ad6bfb 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -583,17 +583,17 @@ alter table tenk2 set (parallel_workers = 0);
 explain (analyze, timing off, summary off, costs off, buffers off)
    select count(*) from tenk1, tenk2 where tenk1.hundred > 1
         and tenk2.thousand=0;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                                 QUERY PLAN                                  
+-----------------------------------------------------------------------------
  Aggregate (actual rows=1 loops=1)
    ->  Nested Loop (actual rows=98000 loops=1)
          ->  Seq Scan on tenk2 (actual rows=10 loops=1)
                Filter: (thousand = 0)
                Rows Removed by Filter: 9990
-         ->  Gather (actual rows=9800 loops=10)
+         ->  Gather (actual rows=9800.00 loops=10)
                Workers Planned: 4
                Workers Launched: 4
-               ->  Parallel Seq Scan on tenk1 (actual rows=1960 loops=50)
+               ->  Parallel Seq Scan on tenk1 (actual rows=1960.00 loops=50)
                      Filter: (hundred > 1)
                      Rows Removed by Filter: 40
 (11 rows)
@@ -617,21 +617,21 @@ begin
 end;
 $$;
 select * from explain_parallel_sort_stats();
-                       explain_parallel_sort_stats                        
---------------------------------------------------------------------------
+                         explain_parallel_sort_stats                         
+-----------------------------------------------------------------------------
  Nested Loop Left Join (actual rows=30000 loops=1)
    ->  Values Scan on "*VALUES*" (actual rows=3 loops=1)
-   ->  Gather Merge (actual rows=10000 loops=3)
+   ->  Gather Merge (actual rows=10000.00 loops=3)
          Workers Planned: 4
          Workers Launched: 4
-         ->  Sort (actual rows=2000 loops=15)
+         ->  Sort (actual rows=2000.00 loops=15)
                Sort Key: tenk1.ten
                Sort Method: quicksort  Memory: xxx
                Worker 0:  Sort Method: quicksort  Memory: xxx
                Worker 1:  Sort Method: quicksort  Memory: xxx
                Worker 2:  Sort Method: quicksort  Memory: xxx
                Worker 3:  Sort Method: quicksort  Memory: xxx
-               ->  Parallel Seq Scan on tenk1 (actual rows=2000 loops=15)
+               ->  Parallel Seq Scan on tenk1 (actual rows=2000.00 loops=15)
                      Filter: (ten < 100)
 (14 rows)
 
@@ -1170,12 +1170,12 @@ explain (costs off)
 SAVEPOINT settings;
 SET LOCAL debug_parallel_query = 1;
 EXPLAIN (analyze, timing off, summary off, costs off, buffers off) SELECT * FROM tenk1;
-                         QUERY PLAN                          
--------------------------------------------------------------
+                           QUERY PLAN                           
+----------------------------------------------------------------
  Gather (actual rows=10000 loops=1)
    Workers Planned: 4
    Workers Launched: 4
-   ->  Parallel Seq Scan on tenk1 (actual rows=2000 loops=5)
+   ->  Parallel Seq Scan on tenk1 (actual rows=2000.00 loops=5)
 (4 rows)
 
 ROLLBACK TO SAVEPOINT settings;

---

From: Andrei Lepikhov <[email-masked]>
Date: Tue, 18 Feb 2025 21:55:01 +0100

> can somebody please spell out what that reason is so we can talk about
I can understand two decimal places. You might be concerned about 
potential issues with some codes that parse PostgreSQL explains.
However, I believe it would be beneficial to display fractional parts 
only when iterations yield different numbers of tuples. Given that I 
often work with enormous explains, I think this approach would enhance 
the readability and comprehension of the output. Frequently, I may see 
only part of the EXPLAIN on the screen. A floating-point row number 
format may immediately give an idea about parameterisation (or another 
reason for the subtree's variability) and trace it down to the source.

-- 
regards, Andrei Lepikhov

---

From: Ilia Evdokimov <[email-masked]>
Date: Wed, 19 Feb 2025 17:01:06 +0300

> reason for the subtree's variability) and trace it down to the source.

The idea of indicating to the user that different iterations produced 
varying numbers of rows is quite reasonable. Most likely, this would 
require adding a new boolean field to the Instrumentation structure, 
which would track this information by comparing the rows value from the 
current and previous iterations.

However, there is a major issue: this case would be quite difficult to 
document clearly. Even with an example and explanatory text, users may 
still be confused about why rows=100 means the same number of rows on 
all iterations, while rows=100.00 indicates variation. Even if we 
describe this in the documentation, a user seeing rows=100.00 will most 
likely assume it represents an average of 100 rows per iteration and may 
still not realize that the actual number of rows varied.

If we want to convey this information more clearly, we should consider a 
more explicit approach. For example, instead of using a fractional 
value, we could display the minimum and maximum row counts observed 
during execution (e.g.,rows=10..20, formatting details could be 
discussed). However, in my opinion, this discussion is beyond the scope 
of this thread.

Any thoughts?

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.

---

From: Andrei Lepikhov <[email-masked]>
Date: Wed, 19 Feb 2025 16:37:40 +0100

> discussed). However, in my opinion, this discussion is beyond the scope 
I think this approach is redundant. Usually, a problem is an unknown 
number of tuples because, having millions of loops, we produce a small 
number of tuples, but each tuple costs a lot because of a subplan or 
something like that. By printing fractional digits (of course, we need 
to view significant ones, but we already agreed on a couple of digits 
for now), we may resolve this corner-case problem fully - at least all 
examples in my experience would be resolved, and I have even maintained 
a patch for years to manage this.
Moreover, it is too simple to implement. I just can't imagine why not to 
do it right now.

-- 
regards, Andrei Lepikhov

---

From: Robert Haas <[email-masked]>
Date: Fri, 21 Feb 2025 16:20:08 -0500

> current and previous iterations.

Yep.

> still not realize that the actual number of rows varied.

I imagine this is a surmountable problem.

> discussed). However, in my opinion, this discussion is beyond the scope

I quite agree. I think we've spent too much time on this already; this
idea was first proposed in 2009, and we just haven't gotten around to
doing anything about it. Redesigning the feature a few more times
(with an expanded scope) isn't going to make that better.

So, I've committed v11-0001. I'm not altogether convinced that
v11-0002 is necessary -- no portion of the documentation that it
modifies is falsified by the committed patch. Maybe we can just call
this one done for now and move on?

---

From: Robert Haas <[email-masked]>
Date: Fri, 21 Feb 2025 16:44:28 -0500

> this one done for now and move on?

Well, not quite yet, at least. There are two buildfarm failures since
I committed this. The first is here:

https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=conchuela&dt=2025-02-21%2021%3A22%3A30

diff -U3 /home/pgbf/buildroot/HEAD/pgsql.build/src/test/regress/expected/explain.out
/home/pgbf/buildroot/HEAD/pgsql.build/src/test/regress/results/explain.out
--- /home/pgbf/buildroot/HEAD/pgsql.build/src/test/regress/expected/explain.out
2025-02-21 22:20:39.229225000 +0100
+++ /home/pgbf/buildroot/HEAD/pgsql.build/src/test/regress/results/explain.out
2025-02-21 22:22:38.193054000 +0100
@@ -528,7 +528,7 @@
                              "Plan Rows": 0,                +
                              "Plan Width": 0,               +
                              "Total Cost": 0.0,             +
-                             "Actual Rows": 0.0,            +
+                             "Actual Rows": 0,              +
                              "Actual Loops": 0,             +
                              "Startup Cost": 0.0,           +
                              "Async Capable": false,        +
@@ -575,7 +575,7 @@
                      "Plan Rows": 0,                        +
                      "Plan Width": 0,                       +
                      "Total Cost": 0.0,                     +
-                     "Actual Rows": 0.0,                    +
+                     "Actual Rows": 0,                      +
                      "Actual Loops": 0,                     +
                      "Startup Cost": 0.0,                   +
                      "Async Capable": false,                +

I imagine what happened here is that the parallel workers didn't
start, and so nloops was 1 instead of >1, and so we got an integer
output instead of a fractional one. It looks like there's some kind of
JSON-based EXPLAIN filtering happening here, so maybe we should just
also be filtering out the actual rows column? But I fear this will
turn out to be a problem for other parallel-query using tests that
were modified by this commit: it can always happen that the workers
start slowly enough that the leader finishes the whole query before
the worker starts up. Maybe making the number of decimal digits
contingent on nloops wasn't such a hot idea - I think it means that
every parallel query has a small but non-zero probability of producing
different EXPLAIN output in the probably-rare case where the worker
startup is slow.

The other failure is here:

https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=loach&dt=2025-02-21%2021%3A25%3A19

This failure seems not to have uploaded any log artifacts, so I'm not
sure how to tell what caused this.

---

From: Tom Lane <[email-masked]>
Date: Fri, 21 Feb 2025 19:04:31 -0500

> I committed this. The first is here:

A very significant fraction of the buildfarm is now pink.
If you don't have a fix pretty nearly ready, please revert.

> output instead of a fractional one.

Sounds plausible.  I note that this run:

https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=skimmer&dt=2025-02-21%2023%3A00%3A10

got through a couple of iterations of the core tests before failing,
showing that it's not 100% reproducible even on the machines where
it's happening.

			regards, tom lane

---

From: Robert Haas <[email-masked]>
Date: Fri, 21 Feb 2025 20:09:24 -0500

> If you don't have a fix pretty nearly ready, please revert.

When we're going to do a release, you want no commits for at least 24
hours before the release so that we can make sure the buildfarm is
clean. But when I commit something and the buildfarm fails, you want
it reverted within a handful of hours before I can even be sure of
having seen all the failures the commit caused, let alone had time to
think about what the best fix might be. It doesn't make sense to me
that we need 24 hours to be sure that the buildfarm is passing, but in
3 hours I'm supposed to see all the failures -- including the ones
that only happen on buildfarm animals that run once a day, I guess? --
and analyze them -- and decide on a fix -- naturally without any sort
of discussion because there's no time for that -- and code the fix --
and push it. I have a really hard time seeing how that's a reasonable
expectation.

I understand that the buildfarm can't be red all the time or nobody
can distinguish the problems they caused from preexisting ones. At the
same time, it seems completely unreasonable for us to say that, on the
one hand, the buildfarm has to be green at absolutely all times, and
on the other time, buildfarm owners are not required to provide any
sort of resources to test things before they are committed. IMHO, one
of those policies absolutely has to change. The current situation is
way too stressful for committers and it's burning people out and
making them unwilling to commit things -- or if they do commit things,
then they end up insta-reverting them, committing them again later,
maybe insta-reverting them a second time because they didn't actually
find all the problems the first time, and then maybe even round three,
four, or five. The commit log ends up with a bunch of garbage from the
repeated commits and reverts, and if it goes on long enough,
eventually somebody shows up to say "wow, this patch seems to be in
terrible shape, maybe it shouldn't ever be committed again" right when
the committer's stress level is already going through the ceiling. And
sometimes that is justified, but sometimes it isn't.

In the case of my commit today, the failures are the result of a
2-line regression diff with no functional impact that neither CI nor
any of the 11 reviewers noticed. That just shouldn't be the sort of
thing that results in somebody having to work evenings and weekends.
Perhaps if it DIDN'T result in a committer having to work evenings and
weekends, it wouldn't have taken 16 years for us to do something about
that problem.

---

From: Robert Haas <[email-masked]>
Date: Fri, 21 Feb 2025 20:16:20 -0500

> showing that it's not 100% reproducible even on the machines where

Yeah, I pushed an attempt at a band-aid fix. Hopefully that will make
it clear whether there are other problems and how bad they are. It
looks like the XversionUpgrade tests are not this commit's fault.

---

From: Tom Lane <[email-masked]>
Date: Fri, 21 Feb 2025 21:11:07 -0500

> looks like the XversionUpgrade tests are not this commit's fault.

They are not, they're Jeff's problem.

I apologize if I ruffled your feathers.  But the buildfarm is a shared
resource and it doesn't do to leave it significantly broken over a
weekend, which I feared you might be about to do.  Committing stuff
at 4PM on a Friday is perhaps not the best plan if your intention
is to not work weekends.

On a positive note, at least some of the formerly-pink members
seem to be happy now.  So as you hoped, we can get more info about
whether there are less-frequent problems.

As for a permanent fix: maybe, instead of printing fractions when
actual nloops > 1, we should print fractions if *potential*
nloops > 1?  That is, anytime we're on the inside of a nestloop,
under a Gather, etc.  Seems like this'd not be too hard to track
while recursively descending the PlanState tree.

			regards, tom lane

---

From: Andres Freund <[email-masked]>
Date: Fri, 21 Feb 2025 21:14:33 -0500

Hi,

> weekends, it wouldn't have taken 16 years for us to do something about

I think you're right that it really depends on what the breakage is.

If just about all animals die early in the test, it's more important the
problem gets fixed more or the faulty commit(s) reverted. For one it prevents
detecting other problems, as later, independent failures are hidden. For
another it quite likely will affect other developers "at home", too.

I'd also say that breaking CI and BF is probably something I'd consider more
urgent, as that could indicate the commit was just generally less well tested.

If, as the case here, a few animals fail with a minor regression.diffs output,
one that doesn't indicate any major issue but just some corner case change in
output, it's ok to leave it for the next day or so (*).

Greetings,

Andres Freund

(*) With some adjustments based on circumstances. If the breakage is over a
weekend, it's perhaps a bit less urgent. However, if it's the Friday before a
feature freeze, it's obviously a differnt story.

---

From: Robert Haas <[email-masked]>
Date: Fri, 21 Feb 2025 22:00:59 -0500

> at 4PM on a Friday is perhaps not the best plan if your intention

I really, really think we need some policy changes. Several committers
have told me that they literally won't commit anything on Friday for
fear of this exact kind of problem, but that means we lose a lot of
hours every week that people could potentially be dealing with patches
that have been waiting for an exceedingly long time for someone to
commit them. I feel like this is entirely a self-inflicted injury. We
could decide that CI is primary and the buildfarm is secondary, so
that as long as CI is green, you have a week to deal with anything
else that breaks. We could decide, as I said on the new thread I
started, to insist on having a vehicle for pre-commit buildfarm
testing. We could decide to just chill out over a couple of lines of
regression test diffs over a weekend where most people aren't going to
be working anyway. We could *at the very least* decide that you get 24
hours before you have to revert, perhaps with an exception if we're in
the last 14 days before feature freeze. There are probably other
things we could do. Yelling at people every time a patch turns the
buildfarm red for a few hours seems worse to me than MANY of the other
options.

Really, I feel like the way the buildfarm works is a relic of the
past. We're living in an era where cfbot is testing hundreds of
branches on a daily basis, all of which are completely untrusted code
downloaded from the Internet, but our buildfarm can't manage more than
about six and can't provide any testing resources even to committers.
In 2025, that's really far from the state of the art -- and CI seems
to be progressing a great deal faster than the BF, so the gap between
them figures to only get wider.

> whether there are less-frequent problems.

Cool.

> while recursively descending the PlanState tree.

I guess we could do that, but how about just always displaying two
decimal places? I feel like you're really worried that people will be
confused if the row count is not an integer, but I'm not sure anyone
else has echoed that concern, and I am doubtful that it's really a
problem. Either people don't understand that the row count is divided
through by nloops, in which case the decimal places might actually
give them a clue that they've misunderstood, or they do understand
that, in which case the decimal places are not confusing. I feel like
we've gone to a lot of trouble to come up with complex solutions --
what was actually committed was one of the simpler things proposed --
but I am still not convinced that the simplest solution isn't for the
best.

---

From: Robert Haas <[email-masked]>
Date: Fri, 21 Feb 2025 22:38:11 -0500

> urgent, as that could indicate the commit was just generally less well tested.

This is possible, but I think we oversell it by quite a lot. It's
quite possible for someone to put hundreds of hours into a patch and
forget to check CI before pushing; and it's also quite possible for
someone to do a terrible job vetting a patch where CI is clean. When
somebody is already stressed out about their patch breaking the
buildfarm, the very last thing they need is somebody who hasn't read
the patch or understood what the problems are to show up and say "hey,
maybe this patch should be reverted for all time and never considered
again ever!". It's just making a very stressful situation more
stressful. And it's cheap. If somebody shows up and says "hey, this
was improvidently committed for the following six design-level
reasons," that is abundantly fair and deserves major respect. Such
reviews take real time, thought, and work. Idly speculating that
someone's failure to check CI is a sign that they've also done
everything else wrong takes almost no work at all. I hate that we do
that to people. As much as I hate it when it happens to me, I think I
hate it even more when it happens to other people. It's a terrible way
to treat people who have poured their heart and soul into becoming
committers and who really care about the project, at least until we
beat the caring out of them.

But the real point of my previous email is that I just do not think
it's reasonable to expect people to fix complex programming problems
within hours. As much as I can be grumpy about CI, anything that goes
wrong with CI should in theory be something you can avoid ever having
to deal with on a Friday night no matter when you choose to commit,
because you can test things in advance. I know the BF has more
configurations than CI, but instead of having LESS capability to test
things in advance, it has NONE. Twenty years ago, post-commit testing
was probably the best you could hope for, but today it isn't.

---

From: Jelte Fennema-Nio <[email-masked]>
Date: Sun, 23 Feb 2025 12:30:16 +0100

This was very painful to read. +1 on making changes. Both for a
culture change to require less urgency over the weekend if it's a
minor failure, and probably also a tooling change to make this less of
a problem in general.

On Sat, 22 Feb 2025 at 04:38, Robert Haas <[email-masked]> wrote:
> forget to check CI before pushing;
...snip...
> Twenty years ago, post-commit testing

100% agreed. As someone also working on other open source projects,
this reads incredibly foreign. This is a problem that simply cannot
happen in most projects I've worked on: GitHub will simply not allow
you to push to master/main if CI fails (at least not without an
explicit override). Postgres is the only project I work on where all
these things only run post-push to master.

To be clear, I'm not saying that we should be using GitHub for this,
although I personally think that would be a good idea given our CI is
triggered by pushes to GitHub. But it at least seems like one thing
that we need is a process change to make it less likely that people
accidentally push broken changes to master. e.g. We create a staging
repo (which could be on GitHub) and each committer has their own
branch (or branch prefix). Then when a committer pushes there, CI is
run and probably also some significant part of the build farm (and
maybe a committer could even opt in to running the full build farm).
If they pass, then the branch is automatically rebased on top of
master and pushed to the production repo its master branch.

---

From: Ilia Evdokimov <[email-masked]>
Date: Mon, 24 Feb 2025 14:03:13 +0300

> this one done for now and move on?

Not quite. If no one objects to leaving the documentation as it is, 
there is one nuance that needs to be fixed—in example [0], there is a 
node with loops=10, so it should be updated to reflect the current 
state. I've attached a patch for that.
From c17c1926598fbb35a829a98ea35db3c41f1c7ccc Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <[email-masked]>
Date: Mon, 24 Feb 2025 13:55:15 +0300
Subject: [PATCH v12] Update documentation after changing the rows format in
 EXPLAIN ANALYZE

Following commit ddb17e3, the format of the average rows" field
in EXPLAIN ANALYZE was modified to always display fractional values
when loops > 1. This commit updates the example in the documentation
to reflect the new output format, ensuring consistency with the latest behavior.
---
 doc/src/sgml/perform.sgml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/doc/src/sgml/perform.sgml b/doc/src/sgml/perform.sgml
index a502a2aaba..6a3dcc0bfc 100644
--- a/doc/src/sgml/perform.sgml
+++ b/doc/src/sgml/perform.sgml
@@ -730,7 +730,7 @@ WHERE t1.unique1 &lt; 10 AND t1.unique2 = t2.unique2;
          -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0) (actual time=0.004..0.004 rows=10 loops=1)
                Index Cond: (unique1 &lt; 10)
                Buffers: shared hit=2
-   -&gt;  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.90 rows=1 width=244) (actual time=0.003..0.003 rows=1 loops=10)
+   -&gt;  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.90 rows=1 width=244) (actual time=0.003..0.003 rows=1.00 loops=10)
          Index Cond: (unique2 = t1.unique2)
          Buffers: shared hit=24 read=6
  Planning:
-- 
2.34.1

---

From: Ilia Evdokimov <[email-masked]>
Date: Mon, 24 Feb 2025 15:27:02 +0300

Hi,

Regarding the patch push, I am not a committer, but perhaps my 
perspective might be interesting. When I noticed late on Friday evening 
that the tests had failed, I was quite anxious about the situation, 
thinking I would need to fix it right away. However, Robert committed 
the fix before that.

In general, when someone commits in any project, I believe the scale of 
the commit should be taken into account. In the case of postgres, if the 
commit changes code in critical areas like the planner, WAL, or some 
API, or involves large code changes, it’s important to be prepared to 
fix any issues that may arise. However, when changes are more minor—such 
as documentation, renaming something, or small refactors—committing late 
on a Friday may be less of a concern.

In other words, the larger the changes or the more vulnerable the areas 
of the code, the more one should be prepared for potential issues. But 
how to determine this boundary in postgres, I am not sure. I am 
confident that you will have a much better sense and experience of how 
to handle it.

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.

---

From: Robert Haas <[email-masked]>
Date: Mon, 24 Feb 2025 11:20:53 -0500

> but I am still not convinced that the simplest solution isn't for the

So here's a patch for that. Thoughts?

---

From: Ilia Evdokimov <[email-masked]>
Date: Mon, 24 Feb 2025 20:12:11 +0300

> So here's a patch for that. Thoughts?

If no one is concerned about rows being a non-integer, then I support 
this, as it's quite strange for the average rows to be an integer only 
for me. If we go with this approach, we should also update all examples 
in the documentation accordingly. I attached patch with changes in 
documentation.

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.
From 7ce5d477ddd360ca3a5045949cc0e44db0658887 Mon Sep 17 00:00:00 2001
From: Ilia Evdokimov <[email-masked]>
Date: Mon, 24 Feb 2025 20:07:09 +0300
Subject: [PATCH v1] EXPLAIN: Always use two fractional digits for row counts.

Commit ddb17e387aa28d61521227377b00f997756b8a27 attempted to avoid
confusing users by displaying digits after the decimal point only when
nloops > 1, since it's impossible to have a fraction row count after a
single iteration. However, this made the regression tests unstable since
parallal queries will have nloops>1 for all nodes below the Gather or
Gather Merge in normal cases, but if the workers don't start in time and
the leader finishes all the work, they will suddenly have nloops==1,
making it unpredictable whether the digits after the decimal point would
be displayed or not.

Various fixes are possible here. For example, it has previously been
proposed that we should try to display the digits after the decimal
point only if rows/nloops is an integer, but rows is a float so it's not
theoretically an exact quantity -- precision could be lost in extreme
cases. It has also been proposed that we should try to display the
digits after the decimal point only if we're under some sort of
construct that could potentially cause looping regardless of whether it
actually does. While such ideas are not without merit, this patch adopts
the much simpler solution of always display two decimal digits. If that
approach stands up to scrutiny from the buildfarm and human users, it
spares us the trouble of doing anything more complex; if not, we can
reassess.

This commit incidentally reverts 44cbba9a7f51a3888d5087fc94b23614ba2b81f2,
which should no longer be needed.
---
 doc/src/sgml/auto-explain.sgml | 10 ++++----
 doc/src/sgml/bloom.sgml        | 16 ++++++-------
 doc/src/sgml/jit.sgml          |  8 +++----
 doc/src/sgml/perform.sgml      | 42 +++++++++++++++++-----------------
 doc/src/sgml/planstats.sgml    | 24 +++++++++----------
 doc/src/sgml/ref/explain.sgml  |  4 ++--
 doc/src/sgml/rules.sgml        | 18 +++++++--------
 7 files changed, 61 insertions(+), 61 deletions(-)

diff --git a/doc/src/sgml/auto-explain.sgml b/doc/src/sgml/auto-explain.sgml
index 0c4656ee30..15c868021e 100644
--- a/doc/src/sgml/auto-explain.sgml
+++ b/doc/src/sgml/auto-explain.sgml
@@ -337,13 +337,13 @@ LOG:  duration: 3.651 ms  plan:
   Query Text: SELECT count(*)
               FROM pg_class, pg_index
               WHERE oid = indrelid AND indisunique;
-  Aggregate  (cost=16.79..16.80 rows=1 width=0) (actual time=3.626..3.627 rows=1 loops=1)
-    ->  Hash Join  (cost=4.17..16.55 rows=92 width=0) (actual time=3.349..3.594 rows=92 loops=1)
+  Aggregate  (cost=16.79..16.80 rows=1 width=0) (actual time=3.626..3.627 rows=1.00 loops=1)
+    ->  Hash Join  (cost=4.17..16.55 rows=92 width=0) (actual time=3.349..3.594 rows=92.00 loops=1)
           Hash Cond: (pg_class.oid = pg_index.indrelid)
-          ->  Seq Scan on pg_class  (cost=0.00..9.55 rows=255 width=4) (actual time=0.016..0.140 rows=255 loops=1)
-          ->  Hash  (cost=3.02..3.02 rows=92 width=4) (actual time=3.238..3.238 rows=92 loops=1)
+          ->  Seq Scan on pg_class  (cost=0.00..9.55 rows=255 width=4) (actual time=0.016..0.140 rows=255.00 loops=1)
+          ->  Hash  (cost=3.02..3.02 rows=92 width=4) (actual time=3.238..3.238 rows=92.00 loops=1)
                 Buckets: 1024  Batches: 1  Memory Usage: 4kB
-                ->  Seq Scan on pg_index  (cost=0.00..3.02 rows=92 width=4) (actual time=0.008..3.187 rows=92 loops=1)
+                ->  Seq Scan on pg_index  (cost=0.00..3.02 rows=92 width=4) (actual time=0.008..3.187 rows=92.00 loops=1)
                       Filter: indisunique
 ]]></screen>
  </sect2>
diff --git a/doc/src/sgml/bloom.sgml b/doc/src/sgml/bloom.sgml
index 6a8a60b8c7..663a0a4a68 100644
--- a/doc/src/sgml/bloom.sgml
+++ b/doc/src/sgml/bloom.sgml
@@ -118,7 +118,7 @@ SELECT 10000000
 =# EXPLAIN ANALYZE SELECT * FROM tbloom WHERE i2 = 898732 AND i5 = 123451;
                                               QUERY PLAN
 -------------------------------------------------------------------&zwsp;-----------------------------------
- Seq Scan on tbloom  (cost=0.00..213744.00 rows=250 width=24) (actual time=357.059..357.059 rows=0 loops=1)
+ Seq Scan on tbloom  (cost=0.00..213744.00 rows=250 width=24) (actual time=357.059..357.059 rows=0.00 loops=1)
    Filter: ((i2 = 898732) AND (i5 = 123451))
    Rows Removed by Filter: 10000000
    Buffers: shared hit=63744
@@ -142,7 +142,7 @@ CREATE INDEX
 =# EXPLAIN ANALYZE SELECT * FROM tbloom WHERE i2 = 898732 AND i5 = 123451;
                                               QUERY PLAN
 -------------------------------------------------------------------&zwsp;-----------------------------------
- Seq Scan on tbloom  (cost=0.00..213744.00 rows=2 width=24) (actual time=351.016..351.017 rows=0 loops=1)
+ Seq Scan on tbloom  (cost=0.00..213744.00 rows=2 width=24) (actual time=351.016..351.017 rows=0.00 loops=1)
    Filter: ((i2 = 898732) AND (i5 = 123451))
    Rows Removed by Filter: 10000000
    Buffers: shared hit=63744
@@ -166,12 +166,12 @@ CREATE INDEX
 =# EXPLAIN ANALYZE SELECT * FROM tbloom WHERE i2 = 898732 AND i5 = 123451;
                                                      QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------------------------------------------------
- Bitmap Heap Scan on tbloom  (cost=1792.00..1799.69 rows=2 width=24) (actual time=22.605..22.606 rows=0 loops=1)
+ Bitmap Heap Scan on tbloom  (cost=1792.00..1799.69 rows=2 width=24) (actual time=22.605..22.606 rows=0.00 loops=1)
    Recheck Cond: ((i2 = 898732) AND (i5 = 123451))
    Rows Removed by Index Recheck: 2300
    Heap Blocks: exact=2256
    Buffers: shared hit=21864
-   -&gt;  Bitmap Index Scan on bloomidx  (cost=0.00..178436.00 rows=1 width=0) (actual time=20.005..20.005 rows=2300 loops=1)
+   -&gt;  Bitmap Index Scan on bloomidx  (cost=0.00..178436.00 rows=1 width=0) (actual time=20.005..20.005 rows=2300.00 loops=1)
          Index Cond: ((i2 = 898732) AND (i5 = 123451))
          Buffers: shared hit=19608
  Planning Time: 0.099 ms
@@ -201,15 +201,15 @@ CREATE INDEX
 =# EXPLAIN ANALYZE SELECT * FROM tbloom WHERE i2 = 898732 AND i5 = 123451;
                                                         QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------------------------------------------------------
- Bitmap Heap Scan on tbloom  (cost=9.29..13.30 rows=1 width=24) (actual time=0.032..0.033 rows=0 loops=1)
+ Bitmap Heap Scan on tbloom  (cost=9.29..13.30 rows=1 width=24) (actual time=0.032..0.033 rows=0.00 loops=1)
    Recheck Cond: ((i5 = 123451) AND (i2 = 898732))
    Buffers: shared read=6
-   -&gt;  BitmapAnd  (cost=9.29..9.29 rows=1 width=0) (actual time=0.047..0.047 rows=0 loops=1)
+   -&gt;  BitmapAnd  (cost=9.29..9.29 rows=1 width=0) (actual time=0.047..0.047 rows=0.00 loops=1)
          Buffers: shared hit=6
-         -&gt;  Bitmap Index Scan on btreeidx5  (cost=0.00..4.52 rows=11 width=0) (actual time=0.026..0.026 rows=7 loops=1)
+         -&gt;  Bitmap Index Scan on btreeidx5  (cost=0.00..4.52 rows=11 width=0) (actual time=0.026..0.026 rows=7.00 loops=1)
                Index Cond: (i5 = 123451)
                Buffers: shared hit=3
-         -&gt;  Bitmap Index Scan on btreeidx2  (cost=0.00..4.52 rows=11 width=0) (actual time=0.007..0.007 rows=8 loops=1)
+         -&gt;  Bitmap Index Scan on btreeidx2  (cost=0.00..4.52 rows=11 width=0) (actual time=0.007..0.007 rows=8.00 loops=1)
                Index Cond: (i2 = 898732)
                Buffers: shared hit=3
  Planning Time: 0.264 ms
diff --git a/doc/src/sgml/jit.sgml b/doc/src/sgml/jit.sgml
index 458f8acb41..44e18bf1a6 100644
--- a/doc/src/sgml/jit.sgml
+++ b/doc/src/sgml/jit.sgml
@@ -148,9 +148,9 @@
 =# EXPLAIN ANALYZE SELECT SUM(relpages) FROM pg_class;
                                                  QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------------------------------------
- Aggregate  (cost=16.27..16.29 rows=1 width=8) (actual time=0.303..0.303 rows=1 loops=1)
+ Aggregate  (cost=16.27..16.29 rows=1 width=8) (actual time=0.303..0.303 rows=1.00 loops=1)
    Buffers: shared hit=14
-   ->  Seq Scan on pg_class  (cost=0.00..15.42 rows=342 width=4) (actual time=0.017..0.111 rows=356 loops=1)
+   ->  Seq Scan on pg_class  (cost=0.00..15.42 rows=342 width=4) (actual time=0.017..0.111 rows=356.00 loops=1)
          Buffers: shared hit=14
  Planning Time: 0.116 ms
  Execution Time: 0.365 ms
@@ -165,9 +165,9 @@ SET
 =# EXPLAIN ANALYZE SELECT SUM(relpages) FROM pg_class;
                                                  QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------------------------------------
- Aggregate  (cost=16.27..16.29 rows=1 width=8) (actual time=6.049..6.049 rows=1 loops=1)
+ Aggregate  (cost=16.27..16.29 rows=1 width=8) (actual time=6.049..6.049 rows=1.00 loops=1)
    Buffers: shared hit=14
-   ->  Seq Scan on pg_class  (cost=0.00..15.42 rows=342 width=4) (actual time=0.019..0.052 rows=356 loops=1)
+   ->  Seq Scan on pg_class  (cost=0.00..15.42 rows=342 width=4) (actual time=0.019..0.052 rows=356.00 loops=1)
          Buffers: shared hit=14
  Planning Time: 0.133 ms
  JIT:
diff --git a/doc/src/sgml/perform.sgml b/doc/src/sgml/perform.sgml
index 6a3dcc0bfc..91feb59abd 100644
--- a/doc/src/sgml/perform.sgml
+++ b/doc/src/sgml/perform.sgml
@@ -721,13 +721,13 @@ WHERE t1.unique1 &lt; 10 AND t1.unique2 = t2.unique2;
 
                                                            QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------------------------------------------------------------
- Nested Loop  (cost=4.65..118.50 rows=10 width=488) (actual time=0.017..0.051 rows=10 loops=1)
+ Nested Loop  (cost=4.65..118.50 rows=10 width=488) (actual time=0.017..0.051 rows=10.00 loops=1)
    Buffers: shared hit=36 read=6
-   -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.38 rows=10 width=244) (actual time=0.009..0.017 rows=10 loops=1)
+   -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.38 rows=10 width=244) (actual time=0.009..0.017 rows=10.00 loops=1)
          Recheck Cond: (unique1 &lt; 10)
          Heap Blocks: exact=10
          Buffers: shared hit=3 read=5 written=4
-         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0) (actual time=0.004..0.004 rows=10 loops=1)
+         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0) (actual time=0.004..0.004 rows=10.00 loops=1)
                Index Cond: (unique1 &lt; 10)
                Buffers: shared hit=2
    -&gt;  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.90 rows=1 width=244) (actual time=0.003..0.003 rows=1.00 loops=10)
@@ -772,23 +772,23 @@ WHERE t1.unique1 &lt; 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous;
 
                                                                  QUERY PLAN
 -------------------------------------------------------------------&zwsp;-------------------------------------------------------------------&zwsp;------
- Sort  (cost=713.05..713.30 rows=100 width=488) (actual time=2.995..3.002 rows=100 loops=1)
+ Sort  (cost=713.05..713.30 rows=100 width=488) (actual time=2.995..3.002 rows=100.00 loops=1)
    Sort Key: t1.fivethous
    Sort Method: quicksort  Memory: 74kB
    Buffers: shared hit=440
-   -&gt;  Hash Join  (cost=226.23..709.73 rows=100 width=488) (actual time=0.515..2.920 rows=100 loops=1)
+   -&gt;  Hash Join  (cost=226.23..709.73 rows=100 width=488) (actual time=0.515..2.920 rows=100.00 loops=1)
          Hash Cond: (t2.unique2 = t1.unique2)
          Buffers: shared hit=437
-         -&gt;  Seq Scan on tenk2 t2  (cost=0.00..445.00 rows=10000 width=244) (actual time=0.026..1.790 rows=10000 loops=1)
+         -&gt;  Seq Scan on tenk2 t2  (cost=0.00..445.00 rows=10000 width=244) (actual time=0.026..1.790 rows=10000.00 loops=1)
                Buffers: shared hit=345
-         -&gt;  Hash  (cost=224.98..224.98 rows=100 width=244) (actual time=0.476..0.477 rows=100 loops=1)
+         -&gt;  Hash  (cost=224.98..224.98 rows=100 width=244) (actual time=0.476..0.477 rows=100.00 loops=1)
                Buckets: 1024  Batches: 1  Memory Usage: 35kB
                Buffers: shared hit=92
-               -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=5.06..224.98 rows=100 width=244) (actual time=0.030..0.450 rows=100 loops=1)
+               -&gt;  Bitmap Heap Scan on tenk1 t1  (cost=5.06..224.98 rows=100 width=244) (actual time=0.030..0.450 rows=100.00 loops=1)
                      Recheck Cond: (unique1 &lt; 100)
                      Heap Blocks: exact=90
                      Buffers: shared hit=92
-                     -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0) (actual time=0.013..0.013 rows=100 loops=1)
+                     -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0) (actual time=0.013..0.013 rows=100.00 loops=1)
                            Index Cond: (unique1 &lt; 100)
                            Buffers: shared hit=2
  Planning:
@@ -814,7 +814,7 @@ EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE ten &lt; 7;
 
                                                QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------------------------------------
- Seq Scan on tenk1  (cost=0.00..470.00 rows=7000 width=244) (actual time=0.030..1.995 rows=7000 loops=1)
+ Seq Scan on tenk1  (cost=0.00..470.00 rows=7000 width=244) (actual time=0.030..1.995 rows=7000.00 loops=1)
    Filter: (ten &lt; 7)
    Rows Removed by Filter: 3000
    Buffers: shared hit=345
@@ -838,7 +838,7 @@ EXPLAIN ANALYZE SELECT * FROM polygon_tbl WHERE f1 @&gt; polygon '(0.5,2.0)';
 
                                               QUERY PLAN
 -------------------------------------------------------------------&zwsp;-----------------------------------
- Seq Scan on polygon_tbl  (cost=0.00..1.09 rows=1 width=85) (actual time=0.023..0.023 rows=0 loops=1)
+ Seq Scan on polygon_tbl  (cost=0.00..1.09 rows=1 width=85) (actual time=0.023..0.023 rows=0.00 loops=1)
    Filter: (f1 @&gt; '((0.5,2))'::polygon)
    Rows Removed by Filter: 7
    Buffers: shared hit=1
@@ -858,7 +858,7 @@ EXPLAIN ANALYZE SELECT * FROM polygon_tbl WHERE f1 @&gt; polygon '(0.5,2.0)';
 
                                                         QUERY PLAN
 -------------------------------------------------------------------&zwsp;-------------------------------------------------------
- Index Scan using gpolygonind on polygon_tbl  (cost=0.13..8.15 rows=1 width=85) (actual time=0.074..0.074 rows=0 loops=1)
+ Index Scan using gpolygonind on polygon_tbl  (cost=0.13..8.15 rows=1 width=85) (actual time=0.074..0.074 rows=0.00 loops=1)
    Index Cond: (f1 @&gt; '((0.5,2))'::polygon)
    Rows Removed by Index Recheck: 1
    Buffers: shared hit=1
@@ -888,13 +888,13 @@ EXPLAIN (ANALYZE, BUFFERS OFF) SELECT * FROM tenk1 WHERE unique1 &lt; 100 AND un
 
                                                            QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------------------------------------------------------------
- Bitmap Heap Scan on tenk1  (cost=25.07..60.11 rows=10 width=244) (actual time=0.105..0.114 rows=10 loops=1)
+ Bitmap Heap Scan on tenk1  (cost=25.07..60.11 rows=10 width=244) (actual time=0.105..0.114 rows=10.00 loops=1)
    Recheck Cond: ((unique1 &lt; 100) AND (unique2 &gt; 9000))
    Heap Blocks: exact=10
-   -&gt;  BitmapAnd  (cost=25.07..25.07 rows=10 width=0) (actual time=0.100..0.101 rows=0 loops=1)
-         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0) (actual time=0.027..0.027 rows=100 loops=1)
+   -&gt;  BitmapAnd  (cost=25.07..25.07 rows=10 width=0) (actual time=0.100..0.101 rows=0.00 loops=1)
+         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0) (actual time=0.027..0.027 rows=100.00 loops=1)
                Index Cond: (unique1 &lt; 100)
-         -&gt;  Bitmap Index Scan on tenk1_unique2  (cost=0.00..19.78 rows=999 width=0) (actual time=0.070..0.070 rows=999 loops=1)
+         -&gt;  Bitmap Index Scan on tenk1_unique2  (cost=0.00..19.78 rows=999 width=0) (actual time=0.070..0.070 rows=999.00 loops=1)
                Index Cond: (unique2 &gt; 9000)
  Planning Time: 0.162 ms
  Execution Time: 0.143 ms
@@ -916,12 +916,12 @@ EXPLAIN ANALYZE UPDATE tenk1 SET hundred = hundred + 1 WHERE unique1 &lt; 100;
 
                                                            QUERY PLAN
 -------------------------------------------------------------------&zwsp;-------------------------------------------------------------
- Update on tenk1  (cost=5.06..225.23 rows=0 width=0) (actual time=1.634..1.635 rows=0 loops=1)
-   -&gt;  Bitmap Heap Scan on tenk1  (cost=5.06..225.23 rows=100 width=10) (actual time=0.065..0.141 rows=100 loops=1)
+ Update on tenk1  (cost=5.06..225.23 rows=0 width=0) (actual time=1.634..1.635 rows=0.00 loops=1)
+   -&gt;  Bitmap Heap Scan on tenk1  (cost=5.06..225.23 rows=100 width=10) (actual time=0.065..0.141 rows=100.00 loops=1)
          Recheck Cond: (unique1 &lt; 100)
          Heap Blocks: exact=90
          Buffers: shared hit=4 read=2
-         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0) (actual time=0.031..0.031 rows=100 loops=1)
+         -&gt;  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=100 width=0) (actual time=0.031..0.031 rows=100.00 loops=1)
                Index Cond: (unique1 &lt; 100)
                Buffers: shared read=2
  Planning Time: 0.151 ms
@@ -1055,9 +1055,9 @@ EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE unique1 &lt; 100 AND unique2 &gt; 9000
 
                                                           QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------------------------------------------------------
- Limit  (cost=0.29..14.33 rows=2 width=244) (actual time=0.051..0.071 rows=2 loops=1)
+ Limit  (cost=0.29..14.33 rows=2 width=244) (actual time=0.051..0.071 rows=2.00 loops=1)
    Buffers: shared hit=16
-   -&gt;  Index Scan using tenk1_unique2 on tenk1  (cost=0.29..70.50 rows=10 width=244) (actual time=0.051..0.070 rows=2 loops=1)
+   -&gt;  Index Scan using tenk1_unique2 on tenk1  (cost=0.29..70.50 rows=10 width=244) (actual time=0.051..0.070 rows=2.00 loops=1)
          Index Cond: (unique2 &gt; 9000)
          Filter: (unique1 &lt; 100)
          Rows Removed by Filter: 287
diff --git a/doc/src/sgml/planstats.sgml b/doc/src/sgml/planstats.sgml
index a909a5e313..068b804a18 100644
--- a/doc/src/sgml/planstats.sgml
+++ b/doc/src/sgml/planstats.sgml
@@ -492,7 +492,7 @@ SELECT relpages, reltuples FROM pg_class WHERE relname = 't';
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1;
                                  QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------
- Seq Scan on t  (cost=0.00..170.00 rows=100 width=8) (actual rows=100 loops=1)
+ Seq Scan on t  (cost=0.00..170.00 rows=100 width=8) (actual rows=100.00 loops=1)
    Filter: (a = 1)
    Rows Removed by Filter: 9900
 </programlisting>
@@ -509,7 +509,7 @@ EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1;
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1 AND b = 1;
                                  QUERY PLAN
 -------------------------------------------------------------------&zwsp;----------
- Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual rows=100 loops=1)
+ Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual rows=100.00 loops=1)
    Filter: ((a = 1) AND (b = 1))
    Rows Removed by Filter: 9900
 </programlisting>
@@ -533,7 +533,7 @@ ANALYZE t;
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1 AND b = 1;
                                   QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------
- Seq Scan on t  (cost=0.00..195.00 rows=100 width=8) (actual rows=100 loops=1)
+ Seq Scan on t  (cost=0.00..195.00 rows=100 width=8) (actual rows=100.00 loops=1)
    Filter: ((a = 1) AND (b = 1))
    Rows Removed by Filter: 9900
 </programlisting>
@@ -554,9 +554,9 @@ EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1 AND b = 1
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT COUNT(*) FROM t GROUP BY a;
                                        QUERY PLAN
 -------------------------------------------------------------------&zwsp;----------------------
- HashAggregate  (cost=195.00..196.00 rows=100 width=12) (actual rows=100 loops=1)
+ HashAggregate  (cost=195.00..196.00 rows=100 width=12) (actual rows=100.00 loops=1)
    Group Key: a
-   -&gt;  Seq Scan on t  (cost=0.00..145.00 rows=10000 width=4) (actual rows=10000 loops=1)
+   -&gt;  Seq Scan on t  (cost=0.00..145.00 rows=10000 width=4) (actual rows=10000.00 loops=1)
 </programlisting>
     But without multivariate statistics, the estimate for the number of
     groups in a query with two columns in <command>GROUP BY</command>, as
@@ -565,9 +565,9 @@ EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT COUNT(*) FROM t GROUP BY a;
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT COUNT(*) FROM t GROUP BY a, b;
                                        QUERY PLAN
 -------------------------------------------------------------------&zwsp;-------------------------
- HashAggregate  (cost=220.00..230.00 rows=1000 width=16) (actual rows=100 loops=1)
+ HashAggregate  (cost=220.00..230.00 rows=1000 width=16) (actual rows=100.00 loops=1)
    Group Key: a, b
-   -&gt;  Seq Scan on t  (cost=0.00..145.00 rows=10000 width=8) (actual rows=10000 loops=1)
+   -&gt;  Seq Scan on t  (cost=0.00..145.00 rows=10000 width=8) (actual rows=10000.00 loops=1)
 </programlisting>
     By redefining the statistics object to include n-distinct counts for the
     two columns, the estimate is much improved:
@@ -578,9 +578,9 @@ ANALYZE t;
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT COUNT(*) FROM t GROUP BY a, b;
                                        QUERY PLAN
 -------------------------------------------------------------------&zwsp;-------------------------
- HashAggregate  (cost=220.00..221.00 rows=100 width=16) (actual rows=100 loops=1)
+ HashAggregate  (cost=220.00..221.00 rows=100 width=16) (actual rows=100.00 loops=1)
    Group Key: a, b
-   -&gt;  Seq Scan on t  (cost=0.00..145.00 rows=10000 width=8) (actual rows=10000 loops=1)
+   -&gt;  Seq Scan on t  (cost=0.00..145.00 rows=10000 width=8) (actual rows=10000.00 loops=1)
 </programlisting>
    </para>
 
@@ -618,7 +618,7 @@ ANALYZE t;
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1 AND b = 1;
                                    QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------
- Seq Scan on t  (cost=0.00..195.00 rows=100 width=8) (actual rows=100 loops=1)
+ Seq Scan on t  (cost=0.00..195.00 rows=100 width=8) (actual rows=100.00 loops=1)
    Filter: ((a = 1) AND (b = 1))
    Rows Removed by Filter: 9900
 </programlisting>
@@ -675,7 +675,7 @@ SELECT m.* FROM pg_statistic_ext join pg_statistic_ext_data on (oid = stxoid),
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1 AND b = 10;
                                  QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------
- Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual rows=0 loops=1)
+ Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual rows=0.00 loops=1)
    Filter: ((a = 1) AND (b = 10))
    Rows Removed by Filter: 10000
 </programlisting>
@@ -688,7 +688,7 @@ EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a = 1 AND b = 1
 EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF) SELECT * FROM t WHERE a &lt;= 49 AND b &gt; 49;
                                 QUERY PLAN
 -------------------------------------------------------------------&zwsp;--------
- Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual rows=0 loops=1)
+ Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual rows=0.00 loops=1)
    Filter: ((a &lt;= 49) AND (b &gt; 49))
    Rows Removed by Filter: 10000
 </programlisting>
diff --git a/doc/src/sgml/ref/explain.sgml b/doc/src/sgml/ref/explain.sgml
index 652ece7213..7daddf03ef 100644
--- a/doc/src/sgml/ref/explain.sgml
+++ b/doc/src/sgml/ref/explain.sgml
@@ -500,11 +500,11 @@ EXPLAIN ANALYZE EXECUTE query(100, 200);
 
                                                        QUERY PLAN
 -------------------------------------------------------------------&zwsp;------------------------------------------------------
- HashAggregate  (cost=10.77..10.87 rows=10 width=12) (actual time=0.043..0.044 rows=10 loops=1)
+ HashAggregate  (cost=10.77..10.87 rows=10 width=12) (actual time=0.043..0.044 rows=10.00 loops=1)
    Group Key: foo
    Batches: 1  Memory Usage: 24kB
    Buffers: shared hit=4
-   -&gt;  Index Scan using test_pkey on test  (cost=0.29..10.27 rows=99 width=8) (actual time=0.009..0.025 rows=99 loops=1)
+   -&gt;  Index Scan using test_pkey on test  (cost=0.29..10.27 rows=99 width=8) (actual time=0.009..0.025 rows=99.00 loops=1)
          Index Cond: ((id &gt; 100) AND (id &lt; 200))
          Buffers: shared hit=4
  Planning Time: 0.244 ms
diff --git a/doc/src/sgml/rules.sgml b/doc/src/sgml/rules.sgml
index 9fdf8b1d91..1d9924a2a3 100644
--- a/doc/src/sgml/rules.sgml
+++ b/doc/src/sgml/rules.sgml
@@ -1029,8 +1029,8 @@ SELECT count(*) FROM words WHERE word = 'caterpiler';
     With <command>EXPLAIN ANALYZE</command>, we see:
 
 <programlisting>
- Aggregate  (cost=21763.99..21764.00 rows=1 width=0) (actual time=188.180..188.181 rows=1 loops=1)
-   -&gt;  Foreign Scan on words  (cost=0.00..21761.41 rows=1032 width=0) (actual time=188.177..188.177 rows=0 loops=1)
+ Aggregate  (cost=21763.99..21764.00 rows=1 width=0) (actual time=188.180..188.181 rows=1.00 loops=1)
+   -&gt;  Foreign Scan on words  (cost=0.00..21761.41 rows=1032 width=0) (actual time=188.177..188.177 rows=0.00 loops=1)
          Filter: (word = 'caterpiler'::text)
          Rows Removed by Filter: 479829
          Foreign File: /usr/share/dict/words
@@ -1042,8 +1042,8 @@ SELECT count(*) FROM words WHERE word = 'caterpiler';
     If the materialized view is used instead, the query is much faster:
 
 <programlisting>
- Aggregate  (cost=4.44..4.45 rows=1 width=0) (actual time=0.042..0.042 rows=1 loops=1)
-   -&gt;  Index Only Scan using wrd_word on wrd  (cost=0.42..4.44 rows=1 width=0) (actual time=0.039..0.039 rows=0 loops=1)
+ Aggregate  (cost=4.44..4.45 rows=1 width=0) (actual time=0.042..0.042 rows=1.00 loops=1)
+   -&gt;  Index Only Scan using wrd_word on wrd  (cost=0.42..4.44 rows=1 width=0) (actual time=0.039..0.039 rows=0.00 loops=1)
          Index Cond: (word = 'caterpiler'::text)
          Heap Fetches: 0
  Planning time: 0.164 ms
@@ -1073,11 +1073,11 @@ SELECT word FROM words ORDER BY word &lt;-&gt; 'caterpiler' LIMIT 10;
 </programlisting>
 
 <programlisting>
- Limit  (cost=11583.61..11583.64 rows=10 width=32) (actual time=1431.591..1431.594 rows=10 loops=1)
-   -&gt;  Sort  (cost=11583.61..11804.76 rows=88459 width=32) (actual time=1431.589..1431.591 rows=10 loops=1)
+ Limit  (cost=11583.61..11583.64 rows=10 width=32) (actual time=1431.591..1431.594 rows=10.00 loops=1)
+   -&gt;  Sort  (cost=11583.61..11804.76 rows=88459 width=32) (actual time=1431.589..1431.591 rows=10.00 loops=1)
          Sort Key: ((word &lt;-&gt; 'caterpiler'::text))
          Sort Method: top-N heapsort  Memory: 25kB
-         -&gt;  Foreign Scan on words  (cost=0.00..9672.05 rows=88459 width=32) (actual time=0.057..1286.455 rows=479829 loops=1)
+         -&gt;  Foreign Scan on words  (cost=0.00..9672.05 rows=88459 width=32) (actual time=0.057..1286.455 rows=479829.00 loops=1)
                Foreign File: /usr/share/dict/words
                Foreign File Size: 4953699
  Planning time: 0.128 ms
@@ -1087,8 +1087,8 @@ SELECT word FROM words ORDER BY word &lt;-&gt; 'caterpiler' LIMIT 10;
     Using the materialized view:
 
 <programlisting>
- Limit  (cost=0.29..1.06 rows=10 width=10) (actual time=187.222..188.257 rows=10 loops=1)
-   -&gt;  Index Scan using wrd_trgm on wrd  (cost=0.29..37020.87 rows=479829 width=10) (actual time=187.219..188.252 rows=10 loops=1)
+ Limit  (cost=0.29..1.06 rows=10 width=10) (actual time=187.222..188.257 rows=10.00 loops=1)
+   -&gt;  Index Scan using wrd_trgm on wrd  (cost=0.29..37020.87 rows=479829 width=10) (actual time=187.219..188.252 rows=10.00 loops=1)
          Order By: (word &lt;-&gt; 'caterpiler'::text)
  Planning time: 0.196 ms
  Execution time: 198.640 ms
-- 
2.34.1

---

From: Robert Haas <[email-masked]>
Date: Mon, 24 Feb 2025 14:16:32 -0500

> in the documentation accordingly. I attached patch with changes in

Thanks, that's very helpful. If we go forward with this, I'll commit
your patch and mine together.

---

From: James Hunter <[email-masked]>
Date: Wed, 26 Feb 2025 11:14:17 -0800

On Fri, Feb 21, 2025 at 5:09 PM Robert Haas <[email-masked]> wrote:
> can distinguish the problems they caused from preexisting ones.

I think you're right, and the solution has to be something like:
1. If it's > 24 hours until a release, then test failures on buildfarm
must be allowed.
2. Buildfarm needs to track failures at a fine enough granularity that
I can tell whether my change caused the failure.

I've worked at a few database companies -- some had workable test
frameworks, others didn't. The biggest mistake some companies made was
to collapse an entire test suite into a single, binary result: "ALL
PASSED" vs. "AT LEAST ONE FAILED." (It's fine to collapse the test
suite *before you merge your commit,* because then you are testing
*only* your commit. After you merge it, however, CI / buildfarm ends
up testing *multiple* new commits at the same time, so the tests need
to report at finer granularity.)

If we can't do CI / buildfarm at the granularity of a single commit
(and, at some point, no organization can -- the test universe becomes
too large!), then we have to design for the possibility that one or
more of multiple commits will cause a test failure, while the
remaining commits are innocent. And so the test framework has to
identify the (likely) culprit for a particular new test failure, and
then give the author *enough time* to fix that failure. And while the
author is fixing his failure, other authors need to be able to merge
their commits, etc.

(Note that actual *compilation* failures are more severe than test
failures, because if PostgreSQL can't compile, then no one can get any
work done if they rebase to TIP. But I think you are discussing tests,
not compilation.)

One company I worked for tracked test failures by individual unit
test. So, "parallel" schedule would have 220+ tests, each tracking
SUCCESS, [EXISTING] FAILURE, and NEW FAILURE. On every NEW FAILURE,
the system notified the unit test owner (this is feasible inside a
corporation, but perhaps not for an open source community) and
concurrently started running the equivalent of "git bisect" to
identify the offending commit. Once it identified (what it thought
was) the offending commit, it notified that committer as well.

> any of the 11 reviewers noticed.

This is exactly the sort of case where tracking NEW FAILURE at the
unit test level is useful. So you made a change that caused a
regression diff, and the new output is better than the old (or at
least neutral). Flag this individual unit test, send it to Robert, and
development continues.

Given enough unit tests, along with reasonable pre-commit CI testing,
the probability of two commits causing regressions / diffs in the same
unit test, over a 1-week period (let's say), is very small.

So: +1 to your proposal.

James

---

From: Robert Haas <[email-masked]>
Date: Thu, 27 Feb 2025 11:51:22 -0500

> your patch and mine together.

Since Tom doesn't seem to want to further object to trying it this
way, I've gone ahead and done this for now. Granted, it's not
altogether clear from the buildfarm that we would have ever gotten any
more failures after 44cbba9a7f51a3888d5087fc94b23614ba2b81f2, but it
seems to me that there's no way to rule out the possibility, and
low-frequency buildfarm failures that might only occur once a year are
in some ways more annoying than high-frequency ones, especially to
people who put a lot of energy into tracking down those failures. It
just seems unprincipled to me to leave things in a state where we know
that such failures are possible, and wonky to have things in a state
where whether parallel query gets used or not could make the
difference between getting a report of X rows and getting a report of
X.00 rows. If a user notices that difference in their own environment
-- regression tests aside -- they're not likely to guess what has
happened.

Of course, if this commit draws objections or runs into problems with
the buildfarm or with users, we might have to reconsider. I'm not dead
set on having it this way rather than any other. But I think it's more
principled than making 0-vs-2 digits predicated on a random event; and
it's a lot simpler than any of the other options that have been
proposed.

---

From: Ilia Evdokimov <[email-masked]>
Date: Fri, 28 Feb 2025 15:27:16 +0300

> it's a lot simpler than any of the other options that have been

Then I am moving the commitfest entry [0] to the Committed status.

[0]: https://commitfest.postgresql.org/patch/5501/

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.

---

From: Alena Rybakina <[email-masked]>
Date: Thu, 6 Mar 2025 16:17:44 +0300

Hi! I got a query plan with a strange number of rows. Could you please 
help me understand it?

create temp table ta (id int primary key, val int);
create temp table tb (id int primary key, aval int);
create temp table tc (id int primary key, aid int);
insert into ta select id, id from generate_series(1,1000) as id;
insert into tb select id, id from generate_series(500,1000) as id;
insert into tc select id, id from generate_series(400,1000) as id;

EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
  SELECT 1
    FROM ta ta1
   WHERE EXISTS (SELECT 1
                   FROM tb
                   JOIN tc
                     ON tb.id = ta1.id and
                        ta1.id < 1000
                     where exists (select 1 from ta ta2 where ta2.id = 
ta1.id));

                                       QUERY PLAN
--------------------------------------------------------------------------------------
  Seq Scan on ta ta1 (actual rows=500.00 loops=1)
    Filter: EXISTS(SubPlan 2)
    Rows Removed by Filter: 500
    SubPlan 2
      ->  Result (actual rows=0.50 loops=1000)
            One-Time Filter: ((ta1.id < 1000) AND (InitPlan 1).col1)
            InitPlan 1
              ->  Index Only Scan using ta_pkey on ta ta2 (actual 
rows=1.00 loops=999)
                    Index Cond: (id = ta1.id)
                    Heap Fetches: 999
            ->  Nested Loop (actual rows=0.50 loops=999)
                  ->  Seq Scan on tb (actual rows=0.50 loops=999)
                        Filter: (id = ta1.id)
                        Rows Removed by Filter: 375
                  ->  Seq Scan on tc (actual rows=1.00 loops=500)
(15 rows)

To be honest I can't understand why 0.50 number of rows here?

-- 
Regards,
Alena Rybakina
Postgres Professional

---

From: Matthias van de Meent <[email-masked]>
Date: Thu, 6 Mar 2025 14:30:13 +0100

> To be honest I can't understand why 0.50 number of rows here?

Because the scan matched only ~(500 rows over 999 iterations = 500/999

>                   ->  Seq Scan on tb (actual rows=0.50 loops=999)

And for this, it was 500 rows total in 1000 iterations, which also

>             One-Time Filter: ((ta1.id < 1000) AND (InitPlan 1).col1)

As of ddb17e38 (and its follow-up 95dbd827), we display fractional
rows-per-loop, with 2 digits of precision, rather than a rounded
integer. This allows a user to distinguish plan nodes with 0.49
rows/loop and 0.01 rows/loop, and that can help inform the user about
how to further optimize their usage of indexes and other optimization
paths.

Kind regards,

Matthias van de Meent
Neon (https://neon.tech)

---

From: Robert Haas <[email-masked]>
Date: Thu, 6 Mar 2025 09:13:45 -0500

> ~=) 0.50 rows for every loop, on average, for these plan nodes:

This is a good and correct explanation, but I'm VERY curious to hear
more from Alena. Like, Tom expressed the concern before we did this
that the fractional digits would confuse people, and the fact that
someone who is a regular participant on this mailing list was one of
the people confused gives credence to that concern. But I want to know
what exactly Alena found (or finds) confusing here. The Nested Loop
executes 999 times, so perhaps Alena thought that 0.50 was the TOTAL
number of rows across ALL of those executions rather than the AVERAGE
number of rows per execution? Because then 0.50 would indeed be a very
surprising result. Or maybe she just didn't realize that part of the
plan executed 999 times? Or something else?

Alena, if you're willing, please elaborate on what you think is confusing here!

---

From: Alena Rybakina <[email-masked]>
Date: Fri, 7 Mar 2025 00:18:52 +0300

> Alena, if you're willing, please elaborate on what you think is confusing here!

To be honest, I initially took it as the total number of tuples and 
couldn't figure out for myself how to interpret the result - 0 tuples or 
1 tuple in the end. Maybe it wasn't quite correct to perceive it that 
way, but Matthias's explanation helped me figure out the reason why such 
a strange result was obtained, although it's not usual to see it.

-- 
Regards,
Alena Rybakina
Postgres Professional

---

From: Robert Haas <[email-masked]>
Date: Thu, 6 Mar 2025 18:32:06 -0500

> a strange result was obtained, although it's not usual to see it.

Yeah, I thought the same back when I first started using PostgreSQL
and had to learn that it wasn't the case. I still think that's what we
*should* be displaying -- I think dividing by nloops obscures the data
users actually want. But it would admittedly be a pretty big
compatibility break at this point, and at least if we display a couple
of decimal places it's more possible to approximate the undivided
value.

---

From: Ilia Evdokimov <[email-masked]>
Date: Tue, 11 Mar 2025 12:58:06 +0300

Hi,

In the stats_ext regression test, there is a function 
check_estimated_rows that returns actual rows as an integer. Currently, 
none of the test cases produce a non-zero fractional part in actual rows.

The question is: should this function be modified to return a fractional 
number instead of an integer?

Personally, I don’t see much value in doing so, because the purpose of 
the test is to compare the estimated row count with the actual number of 
rows. It is also unlikely that there will be test cases where loops > 1, 
and the presence of a fractional part does not change the essence of the 
test.

What do you think?

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.

---

From: Robert Haas <[email-masked]>
Date: Mon, 31 Mar 2025 13:35:49 -0400

> and the presence of a fractional part does not change the essence of the

I suppose the way this is currently coded, it will have the effect of
extracting the integer part of the row estimate. That doesn't really
seem like a bad idea to me, so I'm not sure it's worth trying to
adjust anything here.

One thing that I just noticed, though, is that we added two decimal
places to the actual row count, but not the estimated row count. So
now we get stuff like this:

         ->  Seq Scan on pgbench_branches b  (cost=0.00..1.10 rows=10
width=364) (actual time=0.007..0.009 rows=10.00 loops=1)

But why isn't it just as valuable to have two decimal places for the
estimate? I theorize that the cases that are really a problem here are
those where the row count estimate is between 0 and 1 per row, and
rounding to an integer loses all precision.

---

From: Tom Lane <[email-masked]>
Date: Mon, 31 Mar 2025 13:49:55 -0400

> rounding to an integer loses all precision.

Currently, the planner rounds *all* rowcount estimates to integers
(cf. clamp_row_est()).  Maybe it'd be appropriate to rethink that,
but it's not just a matter of changing EXPLAIN's print format.

			regards, tom lane

---

From: Robert Haas <[email-masked]>
Date: Mon, 31 Mar 2025 15:09:24 -0400

> but it's not just a matter of changing EXPLAIN's print format.

Oh, right. I've never really understood why we round off to integers,
but the fact that we don't allow row counts < 1 feels like something
pretty important. My intuition is that it probably helps a lot more
than it hurts, too.

---

From: Ilia Evdokimov <[email-masked]>
Date: Mon, 31 Mar 2025 23:59:48 +0300

> pretty important. My intuition is that it probably helps a lot more

We definitely shouldn’t remove the row counts < 1 check, since there are 
many places in the planner where we divide by rows. This mechanism was 
added specifically to prevent division by zero. Also, allowing rows 
estimates below 1 can sometimes make the planner overly optimistic, 
leading it to prefer cheaper-looking plans that may not perform well in 
practice. For example, choosing a Nested Loop instead of a more 
appropriate Hash Join.

Allowing fractional rows > 1 might help improve planner accuracy in some 
cases, but this needs further study to fully understand the impact.

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.

---

From: Andrei Lepikhov <[email-masked]>
Date: Tue, 1 Apr 2025 07:20:37 +0200

> rounding to an integer loses all precision.
Issues I've ever seen were about zero rows number - we lose 
understanding of what the job the executor has done in the node - loops 
= 10000000 doesn't tell us any helpful information in that case.
Maybe in the next version, we replace the two fractional numbers rule 
with two valuable numbers. At least, it removes boring xxx.00 numbers 
from EXPLAIN.

-- 
regards, Andrei Lepikhov

---

From: Ilia Evdokimov <[email-masked]>
Date: Wed, 9 Apr 2025 22:40:42 +0300

On 31.03.2025 23:59, Ilia Evdokimov wrote:
>
> We definitely shouldn’t remove the row counts < 1 check, since there 
> are many places in the planner where we divide by rows. This mechanism 
> was added specifically to prevent division by zero. Also, allowing 
> rows estimates below 1 can sometimes make the planner overly 
> optimistic, leading it to prefer cheaper-looking plans that may not 
> perform well in practice. For example, choosing a Nested Loop instead 
> of a more appropriate Hash Join.
>
> Allowing fractional rows > 1 might help improve planner accuracy in 
> some cases, but this needs further study to fully understand the impact.
>

I've been investigating whether it's worth removing rounding in row 
estimates - and I believe it is.

[ v1-0001-Always-use-two-fractional-digits-for-estimated-rows_SRC.patch ]

Currently, we round most row estimates using rint() inside 
clamp_row_est(). However, this function is also used for rounding tuples 
and page counts. These should remain integral, but row estimates can and 
should remain fractional for better precision. To address this, I 
introduced a new function clamp_tuple_est() which retains the existing 
rounding behavior (via rint()), while clamp_tuple_est() no longer 
rounds. I use clamp_tuple_est() only for row estimates and 
clamp_tuple_est() for tuples and pages elsewhere.

After removing rounding, two small issues needed fixing. First, there 
was a check rows > 1 in the cost estimation for Nested Loop joins, which 
is no longer reliable for values like 1.3. I updated it to rows >= 2 to 
retain the original behavior. This can be refined further, but, in my 
opinion, it's a practical compromise. Second, there is still a call to 
rint() in cost of mergejoin which likely should be removed too - though 
I haven’t included that here yet.

Also, if we're no longer rounding estimates, EXPLAIN should display them 
with two decimal digits, just like it already does for actual rows.

[ v1-0002-Always-use-two-fractional-digits-for-estimated-rows_TESTS.patch ]

So, what does this change improve? Here are some of the observed plan 
improvements:

  * Previously, a Parallel Aggregate was chosen. With slightly more
    accurate estimation, the planner switches to FinalizeAggregate,
    which can be more efficient in distributed plans.
  * In certain nested joins with constant subqueries, the planner
    previously inserted an unnecessary Materialize. With improved
    estimates, it now skips that step, reducing memory usage and latency.
  * When the estimated number of iterations becomes non-integer but
    still justifies caching, the planner adds Memoize instead of
    re-running a function like generate_series(). This can speed up
    execution significantly.
  * In one case involving partitioned tables and filter conditions like
    t1.b = 0, the planner now chooses an index-based nested loop join
    instead of a hash join. This results in a more efficient plan with
    fewer memory and CPU costs.

I know this patch still needs documentation updates to describe the new 
estimation display and behaviors. But before that, I’d like to gather 
feedback: does community agree that more precise estimates and 
fractional values are better than always rounding?

If anyone would like to see the EXPLAIN ANALYZE VERBOSE output of 
changes in regression tests, I’d be happy to share them.

All feedback and suggestions welcome!

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC.
From a042ee22dbdae5049f4830116e83a3a72ea5e405 Mon Sep 17 00:00:00 2001
From: Evdokimov Ilia <[email-masked]>
Date: Wed, 9 Apr 2025 22:24:01 +0300
Subject: [PATCH v1] Always use two fractional digits for estimated rows count

---
 contrib/file_fdw/file_fdw.c                |  4 +-
 contrib/postgres_fdw/postgres_fdw.c        |  4 +-
 contrib/tsm_system_rows/tsm_system_rows.c  |  4 +-
 contrib/tsm_system_time/tsm_system_time.c  |  4 +-
 src/backend/access/table/tableam.c         |  2 +-
 src/backend/access/tablesample/bernoulli.c |  2 +-
 src/backend/access/tablesample/system.c    |  4 +-
 src/backend/commands/explain.c             |  4 +-
 src/backend/optimizer/path/costsize.c      | 53 +++++++++++++++-------
 src/backend/optimizer/util/pathnode.c      |  4 +-
 src/backend/utils/adt/selfuncs.c           | 14 +++---
 src/include/optimizer/optimizer.h          |  1 +
 12 files changed, 61 insertions(+), 39 deletions(-)

diff --git a/contrib/file_fdw/file_fdw.c b/contrib/file_fdw/file_fdw.c
index a9a5671d95a..682bb86adde 100644
--- a/contrib/file_fdw/file_fdw.c
+++ b/contrib/file_fdw/file_fdw.c
@@ -1097,7 +1097,7 @@ estimate_size(PlannerInfo *root, RelOptInfo *baserel,
 		double		density;
 
 		density = baserel->tuples / (double) baserel->pages;
-		ntuples = clamp_row_est(density * (double) pages);
+		ntuples = clamp_tuple_est(density * (double) pages);
 	}
 	else
 	{
@@ -1113,7 +1113,7 @@ estimate_size(PlannerInfo *root, RelOptInfo *baserel,
 
 		tuple_width = MAXALIGN(baserel->reltarget->width) +
 			MAXALIGN(SizeofHeapTupleHeader);
-		ntuples = clamp_row_est((double) stat_buf.st_size /
+		ntuples = clamp_tuple_est((double) stat_buf.st_size /
 								(double) tuple_width);
 	}
 	fdw_private->ntuples = ntuples;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index a7e0cc9f323..5e1c7bb32e1 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -3278,7 +3278,7 @@ estimate_path_cost_size(PlannerInfo *root,
 			 * Back into an estimate of the number of retrieved rows.  Just in
 			 * case this is nuts, clamp to at most nrows.
 			 */
-			retrieved_rows = clamp_row_est(rows / fpinfo->local_conds_sel);
+			retrieved_rows = clamp_tuple_est(rows / fpinfo->local_conds_sel);
 			retrieved_rows = Min(retrieved_rows, nrows);
 
 			/*
@@ -3453,7 +3453,7 @@ estimate_path_cost_size(PlannerInfo *root,
 			 * Back into an estimate of the number of retrieved rows.  Just in
 			 * case this is nuts, clamp to at most foreignrel->tuples.
 			 */
-			retrieved_rows = clamp_row_est(rows / fpinfo->local_conds_sel);
+			retrieved_rows = clamp_tuple_est(rows / fpinfo->local_conds_sel);
 			retrieved_rows = Min(retrieved_rows, foreignrel->tuples);
 
 			/*
diff --git a/contrib/tsm_system_rows/tsm_system_rows.c b/contrib/tsm_system_rows/tsm_system_rows.c
index f401efa2131..308bb301da1 100644
--- a/contrib/tsm_system_rows/tsm_system_rows.c
+++ b/contrib/tsm_system_rows/tsm_system_rows.c
@@ -135,7 +135,7 @@ system_rows_samplescangetsamplesize(PlannerInfo *root,
 	/* Clamp to the estimated relation size */
 	if (ntuples > baserel->tuples)
 		ntuples = (int64) baserel->tuples;
-	ntuples = clamp_row_est(ntuples);
+	ntuples = clamp_tuple_est(ntuples);
 
 	if (baserel->tuples > 0 && baserel->pages > 0)
 	{
@@ -151,7 +151,7 @@ system_rows_samplescangetsamplesize(PlannerInfo *root,
 	}
 
 	/* Clamp to sane value */
-	npages = clamp_row_est(Min((double) baserel->pages, npages));
+	npages = clamp_tuple_est(Min((double) baserel->pages, npages));
 
 	*pages = npages;
 	*tuples = ntuples;
diff --git a/contrib/tsm_system_time/tsm_system_time.c b/contrib/tsm_system_time/tsm_system_time.c
index c9c71d8c3af..b2d1d7e37d7 100644
--- a/contrib/tsm_system_time/tsm_system_time.c
+++ b/contrib/tsm_system_time/tsm_system_time.c
@@ -151,7 +151,7 @@ system_time_samplescangetsamplesize(PlannerInfo *root,
 		npages = millis;		/* even more bogus, but whatcha gonna do? */
 
 	/* Clamp to sane value */
-	npages = clamp_row_est(Min((double) baserel->pages, npages));
+	npages = clamp_tuple_est(Min((double) baserel->pages, npages));
 
 	if (baserel->tuples > 0 && baserel->pages > 0)
 	{
@@ -167,7 +167,7 @@ system_time_samplescangetsamplesize(PlannerInfo *root,
 	}
 
 	/* Clamp to the estimated relation size */
-	ntuples = clamp_row_est(Min(baserel->tuples, ntuples));
+	ntuples = clamp_tuple_est(Min(baserel->tuples, ntuples));
 
 	*pages = npages;
 	*tuples = ntuples;
diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c
index a56c5eceb14..e14a315f319 100644
--- a/src/backend/access/table/tableam.c
+++ b/src/backend/access/table/tableam.c
@@ -742,7 +742,7 @@ table_block_relation_estimate_size(Relation rel, int32 *attr_widths,
 		/* note: integer division is intentional here */
 		density = (usable_bytes_per_page * fillfactor / 100) / tuple_width;
 		/* There's at least one row on the page, even with low fillfactor. */
-		density = clamp_row_est(density);
+		density = clamp_tuple_est(density);
 	}
 	*tuples = rint(density * (double) curpages);
 
diff --git a/src/backend/access/tablesample/bernoulli.c b/src/backend/access/tablesample/bernoulli.c
index 5e1c5d2b723..d1993299158 100644
--- a/src/backend/access/tablesample/bernoulli.c
+++ b/src/backend/access/tablesample/bernoulli.c
@@ -117,7 +117,7 @@ bernoulli_samplescangetsamplesize(PlannerInfo *root,
 	/* We'll visit all pages of the baserel */
 	*pages = baserel->pages;
 
-	*tuples = clamp_row_est(baserel->tuples * samplefract);
+	*tuples = clamp_tuple_est(baserel->tuples * samplefract);
 }
 
 /*
diff --git a/src/backend/access/tablesample/system.c b/src/backend/access/tablesample/system.c
index 8db813b89fc..8944fe28799 100644
--- a/src/backend/access/tablesample/system.c
+++ b/src/backend/access/tablesample/system.c
@@ -117,10 +117,10 @@ system_samplescangetsamplesize(PlannerInfo *root,
 	}
 
 	/* We'll visit a sample of the pages ... */
-	*pages = clamp_row_est(baserel->pages * samplefract);
+	*pages = clamp_tuple_est(baserel->pages * samplefract);
 
 	/* ... and hopefully get a representative number of tuples from them */
-	*tuples = clamp_row_est(baserel->tuples * samplefract);
+	*tuples = clamp_tuple_est(baserel->tuples * samplefract);
 }
 
 /*
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index ef8aa489af8..9d9da4880a3 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -1808,7 +1808,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 	{
 		if (es->format == EXPLAIN_FORMAT_TEXT)
 		{
-			appendStringInfo(es->str, "  (cost=%.2f..%.2f rows=%.0f width=%d)",
+			appendStringInfo(es->str, "  (cost=%.2f..%.2f rows=%.2f width=%d)",
 							 plan->startup_cost, plan->total_cost,
 							 plan->plan_rows, plan->plan_width);
 		}
@@ -1819,7 +1819,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 			ExplainPropertyFloat("Total Cost", NULL, plan->total_cost,
 								 2, es);
 			ExplainPropertyFloat("Plan Rows", NULL, plan->plan_rows,
-								 0, es);
+								 2, es);
 			ExplainPropertyInteger("Plan Width", NULL, plan->plan_width,
 								   es);
 		}
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 60b0fcfb6be..27f8d70eb2c 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -205,6 +205,29 @@ static double page_size(double tuples, int width);
 static double get_parallel_divisor(Path *path);

+/*
+ * clamp_tuple_est
+ *		Force a tuple-count estimate to a sane value.
+ */
+double
+clamp_tuple_est(double ntuples)
+{
+	/*
+	 * Avoid infinite and NaN tuple estimates.  Costs derived from such values
+	 * are going to be useless.  Also force the estimate to be at least one
+	 * tuple, to make explain output look better and to avoid possible
+	 * divide-by-zero when interpolating costs.  Make it an integer, too.
+	 */
+	if (ntuples > MAXIMUM_ROWCOUNT || isnan(ntuples))
+		ntuples = MAXIMUM_ROWCOUNT;
+	else if (ntuples <= 1.0)
+		ntuples = 1.0;
+	else
+		ntuples = rint(ntuples);
+
+	return ntuples;
+}
+
 /*
  * clamp_row_est
  *		Force a row-count estimate to a sane value.
@@ -216,14 +239,12 @@ clamp_row_est(double nrows)
 	 * Avoid infinite and NaN row estimates.  Costs derived from such values
 	 * are going to be useless.  Also force the estimate to be at least one
 	 * row, to make explain output look better and to avoid possible
-	 * divide-by-zero when interpolating costs.  Make it an integer, too.
+	 * divide-by-zero when interpolating costs.
 	 */
 	if (nrows > MAXIMUM_ROWCOUNT || isnan(nrows))
 		nrows = MAXIMUM_ROWCOUNT;
 	else if (nrows <= 1.0)
 		nrows = 1.0;
-	else
-		nrows = rint(nrows);
 
 	return nrows;
 }
@@ -249,7 +270,7 @@ clamp_width_est(int64 tuple_width)
 		return (int32) MaxAllocSize;
 
 	/*
-	 * Unlike clamp_row_est, we just Assert that the value isn't negative,
+	 * Unlike clamp_tuple_est, we just Assert that the value isn't negative,
 	 * rather than masking such errors.
 	 */
 	Assert(tuple_width >= 0);
@@ -643,7 +664,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count,
 	run_cost += indexTotalCost - indexStartupCost;
 
 	/* estimate number of main-table tuples fetched */
-	tuples_fetched = clamp_row_est(indexSelectivity * baserel->tuples);
+	tuples_fetched = clamp_tuple_est(indexSelectivity * baserel->tuples);
 
 	/* fetch estimated page costs for tablespace containing table */
 	get_tablespace_page_costs(baserel->reltablespace,
@@ -901,7 +922,7 @@ extract_nonindex_conditions(List *qual_clauses, List *indexclauses)
  * computed for us by make_one_rel.
  *
  * Caller is expected to have ensured that tuples_fetched is greater than zero
- * and rounded to integer (see clamp_row_est).  The result will likewise be
+ * and rounded to integer (see clamp_tuple_est).  The result will likewise be
  * greater than zero and integral.
  */
 double
@@ -3084,7 +3105,7 @@ get_windowclause_startup_tuples(PlannerInfo *root, WindowClause *wc,
 	 * subnode.
 	 */
 
-	return clamp_row_est(return_tuples);
+	return clamp_tuple_est(return_tuples);
 }
 
 /*
@@ -3298,7 +3319,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	 */
 	startup_cost += outer_path->startup_cost + inner_path->startup_cost;
 	run_cost += outer_path->total_cost - outer_path->startup_cost;
-	if (outer_path_rows > 1)
+	if (outer_path_rows >= 2)
 		run_cost += (outer_path_rows - 1) * inner_rescan_start_cost;
 
 	inner_run_cost = inner_path->total_cost - inner_path->startup_cost;
@@ -3323,7 +3344,7 @@ initial_cost_nestloop(PlannerInfo *root, JoinCostWorkspace *workspace,
 	{
 		/* Normal case; we'll scan whole input rel for each outer row */
 		run_cost += inner_run_cost;
-		if (outer_path_rows > 1)
+		if (outer_path_rows >= 2)
 			run_cost += (outer_path_rows - 1) * inner_rescan_run_cost;
 	}
 
@@ -3656,8 +3677,8 @@ initial_cost_mergejoin(PlannerInfo *root, JoinCostWorkspace *workspace,
 	 * Convert selectivities to row counts.  We force outer_rows and
 	 * inner_rows to be at least 1, but the skip_rows estimates can be zero.
 	 */
-	outer_skip_rows = rint(outer_path_rows * outerstartsel);
-	inner_skip_rows = rint(inner_path_rows * innerstartsel);
+	outer_skip_rows = outer_path_rows * outerstartsel;
+	inner_skip_rows = inner_path_rows * innerstartsel;
 	outer_rows = clamp_row_est(outer_path_rows * outerendsel);
 	inner_rows = clamp_row_est(inner_path_rows * innerendsel);
 
@@ -4415,7 +4436,7 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path,
 	 * that way, so it will be unable to drive the batch size below hash_mem
 	 * when this is true.)
 	 */
-	if (relation_byte_size(clamp_row_est(inner_path_rows * innermcvfreq),
+	if (relation_byte_size(clamp_tuple_est(inner_path_rows * innermcvfreq),
 						   inner_path->pathtarget->width) > get_hash_memory_limit())
 		startup_cost += disable_cost;
 
@@ -4449,7 +4470,7 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path,
 		 * to clamp inner_scan_frac to at most 1.0; but since match_count is
 		 * at least 1, no such clamp is needed now.)
 		 */
-		outer_matched_rows = rint(outer_path_rows * extra->semifactors.outer_match_frac);
+		outer_matched_rows = outer_path_rows * extra->semifactors.outer_match_frac;
 		inner_scan_frac = 2.0 / (extra->semifactors.match_count + 1.0);
 
 		startup_cost += hash_qual_cost.startup;
@@ -4573,7 +4594,7 @@ cost_subplan(PlannerInfo *root, SubPlan *subplan, Plan *plan)
 		if (subplan->subLinkType == EXISTS_SUBLINK)
 		{
 			/* we only need to fetch 1 tuple; clamp to avoid zero divide */
-			sp_cost.per_tuple += plan_run_cost / clamp_row_est(plan->plan_rows);
+			sp_cost.per_tuple += plan_run_cost / clamp_tuple_est(plan->plan_rows);
 		}
 		else if (subplan->subLinkType == ALL_SUBLINK ||
 				 subplan->subLinkType == ANY_SUBLINK)
@@ -6517,7 +6538,7 @@ compute_bitmap_pages(PlannerInfo *root, RelOptInfo *baserel,
 	/*
 	 * Estimate number of main-table pages fetched.
 	 */
-	tuples_fetched = clamp_row_est(indexSelectivity * baserel->tuples);
+	tuples_fetched = clamp_tuple_est(indexSelectivity * baserel->tuples);
 
 	T = (baserel->pages > 1) ? (double) baserel->pages : 1.0;
 
@@ -6583,7 +6604,7 @@ compute_bitmap_pages(PlannerInfo *root, RelOptInfo *baserel,
 		 */
 		if (lossy_pages > 0)
 			tuples_fetched =
-				clamp_row_est(indexSelectivity *
+				clamp_tuple_est(indexSelectivity *
 							  (exact_pages / heap_pages) * baserel->tuples +
 							  (lossy_pages / heap_pages) * baserel->tuples);
 	}
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 93e73cb44db..0b9ec6b26fc 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -1687,7 +1687,7 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath,
 	pathnode->param_exprs = param_exprs;
 	pathnode->singlerow = singlerow;
 	pathnode->binary_mode = binary_mode;
-	pathnode->calls = clamp_row_est(calls);
+	pathnode->calls = clamp_tuple_est(calls);
 
 	/*
 	 * For now we set est_entries to 0.  cost_memoize_rescan() does all the
@@ -4049,7 +4049,7 @@ adjust_limit_rows_costs(double *rows,	/* in/out parameter */
 		if (offset_est > 0)
 			offset_rows = (double) offset_est;
 		else
-			offset_rows = clamp_row_est(input_rows * 0.10);
+			offset_rows = clamp_tuple_est(input_rows * 0.10);
 		if (offset_rows > *rows)
 			offset_rows = *rows;
 		if (input_rows > 0)
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index 588d991fa57..0c077c09175 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -2183,7 +2183,7 @@ estimate_array_length(PlannerInfo *root, Node *arrayexpr)
 								 ATTSTATSSLOT_NUMBERS))
 			{
 				if (sslot.nnumbers > 0)
-					nelem = clamp_row_est(sslot.numbers[sslot.nnumbers - 1]);
+					nelem = clamp_tuple_est(sslot.numbers[sslot.nnumbers - 1]);
 				free_attstatsslot(&sslot);
 			}
 		}
@@ -3462,7 +3462,7 @@ estimate_num_groups(PlannerInfo *root, List *groupExprs, double input_rows,
 	 * estimate is usually already at least 1, but clamp it just in case it
 	 * isn't.
 	 */
-	input_rows = clamp_row_est(input_rows);
+	input_rows = clamp_tuple_est(input_rows);
 
 	/*
 	 * If no grouping columns, there's exactly one group.  (This can't happen
@@ -3755,7 +3755,7 @@ estimate_num_groups(PlannerInfo *root, List *groupExprs, double input_rows,
 					(1 - pow((rel->tuples - rel->rows) / rel->tuples,
 							 rel->tuples / reldistinct));
 			}
-			reldistinct = clamp_row_est(reldistinct);
+			reldistinct = clamp_tuple_est(reldistinct);
 
 			/*
 			 * Update estimate of total distinct groups.
@@ -4071,7 +4071,7 @@ estimate_hash_bucket_stats(PlannerInfo *root, Node *hashkey, double nbuckets,
 	if (vardata.rel && vardata.rel->tuples > 0)
 	{
 		ndistinct *= vardata.rel->rows / vardata.rel->tuples;
-		ndistinct = clamp_row_est(ndistinct);
+		ndistinct = clamp_tuple_est(ndistinct);
 	}
 
 	/*
@@ -6168,7 +6168,7 @@ get_variable_numdistinct(VariableStatData *vardata, bool *isdefault)
 	 * If we had an absolute estimate, use that.
 	 */
 	if (stadistinct > 0.0)
-		return clamp_row_est(stadistinct);
+		return clamp_tuple_est(stadistinct);
 
 	/*
 	 * Otherwise we need to get the relation size; punt if not available.
@@ -6189,7 +6189,7 @@ get_variable_numdistinct(VariableStatData *vardata, bool *isdefault)
 	 * If we had a relative estimate, use that.
 	 */
 	if (stadistinct < 0.0)
-		return clamp_row_est(-stadistinct * ntuples);
+		return clamp_tuple_est(-stadistinct * ntuples);
 
 	/*
 	 * With no data, estimate ndistinct = ntuples if the table is small, else
@@ -6197,7 +6197,7 @@ get_variable_numdistinct(VariableStatData *vardata, bool *isdefault)
 	 * that the behavior isn't discontinuous.
 	 */
 	if (ntuples < DEFAULT_NUM_DISTINCT)
-		return clamp_row_est(ntuples);
+		return clamp_tuple_est(ntuples);
 
 	*isdefault = true;
 	return DEFAULT_NUM_DISTINCT;
diff --git a/src/include/optimizer/optimizer.h b/src/include/optimizer/optimizer.h
index 546828b54bd..e1be9dc6175 100644
--- a/src/include/optimizer/optimizer.h
+++ b/src/include/optimizer/optimizer.h
@@ -90,6 +90,7 @@ extern PGDLLIMPORT double recursive_worktable_factor;
 extern PGDLLIMPORT int effective_cache_size;
 
 extern double clamp_row_est(double nrows);
+extern double clamp_tuple_est(double ntuples);
 extern int32 clamp_width_est(int64 tuple_width);
 extern long clamp_cardinality_to_long(Cardinality x);
 
-- 
2.34.1

From a34fd63c47a87e79d6b0fe0a705c527a5b2b22bc Mon Sep 17 00:00:00 2001
From: Evdokimov Ilia <[email-masked]>
Date: Wed, 9 Apr 2025 22:26:16 +0300
Subject: [PATCH v1] Always use two fractional digits for estimated rows count

---
 .../expected/pg_overexplain.out               |  12 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  15 +-
 src/test/regress/expected/explain.out         | 146 +++++++++---------
 src/test/regress/expected/inherit.out         |   8 +-
 src/test/regress/expected/join.out            | 134 ++++++++--------
 src/test/regress/expected/join_hash.out       |  19 +--
 src/test/regress/expected/misc_functions.out  |  84 +++++-----
 src/test/regress/expected/partition_join.out  | 112 +++++++-------
 src/test/regress/expected/select_views.out    |  19 ++-
 src/test/regress/expected/stats_ext.out       |  12 +-
 src/test/regress/sql/stats_ext.sql            |   4 +-
 11 files changed, 277 insertions(+), 288 deletions(-)

diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 28252dbff6c..731a0bf79a2 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -25,9 +25,9 @@ ERROR:  unrecognized EXPLAIN option "debuff"
 LINE 1: EXPLAIN (DEBUFF) SELECT 1;
                  ^
 EXPLAIN (DEBUG) SELECT 1;
-                QUERY PLAN                
-------------------------------------------
- Result  (cost=0.00..0.01 rows=1 width=4)
+                 QUERY PLAN                  
+---------------------------------------------
+ Result  (cost=0.00..0.01 rows=1.00 width=4)
    Disabled Nodes: 0
    Parallel Safe: false
    Plan Node ID: 0
@@ -41,9 +41,9 @@ EXPLAIN (DEBUG) SELECT 1;
 (11 rows)
 
 EXPLAIN (RANGE_TABLE) SELECT 1;
-                QUERY PLAN                
-------------------------------------------
- Result  (cost=0.00..0.01 rows=1 width=4)
+                 QUERY PLAN                  
+---------------------------------------------
+ Result  (cost=0.00..0.01 rows=1.00 width=4)
  RTI 1 (result):
    Eref: "*RESULT*" ()
 (3 rows)
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index d1acee5a5fa..e5b92d2017b 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -10151,13 +10151,16 @@ SELECT t1.a,t2.b,t3.c FROM fprt1 t1 INNER JOIN fprt2 t2 ON (t1.a = t2.b) INNER J
 -- left outer join + nullable clause
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT t1.a,t2.b,t2.c FROM fprt1 t1 LEFT JOIN (SELECT * FROM fprt2 WHERE a < 10) t2 ON (t1.a = t2.b and t1.b = t2.a) WHERE t1.a < 10 ORDER BY 1,2,3;
-                                                                                                                     QUERY PLAN                                                                                                                     
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
- Foreign Scan
+                                                                                    QUERY PLAN                                                                                     
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Sort
    Output: t1.a, fprt2.b, fprt2.c
-   Relations: (public.ftprt1_p1 t1) LEFT JOIN (public.ftprt2_p1 fprt2)
-   Remote SQL: SELECT r5.a, r6.b, r6.c FROM (public.fprt1_p1 r5 LEFT JOIN public.fprt2_p1 r6 ON (((r5.a = r6.b)) AND ((r5.b = r6.a)) AND ((r6.a < 10)))) WHERE ((r5.a < 10)) ORDER BY r5.a ASC NULLS LAST, r6.b ASC NULLS LAST, r6.c ASC NULLS LAST
-(4 rows)
+   Sort Key: t1.a, fprt2.b, fprt2.c
+   ->  Foreign Scan
+         Output: t1.a, fprt2.b, fprt2.c
+         Relations: (public.ftprt1_p1 t1) LEFT JOIN (public.ftprt2_p1 fprt2)
+         Remote SQL: SELECT r5.a, r6.b, r6.c FROM (public.fprt1_p1 r5 LEFT JOIN public.fprt2_p1 r6 ON (((r5.a = r6.b)) AND ((r5.b = r6.a)) AND ((r6.a < 10)))) WHERE ((r5.a < 10))
+(7 rows)
 
 SELECT t1.a,t2.b,t2.c FROM fprt1 t1 LEFT JOIN (SELECT * FROM fprt2 WHERE a < 10) t2 ON (t1.a = t2.b and t1.b = t2.a) WHERE t1.a < 10 ORDER BY 1,2,3;
  a | b |  c   
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index c53bf9c8aa3..28991ccb665 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -55,32 +55,32 @@ set jit = off;
 set track_io_timing = off;
 -- Simple cases
 select explain_filter('explain select * from int8_tbl i8');
-                     explain_filter                      
----------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N)
+                      explain_filter                       
+-----------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N)
 (1 row)
 
 select explain_filter('explain (analyze, buffers off) select * from int8_tbl i8');
-                                         explain_filter                                          
--------------------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                          explain_filter                                           
+---------------------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
  Planning Time: N.N ms
  Execution Time: N.N ms
 (3 rows)
 
 select explain_filter('explain (analyze, buffers off, verbose) select * from int8_tbl i8');
-                                             explain_filter                                             
---------------------------------------------------------------------------------------------------------
- Seq Scan on public.int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                              explain_filter                                              
+----------------------------------------------------------------------------------------------------------
+ Seq Scan on public.int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
    Output: q1, q2
  Planning Time: N.N ms
  Execution Time: N.N ms
 (4 rows)
 
 select explain_filter('explain (analyze, buffers, format text) select * from int8_tbl i8');
-                                         explain_filter                                          
--------------------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                          explain_filter                                           
+---------------------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
  Planning Time: N.N ms
  Execution Time: N.N ms
 (3 rows)
@@ -98,7 +98,7 @@ select explain_filter('explain (analyze, buffers, format xml) select * from int8
        <Alias>i8</Alias>                               +
        <Startup-Cost>N.N</Startup-Cost>                +
        <Total-Cost>N.N</Total-Cost>                    +
-       <Plan-Rows>N</Plan-Rows>                        +
+       <Plan-Rows>N.N</Plan-Rows>                      +
        <Plan-Width>N</Plan-Width>                      +
        <Actual-Startup-Time>N.N</Actual-Startup-Time>  +
        <Actual-Total-Time>N.N</Actual-Total-Time>      +
@@ -147,7 +147,7 @@ select explain_filter('explain (analyze, serialize, buffers, format yaml) select
      Alias: "i8"              +
      Startup Cost: N.N        +
      Total Cost: N.N          +
-     Plan Rows: N             +
+     Plan Rows: N.N           +
      Plan Width: N            +
      Actual Startup Time: N.N +
      Actual Total Time: N.N   +
@@ -195,9 +195,9 @@ select explain_filter('explain (analyze, serialize, buffers, format yaml) select
 (1 row)
 
 select explain_filter('explain (buffers, format text) select * from int8_tbl i8');
-                     explain_filter                      
----------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N)
+                      explain_filter                       
+-----------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N)
 (1 row)
 
 select explain_filter('explain (buffers, format json) select * from int8_tbl i8');
@@ -213,7 +213,7 @@ select explain_filter('explain (buffers, format json) select * from int8_tbl i8'
        "Alias": "i8",              +
        "Startup Cost": N.N,        +
        "Total Cost": N.N,          +
-       "Plan Rows": N,             +
+       "Plan Rows": N.N,           +
        "Plan Width": N,            +
        "Disabled": false,          +
        "Shared Hit Blocks": N,     +
@@ -247,35 +247,35 @@ select explain_filter('explain (buffers, format json) select * from int8_tbl i8'
 select explain_filter('explain verbose select sum(unique1) over w, sum(unique2) over (w order by hundred), sum(tenthous) over (w order by hundred) from tenk1 window w as (partition by ten)');
                                             explain_filter                                             
 -------------------------------------------------------------------------------------------------------
- WindowAgg  (cost=N.N..N.N rows=N width=N)
+ WindowAgg  (cost=N.N..N.N rows=N.N width=N)
    Output: sum(unique1) OVER w, (sum(unique2) OVER w1), (sum(tenthous) OVER w1), ten, hundred
    Window: w AS (PARTITION BY tenk1.ten)
-   ->  WindowAgg  (cost=N.N..N.N rows=N width=N)
+   ->  WindowAgg  (cost=N.N..N.N rows=N.N width=N)
          Output: ten, hundred, unique1, unique2, tenthous, sum(unique2) OVER w1, sum(tenthous) OVER w1
          Window: w1 AS (PARTITION BY tenk1.ten ORDER BY tenk1.hundred)
-         ->  Sort  (cost=N.N..N.N rows=N width=N)
+         ->  Sort  (cost=N.N..N.N rows=N.N width=N)
                Output: ten, hundred, unique1, unique2, tenthous
                Sort Key: tenk1.ten, tenk1.hundred
-               ->  Seq Scan on public.tenk1  (cost=N.N..N.N rows=N width=N)
+               ->  Seq Scan on public.tenk1  (cost=N.N..N.N rows=N.N width=N)
                      Output: ten, hundred, unique1, unique2, tenthous
 (11 rows)
 
 select explain_filter('explain verbose select sum(unique1) over w1, sum(unique2) over (w1 order by hundred), sum(tenthous) over (w1 order by hundred rows 10 preceding) from tenk1 window w1 as (partition by ten)');
                                              explain_filter                                              
 ---------------------------------------------------------------------------------------------------------
- WindowAgg  (cost=N.N..N.N rows=N width=N)
+ WindowAgg  (cost=N.N..N.N rows=N.N width=N)
    Output: sum(unique1) OVER w1, (sum(unique2) OVER w2), (sum(tenthous) OVER w3), ten, hundred
    Window: w1 AS (PARTITION BY tenk1.ten)
-   ->  WindowAgg  (cost=N.N..N.N rows=N width=N)
+   ->  WindowAgg  (cost=N.N..N.N rows=N.N width=N)
          Output: ten, hundred, unique1, unique2, tenthous, (sum(unique2) OVER w2), sum(tenthous) OVER w3
          Window: w3 AS (PARTITION BY tenk1.ten ORDER BY tenk1.hundred ROWS 'N'::bigint PRECEDING)
-         ->  WindowAgg  (cost=N.N..N.N rows=N width=N)
+         ->  WindowAgg  (cost=N.N..N.N rows=N.N width=N)
                Output: ten, hundred, unique1, unique2, tenthous, sum(unique2) OVER w2
                Window: w2 AS (PARTITION BY tenk1.ten ORDER BY tenk1.hundred)
-               ->  Sort  (cost=N.N..N.N rows=N width=N)
+               ->  Sort  (cost=N.N..N.N rows=N.N width=N)
                      Output: ten, hundred, unique1, unique2, tenthous
                      Sort Key: tenk1.ten, tenk1.hundred
-                     ->  Seq Scan on public.tenk1  (cost=N.N..N.N rows=N width=N)
+                     ->  Seq Scan on public.tenk1  (cost=N.N..N.N rows=N.N width=N)
                            Output: ten, hundred, unique1, unique2, tenthous
 (14 rows)
 
@@ -295,7 +295,7 @@ select explain_filter('explain (analyze, buffers, format json) select * from int
        "Alias": "i8",               +
        "Startup Cost": N.N,         +
        "Total Cost": N.N,           +
-       "Plan Rows": N,              +
+       "Plan Rows": N.N,            +
        "Plan Width": N,             +
        "Actual Startup Time": N.N,  +
        "Actual Total Time": N.N,    +
@@ -368,11 +368,11 @@ select explain_filter_to_json('explain (settings, format json) select * from int
 rollback;
 -- GENERIC_PLAN option
 select explain_filter('explain (generic_plan) select unique1 from tenk1 where thousand = $1');
-                                 explain_filter                                  
----------------------------------------------------------------------------------
- Bitmap Heap Scan on tenk1  (cost=N.N..N.N rows=N width=N)
+                                  explain_filter                                   
+-----------------------------------------------------------------------------------
+ Bitmap Heap Scan on tenk1  (cost=N.N..N.N rows=N.N width=N)
    Recheck Cond: (thousand = $N)
-   ->  Bitmap Index Scan on tenk1_thous_tenthous  (cost=N.N..N.N rows=N width=N)
+   ->  Bitmap Index Scan on tenk1_thous_tenthous  (cost=N.N..N.N rows=N.N width=N)
          Index Cond: (thousand = $N)
 (4 rows)
 
@@ -382,16 +382,16 @@ ERROR:  EXPLAIN options ANALYZE and GENERIC_PLAN cannot be used together
 CONTEXT:  PL/pgSQL function explain_filter(text) line 5 at FOR over EXECUTE statement
 -- MEMORY option
 select explain_filter('explain (memory) select * from int8_tbl i8');
-                     explain_filter                      
----------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N)
+                      explain_filter                       
+-----------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N)
    Memory: used=NkB  allocated=NkB
 (2 rows)
 
 select explain_filter('explain (memory, analyze, buffers off) select * from int8_tbl i8');
-                                         explain_filter                                          
--------------------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                          explain_filter                                           
+---------------------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
    Memory: used=NkB  allocated=NkB
  Planning Time: N.N ms
  Execution Time: N.N ms
@@ -408,7 +408,7 @@ select explain_filter('explain (memory, summary, format yaml) select * from int8
      Alias: "i8"              +
      Startup Cost: N.N        +
      Total Cost: N.N          +
-     Plan Rows: N             +
+     Plan Rows: N.N           +
      Plan Width: N            +
      Disabled: false          +
    Planning:                  +
@@ -430,7 +430,7 @@ select explain_filter('explain (memory, analyze, format json) select * from int8
        "Alias": "i8",              +
        "Startup Cost": N.N,        +
        "Total Cost": N.N,          +
-       "Plan Rows": N,             +
+       "Plan Rows": N.N,           +
        "Plan Width": N,            +
        "Actual Startup Time": N.N, +
        "Actual Total Time": N.N,   +
@@ -472,9 +472,9 @@ select explain_filter('explain (memory, analyze, format json) select * from int8
 
 prepare int8_query as select * from int8_tbl i8;
 select explain_filter('explain (memory) execute int8_query');
-                     explain_filter                      
----------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N)
+                      explain_filter                       
+-----------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N)
    Memory: used=NkB  allocated=NkB
 (2 rows)
 
@@ -496,12 +496,12 @@ create table gen_part_2
   partition of gen_part for values in (2);
 -- should scan gen_part_1_1 and gen_part_1_2, but not gen_part_2
 select explain_filter('explain (generic_plan) select key1, key2 from gen_part where key1 = 1 and key2 = $1');
-                              explain_filter                               
----------------------------------------------------------------------------
- Append  (cost=N.N..N.N rows=N width=N)
-   ->  Seq Scan on gen_part_1_1 gen_part_1  (cost=N.N..N.N rows=N width=N)
+                               explain_filter                                
+-----------------------------------------------------------------------------
+ Append  (cost=N.N..N.N rows=N.N width=N)
+   ->  Seq Scan on gen_part_1_1 gen_part_1  (cost=N.N..N.N rows=N.N width=N)
          Filter: ((key1 = N) AND (key2 = $N))
-   ->  Seq Scan on gen_part_1_2 gen_part_2  (cost=N.N..N.N rows=N width=N)
+   ->  Seq Scan on gen_part_1_2 gen_part_2  (cost=N.N..N.N rows=N.N width=N)
          Filter: ((key1 = N) AND (key2 = $N))
 (5 rows)
 
@@ -561,7 +561,7 @@ select jsonb_pretty(
                              "Schema": "public",            +
                              "Disabled": false,             +
                              "Node Type": "Seq Scan",       +
-                             "Plan Rows": 0,                +
+                             "Plan Rows": 0.0,              +
                              "Plan Width": 0,               +
                              "Total Cost": 0.0,             +
                              "Actual Rows": 0.0,            +
@@ -608,7 +608,7 @@ select jsonb_pretty(
                          "tenk1.tenthous"                   +
                      ],                                     +
                      "Node Type": "Sort",                   +
-                     "Plan Rows": 0,                        +
+                     "Plan Rows": 0.0,                      +
                      "Plan Width": 0,                       +
                      "Total Cost": 0.0,                     +
                      "Actual Rows": 0.0,                    +
@@ -652,7 +652,7 @@ select jsonb_pretty(
              ],                                             +
              "Disabled": false,                             +
              "Node Type": "Gather Merge",                   +
-             "Plan Rows": 0,                                +
+             "Plan Rows": 0.0,                              +
              "Plan Width": 0,                               +
              "Total Cost": 0.0,                             +
              "Actual Rows": 0.0,                            +
@@ -703,7 +703,7 @@ as 'begin return sin($1); end';
 select explain_filter('explain (verbose) select * from t1 where pg_temp.mysin(f1) < 0.5');
                        explain_filter                       
 ------------------------------------------------------------
- Seq Scan on pg_temp.t1  (cost=N.N..N.N rows=N width=N)
+ Seq Scan on pg_temp.t1  (cost=N.N..N.N rows=N.N width=N)
    Output: f1
    Filter: (pg_temp.mysin(t1.f1) < 'N.N'::double precision)
 (3 rows)
@@ -711,53 +711,53 @@ select explain_filter('explain (verbose) select * from t1 where pg_temp.mysin(f1
 -- Test compute_query_id
 set compute_query_id = on;
 select explain_filter('explain (verbose) select * from int8_tbl i8');
-                         explain_filter                         
-----------------------------------------------------------------
- Seq Scan on public.int8_tbl i8  (cost=N.N..N.N rows=N width=N)
+                          explain_filter                          
+------------------------------------------------------------------
+ Seq Scan on public.int8_tbl i8  (cost=N.N..N.N rows=N.N width=N)
    Output: q1, q2
  Query Identifier: N
 (3 rows)
 
 -- Test compute_query_id with utility statements containing plannable query
 select explain_filter('explain (verbose) declare test_cur cursor for select * from int8_tbl');
-                       explain_filter                        
--------------------------------------------------------------
- Seq Scan on public.int8_tbl  (cost=N.N..N.N rows=N width=N)
+                        explain_filter                         
+---------------------------------------------------------------
+ Seq Scan on public.int8_tbl  (cost=N.N..N.N rows=N.N width=N)
    Output: q1, q2
  Query Identifier: N
 (3 rows)
 
 select explain_filter('explain (verbose) create table test_ctas as select 1');
-             explain_filter             
-----------------------------------------
- Result  (cost=N.N..N.N rows=N width=N)
+              explain_filter              
+------------------------------------------
+ Result  (cost=N.N..N.N rows=N.N width=N)
    Output: N
  Query Identifier: N
 (3 rows)
 
 -- Test SERIALIZE option
 select explain_filter('explain (analyze,buffers off,serialize) select * from int8_tbl i8');
-                                         explain_filter                                          
--------------------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                          explain_filter                                           
+---------------------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
  Planning Time: N.N ms
  Serialization: time=N.N ms  output=NkB  format=text
  Execution Time: N.N ms
 (4 rows)
 
 select explain_filter('explain (analyze,serialize text,buffers,timing off) select * from int8_tbl i8');
-                                  explain_filter                                   
------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual rows=N.N loops=N)
+                                   explain_filter                                    
+-------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual rows=N.N loops=N)
  Planning Time: N.N ms
  Serialization: output=NkB  format=text
  Execution Time: N.N ms
 (4 rows)
 
 select explain_filter('explain (analyze,serialize binary,buffers,timing) select * from int8_tbl i8');
-                                         explain_filter                                          
--------------------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                          explain_filter                                           
+---------------------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
  Planning Time: N.N ms
  Serialization: time=N.N ms  output=NkB  format=binary
  Execution Time: N.N ms
@@ -765,9 +765,9 @@ select explain_filter('explain (analyze,serialize binary,buffers,timing) select
 
 -- this tests an edge case where we have no data to return
 select explain_filter('explain (analyze,buffers off,serialize) create temp table explain_temp as select * from int8_tbl i8');
-                                         explain_filter                                          
--------------------------------------------------------------------------------------------------
- Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
+                                          explain_filter                                           
+---------------------------------------------------------------------------------------------------
+ Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N.N width=N) (actual time=N.N..N.N rows=N.N loops=N)
  Planning Time: N.N ms
  Serialization: time=N.N ms  output=NkB  format=text
  Execution Time: N.N ms
diff --git a/src/test/regress/expected/inherit.out b/src/test/regress/expected/inherit.out
index 2a8bfba768e..a6657496d58 100644
--- a/src/test/regress/expected/inherit.out
+++ b/src/test/regress/expected/inherit.out
@@ -695,10 +695,10 @@ select tableoid::regclass::text as relname, parted_tab.* from parted_tab order b
 
 -- modifies partition key, but no rows will actually be updated
 explain update parted_tab set a = 2 where false;
-                       QUERY PLAN                       
---------------------------------------------------------
- Update on parted_tab  (cost=0.00..0.00 rows=0 width=0)
-   ->  Result  (cost=0.00..0.00 rows=0 width=10)
+                        QUERY PLAN                         
+-----------------------------------------------------------
+ Update on parted_tab  (cost=0.00..0.00 rows=0.00 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0.00 width=10)
          One-Time Filter: false
 (3 rows)
 
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 14da5708451..25ae9f4e714 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -2422,8 +2422,8 @@ from int4_tbl t1, int4_tbl t2
   left join int4_tbl t3 on t3.f1 > 0
   left join int4_tbl t4 on t3.f1 > 1
 where t4.f1 is null;
-                      QUERY PLAN                       
--------------------------------------------------------
+                   QUERY PLAN                    
+-------------------------------------------------
  Nested Loop
    ->  Nested Loop Left Join
          Filter: (t4.f1 IS NULL)
@@ -2433,10 +2433,9 @@ where t4.f1 is null;
                      Join Filter: (t3.f1 > 1)
                      ->  Seq Scan on int4_tbl t3
                            Filter: (f1 > 0)
-                     ->  Materialize
-                           ->  Seq Scan on int4_tbl t4
+                     ->  Seq Scan on int4_tbl t4
    ->  Seq Scan on int4_tbl t1
-(12 rows)
+(11 rows)
 
 select t1.f1
 from int4_tbl t1, int4_tbl t2
@@ -2573,11 +2572,10 @@ where t1.f1 = coalesce(t2.f1, 1);
                ->  Materialize
                      ->  Seq Scan on int4_tbl t2
                            Filter: (f1 > 1)
-         ->  Materialize
-               ->  Seq Scan on int4_tbl t3
+         ->  Seq Scan on int4_tbl t3
    ->  Materialize
          ->  Seq Scan on int4_tbl t4
-(14 rows)
+(13 rows)
 
 explain (costs off)
 select * from int4_tbl t1
@@ -3740,10 +3738,9 @@ where i41.f1 > 0;
                ->  Seq Scan on int8_tbl i81
                ->  Materialize
                      ->  Seq Scan on int4_tbl i42
-   ->  Materialize
-         ->  Seq Scan on int4_tbl i43
-               Filter: (f1 > 1)
-(12 rows)
+   ->  Seq Scan on int4_tbl i43
+         Filter: (f1 > 1)
+(11 rows)
 
 select * from
   int4_tbl as i41,
@@ -4332,6 +4329,13 @@ select * from tenk1 a join tenk1 b on
 -------------------------------------------------------------------------------------------------
  Nested Loop
    Join Filter: (((a.unique1 = 1) AND (b.unique1 = 2)) OR ((a.unique2 = 3) AND (b.hundred = 4)))
+   ->  Bitmap Heap Scan on tenk1 a
+         Recheck Cond: ((unique1 = 1) OR (unique2 = 3))
+         ->  BitmapOr
+               ->  Bitmap Index Scan on tenk1_unique1
+                     Index Cond: (unique1 = 1)
+               ->  Bitmap Index Scan on tenk1_unique2
+                     Index Cond: (unique2 = 3)
    ->  Bitmap Heap Scan on tenk1 b
          Recheck Cond: ((unique1 = 2) OR (hundred = 4))
          ->  BitmapOr
@@ -4339,15 +4343,7 @@ select * from tenk1 a join tenk1 b on
                      Index Cond: (unique1 = 2)
                ->  Bitmap Index Scan on tenk1_hundred
                      Index Cond: (hundred = 4)
-   ->  Materialize
-         ->  Bitmap Heap Scan on tenk1 a
-               Recheck Cond: ((unique1 = 1) OR (unique2 = 3))
-               ->  BitmapOr
-                     ->  Bitmap Index Scan on tenk1_unique1
-                           Index Cond: (unique1 = 1)
-                     ->  Bitmap Index Scan on tenk1_unique2
-                           Index Cond: (unique2 = 3)
-(17 rows)
+(16 rows)
 
 explain (costs off)
 select * from tenk1 a join tenk1 b on
@@ -4356,17 +4352,16 @@ select * from tenk1 a join tenk1 b on
 ---------------------------------------------------------------------------------------------
  Nested Loop
    Join Filter: (((a.unique1 = 1) AND (b.unique1 = 2)) OR ((a.unique2 = 3) AND (b.ten = 4)))
+   ->  Bitmap Heap Scan on tenk1 a
+         Recheck Cond: ((unique1 = 1) OR (unique2 = 3))
+         ->  BitmapOr
+               ->  Bitmap Index Scan on tenk1_unique1
+                     Index Cond: (unique1 = 1)
+               ->  Bitmap Index Scan on tenk1_unique2
+                     Index Cond: (unique2 = 3)
    ->  Seq Scan on tenk1 b
          Filter: ((unique1 = 2) OR (ten = 4))
-   ->  Materialize
-         ->  Bitmap Heap Scan on tenk1 a
-               Recheck Cond: ((unique1 = 1) OR (unique2 = 3))
-               ->  BitmapOr
-                     ->  Bitmap Index Scan on tenk1_unique1
-                           Index Cond: (unique1 = 1)
-                     ->  Bitmap Index Scan on tenk1_unique2
-                           Index Cond: (unique2 = 3)
-(12 rows)
+(11 rows)
 
 explain (costs off)
 select * from tenk1 a join tenk1 b on
@@ -5110,13 +5105,12 @@ select 1 from
                      Join Filter: (i4.f1 IS NOT NULL)
                      ->  Seq Scan on int4_tbl i4
                            Filter: (2 < f1)
-                     ->  Materialize
-                           ->  Seq Scan on int8_tbl i8
+                     ->  Seq Scan on int8_tbl i8
                ->  Result
                      One-Time Filter: false
          ->  Materialize
                ->  Seq Scan on int4_tbl i42
-(16 rows)
+(15 rows)
 
 --
 -- test for appropriate join order in the presence of lateral references
@@ -6157,18 +6151,17 @@ FROM int4_tbl
      JOIN ((SELECT 42 AS x FROM int8_tbl LEFT JOIN innertab ON q1 = id) AS ss1
            RIGHT JOIN tenk1 ON NULL)
         ON tenk1.unique1 = ss1.x OR tenk1.unique2 = ss1.x;
-                                QUERY PLAN                                
---------------------------------------------------------------------------
+                             QUERY PLAN                             
+--------------------------------------------------------------------
  Nested Loop
+   ->  Nested Loop Left Join
+         Join Filter: NULL::boolean
+         Filter: ((tenk1.unique1 = (42)) OR (tenk1.unique2 = (42)))
+         ->  Seq Scan on tenk1
+         ->  Result
+               One-Time Filter: false
    ->  Seq Scan on int4_tbl
-   ->  Materialize
-         ->  Nested Loop Left Join
-               Join Filter: NULL::boolean
-               Filter: ((tenk1.unique1 = (42)) OR (tenk1.unique2 = (42)))
-               ->  Seq Scan on tenk1
-               ->  Result
-                     One-Time Filter: false
-(9 rows)
+(8 rows)
 
 rollback;
 -- another join removal bug: we must clean up correctly when removing a PHV
@@ -6566,28 +6559,26 @@ where t1.a = t2.a;
 explain (costs off)
 select * from sj x join sj y on x.a = y.a
 left join int8_tbl z on x.a = z.q1;
-             QUERY PLAN             
-------------------------------------
+           QUERY PLAN            
+---------------------------------
  Nested Loop Left Join
    Join Filter: (y.a = z.q1)
    ->  Seq Scan on sj y
          Filter: (a IS NOT NULL)
-   ->  Materialize
-         ->  Seq Scan on int8_tbl z
-(6 rows)
+   ->  Seq Scan on int8_tbl z
+(5 rows)
 
 explain (costs off)
 select * from sj x join sj y on x.a = y.a
 left join int8_tbl z on y.a = z.q1;
-             QUERY PLAN             
-------------------------------------
+           QUERY PLAN            
+---------------------------------
  Nested Loop Left Join
    Join Filter: (y.a = z.q1)
    ->  Seq Scan on sj y
          Filter: (a IS NOT NULL)
-   ->  Materialize
-         ->  Seq Scan on int8_tbl z
-(6 rows)
+   ->  Seq Scan on int8_tbl z
+(5 rows)
 
 explain (costs off)
 select * from (
@@ -6614,16 +6605,15 @@ left join (select coalesce(y.q1, 1) from int8_tbl y
 	right join sj j1 inner join sj j2 on j1.a = j2.a
 	on true) z
 on true;
-                QUERY PLAN                
-------------------------------------------
+              QUERY PLAN               
+---------------------------------------
  Nested Loop Left Join
    ->  Result
    ->  Nested Loop Left Join
          ->  Seq Scan on sj j2
                Filter: (a IS NOT NULL)
-         ->  Materialize
-               ->  Seq Scan on int8_tbl y
-(7 rows)
+         ->  Seq Scan on int8_tbl y
+(6 rows)
 
 -- Test that references to the removed rel in lateral subqueries are replaced
 -- correctly after join removal
@@ -6980,32 +6970,36 @@ select p.b from sj p join sj q on p.a = q.a group by p.b having sum(p.a) = 1;
 explain (verbose, costs off)
 select 1 from (select x.* from sj x, sj y where x.a = y.a) q,
   lateral generate_series(1, q.a) gs(i);
-                      QUERY PLAN                      
-------------------------------------------------------
+                         QUERY PLAN                         
+------------------------------------------------------------
  Nested Loop
    Output: 1
    ->  Seq Scan on public.sj y
          Output: y.a, y.b, y.c
          Filter: (y.a IS NOT NULL)
-   ->  Function Scan on pg_catalog.generate_series gs
-         Output: gs.i
-         Function Call: generate_series(1, y.a)
-(8 rows)
+   ->  Memoize
+         Cache Key: y.a
+         Cache Mode: binary
+         ->  Function Scan on pg_catalog.generate_series gs
+               Function Call: generate_series(1, y.a)
+(10 rows)
 
 explain (verbose, costs off)
 select 1 from (select y.* from sj x, sj y where x.a = y.a) q,
   lateral generate_series(1, q.a) gs(i);
-                      QUERY PLAN                      
-------------------------------------------------------
+                         QUERY PLAN                         
+------------------------------------------------------------
  Nested Loop
    Output: 1
    ->  Seq Scan on public.sj y
          Output: y.a, y.b, y.c
          Filter: (y.a IS NOT NULL)
-   ->  Function Scan on pg_catalog.generate_series gs
-         Output: gs.i
-         Function Call: generate_series(1, y.a)
-(8 rows)
+   ->  Memoize
+         Cache Key: y.a
+         Cache Mode: binary
+         ->  Function Scan on pg_catalog.generate_series gs
+               Function Call: generate_series(1, y.a)
+(10 rows)
 
 -- Test that a non-EC-derived join clause is processed correctly. Use an
 -- outer join so that we can't form an EC.
diff --git a/src/test/regress/expected/join_hash.out b/src/test/regress/expected/join_hash.out
index 4fc34a0e72a..262fa71ed8d 100644
--- a/src/test/regress/expected/join_hash.out
+++ b/src/test/regress/expected/join_hash.out
@@ -508,17 +508,18 @@ set local hash_mem_multiplier = 1.0;
 set local enable_parallel_hash = on;
 explain (costs off)
   select count(*) from simple r join extremely_skewed s using (id);
-                           QUERY PLAN                            
------------------------------------------------------------------
- Aggregate
+                              QUERY PLAN                               
+-----------------------------------------------------------------------
+ Finalize Aggregate
    ->  Gather
          Workers Planned: 1
-         ->  Parallel Hash Join
-               Hash Cond: (r.id = s.id)
-               ->  Parallel Seq Scan on simple r
-               ->  Parallel Hash
-                     ->  Parallel Seq Scan on extremely_skewed s
-(8 rows)
+         ->  Partial Aggregate
+               ->  Parallel Hash Join
+                     Hash Cond: (r.id = s.id)
+                     ->  Parallel Seq Scan on simple r
+                     ->  Parallel Hash
+                           ->  Parallel Seq Scan on extremely_skewed s
+(9 rows)
 
 select count(*) from simple r join extremely_skewed s using (id);
  count 
diff --git a/src/test/regress/expected/misc_functions.out b/src/test/regress/expected/misc_functions.out
index d3f5d16a672..6289d3e03d1 100644
--- a/src/test/regress/expected/misc_functions.out
+++ b/src/test/regress/expected/misc_functions.out
@@ -647,27 +647,27 @@ SELECT * FROM tenk1 a JOIN my_gen_series(1,10) g ON a.unique1 = g;
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMPTZ '2024-02-01', TIMESTAMPTZ '2024-03-01', INTERVAL '1 day') g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=30 width=N) (actual rows=30.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=30.00 width=N) (actual rows=30.00 loops=1)
 (1 row)
 
 -- As above but with generate_series_timestamp
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMP '2024-02-01', TIMESTAMP '2024-03-01', INTERVAL '1 day') g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=30 width=N) (actual rows=30.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=30.00 width=N) (actual rows=30.00 loops=1)
 (1 row)
 
 -- As above but with generate_series_timestamptz_at_zone()
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMPTZ '2024-02-01', TIMESTAMPTZ '2024-03-01', INTERVAL '1 day', 'UTC') g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=30 width=N) (actual rows=30.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=30.00 width=N) (actual rows=30.00 loops=1)
 (1 row)
 
 -- Ensure the estimated and actual row counts match when the range isn't
@@ -675,36 +675,36 @@ true, true, false, true);
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMPTZ '2024-02-01', TIMESTAMPTZ '2024-03-01', INTERVAL '7 day') g(s);$$,
 true, true, false, true);
-                                    explain_mask_costs                                     
--------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=5 width=N) (actual rows=5.00 loops=1)
+                                      explain_mask_costs                                      
+----------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=5.00 width=N) (actual rows=5.00 loops=1)
 (1 row)
 
 -- Ensure the estimates match when step is decreasing
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMPTZ '2024-03-01', TIMESTAMPTZ '2024-02-01', INTERVAL '-1 day') g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=30 width=N) (actual rows=30.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=30.00 width=N) (actual rows=30.00 loops=1)
 (1 row)
 
 -- Ensure an empty range estimates 1 row
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMPTZ '2024-03-01', TIMESTAMPTZ '2024-02-01', INTERVAL '1 day') g(s);$$,
 true, true, false, true);
-                                    explain_mask_costs                                     
--------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=1 width=N) (actual rows=0.00 loops=1)
+                                      explain_mask_costs                                      
+----------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=1.00 width=N) (actual rows=0.00 loops=1)
 (1 row)
 
 -- Ensure we get the default row estimate for infinity values
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(TIMESTAMPTZ '-infinity', TIMESTAMPTZ 'infinity', INTERVAL '1 day') g(s);$$,
 false, true, false, true);
-                        explain_mask_costs                         
--------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=1000 width=N)
+                          explain_mask_costs                          
+----------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=1000.00 width=N)
 (1 row)
 
 -- Ensure the row estimate behaves correctly when step size is zero.
@@ -719,36 +719,36 @@ ERROR:  step size cannot equal zero
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(1.0, 25.0) g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=25 width=N) (actual rows=25.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=25.00 width=N) (actual rows=25.00 loops=1)
 (1 row)
 
 -- As above but with non-default step
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(1.0, 25.0, 2.0) g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=13 width=N) (actual rows=13.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=13.00 width=N) (actual rows=13.00 loops=1)
 (1 row)
 
 -- Ensure the estimates match when step is decreasing
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(25.0, 1.0, -1.0) g(s);$$,
 true, true, false, true);
-                                     explain_mask_costs                                      
----------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=25 width=N) (actual rows=25.00 loops=1)
+                                       explain_mask_costs                                       
+------------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=25.00 width=N) (actual rows=25.00 loops=1)
 (1 row)
 
 -- Ensure an empty range estimates 1 row
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(25.0, 1.0, 1.0) g(s);$$,
 true, true, false, true);
-                                    explain_mask_costs                                     
--------------------------------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=1 width=N) (actual rows=0.00 loops=1)
+                                      explain_mask_costs                                      
+----------------------------------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=1.00 width=N) (actual rows=0.00 loops=1)
 (1 row)
 
 -- Ensure we get the default row estimate for error cases (infinity/NaN values
@@ -756,25 +756,25 @@ true, true, false, true);
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series('-infinity'::NUMERIC, 'infinity'::NUMERIC, 1.0) g(s);$$,
 false, true, false, true);
-                        explain_mask_costs                         
--------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=1000 width=N)
+                          explain_mask_costs                          
+----------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=1000.00 width=N)
 (1 row)
 
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(1.0, 25.0, 'NaN'::NUMERIC) g(s);$$,
 false, true, false, true);
-                        explain_mask_costs                         
--------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=1000 width=N)
+                          explain_mask_costs                          
+----------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=1000.00 width=N)
 (1 row)
 
 SELECT explain_mask_costs($$
 SELECT * FROM generate_series(25.0, 2.0, 0.0) g(s);$$,
 false, true, false, true);
-                        explain_mask_costs                         
--------------------------------------------------------------------
- Function Scan on generate_series g  (cost=N..N rows=1000 width=N)
+                          explain_mask_costs                          
+----------------------------------------------------------------------
+ Function Scan on generate_series g  (cost=N..N rows=1000.00 width=N)
 (1 row)
 
 -- Test functions for control data
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 6101c8c7cf1..fe960db944f 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -28,8 +28,8 @@ ANALYZE prt2;
 -- inner join
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
-                    QUERY PLAN                    
---------------------------------------------------
+                          QUERY PLAN                           
+---------------------------------------------------------------
  Sort
    Sort Key: t1.a
    ->  Append
@@ -45,13 +45,12 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
                ->  Hash
                      ->  Seq Scan on prt1_p2 t1_2
                            Filter: (b = 0)
-         ->  Hash Join
-               Hash Cond: (t2_3.b = t1_3.a)
-               ->  Seq Scan on prt2_p3 t2_3
-               ->  Hash
-                     ->  Seq Scan on prt1_p3 t1_3
-                           Filter: (b = 0)
-(21 rows)
+         ->  Nested Loop
+               ->  Seq Scan on prt1_p3 t1_3
+                     Filter: (b = 0)
+               ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
+                     Index Cond: (b = t1_3.a)
+(20 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   
@@ -834,15 +833,14 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
                      Index Cond: (((a + b) / 2) = t2_2.b)
          ->  Nested Loop
                Join Filter: (t1_3.a = ((t3_3.a + t3_3.b) / 2))
-               ->  Hash Join
-                     Hash Cond: (t2_3.b = t1_3.a)
-                     ->  Seq Scan on prt2_p3 t2_3
-                     ->  Hash
-                           ->  Seq Scan on prt1_p3 t1_3
-                                 Filter: (b = 0)
+               ->  Nested Loop
+                     ->  Seq Scan on prt1_p3 t1_3
+                           Filter: (b = 0)
+                     ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
+                           Index Cond: (b = t1_3.a)
                ->  Index Scan using iprt1_e_p3_ab2 on prt1_e_p3 t3_3
                      Index Cond: (((a + b) / 2) = t2_3.b)
-(33 rows)
+(32 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t3 WHERE t1.a = t2.b AND t1.a = (t3.a + t3.b)/2 AND t1.b = 0 ORDER BY t1.a, t2.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -855,8 +853,8 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM prt1 t1, prt2 t2, prt1_e t
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) LEFT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t1.b = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
-                          QUERY PLAN                          
---------------------------------------------------------------
+                             QUERY PLAN                              
+---------------------------------------------------------------------
  Sort
    Sort Key: t1.a, t2.b, ((t3.a + t3.b))
    ->  Append
@@ -880,17 +878,15 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                            ->  Hash
                                  ->  Seq Scan on prt1_p2 t1_2
                                        Filter: (b = 0)
-         ->  Hash Right Join
-               Hash Cond: (((t3_3.a + t3_3.b) / 2) = t1_3.a)
-               ->  Seq Scan on prt1_e_p3 t3_3
-               ->  Hash
-                     ->  Hash Right Join
-                           Hash Cond: (t2_3.b = t1_3.a)
-                           ->  Seq Scan on prt2_p3 t2_3
-                           ->  Hash
-                                 ->  Seq Scan on prt1_p3 t1_3
-                                       Filter: (b = 0)
-(33 rows)
+         ->  Nested Loop Left Join
+               ->  Nested Loop Left Join
+                     ->  Seq Scan on prt1_p3 t1_3
+                           Filter: (b = 0)
+                     ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
+                           Index Cond: (b = t1_3.a)
+               ->  Index Scan using iprt1_e_p3_ab2 on prt1_e_p3 t3_3
+                     Index Cond: (((a + b) / 2) = t1_3.a)
+(31 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) LEFT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t1.b = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -911,8 +907,8 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) RIGHT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t3.c = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
-                            QUERY PLAN                             
--------------------------------------------------------------------
+                             QUERY PLAN                              
+---------------------------------------------------------------------
  Sort
    Sort Key: t1.a, t2.b, ((t3.a + t3.b))
    ->  Append
@@ -935,15 +931,14 @@ SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2
                ->  Index Scan using iprt2_p2_b on prt2_p2 t2_2
                      Index Cond: (b = t1_2.a)
          ->  Nested Loop Left Join
-               ->  Hash Right Join
-                     Hash Cond: (t1_3.a = ((t3_3.a + t3_3.b) / 2))
-                     ->  Seq Scan on prt1_p3 t1_3
-                     ->  Hash
-                           ->  Seq Scan on prt1_e_p3 t3_3
-                                 Filter: (c = 0)
-               ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
-                     Index Cond: (b = t1_3.a)
-(30 rows)
+               ->  Seq Scan on prt1_e_p3 t3_3
+                     Filter: (c = 0)
+               ->  Nested Loop Left Join
+                     ->  Index Scan using iprt1_p3_a on prt1_p3 t1_3
+                           Index Cond: (a = ((t3_3.a + t3_3.b) / 2))
+                     ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
+                           Index Cond: (b = t1_3.a)
+(29 rows)
 
 SELECT t1.a, t1.c, t2.b, t2.c, t3.a + t3.b, t3.c FROM (prt1 t1 LEFT JOIN prt2 t2 ON t1.a = t2.b) RIGHT JOIN prt1_e t3 ON (t1.a = (t3.a + t3.b)/2) WHERE t3.c = 0 ORDER BY t1.a, t2.b, t3.a + t3.b;
   a  |  c   |  b  |  c   | ?column? | c 
@@ -1749,8 +1744,8 @@ ALTER TABLE prt2 ATTACH PARTITION prt2_p3 DEFAULT;
 ANALYZE prt2;
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b = 0 ORDER BY t1.a, t2.b;
-                    QUERY PLAN                    
---------------------------------------------------
+                          QUERY PLAN                           
+---------------------------------------------------------------
  Sort
    Sort Key: t1.a
    ->  Append
@@ -1766,13 +1761,12 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.b =
                ->  Hash
                      ->  Seq Scan on prt1_p2 t1_2
                            Filter: (b = 0)
-         ->  Hash Join
-               Hash Cond: (t2_3.b = t1_3.a)
-               ->  Seq Scan on prt2_p3 t2_3
-               ->  Hash
-                     ->  Seq Scan on prt1_p3 t1_3
-                           Filter: (b = 0)
-(21 rows)
+         ->  Nested Loop
+               ->  Seq Scan on prt1_p3 t1_3
+                     Filter: (b = 0)
+               ->  Index Scan using iprt2_p3_b on prt2_p3 t2_3
+                     Index Cond: (b = t1_3.a)
+(20 rows)
 
 -- test default partition behavior for list
 ALTER TABLE plt1 DETACH PARTITION plt1_p3;
@@ -5175,20 +5169,18 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
  Sort
    Sort Key: t1.a, t1.b
    ->  Append
-         ->  Hash Join
-               Hash Cond: ((t1_1.a = t2_1.a) AND (t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
+         ->  Nested Loop
+               Join Filter: ((t1_1.a = t2_1.a) AND (t1_1.b = t2_1.b) AND (t1_1.c = t2_1.c))
                ->  Seq Scan on alpha_neg_p1 t1_1
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
-               ->  Hash
-                     ->  Seq Scan on beta_neg_p1 t2_1
-                           Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
-         ->  Hash Join
-               Hash Cond: ((t1_2.a = t2_2.a) AND (t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
+               ->  Seq Scan on beta_neg_p1 t2_1
+                     Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
+         ->  Nested Loop
+               Join Filter: ((t1_2.a = t2_2.a) AND (t1_2.b = t2_2.b) AND (t1_2.c = t2_2.c))
                ->  Seq Scan on alpha_neg_p2 t1_2
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
-               ->  Hash
-                     ->  Seq Scan on beta_neg_p2 t2_2
-                           Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
+               ->  Seq Scan on beta_neg_p2 t2_2
+                     Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
          ->  Nested Loop
                Join Filter: ((t1_3.a = t2_3.a) AND (t1_3.b = t2_3.b) AND (t1_3.c = t2_3.c))
                ->  Seq Scan on alpha_pos_p2 t1_3
@@ -5201,7 +5193,7 @@ SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2
                      Filter: ((c = ANY ('{0004,0009}'::text[])) AND (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210))))
                ->  Seq Scan on beta_pos_p3 t2_4
                      Filter: (((b >= 100) AND (b < 110)) OR ((b >= 200) AND (b < 210)))
-(29 rows)
+(27 rows)
 
 SELECT t1.*, t2.* FROM alpha t1 INNER JOIN beta t2 ON (t1.a = t2.a AND t1.b = t2.b AND t1.c = t2.c) WHERE ((t1.b >= 100 AND t1.b < 110) OR (t1.b >= 200 AND t1.b < 210)) AND ((t2.b >= 100 AND t2.b < 110) OR (t2.b >= 200 AND t2.b < 210)) AND t1.c IN ('0004', '0009') ORDER BY t1.a, t1.b;
  a  |  b  |  c   | a  |  b  |  c   
diff --git a/src/test/regress/expected/select_views.out b/src/test/regress/expected/select_views.out
index 1aeed8452bd..b3c0cbf0607 100644
--- a/src/test/regress/expected/select_views.out
+++ b/src/test/regress/expected/select_views.out
@@ -1461,18 +1461,17 @@ EXPLAIN (COSTS OFF) SELECT * FROM my_credit_card_usage_normal
 ------------------------------------------------------------------------------
  Nested Loop
    Join Filter: (l.cid = r.cid)
+   ->  Subquery Scan on l
+         Filter: f_leak(l.cnum)
+         ->  Hash Join
+               Hash Cond: (r_1.cid = l_1.cid)
+               ->  Seq Scan on credit_card r_1
+               ->  Hash
+                     ->  Seq Scan on customer l_1
+                           Filter: (name = CURRENT_USER)
    ->  Seq Scan on credit_usage r
          Filter: ((ymd >= '10-01-2011'::date) AND (ymd < '11-01-2011'::date))
-   ->  Materialize
-         ->  Subquery Scan on l
-               Filter: f_leak(l.cnum)
-               ->  Hash Join
-                     Hash Cond: (r_1.cid = l_1.cid)
-                     ->  Seq Scan on credit_card r_1
-                     ->  Hash
-                           ->  Seq Scan on customer l_1
-                                 Filter: (name = CURRENT_USER)
-(13 rows)
+(12 rows)
 
 SELECT * FROM my_credit_card_usage_secure
        WHERE f_leak(cnum) AND ymd >= '2011-10-01' AND ymd < '2011-11-01';
diff --git a/src/test/regress/expected/stats_ext.out b/src/test/regress/expected/stats_ext.out
index 686d8c93aa8..5f17271202a 100644
--- a/src/test/regress/expected/stats_ext.out
+++ b/src/test/regress/expected/stats_ext.out
@@ -18,8 +18,8 @@ begin
     loop
         if first_row then
             first_row := false;
-            tmp := regexp_match(ln, 'rows=(\d*) .* rows=(\d*)');
-            return query select tmp[1]::int, tmp[2]::int;
+            tmp := regexp_match(ln, 'rows=(\d+\.\d{2}) .* rows=(\d+\.\d{2})');
+            return query select round(tmp[1]::numeric)::int, round(tmp[2]::numeric)::int;
         end if;
     end loop;
 end;
@@ -1345,13 +1345,13 @@ SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE
 SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE (a = 1 OR a = 51) AND b = ''1''');
  estimated | actual 
 -----------+--------
-        99 |    100
+       100 |    100
 (1 row)
 
 SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE (a = 1 OR a = 51) AND (b = ''1'' OR b = ''2'')');
  estimated | actual 
 -----------+--------
-        99 |    100
+       100 |    100
 (1 row)
 
 SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE (a = 1 OR a = 2 OR a = 51 OR a = 52) AND (b = ''1'' OR b = ''2'')');
@@ -1687,13 +1687,13 @@ SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE
 SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE ((a * 2) = 2 OR (a * 2) = 102) AND upper(b) = ''1''');
  estimated | actual 
 -----------+--------
-        99 |    100
+       100 |    100
 (1 row)
 
 SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE ((a * 2) = 2 OR (a * 2) = 102) AND (upper(b) = ''1'' OR upper(b) = ''2'')');
  estimated | actual 
 -----------+--------
-        99 |    100
+       100 |    100
 (1 row)
 
 SELECT * FROM check_estimated_rows('SELECT * FROM functional_dependencies WHERE ((a * 2) = 2 OR (a * 2) = 4 OR (a * 2) = 102 OR (a * 2) = 104) AND (upper(b) = ''1'' OR upper(b) = ''2'')');
diff --git a/src/test/regress/sql/stats_ext.sql b/src/test/regress/sql/stats_ext.sql
index b71a6cd089f..f49a7b6a237 100644
--- a/src/test/regress/sql/stats_ext.sql
+++ b/src/test/regress/sql/stats_ext.sql
@@ -20,8 +20,8 @@ begin
     loop
         if first_row then
             first_row := false;
-            tmp := regexp_match(ln, 'rows=(\d*) .* rows=(\d*)');
-            return query select tmp[1]::int, tmp[2]::int;
+            tmp := regexp_match(ln, 'rows=(\d+\.\d{2}) .* rows=(\d+\.\d{2})');
+            return query select round(tmp[1]::numeric)::int, round(tmp[2]::numeric)::int;
         end if;
     end loop;
 end;
-- 
2.34.1