Error loading ASGI app: why uvicorn cannot import your FastAPI module
The app runs perfectly on your laptop. You type fastapi dev main.py, the reloader spins up, the docs page loads, life is good. Then you write a Dockerfile, push it, and the container dies in under a second with six words that tell you nothing:
ERROR: Error loading ASGI app. Could not import module "api".
What follows is usually an hour of thrashing: reinstalling FastAPI, adding __init__.py files in random directories, pinning uvicorn to an older version, adding sys.path.append to the top of the file. None of that is the problem. The problem is that uvicorn does not open your file. It never touches the path you think it does. It takes the text before the colon and hands it to Python's import machinery, which resolves it against sys.path. If the top-level name is not importable from wherever the process was launched, you get that error, and it will keep happening no matter how many packages you reinstall.
uvicorn api:app means "import the module api, then take its attribute app". Uvicorn prepends --app-dir (default: the current working directory) to sys.path, so the string is resolved relative to where you launched the process, not where the file lives. Run this from the same directory and the same venv:
python -c "import api; print(api.__file__)"
If that fails, uvicorn will fail too. Either cd to the directory that contains api.py (or the package root), or pass --app-dir /path/to/that/directory, or use the full dotted path such as uvicorn app.main:app. In Docker this almost always means your WORKDIR and your COPY destination disagree.
The exact errors, and the difference between them
Uvicorn produces three distinct failures from one line of code, and they mean three different things. Getting them mixed up is why people fix the wrong thing.
ERROR: Error loading ASGI app. Could not import module "api".
ERROR: Error loading ASGI app. Attribute "app" not found in module "main".
ERROR: Error loading ASGI app. Import string "main.py:app" must be in format "<module>:<attribute>".
| Message | What it actually means |
|---|---|
| Could not import module "X" | Python could not find a top-level module named X on sys.path. A path problem, not a code problem. |
| Attribute "app" not found in module "M" | The module imported fine. There is no object called app in it. Usually the variable is named something else, or it is created inside a factory function. |
| Import string ... must be in format | You passed a filename. It is main:app, never main.py:app and never app/main.py. |
Why uvicorn cannot import your module
The whole mechanism is about twenty lines in uvicorn/importer.py. It splits your string on the first colon, calls importlib.import_module() on the left half, then walks getattr() down the right half. That is it. There is no file lookup, no directory scan, no clever search of the tree.
Two consequences follow, and both are counter-intuitive.
First: the resolution is relative to the process, not the file. Uvicorn inserts --app-dir at the front of sys.path, and its documented default is the current working directory. So uvicorn api:app launched from /srv/project looks for /srv/project/api.py or /srv/project/api/__init__.py. Launched from /srv, it looks for /srv/api.py. Same command, same file on disk, different outcome. This is why "it works on my machine" is literally true here: your shell happened to be standing in the right place.
Second: the error is deliberately narrow. Uvicorn catches ModuleNotFoundError and checks whether the missing module name equals the name you asked for. If it does not, it re-raises the original exception untouched. That means "Could not import module" always refers to the top-level name in your import string, never to something your code imports internally. If api.py exists but does import redis and redis is not installed, you do not get this error. You get an ordinary traceback ending in ModuleNotFoundError: No module named 'redis'.
If you see "Could not import module", stop reading your application code. The application code was never executed.
The four project layouts and the invocation each one needs
Nearly every occurrence of this error is one of four shapes. Find yours.
| Layout | Run from | Correct command |
|---|---|---|
main.py at repo root | repo root | uvicorn main:app |
app/main.py with app/__init__.py | repo root | uvicorn app.main:app |
src/app/main.py (src layout) | repo root | uvicorn app.main:app --app-dir src |
installed package (pip install -e .) | anywhere | uvicorn mypkg.main:app |
The src layout is where most people give up. uvicorn src.app.main:app looks plausible and will even start if you drop an __init__.py into src/, but you have now created a package called src that does not exist when the project is installed as a wheel, so every import breaks the moment you deploy a different way. Use --app-dir src instead. It does exactly what the packaging tooling does: puts src on the path and keeps app as the top-level name.
Fixing it, step by step
Step 1: Reproduce the failure without uvicorn
From the exact directory, and with the exact interpreter, you use to launch uvicorn:
pwd
python -c "import api; print(api.__file__)"
A working setup prints the resolved path:
/srv/project
/srv/project/api.py
A broken one prints the honest version of uvicorn's message:
ModuleNotFoundError: No module named 'api'
If step 1 fails, every remaining step is about making step 1 pass. If step 1 succeeds but uvicorn still fails, you are running a different interpreter than you think. Check with which uvicorn and python -c "import sys; print(sys.executable)"; the two paths should share a parent directory.
Step 2: Print the path uvicorn will actually search
python -c "import sys; print('\n'.join(sys.path))"
The first entry is the one that matters. Uvicorn puts --app-dir ahead of everything here. If your project directory is not in this list and you are not passing --app-dir, the import cannot succeed, full stop.
Step 3: Choose the import string that matches the layout
For a package layout, confirm the package is real before you argue with the command. Namespace packages make __init__.py technically optional, but adding it to a directory you intend to import removes a whole class of ambiguity:
ls app/__init__.py app/main.py
uvicorn app.main:app --host 0.0.0.0 --port 8000
Expected output on success, printed within a second:
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
Step 4: Make the Dockerfile agree with itself
This is the single most common production trigger. The image copies the project into /app, but the project already contains a directory called app, so the module lands at /app/app/main.py while WORKDIR is /app. Now uvicorn main:app fails, uvicorn app.main:app works, and nobody can explain why.
FROM python:3.12-slim
WORKDIR /code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /code/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Two things make this reliable. WORKDIR is /code, deliberately not named after any package in the project, so /code/app is unambiguous. And CMD is the exec-form JSON array, so uvicorn is PID 1 and receives SIGTERM directly instead of having it swallowed by a shell. If you have to debug the built image, do not guess:
docker run --rm -it --entrypoint sh myimage:latest \
-c 'pwd && ls -la && python -c "import app.main"'
That one line answers the question in about four seconds. If you are new to reasoning about images this way, my practical on-ramp to Docker covers the build context and WORKDIR model in more depth.
Step 5: Make systemd agree too
A systemd unit has no shell and no inherited working directory, so an import string that works in your terminal will fail under the unit unless you say where to stand:
[Service]
User=appuser
WorkingDirectory=/srv/project
Environment="PATH=/srv/project/.venv/bin"
ExecStart=/srv/project/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
WorkingDirectory is what puts /srv/project at the front of sys.path. The absolute path to the venv's uvicorn is what guarantees the right interpreter. If the service restarts in a loop after this, the cause has moved on from imports, and the rest of the unit file matters more than the import string.
Verification
Do not settle for "it started". Prove all three layers:
# 1. the import resolves from the launch directory
python -c "from app.main import app; print(type(app))"
# <class 'fastapi.applications.FastAPI'>
# 2. the process is really listening
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs
# 200
# 3. it survives a restart, not just a first boot
docker compose restart api && docker compose logs --tail=20 api
The third check catches the case where the app only imported because of a stale bind mount or a leftover PYTHONPATH in your interactive shell.
What people get wrong
"Reinstall FastAPI and uvicorn." This never fixes it. The error is raised by uvicorn's own importer, which means uvicorn is installed and running perfectly well. If uvicorn were broken you would not be getting a nicely formatted uvicorn error message.
"Add __init__.py everywhere." Adding one to a directory you intend to import as a package is correct and harmless. Adding one to src/, or to your repository root, is actively harmful: it converts a directory that is meant to be a path entry into a package, which changes your import strings and then breaks them again when the project is installed properly.
"Put sys.path.append(os.path.dirname(__file__)) at the top of main.py." Read the mechanism again. Your file is never executed, because it was never imported. Code inside the file cannot repair a failure that happens before the file is opened.
"Use uvicorn main.py:app." That produces the third error message above. The left side is a dotted module path, not a filename. There is no .py in it, ever, and no slashes either.
"Set PYTHONPATH=. in the Dockerfile and move on." It works, and it is the fix I dislike most, because it makes the import string correct only under one specific working directory and hides the layout problem from the next person who touches the image. If you genuinely need an extra path entry, --app-dir says so out loud, in the command that fails.
When it is still broken
- You now get a full traceback instead of the short message. Good news: that is your code failing at import time. Read the last line. Uvicorn deliberately passes nested
ModuleNotFoundErrors through untouched, so this is real information. - "Attribute app not found" after fixing the path. Your app object has a different name, or it is built by a factory. For
def create_app() -> FastAPI, runuvicorn app.main:create_app --factory. --reloadworks but plain uvicorn does not, or the reverse. The reloader launches a child process; a mismatch here nearly always means one of the two inherits a differentPYTHONPATHfrom your shell profile. Compare withenv | grep -i python.- "attempted relative import beyond top-level package". The module imported, but you are doing
from ..models import Xfrom a module Python does not consider deep enough inside a package. Switch to absolute imports (from app.models import X) and launch from the repo root. Relative imports plus ad-hoc launch directories is an argument you will not win. - Kubernetes crash loop with nothing in the logs. The container may be dying too fast to ship anything. Diagnosing a CrashLoopBackOff with no logs covers how to recover the first 200 milliseconds of output.
The rule worth keeping: an import string is a contract between your directory layout and the command that starts the process. Change either side and you must change the other. Write the import string down in exactly one place, the Dockerfile or the unit file, and make every other environment match it, instead of letting each environment invent its own.
Frequently asked questions
- What does 'Error loading ASGI app. Could not import module' actually mean?
- It means Python could not find a top-level module with that name on sys.path. Uvicorn splits your 'module:attribute' string on the colon and calls importlib.import_module on the left half, resolved against the current working directory or whatever you pass to --app-dir. Your application file is never opened, so the problem is where you launched the process from, not what is inside the file.
- Should I run uvicorn as main:app or app.main:app?
- Use main:app when main.py sits directly in the directory you launch from. Use app.main:app when your code lives in an app/ package inside that directory. The rule is that the dotted path must be importable from the current working directory, so run python -c "import app.main" from that directory first to confirm it before touching the uvicorn command.
- How do I run uvicorn with a src/ layout?
- Pass --app-dir src and keep the package as the top-level name, for example uvicorn app.main:app --app-dir src. Do not write uvicorn src.app.main:app, because that makes src an importable package name that will not exist once the project is installed as a wheel, and your imports will break at deploy time.
- Why does my FastAPI app import fine locally but fail inside Docker?
- Almost always because WORKDIR and the COPY destination disagree. If you COPY ./app /app and set WORKDIR /app, your module is now at /app/main.py and the correct string is main:app, not app.main:app. Set WORKDIR to a neutral directory such as /code, copy into /code/app, and run uvicorn app.main:app. Confirm with docker run --rm -it --entrypoint sh yourimage -c 'pwd && ls -la'.