Tuning for production and high concurrency¶
Each item says what backs it: measured (a committed result under bench/results/), tested
(a test in this repository asserts it), or guidance (sound practice that nothing here verifies).
Connections and timeouts¶
Any keyword Registry does not know is passed to the driver, so the pool is configured there:
registry = Registry(
uri,
models=[...],
max_time_ms=5_000, # default time limit for reads
maxPoolSize=50, # connections per process (driver default: 100)
minPoolSize=5, # kept open, so a burst does not pay for handshakes
waitQueueTimeoutMS=500, # fail instead of queueing for a connection without bound
serverSelectionTimeoutMS=5_000,
connectTimeoutMS=2_000,
)
- Set
waitQueueTimeoutMS. Without it a request that finds the pool empty waits indefinitely, so overload shows up as every request getting slower. With it the operation raisesPoolTimeout, which the web layer answers with 503 andRetry-After. Tested (test_a_full_pool_fails_fast_when_a_wait_limit_is_set,test_a_pool_timeout_is_a_503_with_retry_after). - Size the pool for the database, not for the process.
maxPoolSize× processes is the number of connections the server must hold. One event loop rarely makes use of more than a few dozen. Guidance. - Give reads a time limit.
Registry(max_time_ms=...)orSpecConfig(max_time_ms=...); an operation that exceeds it raisesOperationTimeout(HTTP 504). The web layer applies 10 s to reads made for a request when neither is set. Tested (test_listing_limits). - One registry per process and event loop. Open it when the server starts and close it when it
stops (
plug.lifespan(registry),init_app(..., registry=registry)). A model class is bound to one open registry; useregistry.manager(Model)where you need several. Tested (lifecycle and binding tests); do not share a registry across event loops. Guidance.
Refusing work you cannot do¶
api = Api(*resources, max_concurrency=200, max_body=1_048_576)
max_concurrencyanswers the request beyond the limit at once with 503 andRetry-After: 1instead of letting it queue. Set it a little above the concurrency your pool and database sustain. Tested (test_requests_beyond_max_concurrency_are_refused_at_once); the right number for your deployment is guidance: find it with the load workload below.max_bodyis enforced while the body is read, on every plug. Tested (test_3_no_plug_reads_a_body_past_the_limit).Offset(max_offset=...)caps how far a client can page with skip/limit, andCursor(max=...)the page size. Tested.
Reads¶
- Use
lean()for anything you will not save. A tracked document also keeps its stored bytes: 3.6 KB against 2.4 KB of heap per document for the benchmark's shape (msgspec). The time difference between tracked and lean reads is small.raw()skips the model altogether and roughly halves the time, but a dict takes more memory than a model (5.3 KB here). (bench/results/6dc837f/B8.json). Measured. - Project.
select("name", "price")fetches only those fields; mark large, rarely needed fieldsDeferredso they are left out by default. Tested. - Stream large results with
async for doc in Model.find(...)instead of.all(), which holds the whole result. Pydantic instances take roughly 2.6 times the memory of msgspec or attrs ones (1829 against 709 bytes per lean document of the review's sample shape). Measured by the reviewer; there is no committed result for it. - The web layer reads without building models where it can.
GETon a resource fetches only the readable fields and serialises the stored document directly when the model has nothing that could change a value on the way (validators, converters, computed fields, a discriminator); any other model, and any single document whose stored values do not have their declared types, goes through the model. Serialising alone costs 2.5 µs per document this way against 4.3 (msgspec), 6.4 (attrs) and 6.7 (pydantic) through the model; for a whole request the gain is smaller because the driver dominates (bench/results/fb543de/B9.json). Measured, and tested to give the same response (test_raw_reads_equal_model_reads).Resource.raw_reads = Falseturns it off.
Pagination¶
- Prefer cursors.
Cursor()(keyset) costs the same on page 1 and page 10,000;Offset()makes the server walk and discard everything before the page: at page 1,250 of a 50,000-document collection, 7.1 ms against 0.66 ms. Measured (bench/results/6dc837f/B5.json). - The index must end in
_id. Cursor pagination sorts by your key and then by_id: declareIndex("price", "id"). With an index on the key alone the first page took 28.8 ms instead of 0.58 ms. Measured (B5). - Documents without a value for the sort key are paged too (first ascending, last descending).
Tested (
test_cursor_pages_include_documents_without_the_sort_key). ?total=trueis a second query over every match. It is off unless the resource opts in withCursor(total=True). There is no cached or estimated total; if you need one, keep a counter. Tested (two commands;test_listing_limits).
Relations¶
include()uses one query per relation for the whole page by default. Independent relations are sent together, except inside a transaction. Tested (test_independent_includes_are_in_flight_together).- Give reverse relations a
per_parent_limit.include("reviews", per_parent_limit=5)chooses the children on the server, so a parent with 100,000 children costs the same as one with five. With 20 parents of 100 children each this took 1.5 ms of client CPU against 23 ms when every child was fetched (bench/results/49a32a9/B9.jsonbefore,fb543deafter). Measured and tested. - Index the foreign key of every reverse relation (
plan.advisorieslists the ones that lack one). Tested.
Writes¶
insert_many,bulk_saveandbulk_writesend one command per batch; inserting 1,000 documents costs 1.08 times the driver's owninsert_many(bench/results/6dc837f/B4.json). Measured.save()sends only the changed fields;Model.update(id, Inc(...))is one atomic round trip and needs no read. Tested.- Documents are validated before
insert,save,insert_manyandbulk_savewrite them. The check took about 3 microseconds per document on msgspec and 5 on pydantic for a seven-field document with two embedded objects, against about 6 and 4 for encoding it. For bulk loads of data that is already known to be sound,validate=Falseon the call orSpecConfig(validate_writes=False)on the model skips it. Measured once, on one machine; not part of the recorded benchmark runs, which predate the check. - A web
DELETEis one conditional write. It needs a read first only when the model has delete hooks, or cascades on a hard delete. Tested (test_delete_is_one_write_unless_the_document_is_needed).
What needs a replica set¶
Transactions and change streams need a replica set (a single-node one is enough). That includes
hard-deleting a document that other documents refer to with OnDelete: the cascade and the
delete run in one transaction, which a standalone mongod cannot do. Soft deletes do not cascade
and need no transaction. Tested on a replica set; not supported on a standalone server.
Change streams resume across a primary step-down (bench/results/6dc837f/X6.json). To resume
across a restart of your own process, store event.resume_token and pass it back as
resume_after=. Measured for the step-down; the restart pattern is guidance.
Observability¶
on_command listeners run for every command, inline. The logging, OpenTelemetry and Sentry
listeners do little per command, but anything slow in a listener is added to every operation.
Guidance: their cost has not been measured.
Measuring your own workload¶
make up # MongoDB replica set and sharded cluster
uv run python -m mongospecs_bench.cli run B9 # CPU per call and commands per operation
uv run python -m mongospecs_bench.cli run LOAD # the storefront under HTTP load, against raw pymongo
Results are written to bench/results/<git sha>/. CPU time and command counts are stable from
run to run; wall-clock numbers on a shared machine vary by 20 to 30 percent, so compare those only
when the difference is large.