Deep Engineering
Advanced·Published·3.13·25 MIN

A DI container: what it actually does for you — and how dishka differs from dependency-injector

A container does not solve “reduce coupling”; it solves two concrete jobs: assemble the object graph and draw the boundary of how long those objects live. The main divergence between the two libraries is on the second one. From that follows both the fact that two repositories of one request end up in different transactions, and the answer to which of them is faster — which depends on the first question.

Full technical treatment

TL;DR

A container does two jobs, and the second one usually goes unnamed:

  • assemble the graph — build an object together with everything it needs, without listing that at every call site;
  • draw the lifetime boundary — say that this session lives for exactly one request, is shared by everyone who asks for it during that request, and is closed on the way out.

dishka builds its model around the second job: "Scope is a lifespan of a dependency", the boundary is a context manager, and a dependency is found by its type annotation.

dependency-injector builds its model around the first: providers gathered into a container, with the request boundary drawn by you. Its Factory is documented outright — "Factory provider creates new objects" — and therefore hands two repositories of one request two different sessions, that is, two transactions.

What the measurements say (3.13.7, dishka 1.10.1, dependency-injector 4.49.1):

  • on the same work the order is reversed: handing out a ready object — dependency-injector is 6 times faster (40 ns against 238); doing "one session per request" — dishka is 2.3 times faster (2462 ns against 5583);
  • a wiring mistake is visible in dishka when the container is created, in dependency-injector on the first call — and a forgotten wire() produces no error at all: the marker object arrives as the argument;
  • the common claim that dependency-injector is untyped does not survive a check: mypy catches a mismatch equally well in both. What it does not catch is something else — a substitution inside Provide[...].

What a container actually does

The term is twenty-two years old and came from a text with a precise date.

As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.

Martin Fowler, 23 January 2004

Fowler states the idea itself through a separate assembler object:

The basic idea of the Dependency Injection is to have a separate object, an assembler, that populates a field in the lister class with an appropriate implementation for the finder interface.

The same page

Notice what the definition does not contain. No "reduce coupling", no "easier testing", no "inversion of control" — those are consequences, not the mechanism. The mechanism is an assembler that knows what to fill with what.

The container's first job is to assemble the graph. Without one the code looks like this:

PYTHON
def handler(request):
    session = Session(pool)
    users = UserRepo(session)
    orders = OrderRepo(session)
    unit = UnitOfWork(users, orders)
    try:
        return unit.run(request)
    finally:
        session.close()

Almost the whole body of the handler is assembly: four lines out of eight do nothing but create objects. Add one more dependency to UnitOfWork and you walk through every handler.

The second job — and this is the one that goes unnamed. Look at session in that example: it is shared by both repositories and it is closed in a finally. Neither property is about assembly; both are about lifetime — how long an object lives and who shares it over that stretch. This is exactly where the two libraries diverge, and the difference costs more than any difference in speed.

One session per request — or one each

The check is simple. Two repositories in one request, both needing a session.

dishka answers that question with a notion of lifespan.

Scope is a lifespan of a dependency. Standard scopes are (with some skipped): APPREQUESTACTIONSTEP

dishka, Key concepts
PYTHON
class MyProvider(Provider):
    pool = provide(Pool, scope=Scope.APP)
    users = provide(UserRepo, scope=Scope.REQUEST)
    orders = provide(OrderRepo, scope=Scope.REQUEST)
 
    @provide(scope=Scope.REQUEST)
    def session(self, pool: Pool) -> Iterable[Session]:
        session = Session(pool)
        yield session
        session.close()

Within one REQUEST the session is single, verified with an is comparison. On leaving the scope, whatever follows the yield runs — "The finalization of dependencies runs in reverse creation order." The boundary is a context manager: "To enter a nested scope, you call it and use it as a context manager."

dependency-injector has no built-in hierarchy of scopes — and does not hide it. Its main provider is documented like this:

Factory provider creates new objects. Factory injects the dependencies every time when creates a new object.

dependency-injector, Factory provider

Every time means every time. UserRepo asked for a session and got a new one; OrderRepo asked and got another. Measured: two different session numbers, and users.session is orders.session is False. From which follows something the measurement itself does not show: two writes of one request go into different transactions, and you find out on the first rollback.

If one session per request is what you need, the library provides ContextLocalSingleton for it: the session becomes one per execution context. But now you draw the request boundary yourself:

PYTHON
try:
    return container.uow()
finally:
    container.session.reset()      # without this the session outlives the request

dependency-injector does have a provider with teardown — Resource: "Resource provider provides a component with initialization and shutdown". But its initialization "happens only once", that is, it is application level rather than request level; for a request the documentation points to a separate ContextLocalResource, and its boundary is still yours to draw.

And here is the important part: without reset() everything looks correct for exactly one request. The session is shared, the transaction is single, the tests are green. The second request gets the very same session — and that is visible only once there are two requests.

How a dependency is found

The second divergence is in how you say what to inject.

dishka looks it up by type annotation. container.get(Service) and that is all; the factory is found by the type you declared. Same in a handler: a parameter typed FromDishka[Service].

dependency-injector looks it up by a marker pointing at a specific attribute of a specific container:

Wiring marker specifies what dependency to inject, e.g. Provide[Container.bar]. When wiring is done functions and methods with the markers are patched to provide injections when called.

dependency-injector, Wiring

The difference looks stylistic and has three practical consequences, all three checkable.

First: the annotation and the marker are never compared. Here is code that passes both the container and mypy:

PYTHON
@inject
def handler(service: Service = Provide[Container.config]) -> str:
    return service.run()

A parameter declared Service receives a Config. It fails later — AttributeError: 'Config' object has no attribute 'run' — and somewhere else.

Second: a forgotten wire() produces no error. There is nothing to patch, the default value of the argument stays as it was, and the handler receives the marker object itself:

PYTHON
print(type(handler()).__name__)   # 'Provide'

No exception, no warning. The symmetric case in dishka — an unwrapped handler — fails at once: TypeError: missing 1 required positional argument.

Third: a dependency found by type can be checked in advance. Since the factory is looked up by the annotation, the whole graph can be walked when the container is built — which is what dishka does.

How much code has to be rewritten if you change libraries

The previous section raises a question worth asking before the choice: if the library is replaced in a year, what exactly gets rewritten.

The answer starts the same way for both, and that is worth stating outright, because the argument is usually had about something else. Neither touches the application layer. dishka states this as a requirement on itself:

  1. Usage of container must not require modification of objects we are creating.
  2. Container must not require being a global variable.
  3. Container can require code changes on the borders of scopes (e.g. application start, middlewares, request handlers).
dishka — Technical requirements

The third point is not a footnote but an honestly named zone of presence: the borders of scopes. dependency-injector promises the same about application structure and addresses its markers to the same place:

With wiring you do not need to change the traditional application structure of your framework. […] Place wiring markers in the functions and methods where you want the providers to be injected (Flask or Django views, Aiohttp or Sanic handlers, etc).

dependency-injector — Wiring

So the question is not "how much code is infected" but "what does the marker name in the adapter layer".

And here the difference is fundamental. The form of the markers is identical — both are Annotated: FromDishka[Service] is Annotated[Service, FromComponent()], and dependency-injector supports Annotated[Service, Provide[Container.service]]. What differs is the content. The first names a type. The second names a path to an attribute of a specific container.

Hence the price of moving:

  • a marker that names a type becomes another marker of the same type when the library changes — a mechanical edit, and a checkable one: the type in the annotation is still there;
  • a marker that names Container.service carries the container class's name and its attribute's name into an application file. Moving is not an import swap but a rename across the whole adapter layer, and a typo in it is not caught by a type checker (exactly the defect taken apart above).

There is also something dishka does not have at all: a list of files with markers. dependency-injector has one explicitly, because wiring is done by a call:

PYTHON
container.wire(modules=["yourapp.module1", "yourapp.module2"])

That is at once an inconvenience (the list has to be maintained) and the thing the other library lacks: the boundary of infection is visible in one place, and it can be counted. Counting that is what to do when choosing — the number of files in wire() against the number of files carrying FromDishka[].

Both document ways to loosen the coupling, and neither is free. dependency-injector has string identifiers: With string identifiers you don't need to use a container to specify an injection. The container import leaves the application module — and the type check weakens even further than in the case above.

dishka has DishkaRoute, which removes @inject but not FromDishka[]: automatic injection works only for HTTP, not for websockets, and the parameter marker itself stays mandatory. Removing that too is possible — in the source, default_parse_dependency returns None for a bare type annotation, so nothing is injected by default, but wrap_injection's parse_dependency parameter is public, and your own integration may inject by type. At the price of your own integration.

The point to take away: what is detachable in both is not the application layer — that is detachable to begin with — but the adapter layer. And the question when choosing is one: does the marker name your type, or their container.

When you find out it was wired wrongly

This is the property people notice last and pay for most.

The difference is one line of code:

PYTHON
container = make_container(MyProvider())
# dishka: GraphMissingFactoryError — right here, at application startup
 
container = MyContainer()
# dependency-injector: container built; the TypeError comes on the first call

dependency-injector does have check_dependencies(), and it is good: it finds an unbound dependency with a clear message. But you have to call it yourself, and it covers only declared providers.Dependency — a forgotten constructor argument is not included.

On typing — against expectation. The claim that dishka is type-safe and dependency-injector is not was checked with a mypy run and did not hold: container.get(Service) and container.service() both return Service, and assigning the result to a variable of another type is caught in both cases. What mypy does not catch is exactly one thing — a substitution inside Provide[...], because nothing ever compares the marker with the annotation.

dishka has a symmetric case of its own: requesting a REQUEST-scoped dependency at APP level gives a NoFactoryError naming the scope — clear, but already at run time rather than inside make_container. dependency-injector cannot have this error at all: there is no declared scope in it to get wrong — you draw the boundary yourself.

What it costs

These libraries can be compared on speed only where they do the same thing — and there are two such places.

Handing out an object that already exists. Here dependency-injector is six times faster: 40 ns against 238. The reason shows up in one line:

PYTHON
from dependency_injector import providers
print(providers.__file__)     # .../providers.abi3.so

That is a compiled module. dishka is pure Python.

Doing "one session per request". Here the order is reversed: 2462 ns for dishka against 5583 for ContextLocalSingleton with a reset. That is 2.3× in favour of the uncompiled library: the compiled one has to reach the same result the long way round. The caveat is the same as in the paragraph below, only pointing the other way: within those 2462 ns dishka also closes the session, while reset() merely drops the reference. The slower one here is the one doing more.

Everything else in the measurement must not be compared row to row. Factory assembles the graph in 1840 ns against dishka's 2462 — and opens no request boundary, closes no session, and hands out two instead of one. "Faster" here means "did less".

Worth its own line is the price of the boundary itself: entering and leaving a scope without a single resolve costs 785 ns out of 2462. Almost a third of a request's cost is not assembly but lifetime.

And the startup price: make_container takes 969 µs against 170 µs for a DeclarativeContainer. At application startup that means nothing; in a test suite that builds a container per test it means something. What it buys is a walk over the whole graph.

How to choose

The question is not speed — the numbers above show that the answer to that depends on which operation your application performs more often. The question is whether you need the request boundary as a concept of the language.

You do, if a single request has more than one consumer of a shared resource: two repositories with one session are enough for Factory to become a source of silent bugs and for ContextLocalSingleton to become an obligation not to forget reset() in every branch. Then dishka closes the question with a mechanism rather than with discipline.

You do not, if your container is a registry of singletons: configuration, a pool, clients for external services, everything living as long as the process. Then dependency-injector does exactly what is needed and does it faster — plus it has a Configuration provider that reads YAML, INI and environment variables.

What is worth avoiding in either case is choosing by the benchmarks in a README. Two hundred nanoseconds on a resolve are invisible in any web application: next to a single database round trip that is zero. The difference that is visible is the other one — where two writes ended up in different transactions.

Version history

VersionChangeWhat this means for your code
2004Martin Fowler publishes the text in which the technique gets its name: “we settled on the name Dependency Injection”. The same text names the three forms — constructor, setter and interface injection — and draws the line against a service locator: “every user of a service has a dependency to the locator”.
dependency-injector 4.xThe library ships as a compiled extension (providers.abi3.so), and it shows in the price of the operation it exists for: handing out a ready object, 40 ns. The model stays as it was: providers, Provide[...] markers and wiring through wire(); there is no built-in hierarchy of lifespans in it.
dishka 1.xThe model is built around lifespan: Scope.APP → REQUEST → ACTION → STEP, entered through a context manager, finalized in reverse creation order. A dependency is found by its type annotation, and therefore the whole graph is validated inside make_container — a wiring mistake is visible at startup rather than in production.
checked onPython 3.13.7, dishka 1.10.1, dependency-injector 4.49.1, mypy 2.3.1. The numbers belong to these builds, not to “the libraries”: in six months they need taking again.

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Common misconceptions

Claim

A DI container is there to reduce coupling

Actually

What reduces coupling is dependency injection itself — the fact that an object receives its parts rather than creating them. No container is needed for that: passing them as constructor arguments is enough. A container solves two other jobs: it assembles the graph so the dependencies are not listed in every handler, and it draws the lifetime boundary. Fowler names it outright: an assembler “that populates a field in the lister class with an appropriate implementation”.

Claim

A compiled library is faster, therefore dependency-injector is faster

Actually

Faster at the operation it exists for: handing out an object that already exists, 40 ns against dishka's 238, six times. On the job “one session per request” the order is reversed: 2462 ns for pure Python against 5583 for the compiled library, because the compiled one has to reach that result the long way round — through ContextLocalSingleton and a hand-written reset().

Claim

dependency-injector's Factory is the equivalent of a request scope

Actually

The opposite. Measured: two repositories of one request get TWO different sessions, and users.session is orders.session is False. The documentation promises exactly this: “Factory injects the dependencies every time when creates a new object”. For one session per request you need ContextLocalSingleton — and a reset() that you call.

Claim

dependency-injector is untyped, so its errors only show up at run time

Actually

Checked with a run of mypy 2.3.1: container.service() returns Service, and assigning the result to a variable of another type is caught — exactly as with dishka's container.get(Service). The divergence is elsewhere: a substitution inside Provide[...] is invisible to both mypy and the container. A parameter declared Service and receiving a Config passes both checks.

Claim

If the container is wired wrongly the application simply will not start

Actually

That depends on the library, and the spread is wide. dishka fails inside make_container — at startup. dependency-injector builds the container silently, and the TypeError arrives on the first call. And a forgotten wire() produces no error AT ALL: the handler receives the internal Provide object as its argument, and the failure happens later, elsewhere, with a message that has nothing to do with wiring.

Claim

The difference between containers is a matter of taste; the mechanics are the same

Actually

The mechanics differ at the foundation: dishka looks a factory up BY THE TYPE ANNOTATION and can therefore walk the whole graph at startup; dependency-injector looks it up BY A MARKER pointing at an attribute of a specific container, and therefore patches your functions on wire(). From that follow the different failure behaviour, the different checkability and the different price of the request boundary. Taste has nothing to do with it.

Check yourself

Question 1 of 4

Two repositories in one request, both needing a session. The provider is declared as providers.Factory(Session, pool=pool). How many sessions are there?

Sources & further reading

6 SOURCES

  1. Martin Fowler — Inversion of Control Containers and the Dependency Injection patternSource. The 23 January 2004 text in which the term appeared: «As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection». The forms of injection come from the same page: «There are three main styles of dependency injection. The names I'm using for them are Constructor Injection, Setter Injection, and Interface Injection», as does the definition of what a container is for: «The basic idea of the Dependency Injection is to have a separate object, an assembler, that populates a field in the lister class with an appropriate implementation for the finder interface». And the criterion separating injection from a locator: «The key difference is that with a Service Locator every user of a service has a dependency to the locator».https://martinfowler.com/articles/injection.html
  2. dishka — Key conceptsOfficial documentation. The definition the whole library is built around: «Scope is a lifespan of a dependency», and the standard ladder: «Standard scopes are (with some skipped): APP → REQUEST → ACTION → STEP». From the same page on the container — «Container is an object you use to get your dependencies» — and on a nested scope: «To enter a nested scope, you call it and use it as a context manager». The order of teardown is stated outright too: «The finalization of dependencies runs in reverse creation order».https://dishka.readthedocs.io/en/stable/concepts.html
  3. dependency-injector — Factory providerOfficial documentation. The promised behaviour of the main provider, from which the whole two-sessions section grows: «Factory provider creates new objects» and «Factory injects the dependencies every time when creates a new object». This is not an oversight in the library but its documented contract.https://python-dependency-injector.ets-labs.org/providers/factory.html
  4. dependency-injector — Resource providerOfficial documentation. The only provider with teardown: «Resource provider provides a component with initialization and shutdown». And its extent: «Resource initialization happens only once» — that is, application level rather than request level; for per-context instances the documentation points to a separate Context Local Resource.https://python-dependency-injector.ets-labs.org/providers/resource.html
  5. dependency-injector — WiringOfficial documentation. The mechanism that separates this library from dishka by how a dependency is found: «Wiring feature provides a way to inject container providers into the functions and methods», «Wiring marker specifies what dependency to inject, e.g. Provide[Container.bar]». And what happens on wiring: «When wiring is done functions and methods with the markers are patched to provide injections when called». Hence the behaviour when wire() is forgotten: there is nothing to patch, and the marker itself arrives as the argument.https://python-dependency-injector.ets-labs.org/wiring.html
  6. dishka — QuickstartOfficial documentation. The way the container is asked, from which the whole difference in checkability follows: the documentation examples show `container.get(APIClient)` and `request_container.get(Service)` — a dependency is requested by type, not by provider name. The same page declares a factory with a scope: `@provide(scope=Scope.REQUEST)`.https://dishka.readthedocs.io/en/stable/quickstart.html