ЁЯПл The SchoolтА║ЁЯПОя╕П PerformanceтА║ЁЯФБ рдзрдбрд╛ 07 тАФ I/O, N+1 рдЖрдгрд┐ batching: рдкреНрд░рддреНрдпреЗрдХ рд╣рд╕реНрддрд╛рдВрддрд░рд╛рд▓рд╛ рдПрдХ рдлреЗрд░реА рд▓рд╛рдЧрддреЗ
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯФБ рдзрдбрд╛ 07 тАФ I/O, N+1 рдЖрдгрд┐ batching: рдкреНрд░рддреНрдпреЗрдХ рд╣рд╕реНрддрд╛рдВрддрд░рд╛рд▓рд╛ рдПрдХ рдлреЗрд░реА рд▓рд╛рдЧрддреЗ

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 07 ┬╖ рдорд╛рдЧреЗ: lesson-06-caching ┬╖ рдкреБрдвреЗ: lesson-08-concurrency


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

рдзрдбреЗ 01тАУ06, рдЕрдзрд┐рдХ I/O: database, disk рдХрд┐рдВрд╡рд╛ рджреБрд╕рд▒реНрдпрд╛ service рдХрдбреЗ рдЬрд╛рдгрд╛рд▒реНрдпрд╛ рдкреНрд░рддреНрдпреЗрдХ рдлреЗрд░реАрд▓рд╛ рдард░рд╛рд╡рд┐рдХ toll рднрд░рд╛рд╡рд╛ рд▓рд╛рдЧрддреЛ, рддреБрдореНрд╣реА рдиреЗрдд рдЕрд╕рд▓реЗрд▓реА рдЧреЛрд╖реНрдЯ рдХрд┐рддреАрд╣реА рд▓рд╣рд╛рди рдЕрд╕рд▓реА рддрд░реА. рдкреНрд░рд╕рд┐рджреНрдз рдирд╛рд╕рд╛рдбреА рдореНрд╣рдгрдЬреЗ N+1 query: рдпрд╛рджреАрд╕рд╛рдареА рдПрдХ query, рдордЧ рдкреНрд░рддреНрдпреЗрдХ item рд╕рд╛рдареА рдЖрдгрдЦреА рдПрдХ. рдЙрдкрд╛рдп рдЖрд╣реЗрдд batching (IN (...), рдПрдХ JOIN) рдЖрдгрд┐ buffering. perf/demo.py рдордзрд▓реЗ io() рдЖрдгрд┐ perf/sim.py рдордзрд▓реЗ make_db, counting, n_plus_one, in_batch, joined рдЖрдгрд┐ write_lines.

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

рдкреНрд░рдорд╛рдгрдкрддреНрд░рд╛рдВрд╕рд╛рдареА рд░рд┐рд▓реЗ рд╕рдВрдШрд╛рд▓рд╛ рдкреНрд░рддреНрдпреЗрдХрд╛рдЪрд╛ рд╕рд░реНрд╡реЛрддреНрддрдо lap рд╣рд╡рд╛ рдЖрд╣реЗ. ЁЯПЕ Laps рдореИрджрд╛рдирд╛рдЪреНрдпрд╛ рдкрд▓реАрдХрдбрдЪреНрдпрд╛ office рдордзреНрдпреЗ рд▓рд┐рд╣рд┐рд▓реЗрд▓реЗ рдЖрд╣реЗрдд.

рдкреНрд░рддреНрдпреЗрдХ рдлреЗрд░реАрд▓рд╛ рдлрдХреНрдд рддрд┐рдереЗ рдЬрд╛рдКрди рдкрд░рдд рдпрд╛рдпрд▓рд╛рдЪ рд╡реЗрд│ рд▓рд╛рдЧрддреЛ, рдЙрддреНрддрд░ рдПрдХ рдЫреЛрдЯрд╛ рдЖрдХрдбрд╛ рдЕрд╕рд▓рд╛ рддрд░реА. рдореНрд╣рдгреВрди рдкреНрд░рддреНрдпреЗрдХ рдлреЗрд░реАрдд рдЕрдиреЗрдХ рдЧреЛрд╖реНрдЯреА рдиреНрдпрд╛.

рд▓рд┐рд╣рд┐рдгреНрдпрд╛рдЪреЗрд╣реА рдЕрд╕реЗрдЪ рдЖрд╣реЗ. рдкреНрд░рддреНрдпреЗрдХ result рд╡реЗрдЧрд│реНрдпрд╛ card рд╡рд░ рд▓рд┐рд╣реВрди post рдХрд░рдгреЗ рд╣рд│реВ рдЖрд╣реЗ. рдЖрдзреА рд╕рдВрдкреВрд░реНрдг рдкрд╛рдХреАрдЯ рднрд░реВрди рддреЗ рдПрдХрджрд╛рдЪ post рдХрд░рдгреЗ рдЬрд▓рдж рдЖрд╣реЗ. рдпрд╛рд▓рд╛рдЪ buffering рдореНрд╣рдгрддрд╛рдд. тЬЙя╕П

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

flowchart LR
    app["ЁЯПГ the app"] -->|"1 query: the runners"| db[("ЁЯЧГя╕П SQLite")]
    app -->|"+50 queries: one per runner"| db
    n1["N+1 тЖТ 51 queries<br/>51 ms at 1 ms a round trip"]
    b["IN (...) тЖТ 2 queries"]
    j["JOIN + GROUP BY тЖТ 1 query"]
    w["10,000 lines ┬╖ 210,000 bytes:<br/>unbuffered 10,000 writes ┬╖ buffered 26"]

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

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

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг рд░реЛрдЬрдЪреНрдпрд╛ рдмрд╣реБрддреЗрдХ services рдЖрдкрд▓рд╛ рд╡реЗрд│ I/O рдордзреНрдпреЗрдЪ рдШрд╛рд▓рд╡рддрд╛рдд. рдкреНрд░рддреНрдпреЗрдХреА 1 ms рдЪреНрдпрд╛ 51 queries рдХрд░рдгрд╛рд░рд╛ endpoint 51 ms рд╡рд╛рдЯ рдкрд╛рд╣рдгреНрдпрд╛рдд рдШрд╛рд▓рд╡рддреЛ, Python рдХрд┐рддреАрд╣реА рдЬрд▓рдж рдЕрд╕рд▓рд╛ рддрд░реА, рдЖрдгрд┐ load рдЦрд╛рд▓реА рддреНрдпрд╛ queries database рдордзреНрдпреЗ рд░рд╛рдВрдЧреЗрддрд╣реА рдЙрднреНрдпрд╛ рд░рд╛рд╣рддрд╛рдд (рдзрдбрд╛ 09). N+1 local database рдЖрдгрд┐ 3 rows рдЕрд╕рд▓реЗрд▓реНрдпрд╛ laptop рд╡рд░ рджрд┐рд╕рддрд╣реА рдирд╛рд╣реА. рддреЗ production рдордзреНрдпреЗ рджрд┐рд╕рддреЗ, network рдЖрдгрд┐ 200 rows рд╕рд╣.

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

perf/sim.py рдордзрд▓реЗ make_db() 50 рдзрд╛рд╡рдкрдЯреВ рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХреА 4 laps рдЕрд╕рд▓реЗрд▓рд╛ in-memory SQLite database рдмрдирд╡рддреЗ. counting(conn) sqlite3 рдЪрд╛ trace callback рд╕реБрд░реВ рдХрд░рддреЗ, рдЬреЛ connection рдЪрд╛рд▓рд╡рдд рдЕрд╕рд▓реЗрд▓реНрдпрд╛ рдкреНрд░рддреНрдпреЗрдХ SQL statement рд╕рд╣ рдПрдХ function call рдХрд░рддреЛ тАФ рдореНрд╣рдгреВрди query counts рдЦрд░реЗ рдЖрд╣реЗрдд. n_plus_one, in_batch рдЖрдгрд┐ joined рддреЗрдЪ рдЙрддреНрддрд░ рддреАрди рдкреНрд░рдХрд╛рд░реЗ рдорд┐рд│рд╡рддрд╛рдд. SQLite рдпрд╛рдЪ process рдЪреНрдпрд╛ рдЖрдд рдЪрд╛рд▓рддреЛ, рдореНрд╣рдгреВрди рдЗрдереЗ рддреНрдпрд╛рдЪреНрдпрд╛ queries рд╕реНрд╡рд╕реНрдд рдЖрд╣реЗрдд; "рдкреНрд░рддреНрдпреЗрдХ round trip рд▓рд╛ 1 ms" рд╣рд╛ network рд╡рд░рдЪреНрдпрд╛ database server рдЪрд╛ model рдЖрд╣реЗ. write_lines(lines, buffered) CountingRaw рдордзреВрди рд▓рд┐рд╣рд┐рддреЗ, рдЬреА рдкреНрд░рддреНрдпреЗрдХ raw write рдореЛрдЬрдгрд╛рд░реА рдмрдирд╛рд╡рдЯ file рдЖрд╣реЗ, Python рдЪреНрдпрд╛ рдЦрд▒реНрдпрд╛ io.BufferedWriter рд╕рд╣ рдХрд┐рдВрд╡рд╛ рддреНрдпрд╛рд╢рд┐рд╡рд╛рдп.

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

python3 perf/demo.py io
python3 - <<'EOF'
import sys; sys.path.insert(0, "perf"); import sim
for n in (10, 50, 200):
    db = sim.make_db(n_runners=n); log = sim.counting(db)
    sim.n_plus_one(db); a = len(log); log.clear(); sim.joined(db); b = len(log)
    print(f"{n:>3} runners тЖТ N+1 {a:>3} queries ┬╖ JOIN {b} ┬╖ at 2 ms a round trip: {a * 2:>3} ms vs {b * 2} ms")
db = sim.make_db(n_runners=3); log = sim.counting(db); sim.n_plus_one(db)
print("\n".join(log))
lines = [b"x" * 20 + b"\n"] * 10_000
for size in (1024, 8192, 65536):
    print(f"buffer {size:>5} bytes тЖТ {sim.write_lines(lines, True, size)[0]:>4} raw writes")
EOF

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

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

тФАтФА best lap for each of 50 runners, from SQLite (sqlite3's trace callback counts the queries)
   N+1: one query for the runners, then one per runner тЖТ 51 queries
   batched with IN (...)                            тЖТ 2 queries
   one JOIN + GROUP BY                              тЖТ 1 query ┬╖ same answer all three ways: True
   at 1 ms round trip to a database server: 51 ms vs 2 ms vs 1 ms of waiting (here SQLite is in-process)
тФАтФА writing 10,000 result lines (210,000 bytes) unbuffered     тЖТ 10,000 raw writes
тФАтФА writing 10,000 result lines (210,000 bytes) buffered (8 KB) тЖТ 26 raw writes

рддреБрдордЪрд╛ snippet рд╣реЗ рдЫрд╛рдкрддреЛ:

 10 runners тЖТ N+1  11 queries ┬╖ JOIN 1 ┬╖ at 2 ms a round trip:  22 ms vs 2 ms
 50 runners тЖТ N+1  51 queries ┬╖ JOIN 1 ┬╖ at 2 ms a round trip: 102 ms vs 2 ms
200 runners тЖТ N+1 201 queries ┬╖ JOIN 1 ┬╖ at 2 ms a round trip: 402 ms vs 2 ms
SELECT id, name FROM runners ORDER BY id
SELECT MIN(lap_ms) FROM laps WHERE runner_id = 1
SELECT MIN(lap_ms) FROM laps WHERE runner_id = 2
SELECT MIN(lap_ms) FROM laps WHERE runner_id = 3
buffer  1024 bytes тЖТ  209 raw writes
buffer  8192 bytes тЖТ   26 raw writes
buffer 65536 bytes тЖТ    4 raw writes

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

N+1 data рд╕реЛрдмрдд рд╡рд╛рдврддреЗ тАФ 11, 51, 201 queries тАФ рддрд░ JOIN 1 рд╡рд░рдЪ рд░рд╛рд╣рддреЛ. Trace log рдиреЗрдордХреЗ рддреЗрдЪ рджрд╛рдЦрд╡рддреЛ рдЬреЗ ORM рд▓рдкрд╡реЗрд▓: рддреНрдпрд╛рдЪ рдЖрдХрд╛рд░рд╛рдЪреА query, рдкреНрд░рддреНрдпреЗрдХ рдзрд╛рд╡рдкрдЯреВрд╕рд╛рдареА рдПрдХрджрд╛. Buffering рдиреЗ 10,000 raw writes 26 рд╡рд░ рдЖрдгрд▓реЗ (210,000 bytes ├╖ 8192 тЙИ 25.6); рдореЛрдард╛ buffer рдореНрд╣рдгрдЬреЗ рдХрдореА writes, рдЖрдгрд┐ program flush рдХрд░рдгреНрдпрд╛рдЖрдзреА crash рдЭрд╛рд▓реНрдпрд╛рд╕ рдЬрд╛рд╕реНрдд data рд╣рд░рд╡рдгреЗ.

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

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

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ N+1 рдЯрд╛рд│рдгреНрдпрд╛рдЪреА ORM рдкрджреНрдзрдд. Django:

runners = Runner.objects.prefetch_related("laps")          # 2 queries, not N+1
laps = Lap.objects.select_related("runner")                # 1 query with a JOIN
from django.test.utils import CaptureQueriesContext        # count queries in a test

SQLAlchemy:

from sqlalchemy.orm import selectinload
stmt = select(Runner).options(selectinload(Runner.laps))   # 1 + 1 queries, IN (...)

PostgreSQL pg_stat_statements extension рд╡рд╛рдкрд░реВрди рдкреНрд░рддреНрдпреЗрдХ query рдЖрдХрд╛рд░рд╛рд╕рд╛рдареА calls рдореЛрдЬрддреЛ тАФ рдкреНрд░рдЪрдВрдб calls рдЖрдгрд┐ рдЕрдЧрджреА рд▓рд╣рд╛рди mean_exec_time рдЕрд╕рд▓реЗрд▓реА query рдмрд▒реНрдпрд╛рдЪрджрд╛ N+1 рдЕрд╕рддреЗ:

SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms, query
FROM pg_stat_statements ORDER BY calls DESC LIMIT 5;

Bulk inserts: рдЕрдиреЗрдХ rows рд╕рд╛рдареА рдПрдХ statement, рдХрд┐рдВрд╡рд╛ PostgreSQL рдордзреНрдпреЗ COPY:

cur.executemany("INSERT INTO laps (runner_id, lap_ms) VALUES (%s, %s)", rows)

ЁЯПн Production рдордзреНрдпреЗ рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ рдЖрд╣реЗ: рддреБрдордЪреНрдпрд╛ tests рдордзреНрдпреЗ query counter рдареЗрд╡рд╛ (Django рдЪреЗ assertNumQueries, рдХрд┐рдВрд╡рд╛ SQLAlchemy event listener) рдЖрдгрд┐ рдПрдЦрд╛рджреНрдпрд╛ page рдЪрд╛ count data рд╕реЛрдмрдд рд╡рд╛рдврд▓рд╛ рддрд░ build fail рдХрд░рд╛. рдзрдбрд╛ 12 рддреНрдпрд╛рдЪреЗ budget рдордзреНрдпреЗ рд░реВрдкрд╛рдВрддрд░ рдХрд░рддреЛ.

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

рджреАрдкрд┐рдХрд╛ office рдХрдбреЗ рдЪрд╛рд▓рдд рдЧреЗрд▓реА рддреЗрд╡реНрд╣рд╛ рд╕рдВрдШрд╛рддрд▓реЗ рдмрд╛рдХреАрдЪреЗ рдиреБрд╕рддреЗ рдЙрднреЗ рд░рд╛рд╣рд┐рд▓реЗ. рд╡рд╛рдЯ рдкрд╛рд╣рддрд╛рдирд╛ рддреЗ рджреБрд╕рд░реЗ рдХрд╛рд╣реА рдХрд░реВ рд╢рдХрд▓реЗ рдЕрд╕рддреЗ рдХрд╛? рдкреБрдвреЗ: concurrency тАФ threads, asyncio, processes, рдЖрдгрд┐ GIL.

git checkout lesson-08-concurrency

ЁЯФБ Lesson 07 тАФ I/O, N+1 & batching: every hand-off costs a trip

ЁЯУН You are here: Lesson 07 of 12 ┬╖ Previous: lesson-06-caching ┬╖ Next: lesson-08-concurrency


ЁЯУж What's in this branch

Lessons 01тАУ06, plus I/O: every trip to a database, a disk or another service pays a fixed toll, however small the thing you carry. The famous waste is the N+1 query: one query for a list, then one more per item. The fixes are batching (IN (...), a JOIN) and buffering. io() in perf/demo.py and make_db, counting, n_plus_one, in_batch, joined and write_lines in perf/sim.py.

ЁЯзТ Explain like I'm 5

The relay team needs everyone's best lap for the certificates. ЁЯПЕ The laps are written in the office across the field.

Every trip takes time just to walk there and back, even if the answer is one small number. So carry many things per trip.

It is the same with writing. Writing each result on a separate card and posting it is slow. Filling a whole envelope first and posting it once is fast. That is buffering. тЬЙя╕П

ЁЯЧ║я╕П Diagram

flowchart LR
    app["ЁЯПГ the app"] -->|"1 query: the runners"| db[("ЁЯЧГя╕П SQLite")]
    app -->|"+50 queries: one per runner"| db
    n1["N+1 тЖТ 51 queries<br/>51 ms at 1 ms a round trip"]
    b["IN (...) тЖТ 2 queries"]
    j["JOIN + GROUP BY тЖТ 1 query"]
    w["10,000 lines ┬╖ 210,000 bytes:<br/>unbuffered 10,000 writes ┬╖ buffered 26"]

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

тЭУ What

ЁЯдФ Why

Because I/O is where most everyday services spend their time. An endpoint that makes 51 queries at 1 ms each spends 51 ms waiting, however fast the Python is, and under load those queries also queue up in the database (lesson 09). N+1 is also invisible on a laptop with a local database and 3 rows. It shows up in production, with a network and 200 rows.

ЁЯФз How (in this repo)

make_db() in perf/sim.py builds an in-memory SQLite database with 50 runners and 4 laps each. counting(conn) turns on sqlite3's trace callback, which calls a function with every SQL statement the connection runs тАФ so the query counts are real. n_plus_one, in_batch and joined get the same answer three ways. SQLite runs inside this process, so its queries are cheap here; the "1 ms per round trip" is a model of a database server on a network. write_lines(lines, buffered) writes through CountingRaw, a pretend file that counts each raw write, with or without Python's real io.BufferedWriter.

ЁЯзк Try it

python3 perf/demo.py io
python3 - <<'EOF'
import sys; sys.path.insert(0, "perf"); import sim
for n in (10, 50, 200):
    db = sim.make_db(n_runners=n); log = sim.counting(db)
    sim.n_plus_one(db); a = len(log); log.clear(); sim.joined(db); b = len(log)
    print(f"{n:>3} runners тЖТ N+1 {a:>3} queries ┬╖ JOIN {b} ┬╖ at 2 ms a round trip: {a * 2:>3} ms vs {b * 2} ms")
db = sim.make_db(n_runners=3); log = sim.counting(db); sim.n_plus_one(db)
print("\n".join(log))
lines = [b"x" * 20 + b"\n"] * 10_000
for size in (1024, 8192, 65536):
    print(f"buffer {size:>5} bytes тЖТ {sim.write_lines(lines, True, size)[0]:>4} raw writes")
EOF

тЬЕ Verify тАФ what you should see

io prints:

тФАтФА best lap for each of 50 runners, from SQLite (sqlite3's trace callback counts the queries)
   N+1: one query for the runners, then one per runner тЖТ 51 queries
   batched with IN (...)                            тЖТ 2 queries
   one JOIN + GROUP BY                              тЖТ 1 query ┬╖ same answer all three ways: True
   at 1 ms round trip to a database server: 51 ms vs 2 ms vs 1 ms of waiting (here SQLite is in-process)
тФАтФА writing 10,000 result lines (210,000 bytes) unbuffered     тЖТ 10,000 raw writes
тФАтФА writing 10,000 result lines (210,000 bytes) buffered (8 KB) тЖТ 26 raw writes

Your snippet prints:

 10 runners тЖТ N+1  11 queries ┬╖ JOIN 1 ┬╖ at 2 ms a round trip:  22 ms vs 2 ms
 50 runners тЖТ N+1  51 queries ┬╖ JOIN 1 ┬╖ at 2 ms a round trip: 102 ms vs 2 ms
200 runners тЖТ N+1 201 queries ┬╖ JOIN 1 ┬╖ at 2 ms a round trip: 402 ms vs 2 ms
SELECT id, name FROM runners ORDER BY id
SELECT MIN(lap_ms) FROM laps WHERE runner_id = 1
SELECT MIN(lap_ms) FROM laps WHERE runner_id = 2
SELECT MIN(lap_ms) FROM laps WHERE runner_id = 3
buffer  1024 bytes тЖТ  209 raw writes
buffer  8192 bytes тЖТ   26 raw writes
buffer 65536 bytes тЖТ    4 raw writes

ЁЯПБ What you just proved

N+1 grows with the data тАФ 11, 51, 201 queries тАФ while the JOIN stays at 1. The trace log shows exactly what an ORM would hide: the same query shape, once per runner. Buffering cut 10,000 raw writes to 26 (210,000 bytes ├╖ 8192 тЙИ 25.6); a bigger buffer means fewer writes, and more data lost if the program crashes before it flushes.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ the ORM way to avoid N+1. Django:

runners = Runner.objects.prefetch_related("laps")          # 2 queries, not N+1
laps = Lap.objects.select_related("runner")                # 1 query with a JOIN
from django.test.utils import CaptureQueriesContext        # count queries in a test

SQLAlchemy:

from sqlalchemy.orm import selectinload
stmt = select(Runner).options(selectinload(Runner.laps))   # 1 + 1 queries, IN (...)

PostgreSQL counts calls per query shape with the pg_stat_statements extension тАФ a query with a huge calls and a tiny mean_exec_time is often an N+1:

SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms, query
FROM pg_stat_statements ORDER BY calls DESC LIMIT 5;

Bulk inserts: one statement for many rows, or COPY in PostgreSQL:

cur.executemany("INSERT INTO laps (runner_id, lap_ms) VALUES (%s, %s)", rows)

ЁЯПн Why this matters in production: put a query counter in your tests (Django's assertNumQueries, or a SQLAlchemy event listener) and fail the build when a page's count grows with the data. Lesson 12 turns that into a budget.

тПня╕П Next

While Dipika walked to the office, the rest of the team stood still. Could they do something else while waiting? Next: concurrency тАФ threads, asyncio, processes, and the GIL.

git checkout lesson-08-concurrency
тЖР PreviouscachingNext тЖТconcurrency

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