Python dependency injection: Patterns & code you can reuse

Dependency injection in Python gets dismissed as Java ceremony until a test suite collapses under global state and monkey-patched imports. The real trade-off isn't 'DI vs no DI', it's explicit composition roots vs hidden coupling that only surfaces during a painful refactor.

Senior engineers who skip DI early often pay for it later in test setup time and brittle mocks.

Dependency injection in Python: The short answer

Dependency injection in Python means passing an object's collaborators into it, usually through the constructor, instead of letting the object build or import them itself. It is the concrete mechanic behind inversion of control: the calling code no longer decides how a dependency gets created, a container or factory does.

Teams building FastAPI or Django services regularly hit this wall once global config imports and hardcoded clients spread across modules, making unit tests slow and brittle. Swapping those imports for constructor injection is the fix we reach for first, and it consistently cuts the amount of test setup and mocking code teams have to maintain.

This piece covers constructor injection patterns, when an IoC container earns its keep, and how python-dependency-injector, kink, and unittest.mock compare for teams of different sizes.

What is dependency injection? Inversion of control explained

Dependency injection is the concrete implementation of inversion of control: instead of a class reaching out to build its own dependency, something external hands the object what it needs. Martin Fowler formalized the term in his 2004 essay, Inversion of Control Containers and the Dependency Injection pattern, and the split it describes, deciding how a dependency is created versus using it, still holds.

Constructor injection is the pattern most Python developers should default to. A class declares its dependency as a typed parameter in __init__, and the caller, usually a container or a composition root, passes in the concrete instance at wiring time. No import buried in the class body, no hidden global lookup.

That one change makes tests easier to write: swap the real dependency for a fake at the call site, and the class under test never sees the difference. The container, not the class, now owns lifecycle and configuration for every dependency in the service.

Without DI vs. With DI: A Before/After refactor

A service class that builds its own database connection inside __init__ is hard to test and hard to change. Constructor injection fixes this by passing the dependency in, so the class never constructs what it needs itself.

# Before: Hidden dependency, global state
class OrderService:
 def __init__(self):
 self.db = connect_to_prod_db # baked in

# After: Constructor injection
class OrderService:
 def __init__(self, db: Database):
 self.db = db

The second version takes a Database protocol (see typing.Protocol) as an argument. A provider in a python-dependency-injector container decides what concrete object fills that slot, a real Postgres connection in production, a unittest.mock.Mock in tests. No monkeypatching, no import-time side effects in the main codebase.

Teams refactoring a legacy monolith along these lines typically replace global config imports scattered across modules with a single container and provider wiring in a bootstrap.py composition root.

The practical payoff shows up first in test files. Fixtures shrink because providers, not test authors, own object construction. It also makes swapping a real payment gateway for a stub in staging a one-line change in the container instead of a code change across five files, which is the actual argument for dependency injection beyond style.

Constructor injection vs. Method injection

Constructor injection is the default: pass dependencies through __init__, and the class never has a choice about which implementation it gets. Method injection passes a dependency into a single function call instead, useful when a dependency only applies to one code path, not the whole object's lifetime.

Pattern Best for Cost
Constructor injection Services with a stable dependency set for their whole life Container or bootstrap.py wiring needed
Method injection One-off overrides, request-scoped dependencies, test-only substitutions No container change, but scattered wiring

Our rule of thumb: default to constructor injection for anything registered in an IoC container (python-dependency-injector or the lighter kink), and reserve method injection for edge cases like passing a request-scoped typing.Protocol implementation into one handler. Mixing both in the same service usually signals the container's provider scope is wrong, not that method injection was the right call.

Containers and providers: How an IoC container works

An IoC container is a registry that maps abstractions to concrete implementations and hands out fully wired objects on request, so a service never constructs its own dependencies. A provider is the container's unit of configuration: it declares how one dependency gets built, whether as a singleton, a factory, or a per-call instance.

python-dependency-injector formalizes this with a DeclarativeContainer class holding provider attributes, each wired to a config key or another provider. Swap a Factory provider for a Singleton and every consumer picks up the change without touching call sites.

Where most DI writeups stop at container plus provider, the pattern most teams skip is typing.Protocol for auto-wiring. Instead of registering against a concrete class, declare a Protocol describing the required interface and let the container inject anything structurally compatible.

A container that resolves providers against typing.Protocol types, rather than concrete classes, lets you swap a Postgres repository for an in-memory test double without editing a single import statement, per PEP 544.

That structural typing is what makes injection genuinely static-checkable: mypy validates the contract at the call site, not just at runtime. Mid-size FastAPI services that replace ad hoc global config imports with a container-based composition root see this Protocol-first wiring meaningfully cut the fixture and patch boilerplate teams have to maintain.

Comparing Python DI libraries: Dependency-injector, kink, punq, wireup, lagom

Five libraries dominate Python dependency injection in 2026, and picking one comes down to team size, learning curve, and the way you want to manage container boilerplate. When a container's wiring goes wrong, tracing the failure often demands the right debugging tools and techniques rather than guesswork.

Python-dependency-injector remains the heaviest option: declarative containers, explicit providers, and wiring by pattern, applying inversion of control formally rather than implicitly. Its learning curve is steeper than the others, but its ecosystem is the most mature of the five, with the deepest documentation and the widest range of community examples.

It earns that weight on codebases with dozens of services where an IoC container needs to stay auditable. python-dependency-injector pulls over 8 million PyPI downloads a month, per PyPI Download Stats — by far the most-downloaded library in this comparison.

Kink takes the opposite bet: a lightweight container built on typing.Protocol and decorator-based registration, with almost no ceremony and a learning curve measured in minutes rather than days. Teams under 15 developers tend to prefer it because there's less container code to change when requirements shift, and startup overhead stays negligible even in a small application.

Punq favors an explicit, code-first registry with no auto-wiring magic, which makes a dependency graph easier to trace in review. Its ecosystem is smaller, but the API surface is tiny enough that a dev can read the entire source in one sitting.

Wireup infers wiring from type hints and reads close to FastAPI's own dependency system, which shortens onboarding for teams already on that framework. Lagom checks structural typing against typing.Protocol at registration time, catching interface mismatches before a test runs rather than at call time, with runtime overhead comparable to kink's in most benchmarks.

Library Best for Auto-wiring Container weight
python-dependency-injector Large, multi-service systems needing an auditable container Explicit providers, no magic Heaviest
kink Teams under 15 developers wanting low ceremony Decorator-based, via typing.Protocol Lightest
punq Small teams wanting an explicit, auditable dependency graph None (explicit, code-first registration) Minimal
wireup Teams already on FastAPI's dependency style Infers wiring from type hints Light
lagom Teams wanting interface mismatches caught at registration time Structural typing via typing.Protocol Light, comparable to kink

Dependency injection in Flask and FastAPI

FastAPI ships dependency injection as a first-class feature: Depends resolves constructor injection at the request layer, so most teams never touch an IoC container for request-scoped values. Flask has no built-in equivalent, you either wire python-dependency-injector's Flask integration or hand-roll a bootstrap.py composition root that builds the app's providers once at startup.

The python-dependency-injector documentation lists native FastAPI and Flask support as of version 4.41, replacing manual Depends calls with its Provide marker so the same container serves both frameworks.

A provider defined once, a Singleton for a database session, a Factory for a request-scoped service, makes swapping a real dependency for a test double a config change, not a code change. That's the part competitors gloss over: DI's payoff shows up post-launch, not at scaffolding time, when a new feature needs a different config source and you don't want to touch call sites.

Mid-size FastAPI services that replace global config imports with a container-managed provider see the fixture and mock boilerplate needed for test setup drop. The self-contained wiring makes the change easier to monitor after launch, too, swapping a provider's implementation for canary traffic works without touching route handlers.

How DI makes unit testing easier: Unittest.mock vs. Injected fakes

Dependency injection makes unit testing easier because fakes get passed at the constructor boundary instead of patched into module internals. Instead of reaching for unittest.mock.patch to override an import path, you inject a typing.Protocol-typed fake directly into the class under test, no container, no monkeypatching, no test-only import gymnastics.

That distinction compounds as a codebase grows. unittest.mock.patch ties a test to the exact import path of a dependency; move the module during a refactor and the patch target breaks silently, often weeks later. A typing.Protocol-typed fake fails at construction time instead, which mypy catches in CI before the test even runs.

Mid-size FastAPI services rebuilt around this pattern, replacing global config imports with explicit constructor injection wired through python-dependency-injector's providers, see the fixture and patch boilerplate wrapped around every test shrink noticeably. Fewer patches mean fewer tests that pass against a mock and fail against the real dependency.

Dependency injection vs. Service locator (and when to skip DI)

Dependency injection and the service locator pattern both build inversion of control, but they resolve it in opposite directions. Constructor injection makes a class's dependencies explicit at creation; a service locator hides them behind a runtime lookup, which is why Martin Fowler's original writeup treats the locator as the weaker form of IoC, it trades discoverability for convenience.

Skip DI entirely below a certain size, for example when handling small datasets. A single-file script, a small Lambda handler, or anything under a few hundred lines gains nothing from a container, plain imports and a bootstrap.py composition root are easier to read and change.

Pattern Coupling Testability Boilerplate
Service locator Hidden, resolved at runtime Needs unittest.mock patching of the locator itself Low
Constructor injection Explicit, visible in the signature Swap typing.Protocol fakes directly, no patching Medium
IoC container (python-dependency-injector, kink) Explicit, declarative providers Swap providers per environment Higher upfront

Teams under ten developers typically benefit most from plain constructor injection. A full container's provider wiring rarely pays for itself until several services share one composition root.

FAQ: Python dependency injection

How to build dependency injection in Python?

Define dependencies as constructor parameters, then wire them from one composition root, typically a bootstrap.py file that builds a container and injects providers into each class. python-dependency-injector formalizes this with a DeclarativeContainer, keeping privacy-sensitive config like API keys out of scattered imports. This works well once a codebase passes a handful of services.

How to do dependency injection in Python without a library?

Pass dependencies through constructors by hand and wire them in one composition root function, no container required. A small bootstrap.py that builds a database client and hands it to each self-contained service class covers most small codebases. Skip a DI library until wiring by hand across a dozen modules feels repetitive.

What is the difference between dependency injection and a service locator in Python?

Dependency injection passes dependencies explicitly through a constructor; a service locator hides them behind a global registry a class queries at runtime. The locator buries a change to a dependency inside a method body until a test fails. We recommend constructor injection for new Python code since it keeps dependencies visible and mockable.

How do I use dependency injection with Flask?

Flask supports dependency injection by attaching a container to the app context, or with flask-injector wiring providers directly into route handler arguments. python-dependency-injector ships a Flask extension whose container features bind a DeclarativeContainer and inject services via decorators. This replaces global imports of database sessions with explicit, testable dependencies per route.

Which Python DI library should I use: Wireup or lagom?

Both are lightweight options, so the choice comes down to how they catch mistakes. Wireup leans on type hints for wiring and mirrors FastAPI's own dependency style, so a team already fluent in Depends picks it up fast. Lagom instead validates the typing.Protocol contract at registration time, surfacing an interface mismatch before a test ever runs rather than at call time. Pick Wireup if your team already thinks in FastAPI's Depends style; pick lagom if you want that registration-time check as the safety net.

Conclusion: Start with a composition root, not a framework

Start every Python dependency injection decision at the composition root, not the library aisle. A bootstrap.py that wires providers into one IoC container at startup gives you the same testability gains as python-dependency-injector or kink, with less new code to maintain and fewer terms to learn before shipping a change.

Pick the container only once the composition root is clear. Small teams often skip a framework entirely and hand-wire constructor injection; larger services benefit from a declarative container once provider counts grow past a dozen. Either path makes unittest.mock swaps easier and keeps typing.Protocol doing the contract work.

This staged approach works well on production Python services where teams need dependency injection without a rewrite.

If your team is weighing a container migration or untangling a global-config codebase, talk to our team about the dependency injection features and test setup that fit your architecture, plus our engineering blog and Python development services for further reading.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business