Skip to content

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() and shutdown_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_complete costs 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.Dependency with .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_container locks every get() by default, which its C1-C3 cells include; lock_factory=None turns 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