Skip to content

fix: clamp count() at 0 when offset exceeds total#2208

Merged
waketzheng merged 3 commits into
tortoise:developfrom
koriyoshi2041:fix/count-negative-offset
May 31, 2026
Merged

fix: clamp count() at 0 when offset exceeds total#2208
waketzheng merged 3 commits into
tortoise:developfrom
koriyoshi2041:fix/count-negative-offset

Conversation

@koriyoshi2041
Copy link
Copy Markdown
Contributor

Description

QuerySet.count() returns a negative number when offset() is larger than the total row count.

COUNT(*) ignores LIMIT/OFFSET, so CountQuery applies the offset in Python by subtracting it (tortoise/queryset.py):

count = list(dict(result[0]).values())[0] - self._offset

When offset > total, this goes negative. For example, with 5 rows:

await Item.all().offset(100).count()        # -> -95
len(await Item.all().offset(100))           # ->  0   (what SQL LIMIT/OFFSET actually returns)

The fix clamps the result at 0, matching the row count the equivalent query would actually return.

Motivation and Context

A count should never be negative; callers using .offset(...).count() (e.g. for pagination "remaining" math) get a nonsensical value.

How Has This Been Tested

Added test_offset_count_beyond_total in tests/test_queryset.py: with the 30-row IntFields fixture, offset(100).count() must equal 0 and match len(await ...offset(100)). It fails before the fix (-70) and passes after. Added a CHANGELOG.rst entry under 1.1.8 → Fixed.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 31, 2026

Merging this PR will not alter performance

✅ 24 untouched benchmarks


Comparing koriyoshi2041:fix/count-negative-offset (3be1b35) with develop (179ea6f)

Open in CodSpeed

@waketzheng
Copy link
Copy Markdown
Contributor

The offset > total fix looks correct. Clamping total - offset at 0 makes count() match the number of rows returned by the equivalent offset query, and the added regression test covers the reported case.

One related edge case may be worth confirming while this code path is being adjusted: limit(0).count() still appears to return the total row count instead of 0.

That seems to come from the existing limit clamp using a truthiness check:

if self._limit and count > self._limit:
    return self._limit

Since self._limit == 0 is false, the limit is skipped. For example, with 30 rows:

await IntFields.all().limit(0).count()  # currently 30
len(await IntFields.all().limit(0))     # 0

This is not introduced by this PR, but it is the same CountQuery LIMIT/OFFSET semantics area. It may be worth considering self._limit is not None here and adding a small regression test for limit(0).count() if the intended behavior is to match the limited query result.

COUNT(*) ignores LIMIT/OFFSET, so CountQuery applies them in Python. Two edge
cases didn't match the limited query result:
- offset greater than the total returned a negative number (e.g.
  .offset(100).count() == -95 for 5 rows) instead of 0.
- limit(0) returned the full total instead of 0, because the limit clamp used
  a truthiness check (`if self._limit`) that skips 0.

Clamp the offset subtraction at 0 and use `self._limit is not None` so an
explicit limit(0) is honored.
@koriyoshi2041 koriyoshi2041 force-pushed the fix/count-negative-offset branch from e8d9c23 to 86f54ae Compare May 31, 2026 08:53
@koriyoshi2041
Copy link
Copy Markdown
Contributor Author

Good catch — fixed in the same change. limit(0).count() now returns 0 (the clamp uses self._limit is not None instead of a truthiness check), and I added a test_limit_zero_count regression test alongside the offset one. Both assert the count matches len(await ...) of the limited query. CHANGELOG updated to mention both edge cases.

Comment thread CHANGELOG.rst Outdated
@koriyoshi2041
Copy link
Copy Markdown
Contributor Author

Added in bb23c76, thanks.

@waketzheng
Copy link
Copy Markdown
Contributor

Please fix lint issue, you can try:

prek run --all-files

@koriyoshi2041
Copy link
Copy Markdown
Contributor Author

Fixed, thanks. I ran ruff format on tests/test_queryset.py and pushed 3be1b35.

Local checks now pass:

  • make check
  • TORTOISE_TEST_DB=sqlite://:memory: uv run --frozen pytest tests/test_queryset.py -k 'limit_zero_count or offset_count_beyond_total' -q

@waketzheng waketzheng merged commit 403a4dc into tortoise:develop May 31, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants