Fixing FastAPI Dependency Injection Circular Imports

Key takeaways:

  • The failure is at import time, not request time — the app never gets far enough to serve anything.
  • The module named in the traceback is the one entered second, not the one at fault; reverse the entry point and the blame moves.
  • "Partially initialized module" means Python is re-entering a file whose body has not finished running.
  • The durable fix is to depend on a Protocol and bind the implementation in the factory, so the graph becomes a tree.
  • TYPE_CHECKING fixes annotation-only imports; a function-local import fixes anything, at the cost of hiding the coupling.

This is a focused troubleshooting guide under Dependency Injection Strategies. If your project is at the stage where cycles keep reappearing in new places, the layout in Structuring Large FastAPI Projects for Scale prevents them rather than patching them.

The Problem This Solves

A router needs the service that fulfils its requests. The service needs something from the router's module — usually the router object itself, to read its prefix, or a dependency defined next to it. Both imports sit at the top of their files, which is where imports are supposed to go, and the application stops booting with a message that appears to be about something else entirely.

The reason this one wastes an afternoon is that the traceback names a file that looks fine when you open it, and the message is one Python only produces in this situation. So it is worth reading a real one closely rather than paraphrasing it.

Why It Happens

Python's import machinery does something surprising for a good reason. Before it executes a module's body, it creates the module object and inserts it into sys.modules. If it did not, two modules importing the same third module would each execute it, and every module would be initialised repeatedly.

The consequence is that a re-entrant import succeeds — it finds the module in sys.modules and returns it. But the module object it returns is only as complete as the lines that have run so far. When your from x import y asks for a name defined below the point execution reached, the attribute is simply not there yet, and Python raises ImportError with the "partially initialized module" wording rather than the misleading AttributeError it used to.

Walk the sequence for a router and a service that import each other:

  1. Something imports shop_broken.router. Python puts an empty shop_broken.router in sys.modules and starts executing it.
  2. Line 3 says from shop_broken.service import OrderService. Python starts executing service.py.
  3. Line 1 of service.py says from shop_broken.router import router. Python finds shop_broken.router in sys.modules and returns it — but its body is still parked on line 3, and router = APIRouter(...) is on line 5.
  4. The name router does not exist yet. ImportError.

Nothing here is FastAPI-specific; FastAPI just makes the shape common, because the natural place to define a dependency is next to the router that uses it, and the natural place to implement it is in the service.

The cycle, and the tree that replaces itOn the left, router and service import each other, forming a cycle. On the right, the router imports only a ports module, the service implements it, and the factory imports both, leaving no cycle.Broken: a cycleFixed: a tree rooted at the factoryrouter.pyservice.pyeach needs a namethe other has notdefined yetmain.pyrouter.pyservice.pyports.pyEvery arrow points down. Nothing points back up,so no module can be re-entered.

The Fix

First, see the actual error

Guessing at this error is how people end up applying the wrong fix. Here are both sides of one real cycle, imported for real and reported exactly as Python raised them:

$ GET /reproduce
200 OK
[
  {
    "import": "shop_broken.router",
    "result": "failed",
    "exception": "ImportError: cannot import name 'router' from partially initialized module 'shop_broken.router' (most likely due to a circular import) (/tmp/fastapi-circular-import-demo/shop_broken/router.py)",
    "traceback_tail": [
      "File \"/tmp/fastapi-circular-import-demo/shop_broken/router.py\", line 3, in <module>",
      "from shop_broken.service import OrderService   # needs the service to build the dependency",
      "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
      "File \"/tmp/fastapi-circular-import-demo/shop_broken/service.py\", line 1, in <module>",
      "from shop_broken.router import router          # needs the router to read its prefix",
      "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
    ]
  },
  {
    "import": "shop_broken.service",
    "result": "failed",
    "exception": "ImportError: cannot import name 'OrderService' from partially initialized module 'shop_broken.service' (most likely due to a circular import) (/tmp/fastapi-circular-import-demo/shop_broken/service.py)",
    "traceback_tail": [
      "File \"/tmp/fastapi-circular-import-demo/shop_broken/service.py\", line 1, in <module>",
      "from shop_broken.router import router          # needs the router to read its prefix",
      "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
      "File \"/tmp/fastapi-circular-import-demo/shop_broken/router.py\", line 3, in <module>",
      "from shop_broken.service import OrderService   # needs the service to build the dependency",
      "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
    ]
  },
  {
    "import": "shop_fixed.main",
    "result": "imported cleanly"
  }
]

Read the two failures side by side, because this is the detail that misleads people. The same cycle produced two different messages naming two different modules. Import the router first and Python blames router.py for not having router. Import the service first and it blames service.py for not having OrderService. Whichever module you entered second is the one that gets named.

That is why the error moves when you change your entry point — running uvicorn shop.main:app and running pytest can blame different files for one bug — and why editing the accused module never helps. The two-frame traceback tail is the part that identifies the real problem: it names both imports that form the loop.

The durable fix: depend on an interface, bind in the factory

The router should not import the thing that does the work. It should import a description of the work:

from typing import Protocol


class OrderRepo(Protocol):
    async def get(self, order_id: int) -> dict: ...

The router depends on the protocol and reads the implementation off app.state at request time:

from typing import Annotated

from fastapi import APIRouter, Depends, Request

from shop_fixed.ports import OrderRepo         # an interface, not an implementation

router = APIRouter(prefix='/orders')


def get_repo(request: Request) -> OrderRepo:
    return request.app.state.order_repo        # bound by the factory, never imported here


RepoDep = Annotated[OrderRepo, Depends(get_repo)]


@router.get('/{order_id}')
async def read_order(order_id: int, repo: RepoDep) -> dict:
    return await repo.get(order_id)

The service may now import the router freely — that direction was never the problem — and the factory is the one module that knows both:

from fastapi import FastAPI

from shop_fixed.router import router
from shop_fixed.service import SqlOrderRepo


def create_app() -> FastAPI:
    app = FastAPI()
    app.state.order_repo = SqlOrderRepo()      # the wiring point
    app.include_router(router)
    return app

shop_fixed.main appears in the transcript above as "imported cleanly". More usefully, the resulting app actually serves the route:

$ GET /fixed-app-works
200 OK
{
  "status": 200,
  "response": {
    "id": 42,
    "prefix": "/orders",
    "repo": "SqlOrderRepo"
  }
}

Note "prefix": "/orders" in the response. The service still reads the router's prefix, which is what it wanted from the router in the first place — the requirement never went away, the direction of the dependency did.

The narrow fix: TYPE_CHECKING

If the only reason for the import is an annotation, it does not need to exist at runtime:

from typing import TYPE_CHECKING

if TYPE_CHECKING:                         # never executed at runtime, so no cycle
    from shop.services.billing import BillingService


def charge(service: "BillingService") -> None:
    ...

Type checkers follow the guarded import; the interpreter never runs it. Add from __future__ import annotations and you can drop the quotes, since annotations are then stored as strings and never evaluated. This is exact and free — but only for annotations. If you need the class at runtime, to instantiate or isinstance against, it does nothing.

The pragmatic fix: import inside the function

def get_service():
    # Imported at call time, when both modules are fully loaded.
    from shop.services.billing import BillingService
    return BillingService()

This always works, because by the time any function runs, module loading has finished. It is a legitimate technique, not a hack. Its real cost is invisibility: static analysis, dependency graphers, and the architecture test in the project-structure guide all read top-level imports, so a coupling expressed this way is one nothing will warn you about. Use it to unblock yourself today, and leave a comment saying which cycle it breaks.

Verification

The cheapest possible check catches the whole class of bug, because a cycle cannot survive a clean import:

python -c "import shop.main; print('import OK')"

Make it a test, so it runs before the app is deployed rather than during startup in production:

def test_the_package_imports_cleanly():
    importlib.import_module("shop.main")


def test_every_module_imports_in_isolation():
    # Catches cycles that only appear from one entry point, as the transcript above shows.
    for module in pkgutil.walk_packages(shop.__path__, prefix="shop."):
        subprocess.run([sys.executable, "-c", f"import {module.name}"], check=True)

The second test is the one that matches what you just saw. A cycle can hide from import shop.main and appear only when a test module imports the service directly, so importing each module in a fresh interpreter is the thorough version. It is slow enough to keep out of the inner loop and cheap enough to run in CI.

Trade-offs and When Not To

The protocol-and-factory fix is not free. You lose the ability to jump from the call site to the implementation, because there is no static link left to follow — that is precisely what you traded away. app.state is untyped, so a misspelled attribute becomes an AttributeError on the first request rather than an ImportError at boot, which trades a loud early failure for a quieter late one. Confine that lookup to one small dependency function, as above, so exactly one line in the codebase is exposed to it.

There is also a real risk of over-applying it. Not every import that looks like coupling is a cycle, and introducing a protocol for a dependency that flows in one direction adds indirection while removing nothing. Reach for it when the modules genuinely import each other, or when you want a seam for overriding dependencies in tests — the pattern's second benefit, and often the one that justifies it.

Finally, if cycles keep appearing in new places, no local fix is the answer. Repeated cycles are a symptom of a module graph with no agreed direction, which is a layout problem rather than an import problem.

FAQ

Why does the ImportError blame a different module depending on how I run the app? Python blames whichever module in the cycle was entered second, and that depends on which one you imported first. A verified run importing each side of the same two-module cycle produces two different messages naming two different modules. Neither module is the bug; the cycle is.

What does "partially initialized module" actually mean? Python inserts a module into sys.modules before executing its body, so a re-entrant import finds a real but half-built module object. The name you asked for is defined further down the file than execution has reached, so the attribute lookup fails and Python reports it as a circular import.

Is importing inside a function a legitimate fix? Yes. The import runs at call time, when both modules are fully loaded, so the cycle cannot form. It is a correct and pragmatic fix, but it hides the coupling from any tool that reads imports statically, so prefer it for genuine one-off cycles rather than as a house style.

Does TYPE_CHECKING fix a real circular import? Only when the import exists purely for annotations. Under TYPE_CHECKING the import never runs at runtime, so the cycle disappears, and with from __future__ import annotations the annotation itself is never evaluated. If you need the object at runtime, it does nothing.

Why does depending on a Protocol break the cycle when the concrete class still gets imported somewhere? Because it moves the import to a module that nothing in the cycle imports back. The router imports only the Protocol, the factory imports both the router and the implementation, and no module the factory imports needs the factory. The graph becomes a tree with the factory at the root.