Dependency injection means giving an object the things it needs from the outside instead of having it construct them itself. In Python you almost never need a DI framework or a container to do this - passing dependencies as function arguments or constructor parameters is dependency injection, and it covers most cases. The payoff is the same one frameworks promise: code that's testable, because you can hand it a fake, and configurable, because the wiring lives in one place.

Inject through the constructor

The default move is to accept collaborators as parameters rather than reaching for globals or building them inline. A loader that needs a database connection and a clock takes both as arguments. Production code passes the real objects; a test passes fakes. Nothing inside the class knows or cares which it got.

from datetime import datetime, timezone

class IngestJob:
    def __init__(self, db, now=lambda: datetime.now(timezone.utc)):
        self.db = db          # injected dependency
        self.now = now        # injected clock, defaulted

    def run(self, rows: list[dict]) -> int:
        ts = self.now().isoformat()
        for r in rows:
            self.db.insert({**r, "ingested_at": ts})
        return len(self.db.rows)

# production
# job = IngestJob(PostgresDb(dsn))

# test: no real DB, frozen clock
class FakeDb:
    def __init__(self): self.rows = []
    def insert(self, r): self.rows.append(r)

fixed = datetime(2026, 1, 1, tzinfo=timezone.utc)
job = IngestJob(FakeDb(), now=lambda: fixed)
print(job.run([{"id": 1}, {"id": 2}]))  # 2

Inject behavior as a callable

Because functions are first-class, a dependency is often just a callable rather than an object with one method. A retry helper takes the operation to run; a pipeline step takes the transform to apply. Default the argument to the real implementation so callers in production don't have to wire anything, and override it in tests or special cases.

import time
from typing import Callable

def with_retry(
    op: Callable[[], dict],
    attempts: int = 3,
    sleep: Callable[[float], None] = time.sleep,
) -> dict:
    last = None
    for i in range(attempts):
        try:
            return op()
        except Exception as e:  # noqa: BLE001
            last = e
            sleep(2 ** i)
    raise last

# test injects a no-op sleep so it runs instantly
calls = {"n": 0}
def flaky():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("boom")
    return {"ok": True}

print(with_retry(flaky, sleep=lambda _: None))  # {'ok': True}

Compose the graph at the edge

Keep the wiring in one place near the program's entry point - the "composition root" - rather than scattering construction through the codebase. A single function builds the real dependencies and assembles the object graph; everything below it just receives what it's given. This is where a tiny factory or a settings object earns its place, and it's the only spot you touch when you swap a backend or point at a test database.

def build_job(settings) -> "IngestJob":
    db = make_connector(settings.db_kind, dsn=settings.dsn)
    return IngestJob(db)

def main(settings):
    job = build_job(settings)
    job.run(load_rows())

Frequently asked questions

Do I need a DI framework in Python? Rarely. Constructor arguments, default callables, and a composition root cover most applications. Reach for a container only when the object graph is large enough that manual wiring becomes the bottleneck.

Isn't passing arguments just... normal code? Yes - that's the point. Dependency injection in Python is mostly the discipline of not hardcoding collaborators, not a special mechanism.

How does this help testing? You substitute a fake or stub for the real dependency at the call site, so tests run without a database, network, or real clock and stay deterministic.

Where should the wiring live? In one composition root near main() or the request handler, so swapping an implementation touches a single place instead of every caller.