Subject: BUG #19533: Wrong results from WindowAgg run-condition pushdown on count() with EXCLUDE CURRENT ROW

From: PG Bug reporting form <[email-masked]>
Date: Wed, 24 Jun 2026 14:41:28 +0000

The following bug has been logged on the website:

Bug reference:      19533
Logged by:          Qifan Liu
Email address:      [email-masked]
PostgreSQL version: 18.4
Operating system:   Ubuntu 20.04 x86-64, docker image postgres:18.4
Description:        

## PoC

```sql
DROP TABLE IF EXISTS t;

CREATE TABLE t (
    id int PRIMARY KEY,
    k  int NOT NULL,
    v  int
);

INSERT INTO t(id, k, v) VALUES
    (1, 1, 1),
    (2, 1, NULL),
    (3, 1, 1),
    (4, 2, 1);

WITH
source_rows AS (
    SELECT id
    FROM (
        SELECT id,
               count(v) OVER (
                   ORDER BY k
                   RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
                   EXCLUDE CURRENT ROW
               ) AS c
        FROM t
    ) s
    WHERE c <= 1
),
reference_rows AS (
    SELECT t1.id
    FROM t AS t1
    WHERE (
        SELECT count(t2.v)
        FROM t AS t2
        WHERE t2.k <= t1.k
          AND t2.id <> t1.id
    ) <= 1
),
metamorphic_rows AS (
    SELECT id
    FROM (
        SELECT id,
               count(v) OVER (
                   ORDER BY k
                   RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
                   EXCLUDE CURRENT ROW
               ) AS c
        FROM t
    ) s
    WHERE c + 0 <= 1
)
SELECT 'source' AS label, coalesce(array_agg(id ORDER BY id), '{}'::int[])
AS ids
FROM source_rows
UNION ALL
SELECT 'reference', coalesce(array_agg(id ORDER BY id), '{}'::int[])
FROM reference_rows
UNION ALL
SELECT 'metamorphic_no_pushdown', coalesce(array_agg(id ORDER BY id),
'{}'::int[])
FROM metamorphic_rows;

EXPLAIN (COSTS OFF)
SELECT id
FROM (
    SELECT id,
           count(v) OVER (
               ORDER BY k
               RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
               EXCLUDE CURRENT ROW
           ) AS c
    FROM t
) s
WHERE c <= 1;
```

## Expected Behavior

The source query should return the same rows as the reference query and the
semantically equivalent metamorphic_no_pushdown query.

## Actual Behavior

The optimized query returns only `{1}`, while the reference and no-pushdown
forms return `{1,3}`.

---

From: Chengpeng Yan <[email-masked]>
Date: Sat, 27 Jun 2026 16:53:37 +0000

Hi,

> On Jun 24, 2026, at 22:41, PG Bug reporting form <[email-masked]> wrote:
>

Thanks for the report. I reproduced the wrong result.

The planner can treat count() as a monotonic window function and turn a
qual such as c <= 1 into a WindowAgg run condition. That is not safe
when the frame has an EXCLUDE option, because exclusion is applied after
the frame bounds are found and can depend on the current row or peer
group. In the reported count(v) case, NULL handling can produce values
such as 1, 2, 1, so the run condition can drop a later valid row.

This is not only about NULLs. count(*) has no NULL issue, and some
excluded-frame cases remain monotone, but not all of them. For example,
GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 FOLLOWING EXCLUDE GROUP can
make count(*) decrease when the current peer group changes.

The attached patch takes the conservative route: int8inc_support() no
longer reports count() as monotonic when the window frame has
FRAMEOPTION_EXCLUSION. The added regression tests cover the reported
case, related EXCLUDE variants, and a count(*) case. They also check
that the excluded-frame query keeps a normal Filter instead of a
WindowAgg Run Condition.

I ran make check, which passed.

Thoughts welcome on whether this is the right boundary, or whether some
narrower excluded-frame cases should still prove monotonicity
separately.

--
Best regards,
Chengpeng Yan

---

From: Richard Guo <[email-masked]>
Date: Mon, 29 Jun 2026 09:39:20 +0900

> that the excluded-frame query keeps a normal Filter instead of a

I think the fix is on the right track.  One nit is that it might be
better to use if/else if structure here.  The other nit is that the
test case is over-heavy; it should be kept as minimal as possible.

> narrower excluded-frame cases should still prove monotonicity

I wonder if we should still support count(*) with EXCLUDE CURRENT ROW,
as that always removes exactly one row, so it is still monotonic.

+   if ((frameOptions & FRAMEOPTION_EXCLUSION) &&
+       ((frameOptions & FRAMEOPTION_EXCLUSION) !=
FRAMEOPTION_EXCLUDE_CURRENT_ROW ||
+        req->window_func->winfnoid != F_COUNT_))
+       monotonic = MONOTONICFUNC_NONE;

- Richard

---

From: David Rowley <[email-masked]>
Date: Tue, 30 Jun 2026 01:44:20 +1200

> +       monotonic = MONOTONICFUNC_NONE;

Yeah, I noticed that one too. I think there are a few other cases we
could also allow.  I'm still looking, but I think COUNT(*) could still
be monotonically increasing when frame_end is CURRENT ROW for any of
EXCLUDE (CURRENT ROW|GROUP|TIES). In all those cases, since we've not
looked at any rows beyond the current row, we're not susceptible to
the exclude option excluding rows from a higher-order peer group,
which may have more rows to count.

I think there might be another with COUNT(ANY): "ORDER BY k RANGE
BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP". Here we're
excluding the entire group, so the count can't go backwards within
that group as we don't count anything due to EXCLUDE GROUP, and since
frame_end is CURRENT ROW, we don't have to concern ourselves with
counting some later group which has more peer rows making the count go
backwards (when that larger number of rows is excluded later when
processing that peer group).

I think there are also a few equivalences, such as:

"order by k rows between unbounded preceding and current row exclude
current row"

I believe is logically the same as:

"order by k rows between unbounded preceding and 1 preceding"

Then, if frame_end was 1 PRECEDING with "GROUPS BETWEEN ...", EXCLUDE
would never see the current row or peer group as the frame ends before
we get to the current group or row.

Anyway, I'm still trying to decide how to balance this. On one hand,
there's an incentive not to disable this optimisation for cases that
are perfectly safe, and on the other hand, there are lots of variables
here, and "perfectly safe" is a little harder to fathom than I'd like
for a bug fix. I'm also not keen on adding very complex and
hard-to-follow logic to the support function. If we can allow a few
more cases but do so as a result of boiling down the logic into
something simpler, then I'd likely be more in favour of covering some
extra cases.

I'll continue to look at this.

David

---

From: David Rowley <[email-masked]>
Date: Fri, 3 Jul 2026 15:38:34 +1200

> I'll continue to look at this.

I've been playing around with the attached. It doesn't get all the
cases that are ok. For example, in theory, I believe 0 FOLLOWING is
the same as CURRENT ROW for the frame_end. I've not made that work
exactly the same as I don't have the same wfunc->winfnoid ==
F_COUNT_ANY check that I added to CURRENT ROW for 0 FOLLOWING. I was
trying to strike some sort of balance between not regressing things
unnecessarily and having to add too much code for cases that are
unlikely ever to be used.  As far as I'm aware, for GROUPS and ROWS
mode, 0 FOLLOWING is the same as CURRENT ROW, so I suspect most people
would just use CURRENT ROW.

It's a little hard for me to know what to do here, as I really don't
know how commonly people use the EXCLUDE clause, and more so, how
often the Run Condition optimisation will apply when those are used.
It would be good to have an idea if the bug discovery was from a
real-world case, or if it was discovered from tooling, or from looking
at the code.

Does anyone think we should cover more or fewer cases? Or see any
problems with this patch?

David
From d1874e3f184240ff9498c35ae7bfd6072d504d29 Mon Sep 17 00:00:00 2001
From: David Rowley <[email-masked]>
Date: Fri, 3 Jul 2026 14:17:42 +1200
Subject: [PATCH v1] Fix COUNT's logic for window run condition support

9d9c02ccd added code to allow the executor to stop early when processing
WindowAgg nodes where a monotonic window function starts producing
values that result in a pushed-down qual no longer matching, and will
never match again due to the window function's monotonic properties.

That commit requires a SupportRequestWFuncMonotonic to exist on the
window function and for it to detect when the function is monotonic.  For
COUNT(ANY) and COUNT(*), the support function failed to consider some
cases where the WindowClause used EXCLUDE to exclude certain rows from
being aggregated.  Some WindowClause definitions mean we aggregate rows
that come after the current row, and when processing those rows later,
if we EXCLUDE certain rows, the monotonic property can be broken.

Here, we disable the "Run Condition" pushdown optimization for COUNT(ANY)
and COUNT(*) when the window clause has an EXCLUDE clause and the
properties of that clause, plus the frame bounds result in the monotonic
properties not being guaranteed.

Bug: #19533
Reported-by: Qifan Liu <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/adt/int8.c         | 101 ++++++-
 src/test/regress/expected/window.out | 436 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      | 215 +++++++++++++
 3 files changed, 751 insertions(+), 1 deletion(-)

diff --git a/src/backend/utils/adt/int8.c b/src/backend/utils/adt/int8.c
index 9b429da86d9..e14aa751478 100644
--- a/src/backend/utils/adt/int8.c
+++ b/src/backend/utils/adt/int8.c
@@ -795,7 +795,106 @@ int8inc_support(PG_FUNCTION_ARGS)
 		MonotonicFunction monotonic = MONOTONICFUNC_NONE;
 		int			frameOptions = req->window_clause->frameOptions;
 
-		/* No ORDER BY clause then all rows are peers */
+		/*
+		 * When the frame options has an EXCLUDE clause, this can affect the
+		 * monotonic guarantees.  Various cases are handled or rejected below.
+		 */
+		if (frameOptions & FRAMEOPTION_EXCLUSION)
+		{
+			WindowFunc *wfunc = req->window_func;
+
+			if (frameOptions & FRAMEOPTION_END_OFFSET_PRECEDING)
+			{
+				Node	   *endOffset = req->window_clause->endOffset;
+
+				/*
+				 * ROWS mode with EXCLUDE CURRENT ROW is fine, as the count
+				 * cannot include rows beyond the current row.  With GROUPS or
+				 * RANGE, peer rows would be included and those peers could be
+				 * after the current row.
+				 */
+				if (frameOptions & FRAMEOPTION_ROWS &&
+					frameOptions & FRAMEOPTION_EXCLUDE_CURRENT_ROW)
+				{
+					/* Allowed */
+				}
+
+				/*
+				 * Otherwise, EXCLUDE is fine when in GROUPS or ROWS mode when
+				 * we have an above zero Const in the N PRECEDING.  This means
+				 * all the would-be-excluded rows are never in the frame's
+				 * scope.  We allow any value of PRECEDING, including
+				 * non-consts when EXCLUDE GROUP is used, as effectively
+				 * that's 1 PRECEDING.  The executor ensures the value is
+				 * never negative.  Range mode is disallowed as the endOffset
+				 * has a different purpose.
+				 */
+				else if ((frameOptions & FRAMEOPTION_EXCLUDE_GROUP) == 0)
+				{
+					endOffset = eval_const_expressions(NULL, endOffset);
+
+					if (frameOptions & FRAMEOPTION_RANGE ||
+						!IsA(endOffset, Const) || castNode(Const, endOffset)->constisnull ||
+						DatumGetInt64(castNode(Const, endOffset)->constvalue) <= 0)
+					{
+						req->monotonic = MONOTONICFUNC_NONE;
+						PG_RETURN_POINTER(req);
+					}
+				}
+			}
+
+			else if (frameOptions & FRAMEOPTION_END_CURRENT_ROW)
+			{
+				/*
+				 * When the frame ends at CURRENT ROW, we must disallow
+				 * COUNT(ANY) when in RANGE or GROUPS mode unless we're
+				 * excluding the entire peer group.  Some values being counted
+				 * may be NULL, which could result in the count going up with
+				 * EXCLUDE CURRENT ROW, or down with EXCLUDE TIES.  ROWS mode
+				 * is fine as the count doesn't include peer rows ahead of the
+				 * current row.
+				 */
+				if (wfunc->winfnoid == F_COUNT_ANY &&
+					frameOptions & (FRAMEOPTION_RANGE | FRAMEOPTION_GROUPS) &&
+					frameOptions & (FRAMEOPTION_EXCLUDE_CURRENT_ROW | FRAMEOPTION_EXCLUDE_TIES))
+				{
+					req->monotonic = MONOTONICFUNC_NONE;
+					PG_RETURN_POINTER(req);
+				}
+
+				/*
+				 * COUNT(*) doesn't have the same problem as COUNT(ANY) since
+				 * rows in the peer group are always counted with GROUPS and
+				 * RANGE modes, and all the EXCLUDE options always exclude the
+				 * same number of rows, just not always the same rows.
+				 */
+			}
+
+			else if (frameOptions & FRAMEOPTION_END_OFFSET_FOLLOWING)
+			{
+				Node	   *endOffset = req->window_clause->endOffset;
+
+				endOffset = eval_const_expressions(NULL, endOffset);
+
+				/*
+				 * If the frame ends beyond the current row or peer group, we
+				 * can't prove any monotonic properties, as counting rows that
+				 * may later be excluded by EXCLUDE could result in the count
+				 * going down.  Technically, someone could write 0 FOLLOWING,
+				 * which is the same as CURRENT ROW in GROUPS or ROWS mode, so
+				 * don't disallow that.
+				 */
+				if (frameOptions & FRAMEOPTION_RANGE ||
+					!IsA(endOffset, Const) || castNode(Const, endOffset)->constisnull ||
+					DatumGetInt64(castNode(Const, endOffset)->constvalue) > 0)
+				{
+					req->monotonic = MONOTONICFUNC_NONE;
+					PG_RETURN_POINTER(req);
+				}
+			}
+		}
+
+		/* Otherwise, no ORDER BY clause means all rows are peers */
 		if (req->window_clause->orderClause == NIL)
 			monotonic = MONOTONICFUNC_BOTH;
 		else
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 90d9f953b81..ce3d6291d29 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4436,6 +4436,442 @@ WHERE c = 1;
                ->  Seq Scan on empsalary
 (9 rows)
 
+--
+-- Ensure we get the correct behavior for run condition pushdown when the
+-- frame option has an EXCLUDE clause
+--
+-- Ensure pushdown occurs for 0 PRECEDING when we exclude the entire peer group
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE GROUP)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- Ensure we don't get pushdown with EXCLUDE CURRENT ROW
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Ensure we don't get pushdown with EXCLUDE TIES
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Ensure pushdown occurs with 0 PRECEDING when in ROWS mode and only
+-- excluding the current row
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- EXCLUDE GROUP is also fine to push down the run condition with 0 PRECEDING
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE GROUP)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- Ensure we don't do run condition pushdown for EXCLUDE TIES
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                        QUERY PLAN                                                         
+---------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Ensure the following 6 tests do pushdown the run condition.
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE GROUP)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                          QUERY PLAN                                                          
+------------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                      QUERY PLAN                                                       
+-----------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE TIES)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                      QUERY PLAN                                                      
+----------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE GROUP)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE TIES)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- For the following 4 tests, ensure there's no pushdown with COUNT(ANY) when
+-- excluding rows from the current peer group.
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                       QUERY PLAN                                                        
+-------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                    QUERY PLAN                                                    
+------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                    QUERY PLAN                                                     
+-------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Ensure the following 2 tests push down the run condition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                 QUERY PLAN                                                  
+-------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                  QUERY PLAN                                                  
+--------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- Ensure the following 3 ROWS mode tests also push down
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                    QUERY PLAN                                                    
+------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                 QUERY PLAN                                                 
+------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                QUERY PLAN                                                 
+-----------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- Ensure RANGE mode with 1 FOLLOWING does not push down the run condition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                       QUERY PLAN                                                        
+-------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary RANGE BETWEEN UNBOUNDED PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Ensure there's no run condition pushdown with a non-const N FOLLOWING value
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1,10) FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                             QUERY PLAN                                                             
+------------------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1, 10) FOLLOWING EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Ensure we push down the run condition with 0 following, which is the same as CURRENT ROW.
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint FOLLOWING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql
index 5ac3a486e16..6465436a153 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1460,6 +1460,221 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE c = 1;
 
+--
+-- Ensure we get the correct behavior for run condition pushdown when the
+-- frame option has an EXCLUDE clause
+--
+
+-- Ensure pushdown occurs for 0 PRECEDING when we exclude the entire peer group
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure we don't get pushdown with EXCLUDE CURRENT ROW
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure we don't get pushdown with EXCLUDE TIES
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+
+-- Ensure pushdown occurs with 0 PRECEDING when in ROWS mode and only
+-- excluding the current row
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- EXCLUDE GROUP is also fine to push down the run condition with 0 PRECEDING
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure we don't do run condition pushdown for EXCLUDE TIES
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+
+-- Ensure the following 6 tests do pushdown the run condition.
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+
+-- For the following 4 tests, ensure there's no pushdown with COUNT(ANY) when
+-- excluding rows from the current peer group.
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure the following 2 tests push down the run condition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure the following 3 ROWS mode tests also push down
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure RANGE mode with 1 FOLLOWING does not push down the run condition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure there's no run condition pushdown with a non-const N FOLLOWING value
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1,10) FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- Ensure we push down the run condition with 0 following, which is the same as CURRENT ROW.
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
-- 
2.54.0.windows.1

---

From: John Naylor <[email-masked]>
Date: Fri, 3 Jul 2026 16:09:52 +0700

> real-world case, or if it was discovered from tooling, or from looking

If it was from tooling, it must have stopped with the first case it
found, as is often the case. I just tried myself and found three more
apparent bugs with EXCLUDE both with and without the v1 patch. Do you
want to see the reproducers, or rethink the risk/reward ratio of
what's handled? I'm also not sure how common that clause is.

Separately, I also I believe I found a counterexample to the part
starting with /* No ORDER BY clause then all rows are peers */ :

CREATE TABLE t7 (x int);
INSERT INTO t7 VALUES (7), (8), (9);

=# SELECT * FROM
  (SELECT x,
          count(*) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
   FROM t7) s
WHERE c >= 2;
 x | c
---+---
(0 rows)

=# SELECT * FROM
  (SELECT x,
          count(*) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
   FROM t7) s
WHERE c + 0 >= 2;    -- baseline
 x | c
---+---
 2 | 2
 2 | 3
(2 rows)

--
John Naylor
Amazon Web Services

---

From: John Naylor <[email-masked]>
Date: Fri, 3 Jul 2026 16:11:13 +0700

> WHERE c + 0 >= 2;    -- baseline

Sorry, this last result should be

 x | c
---+---
 8 | 2
 9 | 3
(2 rows)

-- 
John Naylor
Amazon Web Services

---

From: David Rowley <[email-masked]>
Date: Sat, 4 Jul 2026 11:57:23 +1200

> what's handled? I'm also not sure how common that clause is.

Please share what you discovered. It would be good to understand if
it's just a simple missing check that can be added to handle all
three, or if there's just more complexity in this than making this
work correctly is worth.

> starting with /* No ORDER BY clause then all rows are peers */ :

Thanks for finding that. Looks like I failed to consider ROWs mode for
that, where the function is only monotonically increasing. The
attached includes a fix for that.

David

---

From: John Naylor <[email-masked]>
Date: Sun, 5 Jul 2026 17:44:02 +0700

> three, or if there's just more complexity in this than making this

I was going to share the three examples, but then I had a better idea:
I had Claude generate a PL/pgSQL script to exhaustively (or something
close to it) iterate through the the whole space on a few different
data set shapes, look for plans with run conditions, and compare the
result to the "c+0" oracle. (attached as
count_runcondition_matrix.sql). On master, it found 295 (!) distinct
window specs that give wrong answers with run conditions on at least
one data set (attached as runcond-failures-master.txt). With v2, that
number falls to 162, which is still a surprisingly large number
(attached as runcond-failures-v2.txt). This script takes a while to
run, so it's probably not a good basis for a regression test, but it
seems useful to test against, and it can be modified to test
hypotheses.

I then had Claude generate two patches against master, attached as v3.
One is the minimal fix to get the above test matrix to pass. I didn't
try to determine how many currently working cases got lost that way.
The other is an attempt to provide complete coverage of all
monotonicity proofs. The latter is quite an indigestible mess, so we
probably cannot commit something like that, but I think a reasonable
takeaway is that EXCLUDE presents a really difficult set of cases.

I'll also note that two of v2's tests are valid on the data they work
on, but are not valid in general. I've attached v2-wrong-test-repo.sql
to demonstrate.

--
John Naylor
Amazon Web Services
From cc7ca26237e3b55853ef489306423b5d6828feab Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Sat, 4 Jul 2026 14:54:36 +0700
Subject: [PATCH v3] Minimal fix for COUNT's window run condition support

XXX vibe-coded, and the regression tests are surely not enough.

9d9c02ccd added code to allow the executor to stop early when
processing WindowAgg nodes where a monotonic window function starts
producing values that fail a pushed-down qual and can never satisfy it
again.  The monotonicity of COUNT(*) and COUNT(ANY) is determined by
int8inc_support(), which got two things wrong:

Firstly, it ignored the frame's EXCLUDE clause.  Excluded rows can
rejoin the count as the frame moves over the partition, so the count
can both rise and fall even when a frame bound is pinned to an edge of
the partition.  Proving monotonicity in the presence of an EXCLUDE
clause requires analyzing both frame bounds relative to the current
row's peer group, the frame mode, the exclusion type, and whether the
COUNT argument can be NULL.  That doesn't seem worth the complexity for
such a rarely-used feature, so simply report COUNT as non-monotonic
whenever the frame has an EXCLUDE clause.

Secondly, it assumed that the absence of an ORDER BY clause means the
count is the same for every row.  That's only true in RANGE mode, where
all rows being peers means every permitted frame covers the whole
partition.  In ROWS mode the frame bounds count individual rows
regardless of peerage, so determine monotonicity from the frame bounds
in the same way as when an ORDER BY is present.

Both errors could result in rows incorrectly missing from the query
result when the qual was pushed down as a run condition.

Bug: #19533
Reported-by: Qifan Liu <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/adt/int8.c         |  27 +++-
 src/test/regress/expected/window.out | 183 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      | 100 +++++++++++++++
 3 files changed, 308 insertions(+), 2 deletions(-)

diff --git a/src/backend/utils/adt/int8.c b/src/backend/utils/adt/int8.c
index 9b429da86d9..6f0da84b435 100644
--- a/src/backend/utils/adt/int8.c
+++ b/src/backend/utils/adt/int8.c
@@ -795,8 +795,31 @@ int8inc_support(PG_FUNCTION_ARGS)
 		MonotonicFunction monotonic = MONOTONICFUNC_NONE;
 		int			frameOptions = req->window_clause->frameOptions;
 
-		/* No ORDER BY clause then all rows are peers */
-		if (req->window_clause->orderClause == NIL)
+		/*
+		 * A frame EXCLUDE clause removes rows from within the frame as the
+		 * frame moves over the partition, which can cause the count to both
+		 * rise and fall for the same window definition.  Proving which
+		 * combinations of frame mode, frame bounds, exclusion type and
+		 * COUNT(*) vs COUNT(any) remain monotonic requires analysis of both
+		 * frame bounds relative to the current row's peer group, so we don't
+		 * attempt it; just report the function as non-monotonic.
+		 */
+		if (frameOptions & FRAMEOPTION_EXCLUSION)
+		{
+			req->monotonic = MONOTONICFUNC_NONE;
+			PG_RETURN_POINTER(req);
+		}
+
+		/*
+		 * When there's no ORDER BY clause, all rows are peers of one
+		 * another, so in RANGE mode every permitted frame bound degenerates
+		 * to the edge of the partition and the count is the same for every
+		 * row.  This does not apply in ROWS mode, where frame bounds count
+		 * individual rows regardless of any peerage; those get the ordinary
+		 * treatment below.  (GROUPS mode requires an ORDER BY clause.)
+		 */
+		if (req->window_clause->orderClause == NIL &&
+			frameOptions & FRAMEOPTION_RANGE)
 			monotonic = MONOTONICFUNC_BOTH;
 		else
 		{
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 90d9f953b81..b48316e2b53 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4436,6 +4436,189 @@ WHERE c = 1;
                ->  Seq Scan on empsalary
 (9 rows)
 
+--
+-- Ensure we don't push down the run condition when the frame has an EXCLUDE
+-- clause.  The excluded rows can rejoin the count as the frame moves over
+-- the partition, so the count is not guaranteed monotonic.
+--
+-- The count is 10 minus the size of the current row's peer group, which can
+-- both rise and fall, despite the UNBOUNDED PRECEDING start
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- As above, for the monotonically decreasing claim of the UNBOUNDED
+-- FOLLOWING end
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8;
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c >= 8)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Check the results are sane without the pushdown
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8 ORDER BY empno;
+ empno | salary | c 
+-------+--------+---
+     1 |   5000 | 9
+     2 |   3900 | 9
+     3 |   4800 | 8
+     4 |   4800 | 8
+     5 |   3500 | 9
+     7 |   4200 | 9
+     8 |   6000 | 9
+     9 |   4500 | 9
+    10 |   5200 | 8
+    11 |   5200 | 8
+(10 rows)
+
+-- EXCLUDE TIES keeps the current row, so with COUNT(any) the count can go
+-- down when the current row's argument is NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- As above, spelled 0 FOLLOWING instead of CURRENT ROW
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint FOLLOWING EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- No pushdown here either, even though this particular case happens to be
+-- monotonic: the excluded peer group can never be within the frame
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                          QUERY PLAN                                                          
+------------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+--
+-- Frames without an ORDER BY clause.  In RANGE mode all rows are peers, so
+-- the frame always covers the whole partition and the count is constant.
+-- In ROWS mode the frame bounds count rows as usual, so only the frame
+-- bounds determine monotonicity.
+--
+-- Ensure we get a pushdown in ROWS mode when the frame starts at the start
+-- of the partition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Seq Scan on empsalary
+(4 rows)
+
+-- The count here is the row number counted from the end of the partition,
+-- i.e. monotonically decreasing, so ensure we get a pushdown for >=
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) c
+   FROM empsalary) emp
+WHERE c >= 9;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Run Condition: (count(*) OVER w1 >= 9)
+   ->  Seq Scan on empsalary
+(4 rows)
+
+-- Ensure no pushdown when neither frame bound is the partition edge; the
+-- count is not monotonic in either direction
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                            QUERY PLAN                            
+------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ROWS BETWEEN CURRENT ROW AND CURRENT ROW)
+         ->  Seq Scan on empsalary
+(5 rows)
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql
index 5ac3a486e16..c2d7e36bb0c 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1460,6 +1460,106 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE c = 1;
 
+--
+-- Ensure we don't push down the run condition when the frame has an EXCLUDE
+-- clause.  The excluded rows can rejoin the count as the frame moves over
+-- the partition, so the count is not guaranteed monotonic.
+--
+
+-- The count is 10 minus the size of the current row's peer group, which can
+-- both rise and fall, despite the UNBOUNDED PRECEDING start
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- As above, for the monotonically decreasing claim of the UNBOUNDED
+-- FOLLOWING end
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8;
+
+-- Check the results are sane without the pushdown
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8 ORDER BY empno;
+
+-- EXCLUDE TIES keeps the current row, so with COUNT(any) the count can go
+-- down when the current row's argument is NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- As above, spelled 0 FOLLOWING instead of CURRENT ROW
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- No pushdown here either, even though this particular case happens to be
+-- monotonic: the excluded peer group can never be within the frame
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+--
+-- Frames without an ORDER BY clause.  In RANGE mode all rows are peers, so
+-- the frame always covers the whole partition and the count is constant.
+-- In ROWS mode the frame bounds count rows as usual, so only the frame
+-- bounds determine monotonicity.
+--
+
+-- Ensure we get a pushdown in ROWS mode when the frame starts at the start
+-- of the partition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- The count here is the row number counted from the end of the partition,
+-- i.e. monotonically decreasing, so ensure we get a pushdown for >=
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) c
+   FROM empsalary) emp
+WHERE c >= 9;
+
+-- Ensure no pushdown when neither frame bound is the partition edge; the
+-- count is not monotonic in either direction
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
-- 
2.54.0

From d820f841448fe628cf0ca0e50a4cf5aa6d70181c Mon Sep 17 00:00:00 2001
From: John Naylor <[email-masked]>
Date: Sat, 4 Jul 2026 12:14:48 +0700
Subject: [PATCH v3] Demo full coverage for COUNT's window run condition
 support

XXX vibe-coded, and the regression tests still need thought.

9d9c02ccd added code to allow the executor to stop early when
processing WindowAgg nodes where a monotonic window function starts
producing values that fail a pushed-down qual and can never satisfy it
again.  The monotonicity of COUNT(*) and COUNT(ANY) is determined by
int8inc_support(), which got two things wrong:

Firstly, it ignored the frame's EXCLUDE clause.  Excluded rows can
rejoin the count as the frame moves over the partition, so the count
can both rise and fall even when a frame bound is pinned to an edge of
the partition.  Whether monotonicity survives an EXCLUDE clause depends
on where the moving frame bound can be relative to the current row and
its peer group, and, since COUNT(ANY) skips NULL arguments, on whether
the excluded rows' contribution to the count can vary from row to row.
Add that analysis, applying it to the frame end for the monotonically
increasing case and, as a mirror image, to the frame start for the
monotonically decreasing case.

Secondly, it assumed that the absence of an ORDER BY clause means the
count is the same for every row.  That's only true in RANGE mode, where
all rows being peers means every permitted frame covers the whole
partition.  In ROWS mode the frame bounds count individual rows
regardless of peerage, so determine monotonicity from the frame bounds
in the same way as when an ORDER BY is present.

Both errors could result in rows incorrectly missing from the query
result when the qual was pushed down as a run condition.

Bug: #19533
Reported-by: Qifan Liu <[email-masked]>
Discussion: https://postgr.es/m/[email-masked]
---
 src/backend/utils/adt/int8.c         | 234 ++++++++++++++++--
 src/test/regress/expected/window.out | 343 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      | 178 ++++++++++++++
 3 files changed, 737 insertions(+), 18 deletions(-)

diff --git a/src/backend/utils/adt/int8.c b/src/backend/utils/adt/int8.c
index 9b429da86d9..a97f2d41704 100644
--- a/src/backend/utils/adt/int8.c
+++ b/src/backend/utils/adt/int8.c
@@ -780,6 +780,177 @@ int8dec_any(PG_FUNCTION_ARGS)
 	return int8dec(fcinfo);
 }
 
+/*
+ * count_wfunc_offset_matches
+ *		Check that a ROWS or GROUPS mode frame offset expression is a
+ *		non-NULL Const and that its value is zero (when positive is false)
+ *		or greater than zero (when positive is true).
+ *
+ * This must not be used for RANGE mode offsets, whose type depends on the
+ * ORDER BY column.
+ */
+static bool
+count_wfunc_offset_matches(Node *offset, bool positive)
+{
+	Const	   *cnst;
+
+	offset = eval_const_expressions(NULL, offset);
+
+	if (!IsA(offset, Const))
+		return false;
+
+	cnst = castNode(Const, offset);
+	if (cnst->constisnull)
+		return false;
+
+	if (positive)
+		return DatumGetInt64(cnst->constvalue) > 0;
+	else
+		return DatumGetInt64(cnst->constvalue) == 0;
+}
+
+/*
+ * count_wfunc_exclusion_ok
+ *		Determine whether the window frame's EXCLUDE clause preserves the
+ *		monotonicity of COUNT in one direction.
+ *
+ * COUNT is monotonically increasing when the frame start is pinned to the
+ * partition start and monotonically decreasing when the frame end is pinned
+ * to the partition end, since frame bounds only ever move forwards as the
+ * current row advances through the partition.  An EXCLUDE clause can break
+ * this by removing rows from within the frame in a way that varies from row
+ * to row: rows excluded for one current row can count again for a later
+ * one, and vice versa.  Whether that can happen depends only on where the
+ * moving frame bound can be relative to the current row and its peer group,
+ * so this function inspects the frame end when checking the increasing case
+ * (check_start is false), and the frame start when checking the decreasing
+ * case (check_start is true).  The two cases are exact mirror images, so
+ * the comments below describe the increasing case only.
+ *
+ * count_any must be true for COUNT(any) and false for COUNT(*).  The
+ * difference matters because COUNT(any) skips rows with a NULL argument, so
+ * excluding or re-admitting a particular row can change that count by a
+ * row-dependent amount.
+ */
+static bool
+count_wfunc_exclusion_ok(WindowClause *wc, bool count_any, bool check_start)
+{
+	int			frameOptions = wc->frameOptions;
+	Node	   *offset;
+	bool		bound_current;
+	bool		bound_shrinks;
+	bool		bound_grows;
+	bool		within_group;
+	bool		before_current;
+
+	if (check_start)
+	{
+		/* frame start, for the monotonically decreasing case */
+		bound_current = (frameOptions & FRAMEOPTION_START_CURRENT_ROW) != 0;
+		bound_shrinks = (frameOptions & FRAMEOPTION_START_OFFSET_FOLLOWING) != 0;
+		bound_grows = (frameOptions & FRAMEOPTION_START_OFFSET_PRECEDING) != 0;
+		offset = wc->startOffset;
+	}
+	else
+	{
+		/* frame end, for the monotonically increasing case */
+		bound_current = (frameOptions & FRAMEOPTION_END_CURRENT_ROW) != 0;
+		bound_shrinks = (frameOptions & FRAMEOPTION_END_OFFSET_PRECEDING) != 0;
+		bound_grows = (frameOptions & FRAMEOPTION_END_OFFSET_FOLLOWING) != 0;
+		offset = wc->endOffset;
+	}
+
+	/*
+	 * within_group: the frame end can never pass beyond the current row's
+	 * peer group, so the frame never contains rows after the peer group.
+	 * This holds for CURRENT ROW (the peer group's edge in RANGE and GROUPS
+	 * mode, the current row itself in ROWS mode), for any PRECEDING offset
+	 * (the executor ensures offsets aren't negative), and for a constant
+	 * zero FOLLOWING offset in ROWS or GROUPS mode, where it's equivalent
+	 * to CURRENT ROW.  If the frame end is UNBOUNDED FOLLOWING or can
+	 * otherwise pass the peer group, all of these are false and only
+	 * exclusions that are unconditionally safe are allowed below.
+	 */
+	within_group = bound_current || bound_shrinks ||
+		(bound_grows &&
+		 (frameOptions & (FRAMEOPTION_ROWS | FRAMEOPTION_GROUPS)) &&
+		 count_wfunc_offset_matches(offset, false));
+
+	/*
+	 * before_current: the current row itself can never be in the frame,
+	 * which requires a constant PRECEDING offset greater than zero.  We
+	 * cannot verify RANGE mode offsets, whose type depends on the ORDER BY
+	 * column.  Note that in ROWS mode earlier peers of the current row may
+	 * still be in the frame.
+	 */
+	before_current = bound_shrinks &&
+		(frameOptions & (FRAMEOPTION_ROWS | FRAMEOPTION_GROUPS)) &&
+		count_wfunc_offset_matches(offset, true);
+
+	if (frameOptions & FRAMEOPTION_EXCLUDE_CURRENT_ROW)
+	{
+		/*
+		 * For COUNT(*), excluding the current row is always safe: the count
+		 * is the frame size, less one when the current row is within the
+		 * frame.  For the current row to newly enter the frame, the frame
+		 * end must advance past it, growing the frame by at least the row
+		 * that the exclusion removes, so the count never goes down.
+		 *
+		 * For COUNT(any), the current row's argument may be NULL, making
+		 * the count's dependence on the current row vary from row to row.
+		 * That's safe when the current row can never be in the frame, so
+		 * the exclusion never does anything (before_current); or in ROWS
+		 * mode when the frame cannot extend past the current row
+		 * (within_group), where the result is simply the count over the
+		 * rows before the current row.  It is not safe in RANGE or GROUPS
+		 * mode with the frame end at CURRENT ROW: there the frame covers
+		 * the whole peer group, so the count changes with each row's
+		 * argument as the current row moves through the group.
+		 */
+		if (!count_any)
+			return true;
+		if (frameOptions & FRAMEOPTION_ROWS)
+			return within_group;
+		return before_current;
+	}
+	else if (frameOptions & FRAMEOPTION_EXCLUDE_GROUP)
+	{
+		/*
+		 * Excluding the whole peer group is safe as long as the frame end
+		 * cannot pass beyond it: the result is then the count over the rows
+		 * before the peer group (or before the frame end, whichever comes
+		 * first), and both of those bounds only move forwards.  If the
+		 * frame end can pass the peer group, rows beyond the group are
+		 * counted too, and their number can shrink again when the current
+		 * row moves into a larger peer group.
+		 */
+		return within_group;
+	}
+	else
+	{
+		Assert(frameOptions & FRAMEOPTION_EXCLUDE_TIES);
+
+		/*
+		 * EXCLUDE TIES removes the current row's peers, but not the current
+		 * row itself.  For COUNT(*) this is safe under the same condition
+		 * as EXCLUDE GROUP: with the frame end confined to the peer group,
+		 * the count is the number of rows before the peer group, plus one
+		 * if the current row is within the frame.  The latter is the same
+		 * for every row of a peer group, and crossing into the next peer
+		 * group grows the former by the size of the group just left, so
+		 * the count never goes down.
+		 *
+		 * For COUNT(any) the current row stays in the frame with its
+		 * possibly-NULL argument while its earlier peers are excluded, so
+		 * the count can move both ways as the current row moves through a
+		 * peer group.  It's only safe when the current row can never be in
+		 * the frame, making the result the count of frame rows before the
+		 * peer group.
+		 */
+		return count_any ? before_current : within_group;
+	}
+}
+
 /*
  * int8inc_support
  *		prosupport function for int8inc() and int8inc_any()
@@ -793,29 +964,56 @@ int8inc_support(PG_FUNCTION_ARGS)
 	{
 		SupportRequestWFuncMonotonic *req = (SupportRequestWFuncMonotonic *) rawreq;
 		MonotonicFunction monotonic = MONOTONICFUNC_NONE;
-		int			frameOptions = req->window_clause->frameOptions;
+		WindowClause *wc = req->window_clause;
+		int			frameOptions = wc->frameOptions;
+		bool		increasing;
+		bool		decreasing;
 
-		/* No ORDER BY clause then all rows are peers */
-		if (req->window_clause->orderClause == NIL)
-			monotonic = MONOTONICFUNC_BOTH;
-		else
+		/*
+		 * Frame bounds only ever move forwards as the current row advances
+		 * through the partition, so the count is monotonically increasing
+		 * whenever the frame start is pinned to the partition start, and
+		 * monotonically decreasing whenever the frame end is pinned to the
+		 * partition end.
+		 */
+		increasing = (frameOptions & FRAMEOPTION_START_UNBOUNDED_PRECEDING) != 0;
+		decreasing = (frameOptions & FRAMEOPTION_END_UNBOUNDED_FOLLOWING) != 0;
+
+		/*
+		 * When there's no ORDER BY clause, all rows are peers of one
+		 * another, so in RANGE mode every permitted frame bound degenerates
+		 * to a partition edge and the count is the same for every row.
+		 * This does not apply in ROWS mode, where frame bounds count
+		 * individual rows regardless of any peerage; those are handled by
+		 * the frame bound checks above.  (GROUPS mode requires an ORDER BY
+		 * clause.)
+		 */
+		if (wc->orderClause == NIL && frameOptions & FRAMEOPTION_RANGE)
+			increasing = decreasing = true;
+
+		/*
+		 * A frame EXCLUDE clause can break the monotonicity established
+		 * above, since rows excluded from the count for one current row may
+		 * be counted again for a later one, and vice versa.  Each direction
+		 * must be re-checked separately.
+		 */
+		if (frameOptions & FRAMEOPTION_EXCLUSION)
 		{
-			/*
-			 * Otherwise take into account the frame options.  When the frame
-			 * bound is the start of the window then the resulting value can
-			 * never decrease, therefore is monotonically increasing
-			 */
-			if (frameOptions & FRAMEOPTION_START_UNBOUNDED_PRECEDING)
-				monotonic |= MONOTONICFUNC_INCREASING;
+			bool		count_any;
 
-			/*
-			 * Likewise, if the frame bound is the end of the window then the
-			 * resulting value can never decrease.
-			 */
-			if (frameOptions & FRAMEOPTION_END_UNBOUNDED_FOLLOWING)
-				monotonic |= MONOTONICFUNC_DECREASING;
+			count_any = req->window_func->winfnoid == F_COUNT_ANY;
+
+			if (increasing)
+				increasing = count_wfunc_exclusion_ok(wc, count_any, false);
+			if (decreasing)
+				decreasing = count_wfunc_exclusion_ok(wc, count_any, true);
 		}
 
+		if (increasing)
+			monotonic |= MONOTONICFUNC_INCREASING;
+		if (decreasing)
+			monotonic |= MONOTONICFUNC_DECREASING;
+
 		req->monotonic = monotonic;
 		PG_RETURN_POINTER(req);
 	}
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 90d9f953b81..a7f689362b4 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4436,6 +4436,349 @@ WHERE c = 1;
                ->  Seq Scan on empsalary
 (9 rows)
 
+--
+-- Tests for run condition pushdown with EXCLUDE clauses in the frame
+--
+-- Excluding the current row keeps COUNT(*) monotonic with any frame bounds
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1,10) FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                          QUERY PLAN                                                          
+------------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1, 10) FOLLOWING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- but not COUNT(any), whose argument for the excluded row may be NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                           QUERY PLAN                                                           
+--------------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- COUNT(any) with EXCLUDE CURRENT ROW is fine in ROWS mode when the frame
+-- cannot extend past the current row
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                    QUERY PLAN                                                    
+------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- but not in RANGE or GROUPS mode, where the frame covers the whole peer
+-- group while the current row moves through it
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                       QUERY PLAN                                                        
+-------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- EXCLUDE GROUP is fine as long as the frame end cannot pass the peer group
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                  QUERY PLAN                                                  
+--------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                       QUERY PLAN                                                       
+------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE GROUP)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- with a frame end beyond the peer group, the count is 10 minus the size of
+-- the current row's peer group, which can both rise and fall
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8;
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c >= 8)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Check the results are sane without the pushdown
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8 ORDER BY empno;
+ empno | salary | c 
+-------+--------+---
+     1 |   5000 | 9
+     2 |   3900 | 9
+     3 |   4800 | 8
+     4 |   4800 | 8
+     5 |   3500 | 9
+     7 |   4200 | 9
+     8 |   6000 | 9
+     9 |   4500 | 9
+    10 |   5200 | 8
+    11 |   5200 | 8
+(10 rows)
+
+-- EXCLUDE TIES is like EXCLUDE GROUP for COUNT(*) ...
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE TIES)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- ... but for COUNT(any) the current row stays in the frame with its
+-- possibly-NULL argument while its earlier peers are excluded, so the frame
+-- may not contain the current row at all
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                   QUERY PLAN                                                    
+-----------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                         QUERY PLAN                                                          
+-----------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint FOLLOWING EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                      QUERY PLAN                                                       
+-----------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE TIES)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- The monotonically decreasing case must apply the same checks to the frame
+-- start.  The count of rows from the start of each peer group to the end of
+-- the partition never increases ...
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 3;
+                                                  QUERY PLAN                                                  
+--------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 >= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- ... but from an earlier peer group onwards, it can both rise and fall
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN 1 PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 3;
+                                                          QUERY PLAN                                                          
+------------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c >= 3)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN '1'::bigint PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+--
+-- Frames without an ORDER BY clause.  In RANGE mode all rows are peers, so
+-- the frame always covers the whole partition and the count is constant.
+-- In ROWS mode the frame bounds count rows as usual, so only the frame
+-- bounds determine monotonicity.
+--
+-- Ensure we get a pushdown in ROWS mode when the frame starts at the start
+-- of the partition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Seq Scan on empsalary
+(4 rows)
+
+-- The count here is the row number counted from the end of the partition,
+-- i.e. monotonically decreasing, so ensure we get a pushdown for >=
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) c
+   FROM empsalary) emp
+WHERE c >= 9;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Run Condition: (count(*) OVER w1 >= 9)
+   ->  Seq Scan on empsalary
+(4 rows)
+
+-- Ensure no pushdown when neither frame bound is the partition edge; the
+-- count is not monotonic in either direction
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                            QUERY PLAN                            
+------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ROWS BETWEEN CURRENT ROW AND CURRENT ROW)
+         ->  Seq Scan on empsalary
+(5 rows)
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql
index 5ac3a486e16..d79ad51f9db 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1460,6 +1460,184 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE c = 1;
 
+--
+-- Tests for run condition pushdown with EXCLUDE clauses in the frame
+--
+
+-- Excluding the current row keeps COUNT(*) monotonic with any frame bounds
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1,10) FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- but not COUNT(any), whose argument for the excluded row may be NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- COUNT(any) with EXCLUDE CURRENT ROW is fine in ROWS mode when the frame
+-- cannot extend past the current row
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- but not in RANGE or GROUPS mode, where the frame covers the whole peer
+-- group while the current row moves through it
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- EXCLUDE GROUP is fine as long as the frame end cannot pass the peer group
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- with a frame end beyond the peer group, the count is 10 minus the size of
+-- the current row's peer group, which can both rise and fall
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8;
+
+-- Check the results are sane without the pushdown
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8 ORDER BY empno;
+
+-- EXCLUDE TIES is like EXCLUDE GROUP for COUNT(*) ...
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- ... but for COUNT(any) the current row stays in the frame with its
+-- possibly-NULL argument while its earlier peers are excluded, so the frame
+-- may not contain the current row at all
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- The monotonically decreasing case must apply the same checks to the frame
+-- start.  The count of rows from the start of each peer group to the end of
+-- the partition never increases ...
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 3;
+
+-- ... but from an earlier peer group onwards, it can both rise and fall
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN 1 PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 3;
+
+--
+-- Frames without an ORDER BY clause.  In RANGE mode all rows are peers, so
+-- the frame always covers the whole partition and the count is constant.
+-- In ROWS mode the frame bounds count rows as usual, so only the frame
+-- bounds determine monotonicity.
+--
+
+-- Ensure we get a pushdown in ROWS mode when the frame starts at the start
+-- of the partition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- The count here is the row number counted from the end of the partition,
+-- i.e. monotonically decreasing, so ensure we get a pushdown for >=
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) c
+   FROM empsalary) emp
+WHERE c >= 9;
+
+-- Ensure no pushdown when neither frame bound is the partition edge; the
+-- count is not monotonic in either direction
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
-- 
2.54.0

---

From: Chengpeng Yan <[email-masked]>
Date: Sun, 5 Jul 2026 12:54:38 +0000

Hi,

> On Jul 5, 2026, at 18:44, John Naylor <[email-masked]> wrote:
> 

Thanks for putting together the matrix script. That seems like a very
good way to test hypotheses here.

I have also been thinking about this tradeoff while trying to put
together a patch for it. It looks possible to prove more EXCLUDE cases,
but the code and the test space seem to get complicated quickly. My
current inclination is to be conservative here: return
`MONOTONICFUNC_NONE` unless a case is clearly proven safe. The matrix
results seem to support that direction. That may miss some
run-condition opportunities, but it avoids relying on a monotonicity
claim that is not yet fully justified.

This is also close to why the v1 patch I posted earlier simply disabled
the EXCLUDE cases. That version missed the no-ORDER-BY issue, but the
conservative part matches the direction of your minimal v3: do not try
to infer monotonicity for excluded frames unless the case is very
simple.

One extra axis I noticed in the matrix is FILTER. It currently
enumerates `count(*)` and `count(e)`, but not `count(...) FILTER (...)`,
for example:

```
count(*) FILTER (WHERE ...)
```

That is not the same as plain `count(*)`, and it makes the set of
combinations even larger.

I was about to send a WIP patch that is very close to your minimal v3.
The main code difference is this guard:

```
int exclusion = frameOptions & FRAMEOPTION_EXCLUSION;
bool plain_count_star = req->window_func->winfnoid == F_COUNT_ &&
req->window_func->aggfilter == NULL;

if (exclusion &&
(exclusion != FRAMEOPTION_EXCLUDE_CURRENT_ROW || !plain_count_star))
{
req->monotonic = MONOTONICFUNC_NONE;
PG_RETURN_POINTER(req);
}
```

So compared with the minimal v3 rule that rejects all EXCLUDE cases,
this only keeps plain `count(*) EXCLUDE CURRENT ROW`, with no FILTER.
The reason is that removing the current row removes a fixed
contribution. Everything else with EXCLUDE returns
`MONOTONICFUNC_NONE`. I am not strongly attached to keeping that case
if people prefer the simpler minimal fix, but I think the general shape
should still be reject-by-default rather than allow-by-default.

So I agree with the takeaway from your complete-coverage experiment:
there are valid optimizations in this area, but proving all of them
seems too complex for a bug fix. Starting from the minimal safe rule
and adding back only small, clearly proven cases seems safer to me.

--
Best regards,
Chengpeng Yan

---

From: David Rowley <[email-masked]>
Date: Mon, 6 Jul 2026 19:24:54 +1200

> count(*) FILTER (WHERE ...)

Yeah, looks like we'll need to disable if there's a FILTER clause.

> req->monotonic = MONOTONICFUNC_NONE;

Given how complex Claude's logic became to get all those cases
correct, I now think the above is the best option. I've attached a
patch for that.

David

---

From: Chengpeng Yan <[email-masked]>
Date: Mon, 6 Jul 2026 08:31:42 +0000

Hi David,

> On Jul 6, 2026, at 15:24, David Rowley <[email-masked]> wrote:`
> 

Thanks for updating the patch.  I looked through the code and it looks
good to me.

One minor comment: this test comment seems to be a typo:

-- As above, but check we detect it's monotonically increasing

It looks like the following test is checking that we do not treat the
frame as monotonically increasing.

Other than that, LGTM.

--
Best regards,
Chengpeng Yan

---

From: John Naylor <[email-masked]>
Date: Tue, 7 Jul 2026 12:11:01 +0700

> correct, I now think the above is the best option. I've attached a

Works for me. A tiny question on this comment change:

- /* No ORDER BY clause then all rows are peers */
...
+ /* No ORDER BY clause and RANGE mode means all rows are peers. */

Was the old comment wrong? Or are all rows still peers, but that's not
sufficient to show monotonicity?

-- 
John Naylor
Amazon Web Services

---

From: David Rowley <[email-masked]>
Date: Tue, 7 Jul 2026 17:35:13 +1200

> sufficient to show monotonicity?

The test needed to be updated because detecting monotonically
increasing and decreasing in ROWS mode wasn't correct. The comment got
updated along with the test.

David

---

From: David Rowley <[email-masked]>
Date: Wed, 8 Jul 2026 00:02:30 +1200

On Tue, 7 Jul 2026 at 17:11, John Naylor <[email-masked]> wrote:
> Works for me.

Pushed.

David