Hi,

Since ddb17e387aa, EXPLAIN reports per-loop actual rows with two decimal digits.
The neighbouring counters - Rows Removed by Filter, by Join Filter - are divided
by nloops too, but are still printed with zero digits. In a parameterised inner
side this makes the two numbers contradict each other, for example:

  ->  Index Scan using tenk1_unique1 on tenk1 b (actual rows=0.90 loops=10)
        Index Cond: (unique1 = g.g)
        Filter: (ten <> 0)
        Rows Removed by Filter: 0

The scan returned 0.90 rows per loop out of 1.00 fetched, yet the filter claims
it removed nothing. See the attached patch for complete examples.

The patch is a single-line change in show_instrumentation_count() plus two
tests. It is a concept, not a finished patch: I have not regenerated the other
expected outputs, where many values will change from "N" to "N.00" - you can
find a sketch for that in 0002-* file. Before going through that churn, I would
like to hear whether people agree that these counters should follow the same
rule as actual rows. The options I see are:

1. Always print two digits, as actual rows do now.
2. Leave the math to users and print exact totals without dividing by nloops.

The second approach is a much bigger change, with obvious pros and cons.
The first one is simpler and more conservative, and it is what the attached
patch does. In [1], depesz describes how the switch to fractional actual rows
affected his explain parser, and it needed only a small fix. So I expect this
change to be similarly harmless for tools.

Thoughts?

[1]
https://www.depesz.com/2025/02/28/waiting-for-postgresql-18-allow-explain-to-indicate-fractional-rows/

-- 
regards, Andrei Lepikhov,
pgEdge
From 57d3d8d243dc18fa14f7d88679ba7670dfe5efd9 Mon Sep 17 00:00:00 2001
From: Andrei Lepikhov <[email protected]>
Date: Thu, 5 Nov 2026 19:47:17 +0100
Subject: [PATCH 1/2] Switch JOIN and SCAN filter explain output to floating
 point format

---
 src/backend/commands/explain.c        |  2 +-
 src/test/regress/expected/explain.out | 37 +++++++++++++++++++++++++++
 src/test/regress/sql/explain.sql      |  9 +++++++
 3 files changed, 47 insertions(+), 1 deletion(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 4b1ad4227ac..ace016637af 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -4222,7 +4222,7 @@ show_instrumentation_count(const char *qlabel, int which,
        if (nfiltered > 0 || es->format != EXPLAIN_FORMAT_TEXT)
        {
                if (nloops > 0)
-                       ExplainPropertyFloat(qlabel, NULL, nfiltered / nloops, 
0, es);
+                       ExplainPropertyFloat(qlabel, NULL, nfiltered / nloops, 
2, es);
                else
                        ExplainPropertyFloat(qlabel, NULL, 0.0, 0, es);
        }
diff --git a/src/test/regress/expected/explain.out 
b/src/test/regress/expected/explain.out
index 74a4d87801e..914e5fdcbc4 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -832,3 +832,40 @@ select explain_filter('explain (analyze,buffers off,costs 
off) select sum(n) ove
 (9 rows)
 
 reset work_mem;
+explain (analyze, costs off, timing off, summary off, buffers off)
+select count(*) from generate_series(0, 9) g join tenk1 b on b.unique1 = g
+where b.ten <> 0;
+                                    QUERY PLAN                                 
    
+-----------------------------------------------------------------------------------
+ Aggregate (actual rows=1.00 loops=1)
+   ->  Nested Loop (actual rows=9.00 loops=1)
+         ->  Function Scan on generate_series g (actual rows=10.00 loops=1)
+         ->  Index Scan using tenk1_unique1 on tenk1 b (actual rows=0.90 
loops=10)
+               Index Cond: (unique1 = g.g)
+               Filter: (ten <> 0)
+               Rows Removed by Filter: 0.10
+               Index Searches: 10
+(8 rows)
+
+-- Join Filter of a join that is itself rescanned
+explain (analyze, costs off, timing off, summary off, buffers off)
+select count(*) from generate_series(0, 9) g,
+  lateral (select 1 from tenk1 b join tenk1 c
+             on c.unique2 = b.unique1 and b.ten <> c.ten
+           where b.unique1 = g offset 0) s;
+                                       QUERY PLAN                              
          
+-----------------------------------------------------------------------------------------
+ Aggregate (actual rows=1.00 loops=1)
+   ->  Nested Loop (actual rows=7.00 loops=1)
+         ->  Function Scan on generate_series g (actual rows=10.00 loops=1)
+         ->  Nested Loop (actual rows=0.70 loops=10)
+               Join Filter: (b.ten <> c.ten)
+               Rows Removed by Join Filter: 0.30
+               ->  Index Scan using tenk1_unique1 on tenk1 b (actual rows=1.00 
loops=10)
+                     Index Cond: (unique1 = g.g)
+                     Index Searches: 10
+               ->  Index Scan using tenk1_unique2 on tenk1 c (actual rows=1.00 
loops=10)
+                     Index Cond: (unique2 = g.g)
+                     Index Searches: 10
+(12 rows)
+
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index 2f163c64bf6..213ab2cbcc7 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -191,3 +191,12 @@ select explain_filter('explain (analyze,buffers off,costs 
off) select sum(n) ove
 -- Test tuplestore storage usage in Window aggregate (memory and disk case, 
final result is disk)
 select explain_filter('explain (analyze,buffers off,costs off) select sum(n) 
over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) 
a(n))');
 reset work_mem;
+
+explain (analyze, costs off, timing off, summary off, buffers off)
+select count(*) from generate_series(0, 9) g join tenk1 b on b.unique1 = g
+where b.ten <> 0;
+explain (analyze, costs off, timing off, summary off, buffers off)
+select count(*) from generate_series(0, 9) g,
+  lateral (select 1 from tenk1 b join tenk1 c
+             on c.unique2 = b.unique1 and b.ten <> c.ten
+           where b.unique1 = g offset 0) s;
-- 
2.56.0

From e97fcce76070b7ded53681adefa307f3ee5866c4 Mon Sep 17 00:00:00 2001
From: Andrei Lepikhov <[email protected]>
Date: Fri, 6 Nov 2026 08:44:45 +0000
Subject: [PATCH 2/2] Adjust regression tests to fractional "Rows Removed by"
 counters

---
 .../postgres_fdw/expected/postgres_fdw.out    |  6 ++---
 src/test/regress/expected/explain.out         |  4 ++++
 src/test/regress/expected/gin.out             | 20 ++++++++---------
 src/test/regress/expected/memoize.out         | 18 +++++++--------
 src/test/regress/expected/merge.out           |  2 +-
 src/test/regress/expected/partition_prune.out | 22 +++++++++----------
 src/test/regress/expected/select_parallel.out |  4 ++--
 src/test/regress/sql/explain.sql              |  5 +++++
 src/test/regress/sql/partition_prune.sql      |  2 +-
 9 files changed, 46 insertions(+), 37 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out 
b/contrib/postgres_fdw/expected/postgres_fdw.out
index b946a6416a0..cb2801978f9 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -12836,7 +12836,7 @@ SELECT * FROM local_tbl, async_pt WHERE local_tbl.a = 
async_pt.a AND local_tbl.c
  Nested Loop (actual rows=1.00 loops=1)
    ->  Seq Scan on local_tbl (actual rows=1.00 loops=1)
          Filter: (c = 'bar'::text)
-         Rows Removed by Filter: 1
+         Rows Removed by Filter: 1.00
    ->  Append (actual rows=1.00 loops=1)
          ->  Async Foreign Scan on async_p1 async_pt_1 (never executed)
          ->  Async Foreign Scan on async_p2 async_pt_2 (actual rows=1.00 
loops=1)
@@ -13081,7 +13081,7 @@ SELECT * FROM local_tbl t1 LEFT JOIN (SELECT *, (SELECT 
count(*) FROM async_pt W
 
--------------------------------------------------------------------------------------------
  Nested Loop Left Join (actual rows=1.00 loops=1)
    Join Filter: (t1.a = async_pt.a)
-   Rows Removed by Join Filter: 399
+   Rows Removed by Join Filter: 399.00
    InitPlan expr_1
      ->  Aggregate (actual rows=1.00 loops=1)
            ->  Append (actual rows=400.00 loops=1)
@@ -13131,7 +13131,7 @@ SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
                Filter: (b === 505)
          ->  Seq Scan on async_p3 t1_3 (actual rows=1.00 loops=1)
                Filter: (b === 505)
-               Rows Removed by Filter: 101
+               Rows Removed by Filter: 101.00
 (9 rows)
 
 SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
diff --git a/src/test/regress/expected/gin.out 
b/src/test/regress/expected/gin.out
index 118bc26d8cd..43bf8cc78f8 100644
--- a/src/test/regress/expected/gin.out
+++ b/src/test/regress/expected/gin.out
@@ -187,16 +187,16 @@ from
   lateral execute_text_query_heap($$select string_agg((i, j)::text, ' ') from 
t_gin_test_tbl where $$ || query) res_heap;
                    query                   | return by index | removed by 
recheck | match 
 
-------------------------------------------+-----------------+--------------------+-------
-  i @> '{}'                                | 7.00            | 0               
   | t
-  j @> '{}'                                | 6.00            | 0               
   | t
-  i @> '{}' and j @> '{}'                  | 4.00            | 0               
   | t
-  i @> '{1}'                               | 5.00            | 0               
   | t
-  i @> '{1}' and j @> '{}'                 | 3.00            | 0               
   | t
-  i @> '{1}' and i @> '{}' and j @> '{}'   | 3.00            | 0               
   | t
-  j @> '{10}'                              | 4.00            | 0               
   | t
-  j @> '{10}' and i @> '{}'                | 3.00            | 0               
   | t
-  j @> '{10}' and j @> '{}' and i @> '{}'  | 3.00            | 0               
   | t
-  i @> '{1}' and j @> '{10}'               | 2.00            | 0               
   | t
+  i @> '{}'                                | 7.00            | 0.00            
   | t
+  j @> '{}'                                | 6.00            | 0.00            
   | t
+  i @> '{}' and j @> '{}'                  | 4.00            | 0.00            
   | t
+  i @> '{1}'                               | 5.00            | 0.00            
   | t
+  i @> '{1}' and j @> '{}'                 | 3.00            | 0.00            
   | t
+  i @> '{1}' and i @> '{}' and j @> '{}'   | 3.00            | 0.00            
   | t
+  j @> '{10}'                              | 4.00            | 0.00            
   | t
+  j @> '{10}' and i @> '{}'                | 3.00            | 0.00            
   | t
+  j @> '{10}' and j @> '{}' and i @> '{}'  | 3.00            | 0.00            
   | t
+  i @> '{1}' and j @> '{10}'               | 2.00            | 0.00            
   | t
 (10 rows)
 
 reset enable_seqscan;
diff --git a/src/test/regress/expected/memoize.out 
b/src/test/regress/expected/memoize.out
index 30bf5f5667e..7d50ce553e4 100644
--- a/src/test/regress/expected/memoize.out
+++ b/src/test/regress/expected/memoize.out
@@ -43,7 +43,7 @@ WHERE t2.unique1 < 1000;', false);
    ->  Nested Loop (actual rows=1000.00 loops=N)
          ->  Seq Scan on tenk1 t2 (actual rows=1000.00 loops=N)
                Filter: (unique1 < 1000)
-               Rows Removed by Filter: 9000
+               Rows Removed by Filter: 9000.00
          ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t2.twenty
                Cache Mode: logical
@@ -75,7 +75,7 @@ WHERE t1.unique1 < 1000;', false);
    ->  Nested Loop (actual rows=1000.00 loops=N)
          ->  Seq Scan on tenk1 t1 (actual rows=1000.00 loops=N)
                Filter: (unique1 < 1000)
-               Rows Removed by Filter: 9000
+               Rows Removed by Filter: 9000.00
          ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t1.twenty
                Cache Mode: binary
@@ -117,7 +117,7 @@ WHERE t1.unique1 < 10;', false);
                Hits: 8  Misses: 2  Evictions: Zero  Overflows: 0  Memory 
Usage: NkB
                ->  Subquery Scan on t2 (actual rows=2.00 loops=N)
                      Filter: (t1.two = t2.two)
-                     Rows Removed by Filter: 2
+                     Rows Removed by Filter: 2.00
                      ->  Index Scan using tenk1_unique1 on tenk1 t2_1 (actual 
rows=4.00 loops=N)
                            Index Cond: (unique1 < 4)
                            Index Searches: N
@@ -146,14 +146,14 @@ WHERE s.c1 = s.c2 AND t1.unique1 < 1000;', false);
    ->  Nested Loop (actual rows=1000.00 loops=N)
          ->  Seq Scan on tenk1 t1 (actual rows=1000.00 loops=N)
                Filter: (unique1 < 1000)
-               Rows Removed by Filter: 9000
+               Rows Removed by Filter: 9000.00
          ->  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.00 loops=N)
                      Filter: ((t1.two + 1) = unique1)
-                     Rows Removed by Filter: 9999
+                     Rows Removed by Filter: 9999.00
                      Heap Fetches: N
                      Index Searches: N
 (14 rows)
@@ -179,14 +179,14 @@ WHERE s.c1 = s.c2 AND t1.unique1 < 1000;', false);
    ->  Nested Loop (actual rows=1000.00 loops=N)
          ->  Seq Scan on tenk1 t1 (actual rows=1000.00 loops=N)
                Filter: (unique1 < 1000)
-               Rows Removed by Filter: 9000
+               Rows Removed by Filter: 9000.00
          ->  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.00 loops=N)
                      Filter: ((t1.twenty = unique1) AND (t1.two = two))
-                     Rows Removed by Filter: 9999
+                     Rows Removed by Filter: 9999.00
 (12 rows)
 
 -- And check we get the expected results.
@@ -246,7 +246,7 @@ WHERE t2.unique1 < 1200;', true);
    ->  Nested Loop (actual rows=1200.00 loops=N)
          ->  Seq Scan on tenk1 t2 (actual rows=1200.00 loops=N)
                Filter: (unique1 < 1200)
-               Rows Removed by Filter: 8800
+               Rows Removed by Filter: 8800.00
          ->  Memoize (actual rows=1.00 loops=N)
                Cache Key: t2.thousand
                Cache Mode: logical
@@ -698,7 +698,7 @@ WHERE t2.a IS NULL;', false);
                Hits: 97  Misses: 3  Evictions: Zero  Overflows: 0  Memory 
Usage: NkB
                ->  Subquery Scan on t2 (actual rows=0.67 loops=N)
                      Filter: ((t1.a + 1) = t2.a)
-                     Rows Removed by Filter: 2
+                     Rows Removed by Filter: 2.00
                      ->  Unique (actual rows=2.67 loops=N)
                            ->  Sort (actual rows=67.33 loops=N)
                                  Sort Key: t2_1.a
diff --git a/src/test/regress/expected/merge.out 
b/src/test/regress/expected/merge.out
index 9cb1d87066a..000c2b19e34 100644
--- a/src/test/regress/expected/merge.out
+++ b/src/test/regress/expected/merge.out
@@ -1801,7 +1801,7 @@ WHEN MATCHED AND t.a < 10 THEN
                Sort Method: quicksort  Memory: xxx
                ->  Seq Scan on ex_mtarget t (actual rows=0.00 loops=1)
                      Filter: (a < '-1000'::integer)
-                     Rows Removed by Filter: 54
+                     Rows Removed by Filter: 54.00
          ->  Sort (never executed)
                Sort Key: s.a
                ->  Seq Scan on ex_msource s (never executed)
diff --git a/src/test/regress/expected/partition_prune.out 
b/src/test/regress/expected/partition_prune.out
index 3506ecec477..caa046b36f8 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -2535,16 +2535,16 @@ explain (analyze, costs off, summary off, timing off, 
buffers off) select * from
  Append (actual rows=0.00 loops=1)
    ->  Seq Scan on list_part1 list_part_1 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
-         Rows Removed by Filter: 1
+         Rows Removed by Filter: 1.00
    ->  Seq Scan on list_part2 list_part_2 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
-         Rows Removed by Filter: 1
+         Rows Removed by Filter: 1.00
    ->  Seq Scan on list_part3 list_part_3 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
-         Rows Removed by Filter: 1
+         Rows Removed by Filter: 1.00
    ->  Seq Scan on list_part4 list_part_4 (actual rows=0.00 loops=1)
          Filter: (a = (list_part_fn(1) + a))
-         Rows Removed by Filter: 1
+         Rows Removed by Filter: 1.00
 (13 rows)
 
 rollback;
@@ -2570,7 +2570,7 @@ begin
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: 
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');
+        ln := regexp_replace(ln, 'Rows Removed by Filter: \d+(?:\.\d+)?', 
'Rows Removed by Filter: N');
         perform regexp_matches(ln, 'Index Searches: \d+');
         if found then
           continue;
@@ -3068,7 +3068,7 @@ explain (analyze, costs off, summary off, timing off, 
buffers off) execute ab_q6
          Filter: ((a = $1) AND (b = (InitPlan expr_1).col1))
    ->  Seq Scan on xy_1 (actual rows=0.00 loops=1)
          Filter: ((x = $1) AND (y = (InitPlan expr_1).col1))
-         Rows Removed by Filter: 1
+         Rows Removed by Filter: 1.00
    ->  Seq Scan on ab_a1_b1 ab_4 (never executed)
          Filter: ((a = $1) AND (b = (InitPlan expr_1).col1))
    ->  Seq Scan on ab_a1_b2 ab_5 (never executed)
@@ -3732,7 +3732,7 @@ select * from boolp where a = (select value from 
boolvalues where value);
    InitPlan expr_1
      ->  Seq Scan on boolvalues (actual rows=1.00 loops=1)
            Filter: value
-           Rows Removed by Filter: 1
+           Rows Removed by Filter: 1.00
    ->  Seq Scan on boolp_f boolp_1 (never executed)
          Filter: (a = (InitPlan expr_1).col1)
    ->  Seq Scan on boolp_t boolp_2 (actual rows=0.00 loops=1)
@@ -3747,7 +3747,7 @@ select * from boolp where a = (select value from 
boolvalues where not value);
    InitPlan expr_1
      ->  Seq Scan on boolvalues (actual rows=1.00 loops=1)
            Filter: (NOT value)
-           Rows Removed by Filter: 1
+           Rows Removed by Filter: 1.00
    ->  Seq Scan on boolp_f boolp_1 (actual rows=0.00 loops=1)
          Filter: (a = (InitPlan expr_1).col1)
    ->  Seq Scan on boolp_t boolp_2 (never executed)
@@ -3776,11 +3776,11 @@ explain (analyze, costs off, summary off, timing off, 
buffers off) execute mt_q1
    Subplans Removed: 1
    ->  Index Scan using ma_test_p2_b_idx on ma_test_p2 ma_test_1 (actual 
rows=1.00 loops=1)
          Filter: ((a >= $1) AND ((a % 10) = 5))
-         Rows Removed by Filter: 9
+         Rows Removed by Filter: 9.00
          Index Searches: 1
    ->  Index Scan using ma_test_p3_b_idx on ma_test_p3 ma_test_2 (actual 
rows=1.00 loops=1)
          Filter: ((a >= $1) AND ((a % 10) = 5))
-         Rows Removed by Filter: 9
+         Rows Removed by Filter: 9.00
          Index Searches: 1
 (11 rows)
 
@@ -3799,7 +3799,7 @@ explain (analyze, costs off, summary off, timing off, 
buffers off) execute mt_q1
    Subplans Removed: 2
    ->  Index Scan using ma_test_p3_b_idx on ma_test_p3 ma_test_1 (actual 
rows=1.00 loops=1)
          Filter: ((a >= $1) AND ((a % 10) = 5))
-         Rows Removed by Filter: 9
+         Rows Removed by Filter: 9.00
          Index Searches: 1
 (7 rows)
 
diff --git a/src/test/regress/expected/select_parallel.out 
b/src/test/regress/expected/select_parallel.out
index e1344215644..a71f453a5a3 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -589,13 +589,13 @@ explain (analyze, timing off, summary off, costs off, 
buffers off)
    ->  Nested Loop (actual rows=98000.00 loops=1)
          ->  Seq Scan on tenk2 (actual rows=10.00 loops=1)
                Filter: (thousand = 0)
-               Rows Removed by Filter: 9990
+               Rows Removed by Filter: 9990.00
          ->  Gather (actual rows=9800.00 loops=10)
                Workers Planned: 4
                Workers Launched: 4
                ->  Parallel Seq Scan on tenk1 (actual rows=1960.00 loops=50)
                      Filter: (hundred > 1)
-                     Rows Removed by Filter: 40
+                     Rows Removed by Filter: 40.00
 (11 rows)
 
 alter table tenk2 reset (parallel_workers);
diff --git a/src/test/regress/sql/partition_prune.sql 
b/src/test/regress/sql/partition_prune.sql
index d6e096dc565..b642690061f 100644
--- a/src/test/regress/sql/partition_prune.sql
+++ b/src/test/regress/sql/partition_prune.sql
@@ -680,7 +680,7 @@ begin
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: 
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');
+        ln := regexp_replace(ln, 'Rows Removed by Filter: \d+(?:\.\d+)?', 
'Rows Removed by Filter: N');
         perform regexp_matches(ln, 'Index Searches: \d+');
         if found then
           continue;
-- 
2.56.0

Reply via email to