Two questions that decide among Python backend frameworks

Two questions settle most Python framework choices. First, how much should the framework own: the ORM, schema migrations, an admin interface and authentication? Second, does the workload spend enough time waiting on I/O to justify async Python?

Django answers the first question with "nearly everything." It ships with an ORM, a migration system, an admin site and a user/auth model that fit together. Teams choose it when the API sits on a relational schema with real business rules and they want those parts working together from the first day. FastAPI owns less. It provides routing, request validation and serialisation through Pydantic type hints, generated OpenAPI docs and native ASGI support. Persistence, migrations and auth are left to libraries you pick yourself. Flask owns the least. It handles routing and a request context on WSGI, and everything else comes from extensions.

Framework Owns ORM/migrations/admin/auth Concurrency model Best fit
Django Yes, built in WSGI by default, with partial async support Data-heavy apps with a strong relational model
FastAPI No, you choose the libraries ASGI, async-first I/O-bound services that make many outbound calls
Flask No, extensions fill the gaps WSGI Small, synchronous services

Each framework becomes a liability when used outside its row. In Django, you end up working around parts you don't use. In FastAPI, you end up rebuilding the admin and auth that Django gives you. In Flask, you end up assembling an unofficial Django out of extensions.

Switching frameworks after launch is expensive. Your models, migrations, auth flows and test fixtures are all written against the original framework's APIs. Make the choice on purpose, and don't default to whichever option looks most flexible.

How WSGI and ASGI change concurrency

WSGI is a synchronous calling convention. The server hands a request to your application callable, and that worker (a process or thread) belongs to the request until it returns a response. Flask and classic Django run this way. If the handler spends most of its time waiting on a database, the worker sits idle but cannot take other work, so concurrency equals the number of workers you provision.

ASGI makes the application an async callable running on an event loop. When a handler awaits I/O, the loop runs other requests. FastAPI is ASGI-native, and Django can be served over ASGI. This matters most for long-lived connections. A WebSocket or a streaming response holds a WSGI worker for its whole lifetime, so a few hundred idle chat clients can exhaust a worker pool. Under ASGI, each open connection costs a small amount of memory instead of a whole worker.

Illustrative estimate, not a benchmark. Assume each request spends 50 ms waiting on I/O and 5 ms on CPU, with 8 workers:

  • Sync: each worker finishes about 18 requests per second (1 / 0.055 s), or roughly 145 req/s in total.
  • Async: each process is limited only by CPU, at up to about 200 req/s (1 / 0.005 s), or roughly 1,600 req/s across 8 processes.

The async figure assumes the database and connection pool can absorb that much concurrency, which they often can't.

In practice, query time, network hops and serialization usually dominate latency, so a faster framework rarely changes the outcome.

WSGI: each worker is held per request  ASGI: one event loop, many requests

+----------------------------------+   +----------------------------------+
|     WSGI server (8 workers)      |   |    ASGI server (one process)     |
+----------------------------------+   +----------------------------------+
   |        |        |        |                         |
   |        |        |        |                         v
   v        v        v        v        +----------------------------------+
+------+ +------+ +------+ +------+    |            Event loop            |
| W1   | | W2   | | W3   | | W8   |    |  req A: await db  -> parked      |
| req A| | req B| | WS C | | req H|    |  req B: await db  -> parked      |
| wait | | wait | | held | | wait |    |  WS C : idle      -> parked      |
+------+ +------+ +------+ +------+    |  req D: running on CPU           |
   |        |        |        |        +----------------------------------+
   |        |        |        |                         |
   v        v        v        v                         v
+----------------------------------+   +----------------------------------+
|     Database / upstream I/O      |   |     Database / upstream I/O      |
+----------------------------------+   +----------------------------------+

Concurrency = worker count (8)         Concurrency = in-flight awaits;
Idle waits and open sockets block      limit = CPU time and DB pool size

Under WSGI each request or WebSocket holds a worker while it waits on I/O. Under ASGI, one event loop parks awaiting requests and runs others, so CPU time and the database pool become the limit.

When async Python helps and when it hurts

Async pays off when two conditions hold: most of a request's wall time is spent waiting on I/O (database queries, HTTP calls to other services, cache reads), and every call in the hot path yields to the event loop while it waits. If either condition fails, the async version may be no faster than a threaded sync one, and it can be worse.

The common failure is a single blocking call inside an async def endpoint, such as a synchronous database driver, requests, or a vendor SDK that does socket I/O internally. The event loop runs on one thread, so while that call waits on the network, no other coroutine on that worker can run. Every in-flight request on the worker stalls, and the latency spike shows up across unrelated endpoints, which makes the cause hard to trace.

To spot it, track event-loop lag, meaning how late scheduled callbacks actually run. In development, enable asyncio debug mode with PYTHONASYNCIODEBUG=1 or asyncio.run(..., debug=True). It logs a warning for any callback that holds the loop longer than loop.slow_callback_duration, which defaults to 100 ms. The Python documentation covers these settings under asyncio.

To fix it, move the blocking work off the loop with asyncio.to_thread or loop.run_in_executor, or declare the FastAPI endpoint with a plain def. FastAPI runs plain def endpoints in a thread pool.

Async also has ongoing costs. Stack traces span await points, and sampling profilers attribute time less clearly. Bridges like asgiref's sync_to_async and async_to_sync hand work to threads, which adds overhead. They also make it harder to know which thread holds which database connection, and therefore when connections are opened, reused or closed.

# main.py  run with: PYTHONASYNCIODEBUG=1 uvicorn main:app --loop asyncio
# Debug mode logs 'Executing <Task ...> took 0.500 seconds' for the /bad route.
import asyncio
import time

from fastapi import FastAPI

app = FastAPI()


def fetch_report(report_id: int) -> dict:
    # Stand-in for a blocking call: sync DB driver, requests, or a vendor SDK.
    time.sleep(0.5)
    return {"id": report_id, "status": "ready"}


@app.get("/bad/{report_id}")
async def bad(report_id: int):
    # Holds the event loop for 500 ms, so every request on this worker waits.
    return fetch_report(report_id)


@app.get("/to-thread/{report_id}")
async def to_thread(report_id: int):
    # Runs in a worker thread, so the loop keeps serving other coroutines.
    return await asyncio.to_thread(fetch_report, report_id)


@app.get("/executor/{report_id}")
async def executor(report_id: int):
    # Same idea through the lower-level API. None uses the default executor.
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, fetch_report, report_id)


@app.get("/sync/{report_id}")
def sync_endpoint(report_id: int):
    # Plain def: FastAPI runs it in its thread pool, off the event loop.
    return fetch_report(report_id)

A blocking call inside an async FastAPI endpoint stalls the event loop, and the other three endpoints keep it off the loop with asyncio.to_thread, run_in_executor, or a plain def endpoint.

What each framework owns: FastAPI, Django and Flask models

FastAPI

FastAPI's core is the request contract. You declare Pydantic models with type hints, and FastAPI uses them to validate input, serialize output and generate an OpenAPI schema. Its dependency injection system handles database sessions, auth checks and settings. It does not include an ORM, migrations, an admin interface or user auth, so the team chooses its own data layer, migration tool and project layout. Without shared conventions, two FastAPI services in the same company often end up structured very differently, and engineers moving between them have to relearn each one.

Django

Django provides the ORM, migrations, admin, auth, forms and a central settings module, and Django REST Framework adds serializers and viewsets for APIs. Async views and middleware work. However, parts of the ORM and many third-party apps still run synchronous code underneath, so workloads that hold many long-lived connections, such as WebSockets or streaming, need careful auditing. The ORM also creates coupling. When your data model doesn't fit it, for example with polymorphic records or heavy raw SQL, you work against the framework instead of with it.

Flask

Flask is a small core plus extensions. You get explicit control and assemble the rest of the stack yourself. Flask supports async views, but it remains a WSGI framework: each async view runs its own event loop inside a worker thread, so it does not become ASGI. The bigger long-term risk is the number of extensions. Each one releases on its own schedule, so a Flask upgrade can leave some of them incompatible.

FastAPI Django Flask
Interface ASGI WSGI and ASGI WSGI
Data layer Your choice Built-in ORM Extension
Schema/OpenAPI Built-in Via DRF plus add-ons Extension
Main risk Codebase divergence ORM coupling Extension drift

FastAPI vs Django in practice: one CRUD endpoint

Where the decisions live

In FastAPI, the request and response shapes are Pydantic models declared next to the route function. Persistence comes from a dependency, typically a generator that yields a database session and closes it afterwards, injected with Depends. A missing record is handled by raising HTTPException with a 404. Validation runs before your function is called, and malformed input returns a 422 by default. The OpenAPI schema and the interactive docs at /docs come from the same type annotations, so the endpoint's contract sits in one file. The trade-off is that you choose and wire the ORM, the session lifecycle and the transaction boundaries yourself.

With Django and DRF, the same endpoint is spread across layers. The model defines the table. A ModelSerializer derives validation from the model fields and handles saving. A ModelViewSet supplies list, retrieve and create behavior. A router generates the URLs. Not-found cases become 404s through Http404, and validation errors return 400. Schema generation is a separate step, usually done with an add-on. You write less wiring, but you need to know which layer to override when the default behavior is wrong.

Flask makes none of these choices for you. Validation, ORM and docs each come from an extension you select, and abort(404) handles missing records.

All three approaches have the same blind spot. Generated docs and schema validation only describe the models you wrote. They will accept a well-formed request from a user who shouldn't own that record. Authorization and business rules still need explicit code and tests.

from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from .db import SessionLocal        # sessionmaker bound to your engine
from .models import Note            # SQLAlchemy model: id, title, body, owner_id
from .auth import current_user_id   # your own auth dependency

app = FastAPI()

# The request/response contract: drives validation (422 on bad input) and /docs.
class NoteIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    body: str = ""

class NoteOut(NoteIn):
    id: int
    model_config = {"from_attributes": True}  # read fields from ORM objects

# You own the session lifecycle: one session per request, always closed.
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.post("/notes", response_model=NoteOut, status_code=201)
def create_note(data: NoteIn, db: Session = Depends(get_db), user_id: int = Depends(current_user_id)):
    note = Note(**data.model_dump(), owner_id=user_id)
    db.add(note)
    db.commit()  # transaction boundary is explicit and yours to choose
    db.refresh(note)
    return note

@app.get("/notes/{note_id}", response_model=NoteOut)
def read_note(note_id: int, db: Session = Depends(get_db), user_id: int = Depends(current_user_id)):
    note = db.get(Note, note_id)
    # Neither Pydantic nor /docs knows about ownership: this check is hand-written and needs tests.
    if note is None or note.owner_id != user_id:
        raise HTTPException(status_code=404, detail="Note not found")
    return note

A FastAPI create/read endpoint where Pydantic models define validation and the generated docs, a Depends-injected generator manages the SQLAlchemy session, HTTPException returns the 404, and the ownership check has to be written by hand because the schema can't express it.

Testing and running each framework in production

Each framework provides a client that calls the app in-process, without a network socket. The frameworks differ in how you replace dependencies during tests.

FastAPI's TestClient wraps the ASGI app. app.dependency_overrides maps a dependency function to a test replacement, so you can substitute a fake user or a transactional session without patching imports. Clear the dict after each test, or overrides will leak into later tests.

Django's test runner creates a separate test database and isolates each TestCase in a transaction. You can call the built-in Client, or Django REST framework's APIClient for authentication and JSON helpers. override_settings changes configuration for a single test.

Flask's app.test_client() works best with an app factory. Each test calls something like create_app(test_config), so configuration is fixed before any extension binds to the app.

In production, Flask and Django run as WSGI apps under a process manager such as Gunicorn. Each worker process handles one request per thread. FastAPI, and Django in ASGI mode, run on an event-loop server such as Uvicorn. Gunicorn can supervise Uvicorn workers to provide restarts and multiple processes.

Size the layers together. The worst case is instances × workers × connections per worker, and that total must stay below the database's limit. In threaded WSGI, give each worker a pool at least as large as its thread count. In async workers, the pool size caps how many queries run concurrently.

Illustrative estimate: assume PostgreSQL allows 100 connections and you reserve 15 for migrations and admin access. That leaves 85. With 2 instances × 4 workers × a pool of 10, the total is 80, which fits.

# test_me.py: run with `pytest`
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


def get_current_user() -> dict:
    # In production this would validate a token or session.
    raise RuntimeError("real auth is not available in tests")


@app.get("/me")
def read_me(user: dict = Depends(get_current_user)):
    return {"username": user["username"]}


def fake_user() -> dict:
    # Test replacement. FastAPI calls this instead of get_current_user.
    return {"username": "test-user"}


@pytest.fixture
def client():
    # Override the dependency by function identity. No import patching is needed.
    app.dependency_overrides[get_current_user] = fake_user
    with TestClient(app) as c:  # Runs startup/shutdown events in-process.
        yield c
    # Clear the overrides so they do not leak into later tests.
    app.dependency_overrides.clear()


def test_me_returns_overridden_user(client):
    resp = client.get("/me")
    assert resp.status_code == 200
    assert resp.json() == {"username": "test-user"}

A pytest fixture that replaces a FastAPI auth dependency through app.dependency_overrides, calls the app in-process with TestClient, and clears the overrides after each test.

Decision guide: who should pick which, cost and alternatives

Who should pick which

Framework Pick it when Avoid it when Saves engineering time on Costs engineering time on
Django Data-heavy product, staff need an admin, CRUD dominates Mostly long-lived connections or many small async services Admin, ORM, migrations, auth, forms Learning its conventions; upgrades touch many built-in parts
FastAPI Typed, I/O-bound APIs; internal platforms where the OpenAPI contract is the interface Team is new to async, or the workload is CPU-bound Validation, serialization, generated OpenAPI docs Choosing an ORM, migrations and auth; debugging blocking calls inside async code
Flask Small services, or teams that want to choose every component The app will grow into a large product with no one owning its architecture Very little framework to learn Assembling and upgrading the extension stack yourself

The cost differences come from who owns the integration work. Django has already made the integration decisions, so a new engineer learns one set of conventions, and upgrades follow the framework's release notes. FastAPI's generated docs replace hand-written API specs, but you still assemble the rest of the stack and maintain it. Flask is cheapest on day one and most expensive to onboard into later, because each codebase uses its own combination of components.

Switching later is expensive because the framework shapes everything around it: ORM models, validation schemas, test fixtures and deployment config. Moving frameworks means rewriting all of them, not just the routing.

You don't have to pick only one. A common split keeps Django for the admin and data layer and runs a separate async service for WebSockets or other long-lived connections. The two share the database, and you budget that database's connection pool across both services.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to python backend frameworks, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.