Performance#
What the library actually costs, measured rather than asserted.
A lookup is one dict read on a single ContextVar, and nothing is constructed, resolved or cached along the way.
That is the claim the table below prices.
The numbers#
The first rows are one function doing one read, reached five ways, so they can be read against each other and against the parameter they replace.
operation |
ns |
× |
|---|---|---|
one read in a function, value passed in as a parameter |
23 |
1.0 |
the same read through |
60 |
2.6 |
the same read through |
71 |
3.1 |
the same read through a |
116 |
5.0 |
the same read through a resolved |
138 |
5.9 |
|
45 |
1.9 |
the same lookup through a |
145 |
6.3 |
bare |
16 |
0.7 |
|
632 |
27 |
the same with 8 providers already open |
720 |
31 |
|
1607 |
69 |
|
1783 |
77 |
|
540 |
23 |
CPython 3.14.5 on macOS 26.6, arm64, measured 2026-08-11.
The × column is against handing the value in as a parameter, which is the alternative nodrill removes from the signatures in between.
Reading through use() costs a little over the parameter it replaces.
inject() costs more, because it fills the argument before the body runs.
frozen=True and lazy() add a proxy hop to every attribute the consumer touches.
A ref() key pays for a Python-level hash and one equality check where a class hashes in C, on the lookups that go through a ref and on no others.
A request that reads a provided value a hundred times spends microseconds in nodrill, against hundreds of microseconds for one round trip to a database.
Entering a provider is the expensive end, because it copies the registry so that sibling tasks stay isolated.
That copy is proportional to how many providers are open, which the with provider(...) rows price at one and at eight, and it happens once per scope rather than once per lookup.
A lazy provider pays for the cell it allocates on top, which is the trade the feature is for, a microsecond on entry against a value that is never built at all on the requests that never read it.
An extending layer copies the enclosing namespace on top of the registry, so its row grows with how many attributes have accumulated rather than with how many layers are open, and that second copy is what keeps a sibling task from seeing a layer opened after it started.
What has no row#
Debug mode has none, because it is not for the hot path.
debug() makes entering a provider read the stack and write to a ledger, and leaves a lookup that hits costing what it always cost.
debug(unused=True) also routes every read through a counting registry, which puts a hit at roughly three times its usual price.
Exception notes have none either, because nothing in that path runs until an exception is already leaving a block.
A block that exits cleanly costs one pointer comparison more than it did before annotate_exceptions() existed.
How to read this#
The absolute numbers move with the machine, and the ratios are the part worth reading. A rerun on one machine lands within ten or fifteen percent, so read the digits as approximate and treat a single-row change of that size as noise rather than as a regression. The way to tell them apart is to measure both revisions in one sitting, alternating between them, which is what a change to the lookup path is expected to do before it claims anything.
Regenerate the table with make bench ARGS=--write, which measures on your machine and rewrites the block above.
Nothing here runs in CI, because timing on a shared runner measures the runner.