Every backend framework comparison opens with a requests-per-second table. This one won't, and the reason is the most useful thing I can tell you before you pick.
Why there's no benchmark table here
Synthetic framework benchmarks measure the framework's overhead on a route that does nothing. Your route does something — and the something is almost always the bottleneck.
A typical API request spends the overwhelming majority of its wall-clock time waiting: on a database query, on a cache lookup, on a third-party HTTP call. The framework's own dispatch cost is a rounding error next to one unindexed PostgreSQL query doing a sequential scan. Swapping frameworks to fix that is like buying a faster car to shorten a flight.
They tell you the ceiling on a hello-world route under a load generator on someone else's hardware, with a tuned config you don't have, on a workload you don't run. That number is real — it's just answering a question you don't have. The public benchmark suites (TechEmpower and friends) are worth reading for architectural intuition, not for capacity planning.
So this comparison uses the things that are stable, documented, and actually predict your experience: the concurrency model, the type system, how much comes in the box, the ecosystem, and the deployment story.
The axis that matters most: concurrency
This single design choice explains more about each framework's behaviour than any other. All four handle concurrent requests; they disagree about how.
Django — synchronous by heritage
Django's roots are WSGI: a worker handles one request at a time, start to finish, and you get concurrency by running many workers (processes and/or threads) behind a server like Gunicorn. Simple, predictable, easy to reason about. A blocking call blocks that worker and nothing else.
Django has grown async capability incrementally — ASGI support, async views, and an async interface on the ORM (aget, acreate, and friends). But the async story is layered onto a codebase designed around synchronous access, and mixing the two requires care with sync_to_async and async_to_sync. If your reason for choosing Django is async throughput, you've chosen it for the wrong reason. Choose it for what's in the box.
FastAPI — async-first on ASGI
FastAPI is built on Starlette (the ASGI toolkit) and Pydantic (validation). It's async from the ground up: one event loop, await at every I/O boundary, and high concurrency on I/O-bound workloads without a process per request.
The subtlety that catches newcomers is worth stating plainly. FastAPI lets you declare handlers as either async def or plain def, and it does something sensible with both — def handlers are run in a threadpool so they don't block the loop. The failure mode is the mismatch: an async def handler that calls a blocking library (a sync database driver, requests, a heavy CPU loop) blocks the entire event loop and every other request on that worker.
# Bad: blocking call inside an async handler stalls the whole event loop
@app.get("/users/{user_id}")
async def get_user(user_id: int):
return requests.get(f"https://api.example.com/users/{user_id}").json()
# Fine: async client, awaited properly
@app.get("/users/{user_id}")
async def get_user(user_id: int):
async with httpx.AsyncClient() as client:
r = await client.get(f"https://api.example.com/users/{user_id}")
return r.json()
# Also fine: sync work in a sync handler — FastAPI runs it in a threadpool
@app.get("/report")
def build_report():
return expensive_blocking_pandas_thing()
If you take one thing from this article, take that. It is the most common performance bug in production FastAPI code, and it doesn't show up until you have concurrent traffic.
Express — the event loop, single-threaded
Node.js runs your JavaScript on one thread with an event loop and non-blocking I/O. Same shape as FastAPI's model, same trap: CPU-bound work blocks everything. Node's answer is worker_threads for CPU work and clustering (or just multiple containers) to use multiple cores.
The upside is that the whole npm ecosystem is already async by convention, so you fall into the pit of success more often than in Python, where sync and async libraries coexist and look identical at the call site.
Go — goroutines and real parallelism
Go is the structural outlier. Goroutines are cheap green threads multiplexed by the runtime onto OS threads, so you write straight-line blocking code and the runtime handles concurrency for you:
func getUser(w http.ResponseWriter, r *http.Request) {
// Looks blocking. The runtime parks this goroutine and
// runs others on the same OS thread while it waits.
user, err := db.FindUser(r.Context(), chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
No async/await colouring, no "is this library blocking?" anxiety, and — unlike Python and Node — genuine multi-core parallelism in one process. CPU-bound work is a normal thing to do in a Go handler. That's a real category difference, not a percentage one.
The GIL, honestly
Python's Global Interpreter Lock means one thread executes Python bytecode at a time in a process, which is why Python deployments scale with processes rather than threads. Python 3.13 introduced an experimental free-threaded build (PEP 703) that removes the GIL, and work continues across subsequent releases. It's a genuinely important development — but "experimental" means what it says, and ecosystem support for C extensions lags. Plan today's architecture around the GIL existing.
Batteries included vs bring your own
This is the second big axis, and it's really a question about who makes your decisions.
| | Django | FastAPI | Express | Go (net/http + Chi) | |---|---|---|---|---| | ORM | Built in | Choose (SQLAlchemy, SQLModel) | Choose (Prisma, Drizzle, Knex) | Choose (sqlc, GORM, pgx) | | Admin UI | Built in | None | None | None | | Auth | Built in | Choose / build | Choose (Passport, Lucia) | Build | | Migrations | Built in | Alembic | Per-ORM | golang-migrate, Atlas | | Validation | Forms / DRF serializers | Pydantic, built in | Choose (Zod, Valibot) | Choose, or hand-rolled | | API docs | Via DRF | Automatic OpenAPI | Choose | Choose |
Django's admin is the most underrated feature in this entire comparison. Define your models and you get a working, permissioned CRUD interface over your data — for free. Ops staff use it, support uses it, you use it at 2am. Teams routinely spend weeks rebuilding a worse version of it in React. If your project has any internal-tooling dimension, weigh this heavily.
Django's security defaults deserve the same credit: ORM parameterisation against SQL injection, template auto-escaping against XSS, CSRF middleware, clickjacking protection — on by default, maintained by a security team with a disclosure process. With Express you assemble equivalent protections yourself, and what you forget is your vulnerability.
The flip side: Django has opinions, and fighting them is miserable. If your data model doesn't suit its ORM, you'll feel it every day.
Types, validation, and the docs that write themselves
FastAPI's central trick is that one type declaration does four jobs: request parsing, validation, serialisation, and OpenAPI schema generation.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field
app = FastAPI()
class UserCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
age: int | None = Field(default=None, ge=0, le=150)
class UserOut(BaseModel):
id: int
name: str
email: EmailStr
@app.post("/users", response_model=UserOut, status_code=201)
async def create_user(payload: UserCreate) -> UserOut:
if await users.email_exists(payload.email):
raise HTTPException(status_code=409, detail="Email already registered")
return await users.create(payload)
That's the whole endpoint. Invalid input returns a structured 422 without a line of validation code, response_model guarantees you don't leak a password hash, and interactive Swagger UI and ReDoc pages are generated and served automatically — always in sync, because they're derived from the code rather than maintained alongside it. Anyone who has watched a hand-written API doc rot understands the value. It also means an OpenAPI spec you can import straight into Postman or generate clients from.
Express gives you none of this by default. req.body is any, and TypeScript can't help you — the types you write are compile-time claims about runtime data you haven't checked. The mature pattern is a runtime validator, usually Zod:
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
const UserCreate = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
});
app.post("/users", async (req, res, next) => {
const parsed = UserCreate.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ errors: parsed.error.issues });
}
try {
const user = await users.create(parsed.data); // now correctly typed
res.status(201).json(user);
} catch (err) {
next(err);
}
});
In Express 4, a rejected promise in an async handler is not caught by your error middleware — it becomes an unhandled rejection unless you wrap every handler or add a library. Express 5 addresses this by forwarding rejections from async handlers to the error handler. It's a small change with an outsized effect on real codebases; check the official migration guide for the version you're on rather than assuming.
Go's approach is explicit and verbose: unmarshal into a struct, validate by hand or with a validator library. More typing, fewer surprises. Tools like sqlc — which generates type-safe Go from your actual SQL — are the idiomatic answer to "I want an ORM," and they suit Go's preference for explicit over magic.
Ecosystem and hiring
- Node/Express has the largest package ecosystem by raw count, and the largest hiring pool. That count includes a long tail of unmaintained packages; supply-chain diligence is a real cost.
- Django has the deepest coherent ecosystem — packages that assume Django and fit together. Its documentation is a genuine benchmark for the industry, and the LTS release cadence is a gift to teams that don't want to upgrade constantly.
- FastAPI rides Python's whole ecosystem, which matters enormously if you touch data science or ML. If your model code is Python, an API in the same language removes an entire serialisation boundary and an entire team handoff. This is the strongest structural reason to pick FastAPI.
- Go has a smaller ecosystem but an unusually strong standard library —
net/httpalone is a production-grade server, and frameworks like Chi, Echo, and Gin are thin layers over it, not replacements. Fewer choices, less churn.
The deployment story
Underrated, and it will shape your weeks.
Go compiles to a single static binary with no runtime. Your Docker image can be a scratch or distroless base plus one file — tiny images, fast pulls, minimal CVE surface, trivial rollbacks:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]
On Kubernetes, that difference compounds across every pull, every scale-up, and every image scan.
Python ships a runtime plus dependencies, and you run it behind an ASGI server (Uvicorn) or WSGI server (Gunicorn), usually both — Gunicorn managing Uvicorn workers is the common FastAPI production pattern. Native dependencies make images larger and builds slower.
Node sits in between: runtime plus node_modules, which can get large but is straightforward.
All four want the same supporting cast in production — a connection pool sized to your database, Redis for caching and rate limiting, health endpoints, structured logs. None of that is a framework decision, and all of it will matter more to your latency than the framework will.
So which one?
For an ordinary CRUD API backed by a database, all four are fast enough, and the framework will not be your bottleneck. Choose for what your team already knows, what comes in the box, and what you'll have to operate at 3am — in that order.
Choose Django when you're building a content- or workflow-heavy application, you want auth/admin/migrations/security to be solved on day one, and your team values stability over novelty. The admin alone justifies it for internal-facing products.
Choose FastAPI when you're building APIs in Python — especially near ML or data work — and you want typed, self-documenting endpoints. It's the strongest default for a new Python API today. Just respect the event loop.
Choose Express when your team is JavaScript/TypeScript end to end and you want sharing types between client and server. Its unopinionatedness is genuinely double-edged: total freedom, and every architectural decision is yours. Budget for the structure you'll have to invent.
Choose Go when you're building infrastructure, high-concurrency services, or anything CPU-bound; when you want one binary and boring deployments; or when you're tired of dependency churn. Accept the verbosity — if err != nil is the price of the explicitness that makes Go services easy to reason about years later.
When not to switch
Don't rewrite a working Django app in FastAPI for performance. The rewrite will cost quarters, reintroduce bugs you fixed years ago, and your bottleneck was probably an N+1 query. Profile first, fix the query, and spend those quarters on something users notice.
The honest conclusion is unglamorous: the framework matters far less than your database schema, your caching strategy, and your team's familiarity. Every one of these four runs serious production systems today. Pick the one your team can operate confidently and go build the product.