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:
- total rows read;
- 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?
Related-content queries are common offenders
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
EXISTSsubqueries; - 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:
- Record the time window. Compare the same interval rather than daily totals with different traffic.
- List top SQL statements by total rows read. Find the biggest budget consumers.
- Calculate rows read per execution. Surface scans hidden behind low query counts.
- Map each SQL statement to a user/server path. Homepage? article page? image route? admin loader?
- Run
EXPLAIN QUERY PLAN. Check scans, index use, temp sorts and join paths. - Fix query shape/indexing before micro-optimizing code. Reduce the data the database must examine.
- Then remove duplicate executions. Loader duplication and hydration can multiply an otherwise acceptable query.
- 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.



