HIROSE PAPER MFG. CO., LTD.

Employees' Blog

Why total Alone Can’t Tell You a Paginated List Hit Its LIMIT
Whether there's more data waiting is something no count can tell you — only fetching one row past the limit can.

Published on: 2026.10.01 Last updated: 2026.10.01
An illustration of a grand old library reading room, where a small front table holds a handful of books beside a placard captioned 'Is That Really All?', while endless shelving packed with far more books recedes into shadow behind it

Say you’re building an API that lists orders. It filters on a search condition, returns the matching orders as an array, and the screen wants a headline number too, something like “3,500 orders total.” Here’s the kind of implementation most people reach for first.

const MAX_LIMIT = 1000;

function listOrders(db, requestedLimit) {
  const limit = Math.min(requestedLimit, MAX_LIMIT);
  const items = db.prepare(
    "SELECT id FROM orders WHERE status = 'open' ORDER BY id LIMIT ?").all(limit);
  return { total: items.length, items };
}

The server caps how many rows a single request can return, 1,000 here, so a burst of results never blows up the response. That’s a perfectly ordinary design decision. Write a test for it the day you ship, and it passes: the count matches, nothing throws.

But there’s a gap hiding in this code. If 3,500 orders actually have status = 'open', the screen still reports “1,000 orders total.” Nobody filtered anything down, and no bug dropped a row. The number on screen and the number that actually exists have quietly come apart. So where did things go sideways?

Same Response, Two Very Different Truths

total in that snippet is items.length, the length of the array the server happens to send back. It isn’t counting the population: the total number of rows that actually match the condition. When there are fewer rows than the limit, nothing goes wrong. Once the population exceeds the limit, the story changes.

To see it happen, only one thing was varied: the row count in the database. With exactly 1,000 open orders, and again with 3,500, the same code was asked for limit=5000, a value above the cap.

for (const n of [1000, 3500]) {
  const res = listOrders(makeDb(n), 5000);
  console.log(`${n} rows in DB / requested limit=5000 ->`,
    JSON.stringify({ total: res.total, itemsLength: res.items.length,
      lastId: res.items.at(-1).id }));
}
1000 rows in DB / requested limit=5000 -> {"total":1000,"itemsLength":1000,"lastId":1000}
3500 rows in DB / requested limit=5000 -> {"total":1000,"itemsLength":1000,"lastId":1000}

The two responses are identical, byte for byte: same total, same items length, same trailing id. A caller has no way to tell which of the two situations produced it. No error, no log entry: the 2,500 rows that got truncated away simply vanish.

Rounding the requested limit down to a server-side cap isn’t the problem by itself. That’s a common way to keep a response from ballooning, and the last section of this article comes back to it. The problem is that total never says the rounding happened.

There Are at Least Four Ways to Count “Total”

“Count the length of the array” is only one option for how total gets built. Four different approaches, including two done entirely in SQL, are worth lining up side by side. The population here is 5,000 rows, of which 3,500 have status = 'open', with a limit of 1,000.

-- (a) length of the returned array, computed in JS
SELECT id FROM orders WHERE status = 'open' ORDER BY id LIMIT 1000

-- (b) count the LIMITed result again
SELECT count(*) FROM (
  SELECT id FROM orders WHERE status = 'open' ORDER BY id LIMIT 1000
)

-- (c) a window function, COUNT(*) OVER (), in the same SELECT as LIMIT
SELECT id, count(*) OVER () AS total
FROM orders WHERE status = 'open' ORDER BY id LIMIT 1000

-- (d) a separate query
SELECT count(*) FROM orders WHERE status = 'open'
(a) items.length                 = 1000
(b) count(*) of LIMITed subquery = 1000
(c) count(*) OVER () with LIMIT  = 3500 (rows returned: 1000)
(d) separate count(*) query      = 3500

(a) and (b) both count after the cut, so both land on the limit itself, 1,000: different code, same trap. (c) and (d) count the population that matches the condition, and both correctly return 3,500.

What makes (c) interesting is that COUNT(*) OVER () sits in the very same SELECT as LIMIT 1000. So how does it see past the cutoff? OVER (...) defines a window, the slice of rows a function aggregates over, which is why this is called a window function. Put PARTITION BY inside the parentheses and you split rows into groups; leave it empty, as here, and the window becomes every row that matched the condition, counted as one group.

PostgreSQL’s own documentation explains why window functions can’t appear in WHERE or GROUP BY.

Window functions are permitted only in the SELECT list and the ORDER BY clause of the query. They are forbidden elsewhere, such as in GROUP BY, HAVING and WHERE clauses. This is because they logically execute after the processing of those clauses.

(PostgreSQL docs — Tutorial: Window Functions)

Window functions are evaluated after WHERE has already filtered rows. The docs for SELECT itself lay out the same ordering: the output rows, window functions included, are computed before ORDER BY, and LIMIT/OFFSET come after that.

If the ORDER BY clause is specified, the returned rows are sorted in the specified order. … If the LIMIT (or FETCH FIRST) or OFFSET clause is specified, the SELECT statement only returns a subset of the result rows.

(PostgreSQL docs — SELECT)

That ordering is exactly why COUNT(*) OVER () can report the population before LIMIT throws rows away: one query hands back both the true total and the capped page. It sounds like a free lunch, but it has a character of its own, which the next section gets into.

A warehouse gate marked 1000 with a pile of parcels stacked behind it up to a far wall marked 3500, where a gauge labeled items.length stops right at the gate while gauges labeled COUNT(*) OVER() and count(*) reach all the way to the far wall
Figure 1: two ways of counting stop at the limit, two reach past it

Comparing How to Detect It, and How to Fix It

How do you actually catch a truncated list? The candidates are on the table already: count the population in a separate query, fold COUNT(*) OVER () into the same query, or give up on the total entirely and report only whether there’s more. Each one comes with a tradeoff worth measuring.

A Separate count(*) Query: Accurate the Moment You Ran It

The most obvious fix is swapping total for a count(*) from a second query. It has a catch that’s easy to miss: if rows are inserted or deleted between the moment you counted and the moment you finish paging through, the number drifts.

To see it, 3,500 rows were counted first, then, before the list was paged through, one more row was inserted by someone else, then every page was fetched to the end.

const total = db.prepare("SELECT count(*) AS c FROM orders WHERE status = 'open'").get().c;
// Right after counting, before the list is fetched, another user inserts one row
db.prepare("INSERT INTO orders(status) VALUES ('open')").run();
// Page through every page at page size 1000 (code omitted)
total (counted first) = 3500, rows actually fetched by paging to the end = 3501

The counted total stayed at 3,500, but paging all the way through actually pulled back 3,501 rows. The separate count(*) query isn’t lying: it’s telling you exactly the value at the moment it ran. Pagination spans multiple requests, so it has no way to account for what changes in between.

A tally counter reading 3500 beside a conveyor of parcels, with one more parcel quietly joining from a side chute right after the reading was taken, so a second counter further along reads 3501, showing the gap between the earlier count and the later actual total
Figure 2: a count taken earlier doesn’t track what happens after

COUNT(*) OVER (): One Query, but It Vanishes the Moment Zero Rows Come Back

COUNT(*) OVER () rides along with every row it returns, which means that if zero rows come back, the total disappears with them. Here’s what actually happens once OFFSET runs past the end of the result set.

SELECT id, count(*) OVER () AS total
FROM orders WHERE status = 'open' ORDER BY id LIMIT 1000 OFFSET :offset
OFFSET 3000 -> rows 500, total = 3500
OFFSET 3500 -> rows 0, total = (no row to read it from)
OFFSET 4000 -> rows 0, total = (no row to read it from)

At OFFSET 3000, the remaining 500 rows arrive with total = 3500 riding along. Once OFFSET runs past the population, though, zero rows come back, and with them goes the only place total had to attach itself. What a caller does with total on the page past the last one is something that has to be decided separately; the value simply isn’t there to read.

There’s a second surprise here, this time about cost. The instinct is that fewer round trips must mean COUNT(*) OVER () wins, so it was timed against a separate count(*) on a million-row table with an index in place.

LIMIT 1001 (limit + 1)           median 0.60 ms
count(*) OVER () + LIMIT 1000    median 789.43 ms
separate count(*) query          median 61.87 ms

That instinct was wrong. COUNT(*) OVER () came in an order of magnitude slower than a plain count(*), roughly 12 to 13 times slower across two separate runs. The execution plan, the step-by-step strategy the database actually uses to run a query, visible in SQLite by prefixing a query with EXPLAIN QUERY PLAN, explains why.

count(*) OVER () + LIMIT 1000: CO-ROUTINE (subquery-2) / SEARCH orders USING COVERING INDEX idx_orders_status_id (status=?) / SCAN (subquery-2) / USE TEMP B-TREE FOR ORDER BY
separate count(*) query: SEARCH orders USING COVERING INDEX idx_orders_status_id (status=?)

The window-function version has to materialize every matching row as a subquery first, then re-sort them in a temporary B-tree to satisfy ORDER BY. The plain count(*) just walks the index and counts: no row materialization, no sort. Fewer round trips doesn’t automatically mean less work.

This is one measurement in one environment, SQLite 3.47.0 (the version bundled with Node.js v22.12.0), a million rows, one index, and the absolute numbers move between runs (735 to 789 ms across two measurements). Still, the order held both times: of the two ways to get a real total, the separate count(*) was the lighter one, and COUNT(*) OVER () was the heaviest. The fastest option, LIMIT 1001, doesn’t count a total at all; that’s the pattern coming up next. It’s worth checking the execution plan in your own environment rather than assuming “fewer queries” always means “faster.”

Fetch limit + 1 Rows: Give Up on “How Many” and Learn “Is There More”

There’s also the option of abandoning the total altogether. Fetch one row more than the limit, and use whether that extra row showed up to decide whether there’s more data, nothing else.

const LIMIT = 1000;

function listOrders(db) {
  const rows = db.prepare(
    "SELECT id FROM orders WHERE status = 'open' ORDER BY id LIMIT ?").all(LIMIT + 1);
  const hasMore = rows.length > LIMIT;
  const items = hasMore ? rows.slice(0, LIMIT) : rows;
  return { items, hasMore };
}

Run it across the boundary and the split is clean: right at the limit, hasMore is false; one row over, it flips to true.

   0 rows in DB -> items    0, hasMore=false
 999 rows in DB -> items  999, hasMore=false
1000 rows in DB -> items 1000, hasMore=false
1001 rows in DB -> items 1000, hasMore=true
3500 rows in DB -> items 1000, hasMore=true

There’s one more thing worth checking here. A tempting shortcut is to say that if the returned count equals the limit, it might have been truncated. Without a population count in hand, that looks like a reasonable fallback.

But the shortcut misfires exactly when the population happens to equal the limit. Look at the row for 1,000 real rows: the population lands exactly on the boundary, yet “count equals limit” fires anyway. Actually fetching limit + 1 rows resolves that boundary correctly along with everything else; one extra row is all it costs to avoid the false positive.

A limit+1 check drawn as a toll gate with two lanes, where exactly 1000 tokens pass through cleanly as the gate arm closes flush behind the last one, while in the other lane 1001 tokens are sent through and the extra one pokes past the closing arm, lighting up a hasMore signal
Figure 3: limit + 1 tells exactly-at-the-limit apart from one-over by a single item

Signaling “More” with a Next-Page Token

limit + 1 is a lightweight pattern you can build yourself, but public APIs tend to go one level more abstract. Google’s API design guidance, AIP-158, is explicit about how list APIs should handle pagination.

The API may return fewer results than the number requested (including zero results), even if not at the end of the collection.

If the end of the collection has been reached, the next_page_token field must be empty. This is the only way to communicate “end-of-collection” to users.

(Google AIP-158: Pagination)

The key move is that “is there more” is never decided by counting rows. A response shorter than what was asked for doesn’t by itself mean the end of the collection, per the first quote. The one and only signal for “you’ve reached the end” is whether the next-page token comes back empty, per the second. Instead of relying on how many rows came back, the design relies on a single flag: whether a token is present.

The same document is equally clear about the total-count field itself.

Response messages for collections may provide an int32 total_size field, providing the user with the total number of items in the list.

(Google AIP-158: Pagination)

total_size is a “may,” not a “must.” The pagination-ends signal is carried entirely by the next-page token. This article doesn’t walk through a next-page-token implementation in code; the point worth taking away is the design principle itself: separate “how many total” from “have you reached the end” into two different mechanisms.

A Playbook for Both Sides of the API

Lined up, the four approaches each have a clear strength and a clear weakness.

Approach Accuracy of the total Cost Detecting “is there more” Confirmed by
Array length / count after LIMIT Wrong once truncated No extra cost Can’t Measured
Separate count(*) query Accurate at the moment counted, drifts afterward Moderate Needs something like limit + 1 on top Measured
COUNT(*) OVER () Accurate, unless zero rows come back Heaviest in this environment Needs something separate Measured
limit + 1 with hasMore Doesn’t tell you the total Nearly free Accurate, including at the boundary Measured
Next-page token Optional (total_size is a “may”) Depends on the implementation Presence or absence of the token From the spec
A workbench with four distinct tools laid out side by side, labeled count(*), COUNT(*) OVER(), limit+1, and next page token, each a different shape and size to suggest their different costs and guarantees
Figure 4: counting the total and signaling “there’s more” are two separate tools

That leaves one takeaway for each side of the API.

If you’re building a list API, once you know you’ve hit the limit, say so in plain language on the screen. Instead of “that’s everything,” something closer to “showing the first 1,000, narrow your search” hands the possibility of truncation straight to the person using it. And when total shows up in an API contract, document exactly where it’s counted, population or rows returned, because the field name alone never tells a caller which one it is.

A blunt, shuttered shopfront with a plain sign reading 'That's everything.' on the left, contrasted with a warmer, welcoming shopfront on the right with a helpful sign reading 'Showing the first 1000. Narrow your search.'
Figure 5: say “showing the first 1,000,” not “that’s everything.”

If you’re calling a list API, it helps to assume your requested limit might get silently rounded down. Both AIP-158 and GitHub’s REST API docs are explicit that requesting more than the maximum page size isn’t an error; it just gets capped.

If the user specifies page_size greater than the maximum permitted by the API, the API should coerce down to the maximum permitted page size.

(Google AIP-158: Pagination)

If you specify a value greater than the maximum, GitHub does not return an error. Instead, the value is automatically reduced to the maximum

(GitHub Docs: Using pagination in the REST API)

Capping the limit is a legitimate design choice; multiple public APIs do exactly this. What matters isn’t the capping itself but whether there’s a signal for “there’s more” alongside it. An API that caps silently and offers no such signal has the same gap as the snippet at the top of this article.

Takeaways

total is the same field name everywhere, but what it means depends entirely on where it was counted. items.length, and count(*) run on an already-LIMITed result, only ever see inside the cap. COUNT(*) OVER () and a separate count(*) both reach the real population, each with its own catch: one disappears when zero rows come back, one is only accurate the instant it ran, and in this environment one turned out to be noticeably heavier than expected.

Give up on the total and fetch limit + 1 instead, and you get a correct “is there more,” boundary case included, at almost no extra cost. Go one step further and there’s the next-page-token pattern AIP-158 defines: signal the end with a flag, not a count.

Next time you look at a list API’s total, it’s worth checking exactly where that number came from. If the answer isn’t immediate, that total may not be telling you anything yet.

Primary Sources

Pixcel Art of Aki. holdin a cat.

About the Author

Aki Matsumura

Joined HIROSE PAPER MFG. CO., LTD. in November 2024.

Brings a diverse professional background spanning retail, welfare services, and food service before transitioning into system development.

Currently serves as an in-house systems engineer, responsible for internal database development and system improvement initiatives across the company.

View posts by this author →