Performance¶
modern-di resolves faster than dependency-injector, that-depends, dishka and wireup in most of the scenarios below. It is slower only on a warm singleton lookup, against dependency-injector, that-depends and wireup. A whole request cycle (enter a scope, resolve a cached dependency with an async finalizer, close the scope) costs modern-di 1.66 µs on the machine below.
Measured on 2026-10-17 with modern-di 4.2.0 on an Apple M2 (macOS 26.6.2) with CPython 3.14.7 and the GIL, median over 5 runs. Rival versions: dependency-injector 4.49.1, that-depends 4.2.0, dishka 1.10.1, wireup 2.12.1. The tables are regenerated by hand and lag behind releases. To measure the version you run, see Reproduce it yourself.
Results¶
"At a glance" says how many times faster or slower modern-di is than each rival. The tables after
it give the times. Each rival cell is that rival's time, followed in brackets by modern-di's time
divided by it: below 1.0 (bold) means modern-di is faster. The ratio is taken within each run and
then reduced across runs, so its ± is the run-to-run spread of that ratio. Check it before
reading much into a cell near 1.0.
dishka and wireup resolve only by type, and dependency-injector and that-depends only by provider
reference, so C1-C3 are published twice: modern-di's resolve against the first pair and its
resolve_provider against the second. The two calls cost the same within 3-4 ns, so pick whichever
reads better in your code. C4 and C6 use resolve_provider against all four rivals.
At a glance¶
| Scenario | vs dependency-injector | vs that-depends | vs dishka | vs wireup |
|---|---|---|---|---|
| C1 transient | 2.4x faster | 1.8x faster | 1.4x faster | 1.3x faster |
| C2 warm singleton | 2.2x slower | 1.7x slower | 1.7x faster | 1.4x slower |
| C3 deep chain (6) | 3.9x faster | 2.7x faster | 1.1x faster | 1.7x faster |
| C4 request lifecycle | 64x faster | 7.6x faster | 1.3x faster | 9.7x faster |
| C6 context | 4.8x faster | 3.8x faster | 1.6x faster | 1.8x faster |
By-reference resolution¶
| Scenario | modern-di | vs dependency-injector | vs that-depends |
|---|---|---|---|
| C1 transient | 245 ns ±0.3% | 598 ns (0.41 ±2.2%) | 433 ns (0.56 ±0.6%) |
| C2 warm singleton | 145 ns ±1.7% | 67 ns (2.16 ±2.7%) | 85 ns (1.70 ±2.5%) |
| C3 deep chain (6) | 557 ns ±0.6% | 2.17 µs (0.26 ±1.5%) | 1.48 µs (0.38 ±0.8%) |
Across-run IQR of each side's own median (5 runs): modern-di ≤1.7%, rivals ≤1.8%. 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 | 240 ns ±0.4% | 344 ns (0.70 ±0.9%) | 302 ns (0.80 ±1.0%) |
| C2 warm singleton | 141 ns ±0.5% | 237 ns (0.60 ±0.8%) | 101 ns (1.42 ±1.0%) |
| C3 deep chain (6) | 552 ns ±0.2% | 633 ns (0.87 ±0.4%) | 919 ns (0.60 ±0.4%) |
Across-run IQR of each side's own median (5 runs): modern-di ≤0.5%, rivals ≤1.6%. 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 | 1.66 µs ±1.5% | 106.86 µs (0.02 ±1.3%) | 12.69 µs (0.13 ±2.5%) | 2.12 µs (0.78 ±0.2%) | 16.04 µs (0.10 ±1.9%) |
Across-run IQR of each side's own median (5 runs): modern-di ≤1.5%, rivals ≤0.6%. 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 | 803 ns ±0.4% | 3.81 µs (0.21 ±0.3%) | 3.02 µs (0.26 ±0.7%) | 1.26 µs (0.64 ±1.0%) | 1.41 µs (0.57 ±4.2%) |
Across-run IQR of each side's own median (5 runs): modern-di ≤0.4%, rivals ≤4.7%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.
What each scenario measures¶
Each scenario is the smallest graph that isolates one cost. Every framework is written in its own idiom, with its own request-scope and teardown API, and every published cell runs at the same rounds and iterations for all five frameworks.
| 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 |
C5 (cold build plus first resolve) is measured but not published, because the frameworks do
different work there (see Reproduce it yourself). The per-framework code
for each scenario is described in
benchmarks/README.md.
Caveats¶
- C4 times the whole request cycle around the resolve. modern-di resolves synchronously and
finalizes asynchronously, while the other four await the resolve once the finalizer is async.
dependency-injector also calls
init_resources()andshutdown_resources()every cycle, and the suite does not separate that from its resolve. - C4 runs 100 request cycles per event-loop entry and publishes the batch divided by 100, because
one
run_until_completecosts about 35 µs on any body. About 0.3 µs per request of that floor is still in every C4 cell, the same for all five frameworks (about 18% of modern-di's). - In C6 each framework supplies the request value its own way. dependency-injector
(
providers.Dependencywith.override()) and wireup (a scoped injectable behind a placeholder factory) have no direct equivalent, so their C6 code is the nearest analog. - Each framework runs at its default thread-safety settings. dishka's
make_containerlocks everyget()by default, which its C1-C3 cells include;lock_factory=Noneturns that off for single-threaded use. modern-di's cached reads do not lock (see the thread-safety boundary). - dependency-injector's warm singleton hit is a slot read in compiled Cython code. modern-di's is a Python dict lookup plus a slot read, and pure Python is not expected to reach 67 ns. The suite does not break down why that-depends and wireup win C2.
Why modern-di is fast here¶
Each Factory gets a resolver generated from a source template for its shape, so a resolve runs
one Python frame per node and calls its dependencies' resolvers directly. Overrides are compiled
into the resolvers, so the hot path never checks an overrides registry, and Container.resolve
caches type → resolver, which is why resolving by type costs no more than by reference. Request
scopes are cheap to build and close: a child container holds its cache and context in its own slots,
and the only lock it allocates belongs to a cached factory on its first resolve.
ADR 0001
records the generated-source design, and Performance history lists
every change behind these numbers with its measured effect.
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
Absolute times will differ on your machine; the ratios carry over better. just bench-report also
prints C5 (cold build plus first resolve), which this page leaves out: dependency-injector's C5 is
about 98% provider-graph deepcopy, and that-depends wires at import with no per-container build, so
a ratio would compare different work.
See also¶
- Comparison: how modern-di compares on features.
- Design decisions: why resolution is sync-only.
- Performance history: each optimization and what it measured.
benchmarks/README.md: method, per-framework code and the guard tier.