Overview
In backend performance problems, the layer that most often becomes the bottleneck is the database. This page covers the principle at the root of database performance work and the concrete methods derived from it. For the prerequisites — how to set the goal of an improvement and in what order to consider fixes — see Performance. The decision that comes before this page — which kind of database should hold the data — is covered in Choosing a Database. This page is about using the database you chose, fast. For convenience the examples use a relational database, but many of the ideas here carry over to NoSQL stores (DynamoDB, MongoDB, etc.). Tuning server parameters is out of scope; the focus is on improvements in queries, schema, and the application. The focus is mainly on read performance: in most web services reads far outnumber writes, and the bottleneck appears on the read side first. Write-specific concerns — the maintenance cost of indexes, distributing writes — are covered where they arise.Why the database becomes the bottleneck
The database tends to become the bottleneck because how long reading data takes differs by orders of magnitude depending on where it is read from.
The numbers vary by hardware and configuration, so read them as a comparison of orders of magnitude, not absolutes. For work that makes many reads and round trips, latency determines the time taken; for work that reads a large amount at once, throughput does. In a database, index lookups live in the world of random reads, while full scans and large aggregations live in the world of sequential reads.
Note the last row of the table. In most managed cloud databases the storage itself is network-attached, so a single read carries tens of times the latency of local NVMe.
The large throughput number is no comfort here: random reads pay the latency on every access, so repeating a 1ms read 1,000 times takes a full second by itself. No amount of bandwidth can fill in the waiting that accumulates across repetitions.
Real databases fill in this slowness with large amounts of main memory (the buffer pool), and some configurations add local NVMe as a cache tier. The flip side: the moment a query reads more than the cache can hold, the numbers in the table appear as they are.
No matter how fast the rest of the application is, how much storage the database reads and how many round trips it makes largely determine the speed of the whole system.
The principle: minimize storage reads
The thinking at the root of database performance work therefore collapses into one principle: minimize the amount of data read from storage. Index design, query tuning, and application-side changes all come down to eliminating wasted storage reads. Even when the data is cached in memory (the buffer pool), scanning less is still faster. Some billing models also translate the amount read directly into infrastructure cost (Aurora’s I/O billing, DynamoDB’s read capacity, and so on). Carry the single question “how much storage does this query read?” and the individual techniques that follow stop being items to memorize and connect as applications of one principle.Observe how much is being read
To reduce reads, first learn which queries are reading, and how much. There are three entry points.- Slow query log — Records SQL statements that exceed a threshold (e.g. 0.5 seconds). The first data source for finding candidates that read too much.
- Execution plans (
EXPLAIN) — Show, before running the query, how much it plans to read (how to read them is covered below). - Engine metrics — Show how reads look across the whole system rather than per query: cache hit rate (buffer pool hit rate, etc.; a drop signals growing physical disk reads), CPU and IOPS, active connections (connection pool exhaustion), and lock waits (transactions jamming on each other).
Reducing reads in practice
Once the heavy readers are identified, cut the amount they read. There are four layers, ordered from closest to the query outward: indexes, execution plans, application design, and how the data is stored.What indexes really do: read fewer blocks
An index is a mechanism for locating where in storage the target data lives (which block or page), so unnecessary reads can be skipped. Why a full table scan is slow — Without an index, the database engine must read the whole table from storage even when it is looking for a single row. That is the state where the amount of storage read is at its maximum. On a small table, though, reading everything is often not a problem, and the optimizer may deliberately choose a full scan over an index; it becomes a problem when the amount read is large. How an index cuts reads — With a well-designed index, the database reads only the blocks where the target data lives, after just a few lookups.- Composite index order (leftmost-prefix matching) — For a condition like
WHERE tenant_id = 1 AND status = 'active' AND created_at > :since, place the equality columns first and the range column last. Columns after a range condition cannot be used to narrow the search, so more data blocks get read. Since the order among equality columns barely affects selectivity, choose an order whose prefix can be shared with other queries. - Covering indexes — When every column in the
SELECTis contained in the index, the database can skip reading the table itself and complete the query from the index alone.
Reading execution plans
When tuning a query, first check the execution plan: with what algorithm, and over how much data (rows, pages), does the database intend to execute it? The command to obtain the plan (EXPLAIN, etc.) and its output format differ by database, but the three common things to look for are the same.
- Scan type — Is it reading the whole table from storage instead of using an index?
- Estimated rows — Is filtering failing, producing a plan that reads an unnecessarily huge number of rows?
- Spills to disk — Are sorts or intermediate results exceeding memory and being written out to storage?
Reducing reads through application design
Beyond tuning individual queries, revisiting how the application talks to the database also cuts reads. Eliminating N+1 queries — Issuing a query N times inside a loop incurs a round trip each time, each with its own storage or cache access. Fetch in bulk with joins orIN clauses (eager loading) to cut both the number of accesses and the total blocks read. The round-trip cost is the same for writes: replace repeated one-row INSERTs and UPDATEs with bulk writes.
Avoiding deep OFFSET — A query like SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 100000 reads and discards the first 100,000 rows to return the next 20. Rewrite it to key off the last position fetched (cursor style), and it reads just the next 20 rows without skipping over anything.
Cursor-style pagination depends on the previous fetch position, so it cannot jump to an arbitrary page number. It trades against the UI: it suits “next / previous” navigation and infinite scroll.
in_batches, for example — a method that processes a large number of records in fixed-size batches — re-executes the original query in full every time it fetches a batch. That goes unnoticed while the query is cheap, but with a heavy join or subquery, its expensive part is paid once per batch. When applying a convenience method to a large amount of data, check the actually emitted SQL in the logs.
Reducing reads by changing how data is stored
When query- and application-side work cannot cut reads any further, changing how the data is stored can reduce the work done at read time. Unlike the techniques so far, both of these carry a price (see “What you trade away” in Performance). Denormalization — Values that were assembled with joins on every read are stored in the row ahead of time. Reads become simple lookups, but updates take on the responsibility of keeping multiple places consistent, with the risk of divergence. Pre-aggregation (summary tables) — Aggregation results are written out to a table in advance, and readers reference only the small result. A large scan turns into reading a few rows, but the aggregation axes become fixed and the freshness of the result is bound to the update frequency.Keeping the cache hit rate high
However well the queries are tuned, response time has a floor as long as storage is being accessed. This is where the database’s internal cache, such as the buffer pool, matters: when the frequently accessed data (the working set) fits in memory, no storage I/O occurs. If the cache hit rate still degrades after cutting reads through query work, consider adding memory — or eliminating the full-table-read queries that keep evicting the cache.When a single database is not enough
If the limits are reached even after all of the above, look beyond tuning within a single database, toward architectural separation of roles and specialized engines. For what to add — and what a second database costs — see also Choosing a Database. Read distribution with read replicas — Separate writes (primary) from reads (replicas) and spread the load physically. Requires tolerating the eventual consistency introduced by replication lag. Distributing writes and data volume with functional splits and sharding — Write throughput and total data volume cannot be distributed with replicas. Consider a functional split first — moving a domain or a group of tables to a separate database — and when that is not enough, use sharding: splitting the data across multiple databases by a key such as tenant or user ID. With either split, however, joins and transactions across the boundary are lost, and changing how the data is divided later is hard — so adopt them after confirming the other options are not enough.Instead of operating the split yourself, you can adopt a database that takes it on. Distributed SQL databases such as Cloud Spanner and CockroachDB handle the splitting as a managed layer while keeping SQL and distributed transactions, and partition-first NoSQL stores such as DynamoDB grow write capacity and storage as far as the workload needs. For what each kind is good and bad at, see Choosing a Database.
- Data warehouses (DWH; BigQuery / Snowflake / Redshift, etc.) — Specialized for large-scale batch aggregation and reporting
- Real-time OLAP (ClickHouse, etc.) — Specialized for low-latency aggregation and search over large data: log analytics, dashboards
- HTAP (TiDB, etc.) — Hybrid Transactional/Analytical Processing: OLTP and OLAP combined in one distributed database
Related pages
Performance
The judgment foundation for all tuning: why to improve, in what order, and what to observe.
Choosing a Database
The decision that comes before this page: which kind of database should hold the data. Also covers what a second database costs.
Modifiability
The property of accepting change at low cost and low risk. Denormalization and pre-aggregation can damage it.