ЁЯПл The SchoolтА║ЁЯПОя╕П PerformanceтА║ЁЯЧГя╕П рдзрдбрд╛ 10 тАФ Database query performance: рдкреНрд░рддреНрдпреЗрдХ рдкрддреНрд░рдХ рд╡рд╛рдЪрд╛рдпрдЪреЗ, рдХреА index рд╡рд╛рдкрд░рд╛рдпрдЪрд╛?
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯЧГя╕П рдзрдбрд╛ 10 тАФ Database query performance: рдкреНрд░рддреНрдпреЗрдХ рдкрддреНрд░рдХ рд╡рд╛рдЪрд╛рдпрдЪреЗ, рдХреА index рд╡рд╛рдкрд░рд╛рдпрдЪрд╛?

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 10 ┬╖ рдорд╛рдЧреЗ: lesson-09-queueing ┬╖ рдкреБрдвреЗ: lesson-11-web-performance


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ09, рдЖрдгрд┐ query plans: database query рдЪрд╛рд▓рд╡рдгреНрдпрд╛рдЖрдзреА рддреА рдХрд╢реА рдЪрд╛рд▓рд╡рд╛рдпрдЪреА рдпрд╛рдЪрд╛ рдЖрд░рд╛рдЦрдбрд╛ рдХрд░рддреЛ. SQLite рдордзреАрд▓ EXPLAIN QUERY PLAN (PostgreSQL рдЖрдгрд┐ MySQL рдордзреНрдпреЗ EXPLAIN) рддреЛ рдЖрд░рд╛рдЦрдбрд╛ рджрд╛рдЦрд╡рддреЛ: рдкреНрд░рддреНрдпреЗрдХ row рдЪрд╛ scan, рдХреА index рдордзреВрди search. рд╣рд╛ рдзрдбрд╛ SQLite рдордзрд▓реЗ рдЦрд░реЗ plans рд╡рд╛рдЪрддреЛ, indexes, рдПрдХ covering index рдЖрдгрд┐ рдПрдХ expression index рдЬреЛрдбрддреЛ, рдЖрдгрд┐ index рдХрд╢рд╛рдореБрд│реЗ рд▓рдкрддреЛ рддреЗ рджрд╛рдЦрд╡рддреЛ. perf/demo.py рдордзреАрд▓ database() рдЖрдгрд┐ perf/sim.py рдордзреАрд▓ laps_db рдЖрдгрд┐ plan.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

Results office рдордзреНрдпреЗ рдПрдХрд╛ рдореЛрдареНрдпрд╛ рдврд┐рдЧрд╛рдд 10,000 lap рдкрддреНрд░рдХреЗ рдЖрд╣реЗрдд. ЁЯУЪ рдПрдХ рдкрд╛рд▓рдХ рд╡рд┐рдЪрд╛рд░рддрд╛рдд: "рдзрд╛рд╡рдкрдЯреВ 42 рдЪреЗ laps рдХрд╛рдп рд╣реЛрддреЗ?"

рдкрдг card box рдирд╛рд╡рд╛рдиреБрд╕рд╛рд░ рдЬрд╕реЗ рд▓рд┐рд╣рд┐рд▓реЗ рдЖрд╣реЗ рддрд╕реЗ рд▓рд╛рд╡рд▓реЗрд▓реА рдЖрд╣реЗ. рдПрдЦрд╛рджреНрдпрд╛ рдкрд╛рд▓рдХрд╛рдиреЗ "RUNNER042" рдЕрд╕реЗ рдореЛрдареНрдпрд╛ рдЕрдХреНрд╖рд░рд╛рдд рд╡рд┐рдЪрд╛рд░рд▓реЗ рдЖрдгрд┐ рддреБрд▓рдирд╛ рдХрд░рдгреНрдпрд╛рд╕рд╛рдареА рдРрд╢реНрд╡рд░реНрдпрд╛рд▓рд╛ рдкреНрд░рддреНрдпреЗрдХ рдирд╛рд╡ рд▓рд╣рд╛рди рдЕрдХреНрд╖рд░рд╛рдд рдХрд░рд╛рд╡реЗ рд▓рд╛рдЧрд▓реЗ, рддрд░ рд▓рд╛рд╡рд▓реЗрд▓реНрдпрд╛ box рдЪрд╛ рдХрд╛рд╣реА рдЙрдкрдпреЛрдЧ рдирд╛рд╣реА тАФ рдкреБрдиреНрд╣рд╛ рдкреНрд░рддреНрдпреЗрдХ card рд╡рд╛рдЪрдгреЗ. рдЬреЛрдкрд░реНрдпрдВрдд рддреА рд▓рд╣рд╛рди рдЕрдХреНрд╖рд░рд╛рддреАрд▓ рдирд╛рд╡рд╛рдиреБрд╕рд╛рд░ рд▓рд╛рд╡рд▓реЗрд▓реА рджреБрд╕рд░реА box рдмрдирд╡рдд рдирд╛рд╣реА рддреЛрдкрд░реНрдпрдВрдд.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    q["тЭУ WHERE runner_id = 42"] --> none["no index:<br/>SCAN laps тАФ 10,000 rows"]
    q --> idx["index on runner_id:<br/>SEARCH тАж USING INDEX тАФ 20 rows"]
    q --> cov["index on (runner_id, lap_ms):<br/>USING COVERING INDEX тАФ no table reads"]
    f["WHERE lower(name) = тАж"] --> scan2["SCAN runners"]
    f --> expr["index ON runners(lower(name)):<br/>SEARCH тАж (&lt;expr&gt;=?)"]

ЁЯЧ║я╕П рд░реЗрдЦрд╛рдЯрд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/performance/lesson-diagrams.html#l10

тЭУ рдХрд╛рдп

Database school SQL, joins рдЖрдгрд┐ indexes рд╕реБрд░реБрд╡рд╛рддреАрдкрд╛рд╕реВрди рд╢рд┐рдХрд╡рддреЗ.

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рд╣рд│реВ endpoint рдЪреЗ рд╕рд░реНрд╡рд╛рдд рд╕рд╛рдорд╛рдиреНрдп рдХрд╛рд░рдг рдореНрд╣рдгрдЬреЗ рдирд╕рд▓реЗрд▓рд╛ index, рдЖрдгрд┐ table рдЬрд╕рдЬрд╕реЗ рд╡рд╛рдврддреЗ рддрд╕рддрд╕реЗ рддреЗ рджрд░рд░реЛрдЬ рдЕрдзрд┐рдХ рд╡рд╛рдИрдЯ рд╣реЛрддреЗ. рдзрдбрд╛ 04 рдордзрд▓реЗ O(n) рд╡рд┐рд░реБрджреНрдз O(log n) рдиреЗрдордХреЗ рд╣реЗрдЪ рдЖрд╣реЗ: рдЖрдЬ 10,000 rows рдЪрд╛ scan рдкреБрдврдЪреНрдпрд╛ рд╡рд░реНрд╖реА 1 рдХреЛрдЯреА (10 million) rows рдЪрд╛ scan рд╣реЛрддреЛ. Plan рд╡рд╛рдЪрд▓реНрдпрд╛рдиреЗ "database рд╣рд│реВ рдЖрд╣реЗ" рдпрд╛рдЪреЗ рд░реВрдкрд╛рдВрддрд░ "рд╣реА query laps scan рдХрд░рддреЗ тАФ рд╣рд╛ index рдЬреЛрдбрд╛" рдордзреНрдпреЗ рд╣реЛрддреЗ.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

perf/sim.py рдордзреАрд▓ laps_db() рдПрдХ in-memory SQLite database рдмрдирд╡рддреЗ: 5 рджрд┐рд╡рд╕рд╛рдВрддреАрд▓ 500 рдзрд╛рд╡рдкрдЯреВ рдЖрдгрд┐ 10,000 laps (рдкреНрд░рддреНрдпреЗрдХ рдзрд╛рд╡рдкрдЯреВрдЪреЗ 20). plan(conn, sql, params) EXPLAIN QUERY PLAN рдЪрд╛рд▓рд╡рддреЗ рдЖрдгрд┐ detail column рдкрд░рдд рджреЗрддреЗ тАФ SQLite рдЪреЗ рд╕реНрд╡рддрдГрдЪреЗ рд╢рдмреНрдж. vm_steps progress handler рд╡рд╛рдкрд░реВрди SQLite рдЪреНрдпрд╛ virtual-machine рдкрд╛рдпрд▒реНрдпрд╛ рдореЛрдЬрддреЗ; рд╣реЗ рдХрд╛рдорд╛рдЪреЗ рдвреЛрдмрд│ рдореЛрдЬрдорд╛рдк рддреБрдордЪреНрдпрд╛ SQLite version рд╡рд░ рдЕрд╡рд▓рдВрдмреВрди рдЕрд╕рддреЗ (рдореНрд╣рдгреВрди рддреЗ рдХрдзреАрдЪ рдиреЗрдордХреЗ рд╕рд╛рдВрдЧрд┐рддрд▓реЗ рдЬрд╛рдд рдирд╛рд╣реА). perf/demo.py рдордзреАрд▓ database() рдПрдХреЗрдХ рдХрд░реВрди indexes рдЬреЛрдбрддреЗ рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХрд╛рдирдВрддрд░ plan рдЫрд╛рдкрддреЗ.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 perf/demo.py database
python3 - <<'EOF'
import sys; sys.path.insert(0, "perf"); from sim import laps_db, plan, vm_steps
db = laps_db()
q = "SELECT runner_id, MIN(lap_ms) FROM laps WHERE day = ? GROUP BY runner_id"
print("before:", plan(db, q, ("2026-09-21",)))
db.execute("CREATE INDEX idx_laps_day ON laps(day)")
print("after: ", plan(db, q, ("2026-09-21",)))
q2 = "SELECT * FROM laps WHERE runner_id = ? ORDER BY lap_ms"
print("sort:  ", plan(db, q2, (42,)))
db.execute("CREATE INDEX idx_laps_runner_ms ON laps(runner_id, lap_ms)")
print("sorted:", plan(db, q2, (42,)))
q3 = "SELECT id FROM runners WHERE lower(name) = 'runner042'"
db.execute("CREATE INDEX idx_runners_lower ON runners(lower(name))")
print("expr:  ", plan(db, q3))
db2 = laps_db(); a = vm_steps(db2, "SELECT lap_ms FROM laps WHERE runner_id = 42")
db2.execute("CREATE INDEX i ON laps(runner_id)"); b = vm_steps(db2, "SELECT lap_ms FROM laps WHERE runner_id = 42")
print(f"SQLite steps: scan about {a:,} ┬╖ index about {b:,}   (exact counts depend on your SQLite version)")
EOF

рд╢реЗрд╡рдЯрдЪреА рдУрд│ SQLite рдЪреНрдпрд╛ рдЕрдВрддрд░реНрдЧрдд рдкрд╛рдпрд▒реНрдпрд╛ рдореЛрдЬрддреЗ тАФ SQLite 3.51 рд╕рд╣ scan рд╕рд╛рдареА рд╕реБрдорд╛рд░реЗ 30,000 рдЖрдгрд┐ index рд╕рд╛рдареА 100. рджреБрд╕рд▒реНрдпрд╛ SQLite version рд╕рд╣ рддреБрдордЪреЗ рдЖрдХрдбреЗ рд╡реЗрдЧрд│реЗ рдЕрд╕реВ рд╢рдХрддрд╛рдд; рдордзрд▓реЗ рдЕрдВрддрд░ рдмрджрд▓рдгрд╛рд░ рдирд╛рд╣реА.

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

database рд╣реЗ рдЫрд╛рдкрддреЗ:

тФАтФА 10,000 laps, 500 runners ┬╖ EXPLAIN QUERY PLAN for: SELECT lap_ms FROM laps WHERE runner_id = ?
   no index            тЖТ SCAN laps   (reads all 10,000 rows)
   index on runner_id  тЖТ SEARCH laps USING INDEX idx_laps_runner (runner_id=?)   (jumps to runner 42's 20 rows)
   index on (runner_id, lap_ms) тЖТ SEARCH laps USING COVERING INDEX idx_laps_runner_ms (runner_id=?)
   covering: every column the query needs is in the index, so the table itself is never read
   runners WHERE name = 'runner042'         тЖТ SEARCH runners USING COVERING INDEX idx_runners_name (name=?)
   runners WHERE lower(name) = 'runner042'  тЖТ SCAN runners
   runners WHERE name LIKE '%042'           тЖТ SCAN runners

рддреБрдордЪрд╛ snippet рдЕрд╕рд╛ рд╕реБрд░реВ рд╣реЛрддреЛ:

before: ['SCAN laps', 'USE TEMP B-TREE FOR GROUP BY']
after:  ['SEARCH laps USING INDEX idx_laps_day (day=?)', 'USE TEMP B-TREE FOR GROUP BY']
sort:   ['SCAN laps', 'USE TEMP B-TREE FOR ORDER BY']
sorted: ['SEARCH laps USING INDEX idx_laps_runner_ms (runner_id=?)']
expr:   ['SEARCH runners USING COVERING INDEX idx_runners_lower (<expr>=?)']

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рдлрдХреНрдд рдПрдХ index рдЬреЛрдбреВрди рддреЗрдЪ SQL 10,000 rows рд╡рд╛рдЪрдгреНрдпрд╛рд╡рд░реВрди 20 рд╡рд░ рдЖрд▓реЗ тАФ рдЖрдгрд┐ covering index рд╕рд╣ table рдЪреЗ рд╡рд╛рдЪрдирдЪ рд╢реВрдиреНрдп. Composite index (runner_id, lap_ms) рдиреЗ USE TEMP B-TREE FOR ORDER BY рд╣реА рдкрд╛рдпрд░реАрд╣реА рдХрд╛рдвреВрди рдЯрд╛рдХрд▓реА: rows index рдордзреВрди рдЖрдзреАрдЪ рдХреНрд░рдорд╛рдиреЗ рдпреЗрддрд╛рдд. Expression index рдЬреБрд│реЗрдкрд░реНрдпрдВрдд lower(name) рдиреЗ name index рд▓рдкрд╡рд▓рд╛ рд╣реЛрддрд╛. day query рд▓рд╛ рддрд┐рдЪреНрдпрд╛ GROUP BY рд╕рд╛рдареА рдЕрдЬреВрдирд╣реА temporary B-tree рд▓рд╛рдЧрддреЛ тАФ day рд╡рд░рдЪрд╛ index rows рд╢реЛрдзрддреЛ, рдкрдг рддреНрдпрд╛рдВрдирд╛ рдзрд╛рд╡рдкрдЯреВрдиреБрд╕рд╛рд░ group рдХрд░рдд рдирд╛рд╣реА.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ PostgreSQL рдЪрд╛ EXPLAIN ANALYZE query рдЪрд╛рд▓рд╡рддреЛ рдЖрдгрд┐ plan рд╢реЗрдЬрд╛рд░реА рдЦрд▒реНрдпрд╛ rows рдЖрдгрд┐ рд╡реЗрд│рд╛ рджрд╛рдЦрд╡рддреЛ (рдореНрд╣рдгреВрди UPDATE/DELETE рдмрд╛рдмрдд рдХрд╛рд│рдЬреА рдШреНрдпрд╛ тАФ рддреНрдпрд╛рдВрдирд╛ рдЕрд╢рд╛ transaction рдордзреНрдпреЗ рдЧреБрдВрдбрд╛рд│рд╛ рдЬреЛ рддреБрдореНрд╣реА roll back рдХрд░рд╛рд▓):

EXPLAIN (ANALYZE, BUFFERS) SELECT lap_ms FROM laps WHERE runner_id = 42;
-- look for: Seq Scan vs Index Scan / Index Only Scan / Bitmap Heap Scan,
-- estimated rows vs actual rows, and "Buffers: shared hit=тАж read=тАж"
CREATE INDEX CONCURRENTLY idx_laps_runner ON laps (runner_id);   -- builds without blocking writes
CREATE INDEX idx_runners_lower_name ON runners (lower(name));     -- an expression index
ANALYZE laps;                                                     -- refresh the planner's statistics

рдЖрдзреА рд╣рд│реВ queries рд╢реЛрдзрд╛. PostgreSQL рдПрдХрд╛ рдорд░реНрдпрд╛рджреЗрдкреЗрдХреНрд╖рд╛ рд╣рд│реВ рдЕрд╕рд▓реЗрд▓реА рдкреНрд░рддреНрдпреЗрдХ query log рдХрд░реВ рд╢рдХрддреЛ:

ALTER SYSTEM SET log_min_duration_statement = '200ms';
SELECT pg_reload_conf();

MySQL: EXPLAIN ANALYZE SELECT ... (8.0.18+) рдЖрдгрд┐ slow query log (long_query_time). SQLite, command line рд╡рд░реВрди:

sqlite3 school.db "EXPLAIN QUERY PLAN SELECT lap_ms FROM laps WHERE runner_id = 42;"

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: рддреБрдордЪреНрдпрд╛ рд╕рд░реНрд╡рд╛рдзрд┐рдХ рд╡реЗрд│рд╛ рдЪрд╛рд▓рдгрд╛рд▒реНрдпрд╛ рджрд╣рд╛ рдЖрдгрд┐ рд╕рд░реНрд╡рд╛рдд рд╣рд│реВ рджрд╣рд╛ queries рд╕рд╛рдареА, plan code рд╢реЗрдЬрд╛рд░реА рдареЗрд╡рд╛, рдЖрдгрд┐ table рджрд╣рд╛рдкрдЯ рд╡рд╛рдврд▓реНрдпрд╛рд╡рд░ рддреЛ рдкреБрдиреНрд╣рд╛ рдкрд╛рд╣рд╛.

тПня╕П рдкреБрдвреЗ

рдЖрддрд╛ server рд╡реЗрдЧрд╡рд╛рди рдЖрд╣реЗ. рдкрдг рдкрд╛рд▓рдХ рдирд┐рдХрд╛рд▓ рддреНрдпрд╛рдВрдЪреНрдпрд╛ phones рд╡рд░ рдкрд╛рд╣рдд рдЖрд╣реЗрдд. рдкреБрдвреЗ: web performance тАФ LCP, INP, CLS, payload, compression рдЖрдгрд┐ caching headers.

git checkout lesson-11-web-performance

ЁЯЧГя╕П Lesson 10 тАФ Database query performance: read every sheet, or use the index?

ЁЯУН You are here: Lesson 10 of 12 ┬╖ Previous: lesson-09-queueing ┬╖ Next: lesson-11-web-performance


ЁЯУж What's in this branch

Lessons 01тАУ09, plus query plans: before a database runs a query, it plans how. EXPLAIN QUERY PLAN in SQLite (EXPLAIN in PostgreSQL and MySQL) shows the plan: a scan of every row, or a search through an index. This lesson reads real plans from SQLite, adds indexes, a covering index and an expression index, and shows what hides an index. database() in perf/demo.py and laps_db and plan in perf/sim.py.

ЁЯзТ Explain like I'm 5

The results office has 10,000 lap sheets in one big pile. ЁЯУЪ A parent asks: "What were runner 42's laps?"

But the card box is sorted by the name as written. If a parent asks for "RUNNER042" in capitals and Aishwarya must lower-case every name to compare, the sorted box is no help тАФ back to reading every card. Unless she makes a second box, sorted by the lower-case name.

ЁЯЧ║я╕П Diagram

flowchart LR
    q["тЭУ WHERE runner_id = 42"] --> none["no index:<br/>SCAN laps тАФ 10,000 rows"]
    q --> idx["index on runner_id:<br/>SEARCH тАж USING INDEX тАФ 20 rows"]
    q --> cov["index on (runner_id, lap_ms):<br/>USING COVERING INDEX тАФ no table reads"]
    f["WHERE lower(name) = тАж"] --> scan2["SCAN runners"]
    f --> expr["index ON runners(lower(name)):<br/>SEARCH тАж (&lt;expr&gt;=?)"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/performance/lesson-diagrams.html#l10

тЭУ What

The Database school teaches SQL, joins and indexes from the start.

ЁЯдФ Why

Because a missing index is the most common cause of a slow endpoint, and it gets worse every day the table grows. Lesson 04's O(n) vs O(log n) is exactly this: a scan of 10,000 rows today is a scan of 10 million next year. Reading the plan turns "the database is slow" into "this query scans laps тАФ add this index".

ЁЯФз How (in this repo)

laps_db() in perf/sim.py builds an in-memory SQLite database: 500 runners and 10,000 laps (each runner has 20), over 5 days. plan(conn, sql, params) runs EXPLAIN QUERY PLAN and returns the detail column тАФ SQLite's own words. vm_steps counts SQLite's virtual-machine steps with a progress handler, a rough measure of work that depends on your SQLite version (so it is never quoted exactly). database() in perf/demo.py adds indexes one by one and prints the plan after each.

ЁЯзк Try it

python3 perf/demo.py database
python3 - <<'EOF'
import sys; sys.path.insert(0, "perf"); from sim import laps_db, plan, vm_steps
db = laps_db()
q = "SELECT runner_id, MIN(lap_ms) FROM laps WHERE day = ? GROUP BY runner_id"
print("before:", plan(db, q, ("2026-09-21",)))
db.execute("CREATE INDEX idx_laps_day ON laps(day)")
print("after: ", plan(db, q, ("2026-09-21",)))
q2 = "SELECT * FROM laps WHERE runner_id = ? ORDER BY lap_ms"
print("sort:  ", plan(db, q2, (42,)))
db.execute("CREATE INDEX idx_laps_runner_ms ON laps(runner_id, lap_ms)")
print("sorted:", plan(db, q2, (42,)))
q3 = "SELECT id FROM runners WHERE lower(name) = 'runner042'"
db.execute("CREATE INDEX idx_runners_lower ON runners(lower(name))")
print("expr:  ", plan(db, q3))
db2 = laps_db(); a = vm_steps(db2, "SELECT lap_ms FROM laps WHERE runner_id = 42")
db2.execute("CREATE INDEX i ON laps(runner_id)"); b = vm_steps(db2, "SELECT lap_ms FROM laps WHERE runner_id = 42")
print(f"SQLite steps: scan about {a:,} ┬╖ index about {b:,}   (exact counts depend on your SQLite version)")
EOF

The last line counts SQLite's internal steps тАФ about 30,000 for the scan and 100 for the index with SQLite 3.51. Your numbers may differ with another SQLite version; the gap will not.

тЬЕ Verify тАФ what you should see

database prints:

тФАтФА 10,000 laps, 500 runners ┬╖ EXPLAIN QUERY PLAN for: SELECT lap_ms FROM laps WHERE runner_id = ?
   no index            тЖТ SCAN laps   (reads all 10,000 rows)
   index on runner_id  тЖТ SEARCH laps USING INDEX idx_laps_runner (runner_id=?)   (jumps to runner 42's 20 rows)
   index on (runner_id, lap_ms) тЖТ SEARCH laps USING COVERING INDEX idx_laps_runner_ms (runner_id=?)
   covering: every column the query needs is in the index, so the table itself is never read
   runners WHERE name = 'runner042'         тЖТ SEARCH runners USING COVERING INDEX idx_runners_name (name=?)
   runners WHERE lower(name) = 'runner042'  тЖТ SCAN runners
   runners WHERE name LIKE '%042'           тЖТ SCAN runners

Your snippet starts with:

before: ['SCAN laps', 'USE TEMP B-TREE FOR GROUP BY']
after:  ['SEARCH laps USING INDEX idx_laps_day (day=?)', 'USE TEMP B-TREE FOR GROUP BY']
sort:   ['SCAN laps', 'USE TEMP B-TREE FOR ORDER BY']
sorted: ['SEARCH laps USING INDEX idx_laps_runner_ms (runner_id=?)']
expr:   ['SEARCH runners USING COVERING INDEX idx_runners_lower (<expr>=?)']

ЁЯПБ What you just proved

The same SQL went from reading 10,000 rows to 20, just by adding an index тАФ and to no table reads at all with a covering index. The composite index (runner_id, lap_ms) also removed the USE TEMP B-TREE FOR ORDER BY step: the rows come out of the index already sorted. lower(name) hid the name index until an expression index matched it. The day query still needs a temporary B-tree for its GROUP BY тАФ an index on day finds the rows, but does not group them by runner.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ PostgreSQL's EXPLAIN ANALYZE runs the query and shows real rows and times next to the plan (so be careful with UPDATE/DELETE тАФ wrap them in a transaction you roll back):

EXPLAIN (ANALYZE, BUFFERS) SELECT lap_ms FROM laps WHERE runner_id = 42;
-- look for: Seq Scan vs Index Scan / Index Only Scan / Bitmap Heap Scan,
-- estimated rows vs actual rows, and "Buffers: shared hit=тАж read=тАж"
CREATE INDEX CONCURRENTLY idx_laps_runner ON laps (runner_id);   -- builds without blocking writes
CREATE INDEX idx_runners_lower_name ON runners (lower(name));     -- an expression index
ANALYZE laps;                                                     -- refresh the planner's statistics

Find the slow queries first. PostgreSQL can log every query slower than a limit:

ALTER SYSTEM SET log_min_duration_statement = '200ms';
SELECT pg_reload_conf();

MySQL: EXPLAIN ANALYZE SELECT ... (8.0.18+) and the slow query log (long_query_time). SQLite, from the command line:

sqlite3 school.db "EXPLAIN QUERY PLAN SELECT lap_ms FROM laps WHERE runner_id = 42;"

ЁЯПн Why this matters in production: for your ten most frequent and ten slowest queries, keep the plan next to the code, and look at it again when the table has grown ten times.

тПня╕П Next

The server is fast now. But the parents are looking at the results on their phones. Next: web performance тАФ LCP, INP, CLS, payload, compression and caching headers.

git checkout lesson-11-web-performance
тЖР PreviousqueueingNext тЖТweb performance

This page is the lesson's README from the lesson-10-database-queries branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.