All posts
No-Code & Tools

Cloudflare D1 Rows Read vs Query Count: Why 85 Queries Can Scan 147,000 Rows

A D1 dashboard can show only 85 queries while reporting 147,000 rows read. That is not a contradiction: query count measures executions, while rows read measures how much data those queries scanned.

Elvis Onunwa02 October 20267 min readLast updated: 02 October 2026
Cloudflare D1 Rows Read vs Query Count: Why 85 Queries Can Scan 147,000 Rows

TL;DR

  • D1 `readQueries` counts query executions; `rowsRead` counts rows scanned and is the metric tied to row-read limits and usage.
  • A query that returns one row can still scan thousands of rows if the access path cannot use an effective index.
  • In one production investigation, 85 executions of one related-content query accounted for about 147,820 rows read—roughly 1,740 rows scanned per execution.
  • Use D1 query analytics, query-level rows-read ratios and `EXPLAIN QUERY PLAN` to find the SQL statements consuming quota before optimizing randomly.
On this page⌄

A Cloudflare D1 dashboard can show a surprisingly small number of queries and a surprisingly large number of rows read.

That happened in one production system I investigated.

One SQL statement had executed only 85 times, but those executions accounted for roughly 147,820 rows read.

That works out to about 1,740 rows scanned per execution.

The mistake would have been to say:

85 queries is tiny. This cannot be the problem.

It was the problem.

The key distinction is simple:

query count tells you how often SQL ran. Rows read tells you how much data those executions had to scan.

Once you understand that, D1 usage starts making much more sense.

What Cloudflare’s two metrics actually mean

Cloudflare’s D1 analytics documentation separates readQueries from rowsRead.

readQueries is essentially the number of read-query executions.

rowsRead represents the rows those queries scanned.

Those numbers can differ by orders of magnitude.

For example:

Query executions Average rows scanned Total rows read
100 1 100
100 100 10,000
100 2,000 200,000

The application made the same 100 requests in all three cases.

The database work was completely different.

This matters on the Free plan because Cloudflare’s current D1 limits include an account-wide daily rows-read allowance. Since September 1, 2026, Cloudflare actively fails Free-plan D1 queries after the daily read/write allowance is exhausted until the UTC reset.

So rows read is not an abstract optimization metric. It can become an availability problem.

Why a query can return one row but scan thousands

Consider a query such as:

SELECT id, title
FROM articles
WHERE status = 'published'
  AND category_id = ?
ORDER BY published_at DESC
LIMIT 1;

The result is one article.

But LIMIT 1 does not guarantee one row read.

If the database cannot use an index that matches the useful filter/order path, SQLite may inspect many qualifying rows, sort them, and only then return the first result.

Cloudflare explicitly notes in its D1 indexing guidance that a query returning a small result can still scan a large table.

This is why you should distinguish:

  • rows returned to your application;
  • rows examined by the database to produce them.

D1 usage follows the second concept.

The ratio I look at first

When investigating D1 usage, I calculate:

rows read ÷ executions

That gives you an approximate scan cost per execution.

In the production example:

  • executions: 85
  • rows read: ~147,820
  • ratio: ~1,739 rows per execution

Another query might execute 5,000 times but scan only one or two indexed rows each time.

The high-frequency query can look scary in a request log while consuming less D1 quota than the low-frequency table scan.

Rank queries by both:

  1. total rows read;
  2. rows read per execution.

Those views answer different questions:

  • total: what is consuming the budget?
  • ratio: what SQL shape is inefficient each time it runs?

The 147k-row example came from related-content logic.

These queries are easy to underestimate because the UI asks for something small:

Show four related stories.

The SQL may quietly do much more:

  • compare categories/tags;
  • run EXISTS subqueries;
  • exclude the current article;
  • rank candidate matches;
  • sort by recency;
  • fall back to another pool;
  • repeat for every rendered article.

If the match conditions are not index-friendly, four returned cards can require thousands of scanned rows.

The front-end size tells you almost nothing about the database work.

Homepage fan-out creates a different multiplication

Another pattern I found was category fan-out.

A homepage wanted a few recent stories for each category. The implementation issued many category queries independently.

A sample window showed roughly:

  • 34 category-query executions → ~28,370 rows read;
  • 4 recent-story executions → ~7,960 rows read.

The first pattern ran frequently. The second had a much worse rows-per-execution ratio.

Both deserved attention, but for different reasons.

This is why looking only at “number of D1 calls” is incomplete.

You need to know what each call scans.

Use indexes for the question the application actually asks

An index is valuable when it lets SQLite narrow or order the data without walking irrelevant rows.

For a public-content query, that might mean an index shaped around fields such as:

  • publication state;
  • category;
  • publication timestamp;
  • foreign key used in a join.

Do not create indexes blindly.

Use EXPLAIN QUERY PLAN to check whether the query is actually using the intended access path.

Cloudflare’s indexing guide recommends examining query plans for this reason.

If the plan says the table is being scanned when you expected a targeted search, you have evidence for the next change.

Beware correlated lookups

Another pattern that inflates work is doing a lookup per row.

For example, fetch 20 stories and then run separate or correlated work to find category/author/media data for each one.

Depending on the SQL shape, that can produce repeated scans.

A well-shaped join can often let SQLite solve the relationship in one plan using indexed keys.

This is not a rule that joins are always faster.

The point is to inspect the actual plan rather than assuming “one SQL statement” means “one cheap operation.”

Global ranking queries can be deceptively expensive

Window functions and global CASE ranking are powerful.

They can also scan/rank a large candidate set to return a few records per category.

I found a homepage snapshot rebuild where one global ranking query became the dominant D1 consumer. The fix was not “cache harder” first.

We changed the query strategy so each category requested a small indexed top-N set, then batched those bounded queries into one D1 round trip.

The application still got its category sections.

The database stopped ranking a much larger global dataset on every rebuild.

Query count is still useful—just not as a billing proxy

Do not throw away query-count metrics.

They are useful for finding:

  • N+1 patterns;
  • duplicate loaders;
  • hydration refetches;
  • repeated SSR work;
  • excessive network/database round trips.

Just do not interpret them as the same thing as rows read.

A good investigation uses both axes:

How often are we asking?

and

How much does each answer cost to find?

My D1 rows-read debugging workflow

When D1 usage jumps, I use this sequence:

  1. Record the time window. Compare the same interval rather than daily totals with different traffic.
  2. List top SQL statements by total rows read. Find the biggest budget consumers.
  3. Calculate rows read per execution. Surface scans hidden behind low query counts.
  4. Map each SQL statement to a user/server path. Homepage? article page? image route? admin loader?
  5. Run EXPLAIN QUERY PLAN. Check scans, index use, temp sorts and join paths.
  6. Fix query shape/indexing before micro-optimizing code. Reduce the data the database must examine.
  7. Then remove duplicate executions. Loader duplication and hydration can multiply an otherwise acceptable query.
  8. Measure again in the same units. Rows/hour, queries/hour and per-query scan ratio.

This keeps optimization evidence-driven.

The number that matters is work per useful response

A database is not efficient because it runs few SQL statements.

It is efficient when it does bounded, explainable work for the user outcome.

A page that makes 15 indexed point/range queries can be cheaper than one elegant SQL statement that scans the entire content table.

That is why I stopped asking only:

How many D1 queries does this page make?

and started asking:

How many rows does this request force D1 to examine, and why?

If your Cloudflare app is burning D1 quota and the dashboard numbers do not match your mental model, my MVP development service includes production performance and architecture debugging. The next article in this cluster goes deeper into why D1 row reads explode in SSR apps.

Sources checked

Frequently asked questions

Questions, answered.

Why are my Cloudflare D1 rows read much higher than my query count?

Because the metrics measure different things. Query count is how many SQL statements executed; rows read is how many database rows those statements scanned. One execution can read thousands of rows.

Does LIMIT 1 mean D1 reads only one row?

No. LIMIT controls how many results are returned, not necessarily how many rows SQLite must examine to find them. Without a usable index, a query can scan a large portion of the table before returning one result.

How do I find which D1 query is using the most rows?

Use D1 analytics/query insights to identify statements with high rows read, calculate rows read per execution, then inspect their plans with `EXPLAIN QUERY PLAN` and review indexes, filters, joins and sorts.

Are D1 rows read account limits based on returned rows?

No. Cloudflare documents rows read as rows scanned by queries, which is why indexing and query shape can change usage dramatically without changing the number of records returned to the user.

NEED HELP WITH THE BUILD?

Turn what you learned here into a clearer website or MVP.

Share the goal, current stage, and constraints. I’ll help you identify the smallest responsible next step.

Continue reading

Related notes.