Which requests still reach the database
All writes, plus every cache miss.
Caches absorb repeated reads, which is a large share of traffic and not all of it. Every write and every miss still reaches the database, and if those queries are slow, the database remains the bottleneck.
Writes are the part a cache can never help with, and that is worth stating plainly. A write has to reach the source of truth by definition, so no hit rate improves it.
Slow queries also make misses more expensive than the lesson 3-3 arithmetic suggested. A 500 ms query behind a 90% hit rate still gives a 50 ms average, so the cache hides a problem rather than solving it.
So before adding hardware, we make the queries themselves fast, and that is what indexes do. It is the cheapest fix in this unit and often the largest single win in the whole course.
Why a query gets slow
From your SQL course you know SELECT * FROM users WHERE email = 'ada@x.com'. Without help, the database answers this by checking every row in the table, a full table scan. At 1,000 rows nobody notices. At 10 million rows every query does 10 million checks.
An index is a sorted lookup structure the database maintains next to a table, usually a B-tree. Because it is sorted, the database can find a value the way you find a word in a dictionary: jump to the middle, decide left or right, repeat. Each jump halves the search space, so 10 million rows take about log₂(10,000,000) ≈ 24 checks instead of 10 million.
CREATE INDEX idx_users_email ON users(email);
One line, and that query goes from seconds to under a millisecond.
Full scan against a B-tree
math.log2 counts how many times you can halve the table, and math.ceil rounds up.
import math rows = 10_000_000 print("Rows in table:", rows) print("Full scan checks:", rows) print("B-tree index checks (about):", math.ceil(math.log2(rows)))
Output
Rows in table: 10000000 Full scan checks: 10000000 B-tree index checks (about): 24
Ten million checks against 24 is a factor of roughly 400,000, and that is why an index turns seconds into microseconds. No amount of faster hardware closes a gap that size.
math.log2(rows) is the halving count, and each halving is one decision in the tree. Twenty-four decisions is fewer than the number of times you would halve a phone book, which is a useful way to feel how flat a B-tree is.
Underscores in 10_000_000 are just readable digit separators, and Python ignores them. Writing large numbers this way prevents the classic error of adding one zero too many.
Note that this counts comparisons rather than disk reads, so it is a model of the shape rather than a benchmark. The real advantage is similar and comes with a subtlety, since a full scan reads sequentially while an index does scattered reads, and sequential reads are cheaper per row.
That subtlety is why the query planner sometimes chooses a scan on purpose. A query matching most of the table is faster to scan than to look up row by row, so the database compares estimated costs rather than always using an available index.
The same table at a billion rows
A hundred times the rows, and six more checks.
import math rows = 1_000_000_000 print("Rows in table:", rows) print("Full scan checks:", rows) print("B-tree index checks (about):", math.ceil(math.log2(rows)))
Output
Rows in table: 1000000000 Full scan checks: 1000000000 B-tree index checks (about): 30
The scan cost grew 100x and the index cost went from 24 to 30. That contrast is the entire argument for indexes, and it is the difference between linear and logarithmic growth in a form you can read off two lines of output.
Six extra checks for a hundred times the data is the property that makes B-trees the default. Doubling the table adds exactly one check, so an index that works today keeps working as the table grows.
math.ceil(math.log2(1_000_000_000)) is 30, and the ceiling matters because a fractional comparison is meaningless. The tree depth is a whole number, so rounding up is the honest direction.
Put the two runs side by side and the practical lesson is about which cost you can ignore. The index cost is effectively constant across every table size you will ever encounter, so it stops being a variable in your reasoning.
What indexes cost
Every write must also update each index, and indexes use disk space.
An index is a second copy of the column, kept sorted. Every INSERT and UPDATE must maintain it, so a table with 8 indexes does 8 extra updates per write, and each of those is a tree insertion rather than a simple append.
The space cost is real too, since an index on a large text column can approach the size of the data it indexes. A table with many indexes can occupy several times the space of the rows alone.
There is a subtler cost worth knowing, which is that indexes only help queries that can use them. An index on email does nothing for a query filtering on last_name, and an index on (a, b) helps a query filtering on a but generally not one filtering only on b.
Index the columns you filter and sort by, and resist indexing everything. The columns in your WHERE, JOIN, and ORDER BY clauses are the candidates, and an index no query uses is pure cost.
This read-versus-write trade returns in unit 10's news feed design, where the same question appears at the system level. Doing work at write time to make reads fast is the same bargain an index strikes, scaled up to an entire architecture.