Repository: duckdb/duckdb (2026-07-06T11:04:18Z)

Issue:
COUNT(*) over a reversed ROWS frame can return a negative count
### What happens?

`COUNT(*)` over a reversed `ROWS` frame can return `-1`.
This reproduces on current main, but does not reproduce on v1.5.4.

Not sure whether this is a bug, as COUNT() should be non-negative?



### To Reproduce

To reproduce:

```sql
SELECT
  COUNT(*) OVER (
    ROWS BETWEEN 1 FOLLOWING AND 1 PRECEDING
  ) AS actual
FROM range(1);
```

Run:

```sh
./build/release/duckdb -f bug.sql
```

Actual:

```json
[{"actual":-1}]
```

Expected:

| actual |
| ---: |
| 0 |

or 

`Parser Error: frame starting from following row cannot have preceding rows`

Notes From Minimization:

- `SET debug_window_mode='separate'` makes the same query return `0`.
- `ORDER BY` is not required.
- A named `VALUES` input is not required; `range(1)` is enough.
- The issue requires a reversed `ROWS` frame. Nearby empty but non-reversed frames such as `ROWS BETWEEN 2 PRECEDING AND 1 PRECEDING` return `0` locally.
- v1.5.4 rejects this query: `Parser Error: frame starting from following row cannot have preceding rows`


### OS:

macOS 15.7.4, arm64

### DuckDB Version:

current main (a81547b43c)

### DuckDB Client:

CLI

### Hardware:

_No response_

### Full Name:

Yibo Dong

### Affiliation:

National University of Singapore

### Did you include all relevant configuration (e.g., CPU architecture, Linux distribution) to reproduce the issue?

- [x] Yes, I have

### Did you include all code required to reproduce the issue?

- [x] Yes, I have

### Did you include all relevant data sets for reproducing the issue?

Yes


Discussion:
@DinosL Thanks for checking this!



Patch:
diff --git a/src/function/window/window_value_function.cpp b/src/function/window/window_value_function.cpp
index bf0c431b879a..035636ad81e7 100644
--- a/src/function/window/window_value_function.cpp
+++ b/src/function/window/window_value_function.cpp
@@ -646,7 +646,7 @@ void WindowLeadLagExecutor::GetData(ExecutionContext &context, DataChunk &eval_c
 			}
 
 			// (1) compute the ROW_NUMBER of the own row
-			frame = FrameBounds(frame_begin[i], frame_end[i]);
+			frame = FrameBounds(frame_begin[i], MaxValue(frame_end[i], frame_begin[i]));
 			const auto own_row = glstate.row_tree->Rank(frame.start, frame.end, row_idx) - 1;
 			// (2) adjust the row number by adding or subtracting an offset
 			auto val_idx = NumericCast<int64_t>(own_row);
diff --git a/src/include/duckdb/function/window/window_aggregator.hpp b/src/include/duckdb/function/window/window_aggregator.hpp
index d309a1b33aad..d806b274412e 100644
--- a/src/include/duckdb/function/window/window_aggregator.hpp
+++ b/src/include/duckdb/function/window/window_aggregator.hpp
@@ -33,7 +33,7 @@ class WindowAggregator {
 			idx_t nframes = 0;
 			if (exclude_mode == WindowExcludeMode::NO_OTHER) {
 				auto begin = begins[i];
-				auto end = ends[i];
+				auto end = MaxValue(ends[i], begin);
 				frames[nframes++] = FrameBounds(begin, end);
 			} else {
 				//	The frame_exclusion option allows rows around the current row to be excluded from the frame,
@@ -57,7 +57,7 @@ class WindowAggregator {
 
 				//	WindowExcludePart::LEFT
 				const auto frame_begin = begins[i];
-				const auto frame_end = ends[i];
+				const auto frame_end = MaxValue(ends[i], frame_begin);
 				auto begin = frame_begin;
 				auto end = (exclude_mode == WindowExcludeMode::CURRENT_ROW) ? cur_row : peer_begin[i];
 				end = MinValue(end, frame_end);


Test patch:
diff --git a/test/sql/window/test_empty_frames.test b/test/sql/window/test_empty_frames.test
index 9eea0eae88a3..cc32488b4f49 100644
--- a/test/sql/window/test_empty_frames.test
+++ b/test/sql/window/test_empty_frames.test
@@ -31,7 +31,57 @@ NULL	0
 
 endloop
 
-#
+#  Reversed frames are empty, so COUNT must be 0 (not negative)
+foreach agg count(*) count_star()
+
+query I
+SELECT {agg} OVER (ROWS BETWEEN 1 FOLLOWING AND 1 PRECEDING) AS actual
+FROM range(1);
+----
+0
+
+endloop
+
+query II
+SELECT id, count(*) OVER (PARTITION BY ch ORDER BY id ROWS BETWEEN 3 FOLLOWING AND 1 FOLLOWING)
+FROM t1
+ORDER BY 1;
+----
+1	0
+2	0
+NULL	0
+
+foreach agg count(*) count_star()
+
+# Check aggregates
+query I
+SELECT {agg} OVER (ROWS BETWEEN 1 FOLLOWING AND 1 PRECEDING EXCLUDE TIES) AS actual
+FROM range(1);
+----
+0
+
+endloop
+
+# Check value functions
+query II
+SELECT id, lead(id ORDER BY id) OVER (ORDER BY ch ROWS BETWEEN 3 FOLLOWING AND 1 FOLLOWING)
+FROM t1
+ORDER BY 1;
+----
+1	NULL
+2	NULL
+NULL	NULL
+
+# Check ranking functions
+query II
+SELECT id, rank() OVER (ORDER BY id ROWS BETWEEN 3 FOLLOWING AND 1 FOLLOWING)
+FROM t1
+ORDER BY 1;
+----
+1	1
+2	2
+NULL	3
+
 # All zeroes
 #
 foreach agg approx_count_distinct count entropy