Why Canary¶
Canary does one thing: it wires application-lifetime objects together and runs their startup and shutdown. This page says where that fits, where it does not, and how it compares with other Python libraries.
Every row in the comparison below is measured, not recalled:
benchmarks/comparison.py
builds the same small graph with each library and prints the results. Run it yourself:
Measured on 2026-09-23 with Python 3.13; library versions are pinned in the script's header.
Where Canary fits¶
- Async services, daemons and workers whose long-lived objects hold resources — connection pools, clients, background tasks — and must come up and go down in dependency order.
- Startup and shutdown as part of the object. A unit carries its
@start/@stophooks; the graph comes up concurrently where it can, rolls back what it acquired when startup fails, and can start again afterstop(). Further phases are one line:Phase("migrate", after=init). - Typed attributes instead of lookups.
self.dbis aDatabaseto the type checker, with no container call and no annotation to repeat.
Where it does not¶
- Request-scoped objects. Canary has one instance per type per graph and no request scope.
Use your web framework for per-request values — FastAPI's
Dependscombines well with Canary, see below — or a library with scopes such as dishka. - Domain classes that must not know about a framework. Units subclass
Canary. dishka, dependency-injector and injector wire plain classes through their__init__instead. - Several instances of one type, or constructor arguments. Units are constructed with no
arguments, one per type; values come from dependencies in
@initor@start. - Synchronous programs.
init(),start()andstop()are coroutines. Hooks may be synchronous, but something has to run an event loop. - Maturity. Canary is young and has one maintainer. The others have larger communities and ready-made integrations for web frameworks.
Comparison¶
The scenario: Config, two independent resources Database and Cache that each take 0.1 s to
acquire, and a Service that needs both.
| Canary 1.1.0 | dishka 1.10.1 | dependency-injector 4.49.1 | injector 0.24.0 | FastAPI Depends 0.141.1 |
|
|---|---|---|---|---|---|
| Wiring | dep() attributes on the class |
Provider classes |
a container of providers | modules and binders | Depends(...) in handler signatures |
| Your classes | subclass Canary |
plain classes | plain classes | plain classes, @inject on __init__ |
plain functions |
Type from mypy --strict |
svc.db is Database |
get(Service) is Service |
service() is Service¹ |
get(Service) is Service |
from the parameter annotation |
| Acquire and release | @start / @stop hooks |
async generator factories | Resource providers |
— | generator dependencies |
| Two independent resources | 0.10 s, concurrent | 0.20 s, sequential | 0.10 s, concurrent | — | 0.20 s, per request |
| Dependent resources released in reverse | yes | yes | yes | — | yes, per request |
| A failure midway through startup | released before the exception reaches you | released on close() |
released on shutdown_resources() |
— | per request |
| Start again after shutdown | yes | yes | yes | — | — |
| Replace a dependency in tests | scope_of(root).provide(...) |
a later Provider |
provider.override(...) |
binder.bind(...) |
app.dependency_overrides |
| Per-request or context scope | no | Scope.REQUEST |
ContextLocalSingleton |
threadlocal |
per request by default |
| Third-party packages installed | 0 | 0 | 0 | 0 | 9 |
¹ When any resource in the chain is async, service() returns an awaitable at runtime while its
static type is still Service; and under --strict, await container.init_resources() is
reported as an error because it is typed Awaitable[None] | None.
What the table does and does not say:
- Timings come from resources that sleep for 0.1 s. They show whether independent resources are acquired concurrently, not how fast each library is.
- Failure midway through startup. In every library with a lifecycle, what was acquired gets
released. The difference is who triggers it: Canary rolls back inside
start(), so a caller that only sees the exception has nothing left to clean up; with dishka and dependency-injector the caller releases by callingclose()/shutdown_resources(), typically in afinally. - injector has no lifecycle API, so the lifecycle rows do not apply.
- FastAPI
Dependsis request-scoped: both resources are acquired and released on every request. Objects that live as long as the application go in the app'slifespan, which you write yourself. - Zero dependencies is not unique to Canary. dishka, dependency-injector and injector install nothing else either.
Canary with FastAPI¶
The two do different jobs and combine without friction: Canary owns what lives as long as the
application, and Depends hands per-request values to handlers.
service = LibraryService()
@asynccontextmanager
async def lifespan(_app: FastAPI):
async with service: # the whole graph comes up, and is reclaimed on shutdown
yield
app = FastAPI(lifespan=lifespan)
def books() -> BookRepository:
return service.books # an application-lifetime unit
@app.get("/books/{book_id}")
async def get_book(book_id: int, repo: Annotated[BookRepository, Depends(books)]):
...
Canary-Framework-Example has the
complete version, with tests: the library project is a FastAPI service built this way.