Fix: Uvicorn Not Working — Worker Errors, Reload Issues, and Production Deployment
Part of: Python Errors
Quick Answer
How to fix Uvicorn errors — Address already in use port binding, reload not detecting changes, SSL certificate errors, worker class with gunicorn, WebSocket disconnect, graceful shutdown, and proxy headers behind nginx.
The Error
You start Uvicorn and port 8000 is already taken:
ERROR: [Errno 48] error while attempting to bind on address ('0.0.0.0', 8000):
address already in useOr auto-reload doesn’t detect changes to your code:
uvicorn main:app --reload
# Edit main.py, save, reload doesn't triggerOr you deploy with multiple workers and the app breaks in ways it didn’t locally:
uvicorn main:app --workers 4
# Memory usage × 4, in-memory state lost between requestsOr connections behind a reverse proxy have wrong client IPs:
@app.get("/")
def index(request: Request):
return {"client": request.client.host}
# Returns 127.0.0.1 (the proxy) instead of the real user IPOr SSL/TLS setup fails with cryptic errors:
SSLError: [SSL: NO_PRIVATE_KEY_ASSIGNED] no private key assignedUvicorn is the standard ASGI server for modern Python web apps (FastAPI, Starlette, Quart). It’s lightning-fast (built on uvloop and httptools) but production deployment involves several decisions — worker count, reload settings, proxy headers, TLS — that each have their own failure modes.
Why This Happens
Uvicorn is an ASGI server — it speaks the async Python web protocol, different from WSGI (Flask, Django). It wraps a single async event loop per process. The --workers flag spawns multiple processes (each with its own event loop), but those processes don’t share memory — anything stored in app-level variables is independent per worker.
Auto-reload watches your source tree for file changes. It only reloads the app, not Uvicorn itself, and has specific rules about which files it tracks. Behind a reverse proxy, Uvicorn sees the proxy’s IP as the client unless you tell it to trust forwarded headers — this breaks IP-based rate limiting, analytics, and geolocation.
Diagnostic Timeline
When Uvicorn misbehaves, your first instinct is usually “restart it.” That hides the real fault. Work through this timeline before touching the process.
Minute 0 — Wrong first instinct. You hit Ctrl+C, run uvicorn main:app again, and assume the next start will fix the symptom. It rarely does. A clean restart only resolves a stale socket; it never explains why reload missed your edit or why a request hangs for 60 seconds. Resist the reflex.
Minute 1 — Discriminating evidence. Read the last 50 lines of Uvicorn’s own log before anything else. Three signals tell you almost everything: did the worker print Application startup complete, did it print Will watch for changes in these directories, and is the access log still rolling? Missing startup means the lifespan or import failed silently; missing watch line means --reload is on but the watcher does not see your edit path; missing access log under load means workers are blocked or dead.
Minute 2 — Next check. Confirm process topology with ps -ef | grep uvicorn (or Get-Process uvicorn on Windows). If you launched with --workers N but only see one process, Gunicorn or systemd is not running the manager you think. If you launched with --reload but see four workers, the deploy command leaked --reload into production. The number of processes is the fastest way to catch a bad command line.
Minute 3 — Actual root cause. Three causes account for the majority of “Uvicorn is broken” tickets:
- Wrong worker class with Gunicorn. Running
gunicorn main:appwithout--worker-class uvicorn.workers.UvicornWorkerfalls back to the sync worker, which cannot run an ASGI app. Symptoms: requests hang or return 500 immediately. Fix in Fix 3. - Reload not picking up changes in a Docker bind mount. Native filesystem events do not propagate through Docker Desktop on macOS or Windows. The watcher polls a directory that never reports changes, so edits look ignored. Fix with
--reload-delayand polling, covered in Fix 2. - Signal handling on shutdown. SIGTERM goes to PID 1 in containers, but if Uvicorn is wrapped by
sh -c "uvicorn ...", the shell catches the signal and Uvicorn never sees it. Workers are killed mid-request instead of draining. Useexec uvicorn ...in the entrypoint or run Uvicorn directly as PID 1. Fix 6 covers graceful shutdown.
If none of these fit, then restart. By that point you actually know what you are restarting and why.
Fix 1: Port Already in Use
[Errno 48] error while attempting to bind on address ('0.0.0.0', 8000)Another process is already bound to port 8000. Find and kill it, or use a different port.
Find the process using port 8000:
# Linux / macOS
lsof -i :8000
# COMMAND PID USER ...
# python 12345 user ...
kill -9 12345
# Or one-liner
kill $(lsof -ti :8000)Windows PowerShell:
Get-NetTCPConnection -LocalPort 8000 | Select-Object OwningProcess
Stop-Process -Id <pid>Use a different port:
uvicorn main:app --port 8001Most common cause — you crashed an earlier run and Python left the process running. Ctrl+C should clean up, but a stuck process needs the manual kill.
For general port conflict patterns, see port 3000 already in use.
Fix 2: Auto-Reload and Development Mode
uvicorn main:app --reload--reload rules:
- Watches the current working directory (and imported modules under it) by default
- Only triggers on
.pyfile changes (plus a few extensions like.yaml) - Ignores
venv/,__pycache__/,.git/, etc.
Reload not triggering — common causes:
- Editing a file outside the reload directory:
# Add directories to watch
uvicorn main:app --reload --reload-dir ./src --reload-dir ./config
# Extend watched extensions
uvicorn main:app --reload --reload-include "*.yaml" --reload-include "*.html"- Editor saves creating temp files — some editors (vim with swap files) confuse the watcher. Check with explicit save:
touch main.py # Manually trigger a file event- Running under Docker with volume mounts on macOS — file events may not propagate. Use polling:
uvicorn main:app --reload --reload-include "*.py" --reload-delay 1.0Never use --reload in production — it’s slow, uses more memory, and restart-on-change is unexpected server behavior.
--workers doesn’t work with --reload:
uvicorn main:app --reload --workers 4 # Warning: workers ignored with reloadReload mode is single-process by design.
Common Mistake: Deploying to production with --reload still enabled (from copy-pasting the dev command). Production apps should run without reload; use a process manager (systemd, supervisord) to restart on crashes, and deploy new code via container rebuilds or graceful worker reloads.
Fix 3: Multiple Workers for Production
uvicorn main:app --workers 4 --host 0.0.0.0 --port 8000--workers N spawns N Uvicorn processes, each with its own async event loop. Rule of thumb: (2 × CPU_cores) + 1, tuned to your actual workload.
Workers don’t share memory:
# WRONG — state is per-worker
from fastapi import FastAPI
app = FastAPI()
# Each worker has its own counter
counter = 0
@app.post("/increment")
def increment():
global counter
counter += 1
return {"count": counter}
# Different workers return different countsCORRECT — use shared external state (Redis, DB, etc.):
from fastapi import FastAPI
import redis
app = FastAPI()
r = redis.Redis()
@app.post("/increment")
def increment():
count = r.incr("counter") # Atomic across workers
return {"count": count}When sharing state across workers via Redis, expect to fight connection pool limits and reconnect storms — design the client with retries from day one.
Use Gunicorn as the process manager — better signal handling for production:
pip install gunicorn
gunicorn main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 60 \
--graceful-timeout 30Or with the newer UvicornH11Worker (if you need pure-Python H1 handling):
gunicorn main:app --worker-class uvicorn.workers.UvicornH11Worker --workers 4Pro Tip: uvicorn --workers 4 is fine for small deployments. For anything serious, use Gunicorn with UvicornWorker — it handles worker lifecycle, graceful restarts, and worker timeouts more robustly than Uvicorn’s built-in multi-process mode. The performance is identical; the operational ergonomics are much better.
Fix 4: SSL/TLS Setup
uvicorn main:app \
--ssl-keyfile /path/to/key.pem \
--ssl-certfile /path/to/cert.pem \
--host 0.0.0.0 \
--port 443Common SSL errors:
SSLError: [SSL: NO_PRIVATE_KEY_ASSIGNED] no private key assignedUsually means --ssl-keyfile wasn’t provided or the file is unreadable.
SSL_ERROR_NO_CYPHER_OVERLAPClient and server can’t agree on a cipher suite. Usually the cert uses an unsupported algorithm or the client is too old.
Self-signed cert for development:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-sha256 -days 365 -nodes \
-subj "/CN=localhost"
uvicorn main:app --ssl-keyfile key.pem --ssl-certfile cert.pem --port 8443Production: terminate SSL at the load balancer, not Uvicorn. It’s simpler, more secure, and lets you rotate certs without restarting the app:
[HTTPS client] → [Nginx/ALB with TLS] → [Uvicorn HTTP on :8000]For nginx-specific SSL handshake issues, see nginx SSL handshake failed.
Fix 5: Behind a Reverse Proxy — Forwarded Headers
@app.get("/ip")
def get_ip(request: Request):
return {"client_ip": request.client.host}
# Returns 127.0.0.1 (the proxy) when running behind nginx/ALBUvicorn doesn’t trust X-Forwarded-For headers by default. Enable proxy headers:
uvicorn main:app \
--host 0.0.0.0 \
--port 8000 \
--proxy-headers \
--forwarded-allow-ips="*"--forwarded-allow-ips accepts a comma-separated list of trusted proxy IPs. "*" trusts all (only safe when Uvicorn is not directly exposed to the internet):
# Trust specific proxy IPs
--forwarded-allow-ips="10.0.0.1,10.0.0.2"How it changes behavior:
# Without --proxy-headers
request.client.host → "10.0.0.1" (proxy IP)
request.url.scheme → "http"
# With --proxy-headers
request.client.host → "203.0.113.42" (real client IP from X-Forwarded-For)
request.url.scheme → "https" (from X-Forwarded-Proto)Required nginx configuration to forward the headers:
server {
listen 443 ssl;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Common Mistake: Enabling --proxy-headers on a directly-exposed Uvicorn (not behind a proxy). Attackers can then spoof their IP by sending X-Forwarded-For themselves. Only enable when traffic actually comes through a trusted proxy.
Fix 6: Graceful Shutdown and Signal Handling
When you deploy new code, you want existing requests to complete before the worker shuts down.
from fastapi import FastAPI
import asyncio
import signal
app = FastAPI()
@app.on_event("startup")
async def startup():
# Open DB connections, warm caches, etc.
print("App starting")
@app.on_event("shutdown")
async def shutdown():
# Close DB pools, flush logs
print("App shutting down — cleaning up")Gunicorn graceful timeout:
gunicorn main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--timeout 60 \
--graceful-timeout 30 \
--bind 0.0.0.0:8000--timeout 60— kill worker if it doesn’t respond to heartbeats--graceful-timeout 30— when shutting down, give active requests 30 seconds to finish
FastAPI lifespan context (preferred over on_event):
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
db_pool = await create_pool()
app.state.db = db_pool
yield
# Shutdown
await db_pool.close()
app = FastAPI(lifespan=lifespan)The on_event decorators are deprecated in FastAPI; use lifespan for new code.
For FastAPI dependency lifecycle issues that interact with Uvicorn workers, see FastAPI dependency injection error.
Fix 7: WebSockets
Uvicorn handles WebSockets natively — but disconnections and concurrency need care.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Echo: {data}")
except WebSocketDisconnect:
print("Client disconnected")Common WebSocket errors:
WebSocketDisconnect: code=1006 (no close frame)Client connection dropped without a close handshake — network issue, timeout, or proxy cutting the connection.
Configure nginx for WebSocket support:
location /ws {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # 24 hours
}Without the Upgrade header, nginx proxies as HTTP/1.1 and drops the connection before the WebSocket handshake completes.
For WebSocket proxy issues in nginx, see nginx websocket proxy not working.
Broadcasting to multiple clients requires a connection manager (Uvicorn is per-worker, so cross-worker broadcasts need Redis pub/sub):
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.active: list[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.active.append(ws)
def disconnect(self, ws: WebSocket):
self.active.remove(ws)
async def broadcast(self, message: str):
for ws in self.active:
await ws.send_text(message)
manager = ConnectionManager()
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(data)
except WebSocketDisconnect:
manager.disconnect(websocket)Fix 8: Logging and Debugging
uvicorn main:app --log-level debug # debug, info, warning, error, critical
uvicorn main:app --access-log # Print access log (default on)
uvicorn main:app --no-access-log # Suppress access log (quieter prod logs)Custom logging configuration:
import logging
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stdout",
},
},
"root": {
"level": "INFO",
"handlers": ["console"],
},
"loggers": {
"uvicorn": {"level": "INFO"},
"uvicorn.error": {"level": "INFO"},
"uvicorn.access": {"level": "INFO"},
},
})Via CLI with a YAML config:
uvicorn main:app --log-config logging.yamlAccess log fields — customize the format:
uvicorn main:app --access-log --log-config custom-logging.jsonDebugging slow endpoints:
import time
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def log_time(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed = time.perf_counter() - start
if elapsed > 1.0:
print(f"SLOW: {request.url.path} took {elapsed:.2f}s")
return responseStill Not Working?
Uvicorn vs Gunicorn vs Hypercorn
- Uvicorn — Fastest ASGI server, simple built-in worker mode. Best for small/medium deployments.
- Gunicorn + UvicornWorker — Production-grade process manager with graceful restart. Recommended for production.
- Hypercorn — HTTP/2 and HTTP/3 support. Slower than Uvicorn for HTTP/1.1.
Testing with Uvicorn
import pytest
from fastapi.testclient import TestClient
from main import app
client = TestClient(app) # Uses Uvicorn internally
def test_endpoint():
response = client.get("/")
assert response.status_code == 200Tests using TestClient boot the ASGI app in-process and skip the real network — fine for unit tests, but they will not catch proxy header or worker count bugs. Add a real Uvicorn integration test for those.
Docker Deployment
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# Production — no --reload, explicit host binding
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]Don’t use --workers in Docker containers for Kubernetes — let Kubernetes scale replicas instead. One worker per container keeps horizontal scaling clean:
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]# deployment.yaml
replicas: 4Health Check Endpoint
Every production deployment needs a health check. Add a simple endpoint:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/health/ready")
async def ready():
# Check DB, dependencies
return {"status": "ready"}Configure Kubernetes or your load balancer to hit /health for liveness and /health/ready for readiness.
Uvicorn Hangs on Shutdown for Exactly 30 Seconds
You hit Ctrl+C and Uvicorn waits, then prints a forced exit. The default graceful timeout is 30 seconds, and one of your background tasks is not respecting cancellation. Common culprits: a while True: await something() loop with no cancellation handler, a requests call (sync, blocks the loop), or a database driver that swallows CancelledError. Wrap long-running work in try: ... except asyncio.CancelledError: cleanup(); raise so the worker exits cleanly. Lower --timeout-graceful-shutdown to 5 seconds during development to surface offenders faster.
Reload Triggers Twice on Every Save
Your editor saves a swap file first (vim’s .main.py.swp), then the real file. Uvicorn’s watcher fires for both. The fix is editor-specific: turn off swap files in vim, or in VS Code disable files.saveAtomically. If you cannot change the editor, set --reload-delay 0.5 to debounce events. Less commonly, a cloud-sync tool (Dropbox, OneDrive) is rewriting timestamps in the watched directory — exclude the project from sync or move it outside the synced tree.
Module Reload Caches the Old Code
--reload restarts the worker, which re-imports your modules. But if you import a heavy module at the worker level (e.g., a global ML model loaded in main.py), the restart cost balloons and the new process may briefly serve from the old one due to load balancing. Move heavy imports inside the lifespan startup hook so they happen once per worker, and use --reload-exclude "*.pkl" --reload-exclude "models/*" to skip directories whose changes should not trigger reload.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Litestar Not Working — Dependency Injection, msgspec Validation, and Controller Setup
How to fix Litestar errors — Starlite to Litestar migration, Dependency injection scope, controller route not found, msgspec validation differs from Pydantic, lifespan handler setup, and OpenAPI generation.
Fix: msgspec Not Working — Struct Definition, Type Validation, and JSON/MessagePack Encoding
How to fix msgspec errors — Struct field type not supported, ValidationError on decode, msgspec vs Pydantic differences, custom type hooks, frozen Struct mutation, and JSON Schema generation.
Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors
How to fix Tortoise ORM errors — Tortoise.init not called, no module imported model, fetch_related missing, aerich migration setup, FastAPI integration patterns, and ConfigurationError missing connection.
Fix: SQLModel Not Working — table=True Confusion, Relationship Loading, and Session Errors
How to fix SQLModel errors — table not created without table=True, relationship not eager-loaded MissingGreenlet, AttributeError on lazy attribute, mixing Pydantic and Table classes, Optional vs default None, and async session setup.