Performance¶
This page compares modern-di's resolution performance against four other Python
DI frameworks, states the method, and gives a command to reproduce the numbers.
modern-di has no runtime dependencies and generates no code. The comparison set
includes two frameworks that use exec codegen (dishka, wireup), one with a
Cython-compiled core (dependency-injector), and one pure-Python framework
(that-depends).
Absolute timings depend on the machine and CPython build and will differ on yours. The ratios between frameworks are more portable across machines, so the tables below are expressed as ratios.
What is measured¶
Five scenarios, each the smallest graph that isolates one cost, run with
pytest-benchmark in an isolated
environment with pinned rival versions:
| ID | Scenario | Isolates |
|---|---|---|
| C1 | Transient resolve, single dependency | pure wiring cost |
| C2 | Singleton resolve, warm cache | cache-hit lookup |
| C3 | Deep chain, depth 6 | per-edge wiring |
| C4 | Request lifecycle: enter scope → resolve → async-finalize on exit | whole per-request cost |
| C6 | Per-request context: supply a runtime value by type, resolve a handler that needs it plus an app-scoped dep | the request-injection path every integration uses |
Each framework uses its own idiomatic request-scope and resource-teardown
spelling, not modern-di's names forced onto it. Full per-framework mapping and
rules: benchmarks/README.md.
C1-C3 are published twice for modern-di: once resolved by provider reference
(resolve_provider) and once by type (resolve). dishka and wireup expose only by-type
lookup; that-depends and dependency-injector only by-reference. Each C1-C3 table compares one
modern-di variant against the rivals whose API matches it, because a single column would
flatter modern-di against half the set. By-type resolution adds a fixed dict-lookup cost on top
of resolve_provider — 21 ns on C1, 17 ns on C2 and 23 ns on C3 in the cells below, close to the
same absolute cost each time and 8%, 10% and 3% of the respective baselines. That surcharge was
54-65 ns until 3.3.0 inlined resolve_provider's body into resolve, removing a Python frame
from the by-type path; what is left is close to the bare dict lookup.
C4 does not split this way: modern-di's C4 body resolves by reference throughout, while dishka and wireup can only be measured by type. That asymmetry cuts against modern-di's C4 ratios, not for them — levelling it would add the ~21 ns by-type lookup to modern-di's cell and move the dishka ratio below from 1.22 to about 1.23. The C1-C3 leveling does not apply to C4.
C6 does not split either, for the same reason: modern-di's C6 body resolves by reference and there is no by-type C6 variant to pair against dishka and wireup, so a split would leave that half of the table mixed-basis. The rivals themselves do line up with the C1-C3 grouping here — that-depends resolves its C6 handler by reference exactly as it does on C1-C3 — so it is modern-di's missing variant, not the rivals' idioms, that keeps C6 in one table against all four.
Every published cell is timed at the same rounds × iterations for every framework
(C1-C3 and C6 at 200 × 1000, C4 at 100 × 3), so no cell carries per-round timer overhead or sits
on the platform timer's ~42 ns grid while the cell it is divided by does not. See
benchmarks/README.md
for why that matters and which cells it moved.
Results¶
Measured 2026-08-03 with modern-di 3.3.0 on an Apple M4 (macOS 26.5), CPython 3.14.6,
median over 5 runs (ratios paired within each run); the footnote under each table bounds the
across-run dispersion of each side's own median.
Rival versions: dishka 1.10.1, dependency-injector 4.49.1, that-depends 4.0.2, wireup 2.12.0.
Generated by just bench-report.
Each cell is modern-di ÷ rival: below 1.0 (bold) means modern-di is faster,
above 1.0 means slower. Every ratio is paired within each run — one run measures both sides
under the same machine state, so the published statistic is the median of the per-run ratios,
not a ratio of two independently-reduced medians. Pairing gives each ratio a well-defined
across-run IQR, published as the ±X.X% on the cell; read it before treating a near-1.00 cell
as a verdict.
By-reference resolution¶
| Scenario | modern-di | vs dependency-injector | vs that-depends |
|---|---|---|---|
| C1 transient | 252 ns ±1.7% | 0.53 ±1.2% | 0.65 ±1.2% |
| C2 warm singleton | 157 ns ±0.3% | 2.64 ±0.6% | 1.90 ±0.6% |
| C3 deep chain (6) | 706 ns ±0.5% | 0.38 ±0.8% | 0.53 ±0.1% |
Across-run IQR of each side's own median (5 runs): modern-di ≤1.7%, rivals ≤0.9%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.
By-type resolution¶
| Scenario | modern-di | vs dishka | vs wireup |
|---|---|---|---|
| C1 transient | 273 ns ±0.2% | 0.91 ±0.7% | 1.01 ±0.4% |
| C2 warm singleton | 174 ns ±0.5% | 0.81 ±0.5% | 1.84 ±2.4% |
| C3 deep chain (6) | 729 ns ±0.7% | 1.30 ±0.8% | 0.90 ±1.2% |
Across-run IQR of each side's own median (5 runs): modern-di ≤0.7%, rivals ≤2.2%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.
Request lifecycle (batched, published per request)¶
| Scenario | modern-di | vs dependency-injector | vs that-depends | vs dishka | vs wireup |
|---|---|---|---|---|---|
| C4 request lifecycle | 2.39 µs ±0.8% | 0.02 ±0.3% | 0.19 ±0.3% | 1.22 ±2.2% | 0.13 ±1.2% |
Across-run IQR of each side's own median (5 runs): modern-di ≤0.8%, rivals ≤0.9%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.
Per-request context¶
| Scenario | modern-di | vs dependency-injector | vs that-depends | vs dishka | vs wireup |
|---|---|---|---|---|---|
| C6 context | 1.61 µs ±1.9% | 0.53 ±2.0% | 0.58 ±0.5% | 1.47 ±0.9% | 1.23 ±2.0% |
Across-run IQR of each side's own median (5 runs): modern-di ≤1.9%, rivals ≤1.6%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.
What the numbers show¶
- Against
dependency-injector, modern-di is faster by reference on C1 (0.53) and C3 (0.38), and far faster on the batched C4 request lifecycle (0.02). dependency-injector's C4 body callsinit_resources()/shutdown_resources()every cycle in addition to resolving; the suite doesn't decompose how much of its per-request cost is that lifecycle work versus the resolve itself, so C4 should be read as a whole-lifecycle comparison, not an isolated resolve (see the caveat below). dependency-injector is still faster on C2 warm-singleton (2.64, an implied ~60 ns cache hit against modern-di's 157 ns): its hit is a C-level slot read on a Cython-compiled core, where modern-di's is a Python dict lookup behind an override guard. Pure Python does not reach ~60 ns, so this cell is expected to stay above 1.0 however much of modern-di's own overhead is removed. - Against
that-depends, modern-di leads by reference on C1 (0.65) and C3 (0.53). The C1 cell has moved a long way across publications (1.08, 1.12, 0.98, 0.98, 0.97, 0.89, 0.91, now 0.65) and this is the largest step in that series; it is modern-di moving, not that-depends, whose implied C2 absolute is unchanged at ~83 ns. that-depends remains faster on C2 warm-singleton (1.90); the suite does not decompose itsresolve_synccache-hit path, so no mechanism is asserted for the remaining gap. - Against the two
exec-codegen frameworks, the by-type table has crossed over. modern-di is now faster thandishkaon C1 (0.91) and C2 (0.81), and faster thanwireupon C3 (0.90) while level on C1 (1.01). One publication earlier it was slower than both on every one of these cells. dishka keeps a clear lead on C3 (1.30), the deepest graph, which is consistent with the per-node call frame thatexec-inlined source removes and modern-di keeps — the mechanism this page has always asserted for dishka, and the one cell where it still dominates. modern-di does not generate code (a documented non-goal); the gap closed by removing frames from the interpreted path instead. - The by-type surcharge is now small enough to stop mattering. Dividing modern-di's
by-reference cells by its by-type ones gives a fixed cost of 21/17/23 ns on C1/C2/C3, against
54-65 ns one publication earlier.
Container.resolveno longer delegates toresolve_provider— it carries that body itself — so what remains is close to the bare registry dict lookup. This is why the by-type table moved further than the by-reference one. - On C6 (per-request context) modern-di is faster than
dependency-injector(0.53) andthat-depends(0.58), and slower thandishka(1.47) andwireup(1.23). The direction matches the by-type table — the two codegen frameworks lead, the two others trail — but the cells are not on one basis: each framework supplies the request value through its own idiom, and two of those are structural analogs rather than equivalents (see the caveat below). No mechanism is asserted for the gaps; the suite does not decompose any framework's context lookup. - On C4 (request lifecycle), the corrected batching does not remove the ~35 µs asyncio floor
— it amortizes it. The guard tier's
test_g7c_event_loop_floor_controltimes the same batch shape with an empty body and puts the residual at ~0.35 µs per request still inside every C4 cell (~15% of modern-di's C4 figure), shared identically by all five frameworks. Before batching, that floor was ~93% of every cell and compressed the real differences toward 1.0; the page used to read modern-di as "level with dishka (1.00)" on that basis. With the floor amortized, dishka is measurably faster than modern-di here (1.22 — modern-di is the slower side of that cell), not tied. modern-di remains far faster than that-depends, dependency-injector, and wireup on this scenario.
What moved in this publication, and why the attribution is unusually clean. This is the second publication of the day, on the same machine, the same macOS 26.5 and CPython 3.14.6, and the same four pinned rival versions — a few hours apart. Every rival's implied absolute is unchanged: dependency-injector's C2 hit 59.7 → 59.5 ns, that-depends' 82.6 → 82.6, dishka's 215.3 → 214.8, wireup's 95.0 → 94.6. Nothing drifted, so every cell that moved is modern-di's 3.3.0.
| 3.2.0 | 3.3.0 | ||
|---|---|---|---|
| C1 transient, by reference | 353 ns | 252 ns | −28.6% |
| C3 deep chain, by reference | 965 ns | 706 ns | −26.8% |
| C1 transient, by type | 413 ns | 273 ns | −33.9% |
| C2 warm singleton, by type | 211 ns | 174 ns | −17.5% |
| C3 deep chain, by type | 1.03 µs | 729 ns | −29.2% |
| C6 context | 1.68 µs | 1.61 µs | −4.2% |
| C2 warm singleton, by reference | 157 ns | 157 ns | unchanged |
That last row is the control, and it is unchanged by construction: 3.3.0's two largest changes are the arity-specialised creator call, which is on the cold-miss path a warm cached hit returns before reaching, and the by-type inline, which a by-reference resolve never enters. A warm by-reference cache hit touches neither. It was predicted to be flat before the run and it was.
The wins themselves: a factory with 0 or 1 provider dependencies now compiles to a closure that
names its argument and calls the creator directly, rather than building a list and star-calling
it (C1 and C3, whose nodes are arity 1); Container.resolve carries its own copy of
resolve_provider's body (the whole by-type column); and a context-backed parameter has its
binding folded into the compiled closure (C6).
The C4 gain recorded at 3.1.1 was a library fix, and it stands: every Container used to
store itself in its own _scope_map, making it a reference cycle that reference counting could
never free, so a request-scoped application handed the garbage collector work at its request rate.
Seeding the map from the parent instead removed the cycle. Measured on the C4 benchmark at the
time, that cut the median from 232.7 µs to 194.8 µs per 100-request batch and the standard
deviation from 123.0 µs to 7.9 µs — the tail this scenario used to carry was the collector
reclaiming containers, and it is gone.
C4 is a batched request lifecycle. modern-di resolves the connection synchronously while
finalizing it asynchronously; the other four force an awaited resolve once the finalizer is
async. C4 therefore measures the whole request lifecycle (enter scope → resolve →
async-finalize), not an isolated resolve. It is timed as a batch of 100 cycles per event-loop
entry, because a single run_until_complete entry costs ~35 µs on any body — timing one
request per entry made every framework's cell ~93% asyncio floor. The published figure is the
batch divided by 100. C1–C3 are synchronous resolves for every framework.
C6 is sync for all five, but not one idiom. Each framework supplies the per-request value its
own way: modern-di seeds a child container's context and resolves by reference; dishka uses
from_context; wireup requires the runtime type registered as a scoped injectable behind a raising
placeholder factory; that-depends supplies it through container_context(global_context=); and
dependency-injector injects by reference via providers.Dependency + .override(), a
structural analog rather than an equivalent. modern-di's timed body builds the child, resolves, and closes
it. It calls no open() — a freshly built child is already open as of 3.1, so timing one would
charge modern-di a redundant lock acquire (81 ns, ~6% of the cell) with no counterpart in any
rival's body. It does close, because all four rivals exit their scope inside the timed body; that
teardown is ~110 ns, and omitting it would have flattered modern-di by more than the open()
would have cost it.
Thread-safety configuration differs, at each framework's default. dishka's make_container
defaults to lock_factory=<class '_thread.lock'>, so every get() behind its C1–C3 cells
acquires a lock; modern-di's cached read is lock-free by design (see
Design decisions).
Both run at their defaults, which is the comparison a user
gets out of the box — a dishka user targeting single-threaded work can pass lock_factory=None,
and that would move dishka's C1–C3 cells. The axis is disclosed rather than normalized away.
Why the results look this way¶
Since 2.29.0, modern-di compiles one specialized closure per provider on first
resolve, memoized on the providers registry, replacing a generic per-call
interpreted resolver. Each compiled resolver hoists its scope navigation,
override check, and cache lookup out of the per-call path and calls its
dependencies' resolvers directly. Against dishka the remaining gap widens with
graph depth, consistent with the per-node call frame that exec-inlined source
removes and modern-di keeps; wireup's gap narrows with depth instead, so the same
explanation is not claimed for it (see the by-type discussion above).
3.1.0 removed one more frame from the top of every resolve: resolve_provider
now opens with an inline closed check instead of an unconditional method call,
and build_child_container carries no such check at all. Measured on the guard
suite against 3.0.0 on one machine, that is worth roughly 5–11% on C1- and
C2-shaped resolves and on child construction; the deeper scenarios, which run
through compiled resolvers where the check was already inline, did not move.
3.1.1 removed a reference cycle rather than a frame: every Container stored itself in its own
_scope_map, so no container could be freed by reference counting and each one waited for the
garbage collector. Seeding the map from the parent removed it. Resolution is untouched — the
C1–C3 cells did not move — but the request lifecycle did: C4's median fell from 232.7 µs to
194.8 µs per 100-request batch and its standard deviation from 123.0 µs to 7.9 µs, because the
collector no longer has to reclaim containers that refcounting now frees.
3.1.2 removed two more frames, this time from the warm-hit path. A cached resolve reached its
compiled resolver through ProvidersRegistry.resolver_for and its CacheItem through
CacheRegistry.fetch_cache_item; both methods open with a dict lookup that hits and returns.
Both lookups are now inlined at the call site, with the method called only on a miss — where it
still owns the cycle guard, the memo write, and the setdefault that makes concurrent
first-resolvers share one CacheItem. Worth ~42 ns on a warm hit, and because the first sits in
resolve_provider it applies to every top-level resolve rather than only cached ones.
3.2.0 trimmed three more paths rather than one. The cached resolver's cold-miss thunk is now
built with functools.partial instead of a lambda closing over the target container: a closure
promotes that variable to a cell for the whole resolver, so MAKE_CELL ran in the prologue on
every call — including the warm hit that returns two lines later and the override hit that never
reaches it (−11.3% on a warm hit). The context-kwarg path front-guards its override lookup on
has_overrides (−6.0%), which is the path every framework integration takes for its per-request
values. And an Alias stopped routing through Alias._find_source and Container.resolve_provider
on every hop: it now inlines both lookups and calls its source's compiled resolver directly, one
Python frame per hop instead of four (~322 → ~252 ns). The alias change has no cell on this page
— there is no alias scenario in the comparative suite.
3.3.0 attacked the call, not the lookups. resolve_positional built its arguments with a list
comprehension and star-called the creator, though the dependency count is fixed the moment a
resolver compiles; a factory with 0 or 1 provider dependencies now compiles to a closure that
names its argument and calls the creator directly — no list, no CALL_FUNCTION_EX, and below
3.12 no comprehension frame either. The ladder stops at 1 because that is where the measured win
is (leaves are arity 0, chain nodes are arity 1); rungs beyond it were built, measured, and
dropped. Separately, Container.resolve stopped delegating to resolve_provider and carries
that body itself, which is what shrank the by-type surcharge from 54-65 ns to 21/17/23 ns, and a
context-backed parameter had its binding folded into the compiled closure. Together those are
worth −27 to −34% across C1, C3 and the whole by-type column, against rivals whose absolutes did
not move between the two publications.
Reproduce it yourself¶
git clone https://github.com/modern-python/modern-di
cd modern-di
just bench-report # isolated env; first run resolves the pinned rival deps; runs 5x by default
just bench-report also prints a C5 (cold build + first resolve) scenario that this page does
not publish: its cells are not one axis — dependency-injector's is ~98% provider-graph deepcopy
and that-depends wires at import with no per-container build at all — so a ratio column would
assert a comparison those numbers cannot support. See
benchmarks/README.md.
The comparative environment is isolated and its result files are not committed, so absolute numbers will differ from those above. The ratios are more comparable across machines than the absolute times.
See also¶
- Comparison — how modern-di compares on features.
- Design decisions — why resolution is sync-only and why
execcodegen is a non-goal.