FastAPI vs Flask for API Development: The Decision That Sets Your Compute Bill

Flask is not slow and FastAPI is not magic. The real difference between them is what happens to a worker process while your handler waits on something else — a database, a payment provider, a model endpoint. That single behavioural gap decides how many instances you rent, how your p95 latency behaves when traffic doubles, and how much boilerplate stands between a JSON body and a validated Python object. Part of the Setting Up FastAPI guide, this page settles the comparison with the runnable code, the concurrency arithmetic, and the instance-count numbers you can put in a spreadsheet.

The short version: build a new commercial API on FastAPI. Keep Flask for internal tools, admin panels, and scripts that already work. The rest of this page shows the reasoning, including the two places where Flask still wins outright and the migration route that does not require a rewrite.

Execution model: threads that wait versus a loop that doesn't

Flask speaks WSGI, a synchronous protocol from 2003. A WSGI server hands one request to one worker thread, and that thread owns the request until the response is written. When your handler calls Stripe and waits 400 ms for a reply, the thread sits there consuming memory and a scheduler slot while doing exactly nothing. Concurrency is therefore capped at your thread count: eight threads means eight simultaneous in-flight requests, no matter how idle the CPU is.

FastAPI speaks ASGI. An async def handler that hits an await hands control back to the event loop, which immediately picks up another request that is ready to run. One worker process handles hundreds of overlapping requests on a single thread, because waiting costs nothing but a coroutine's worth of memory — a few kilobytes rather than a whole thread stack.

WSGI thread blocking compared with an ASGI event loop Under Flask each request occupies a thread for the whole upstream wait, so concurrency is capped by the thread pool. Under FastAPI one event loop interleaves many awaited requests inside a single worker. Flask / WSGI (sync) FastAPI / ASGI (async) Thread 1: blocked on I/O Thread 2: blocked on I/O Thread 9: queued, no slot Loop: await request A Same loop: await request B Loop absorbs hundreds more Concurrency = thread count Concurrency = memory budget

Here is the same upstream call in both frameworks. Note the FastAPI version keeps a single pooled client alive for the process lifetime instead of building a new connection pool per request — that detail matters more than the framework choice, and it is covered in depth in the httpx versus requests comparison.

Python
import os
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, HTTPException
from flask import Flask, jsonify

EXTERNAL_API_URL = os.getenv("EXTERNAL_API_URL", "http://localhost:8081/data")
TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "10"))
MAX_CONNECTIONS = int(os.getenv("UPSTREAM_MAX_CONNECTIONS", "100"))

# --- Flask: one thread is held for the whole upstream wait ---
flask_app = Flask(__name__)


@flask_app.get("/data")
def flask_fetch():
    try:
        response = httpx.get(EXTERNAL_API_URL, timeout=TIMEOUT)
        response.raise_for_status()
        return jsonify(response.json())
    except httpx.HTTPError as exc:
        return jsonify({"error": f"upstream failed: {exc}"}), 502


# --- FastAPI: the loop is free while the upstream call is in flight ---
@asynccontextmanager
async def lifespan(app: FastAPI):
    limits = httpx.Limits(max_connections=MAX_CONNECTIONS)
    async with httpx.AsyncClient(timeout=TIMEOUT, limits=limits) as client:
        app.state.http = client
        yield


fastapi_app = FastAPI(lifespan=lifespan)


@fastapi_app.get("/data")
async def fastapi_fetch():
    try:
        response = await fastapi_app.state.http.get(EXTERNAL_API_URL)
        response.raise_for_status()
        return response.json()
    except httpx.HTTPError as exc:
        raise HTTPException(status_code=502, detail=f"upstream failed: {exc}")

The gap is invisible in local testing with one browser tab. It appears the first time real customers hit you concurrently, and it grows non-linearly: past the thread limit, Flask requests queue, queue time adds to latency, clients time out and retry, and the retries make the queue longer. That collapse mode is the actual reason teams migrate.

Flask 3 has async views, and they do not buy you concurrency

This is the most expensive misconception in the comparison, so be precise about it. Install flask[async] and Flask accepts async def view functions. What it does with them is wrap each call in asgiref.sync.async_to_sync, which spins up a fresh event loop, runs your coroutine to completion inside it, and tears it down. The worker thread stays blocked the entire time. You gain the ability to write await inside a view; you gain nothing in throughput, and you pay a small per-request cost for building and destroying the loop.

Python
import asyncio
import os

import httpx
from flask import Flask, jsonify

app = Flask(__name__)
UPSTREAMS = os.getenv("FANOUT_URLS", "http://localhost:8081/a,http://localhost:8081/b")


@app.get("/fanout")
async def fanout():
    # Concurrency INSIDE one request works: three calls overlap.
    # Concurrency ACROSS requests does not: this thread is still blocked.
    urls = [u for u in UPSTREAMS.split(",") if u]
    async with httpx.AsyncClient(timeout=5.0) as client:
        results = await asyncio.gather(*(client.get(u) for u in urls))
    return jsonify([r.status_code for r in results])

That snippet is genuinely useful — fanning out to three vendors in parallel inside one request cuts your handler duration to the slowest call rather than the sum. Just do not mistake it for scaling. The honest way to get concurrency out of Flask is gevent or eventlet workers under Gunicorn, which monkeypatch the socket layer so greenlets yield on I/O. It works, and I have shipped it, but you inherit a category of bug that async native code does not have: any C extension that blocks below the socket layer stalls every greenlet in the process, psycopg2 needs psycogreen patching to cooperate, and stack traces stop meaning what you expect. You are emulating an event loop instead of using the one CPython ships.

Validation and documentation: one declaration or three

Flask hands you request.get_json() and a dictionary. Everything after that is yours: type coercion, required-field checks, length limits, error shaping, and a hand-maintained OpenAPI document that drifts from reality within two sprints. Marshmallow or flask-pydantic closes part of the gap, but validation still lives beside the route rather than in it, so nothing forces the schema and the handler to agree.

FastAPI collapses three artefacts into one. The type annotation on a parameter is simultaneously the parser, the validator, the error responder, and the OpenAPI schema entry.

Validation and documentation pipelines in Flask and FastAPI Flask parses JSON by hand, validates through a separate Marshmallow schema, and needs a hand-written specification file. FastAPI derives the validated object and the OpenAPI document from the same Pydantic annotation. Request body to typed object, and where the docs come from Flask + Marshmallow request.get_json() Marshmallow schema View function Spec file you maintain FastAPI + Pydantic Pydantic model param Validated typed object Async handler OpenAPI 3.1 generated Same annotation, no second source of truth

The commercial argument is not elegance, it is drift. A public API whose documented request shape no longer matches the deployed one generates support tickets, and support tickets on a two-person product are the most expensive thing you own. Because FastAPI derives the document from the code, the two cannot diverge — which is why the OpenAPI documentation workflow is nearly free here and a standing chore in Flask. You still choose how to present it, and the ReDoc versus Swagger UI comparison covers that decision.

Python
import os
from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, Field, StrictInt

app = FastAPI(title=os.getenv("SERVICE_NAME", "ingest"))
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "")


class DataPayload(BaseModel):
    # StrictInt refuses 7.0 and "7" — critical for IDs and money fields.
    user_id: StrictInt
    metadata: str | None = Field(default=None, max_length=255)

    model_config = {"extra": "forbid"}  # unknown keys are a 422, not a shrug


async def verify_api_key(
    x_api_key: Annotated[str, Header()],
) -> str:
    if not INTERNAL_API_KEY or x_api_key != INTERNAL_API_KEY:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
    return x_api_key


@app.post("/data", status_code=status.HTTP_201_CREATED)
async def process_data(
    payload: DataPayload,
    api_key: Annotated[str, Depends(verify_api_key)],
) -> dict[str, str | int]:
    return {"status": "processed", "id": payload.user_id}

Two settings there earn their keep on a paid API. extra="forbid" turns a customer's typo'd field name into a 422 with the offending key named, instead of a silently ignored value and a confused email three days later. StrictInt blocks the coercion class of bug that quietly converts "1e3" into an ID. Both patterns, plus custom error envelopes, are expanded in the guide to validating JSON with Pydantic v2.

What the difference actually costs per million requests

Framework benchmarks that hammer a handler returning {"hello": "world"} tell you nothing, because no commercial endpoint does that. Model your real handler instead: some CPU, then a wait. Take a typical CRUD endpoint that awaits a 40 ms upstream and burns about 4 ms of CPU. On a single vCPU, Flask under Gunicorn with eight threads tops out near 180 requests per second and its p95 climbs the moment concurrent clients exceed the thread count. FastAPI on the same box is limited by CPU, not by waiting, so it holds a flat p95 until the vCPU itself saturates.

p95 latency against concurrent clients for Flask and FastAPI With a 40 millisecond upstream call, Flask with eight threads rises from 45 to 620 milliseconds p95 between 10 and 200 concurrent clients, while FastAPI moves only from 44 to 88 milliseconds. p95 latency as concurrency rises, 40 ms upstream p95 ms 600 400 200 0 620 ms 88 ms 10 25 50 100 200 concurrent clients Flask + 8 threads FastAPI + async

Read the shape, not the absolute values — your hardware will move the numbers, but the divergence point is always your thread count. Now push the upstream wait up, which is exactly what happens the moment you proxy a language model. At a 2.5 second upstream, eight threads serve 3.2 requests per second per instance. One async worker holding 200 concurrent awaits serves roughly 80. To carry a 40 requests-per-second peak you rent thirteen Flask instances or one FastAPI instance. On a 1 vCPU / 2 GB plan at $25 a month, and 10 million requests over the month, that is $32.50 of compute per million requests versus $2.50 — a 13x difference in the only line item you control early on. Run your own version of that arithmetic with cost per API request, and if you are proxying models, pair it with streaming LLM responses through FastAPI, which Flask cannot do cleanly at all.

Be honest about the other direction. If your handler is 3 ms of CPU and a 1 ms cached read, the frameworks are within noise of each other and your instance count is identical. Async pays for waiting, and if you barely wait, it pays nothing. What it never does is cost you money, which is why it remains the correct default.

Failure modes that only appear after launch

  • One blocking call inside an async route stops the whole worker. A single time.sleep, requests.get, or synchronous ORM query freezes every concurrent request on that loop. Either make the call async or push it into a thread with run_in_threadpool, and move genuinely slow work to background jobs with Celery.
  • Async multiplies your database connections. Two hundred concurrent coroutines each wanting a session will drain a pool sized for eight threads within seconds. Size the pool deliberately using async SQLAlchemy access and keep connection pool exhaustion on your alert list.
  • Higher throughput means you hit vendor quotas sooner. Async does not raise anyone else's limits, it just reaches them faster. Add an outbound semaphore and read API rate limiting practices before you get throttled in production.
  • Flask extensions do not survive the move. Flask-Login, Flask-Admin, and anything touching the request-local context have no ASGI equivalent. Inventory them before promising a migration date.
  • Worker counts matter more than the framework. Running one Uvicorn worker on a 4 vCPU box wastes 75 percent of what you pay for; the arithmetic lives in Uvicorn versus Gunicorn worker configuration.

Migration: mount Flask, don't rewrite it

You do not need a big-bang rewrite, and you should not attempt one. Wrap the existing Flask app in a WSGI adapter, mount it under a path prefix inside a new FastAPI application, and ship new routes natively. One process, one deploy, and rollback is deleting a line.

Python
import os

from a2wsgi import WSGIMiddleware
from fastapi import FastAPI
from flask import Flask

legacy_app = Flask(__name__)


@legacy_app.get("/health")
def legacy_health():
    return {"status": "ok", "stack": "wsgi"}


app = FastAPI(title=os.getenv("SERVICE_NAME", "api"))
app.mount(os.getenv("LEGACY_MOUNT_PATH", "/legacy"), WSGIMiddleware(legacy_app))


@app.get("/v2/health")
async def new_health() -> dict[str, str]:
    return {"status": "ok", "stack": "asgi"}
Four-stage migration from Flask to FastAPI with traffic share Mount Flask under a prefix, port read endpoints, then writes and webhooks, then delete the adapter, moving traffic served by FastAPI from ten percent to one hundred percent. Incremental migration, one deploy at a time 1. Mount 2. Move reads 3. Move writes 4. Retire WSGI Flask under /legacy New routes on ASGI Read endpoints port Shared auth helper Writes and webhooks Diff both responses Delete the adapter One process, one loop Share of traffic served by FastAPI 10% 45% 85% 100%

Two rules keep this boring. Port read endpoints before write endpoints, because a wrong read is a bug and a wrong write is a refund. And diff the old and new implementations against the same recorded request bodies before you cut traffic over — the technique in testing async FastAPI endpoints with httpx makes that a fixture rather than a ritual. Cut over behind a zero-downtime deploy so no in-flight request is dropped.

SituationPickWhy
Paid API with external callsFastAPIWaiting is free on the loop
LLM or vendor proxyFastAPILong waits, streaming responses
Internal admin or CMS panelFlaskJinja plus mature extensions
Cron script with a health routeFlaskFewer moving parts
Legacy monolith, no async depsFlaskMount it, port it later

Builder verdict

Choose FastAPI for anything a customer pays for. The concurrency model, the generated documentation, and the validation-at-the-boundary contract each remove a recurring cost, and together they remove the specific failure that kills early APIs: latency collapsing under the first real traffic spike while you are asleep. Flask remains an excellent tool, and I still reach for it when I need server-rendered HTML with an admin, or a single-file utility behind a health check — but neither of those is a commercial API. If your shortlist is broader, compare the batteries-included option in FastAPI versus Django REST Framework and the performance-first challenger in FastAPI versus Litestar. Whichever you land on, pick once, then spend your remaining attention on the thing customers actually pay for.

FAQ

How much does the framework choice change my bill at 10 million requests a month? For a CPU-light handler that waits 40 ms on an upstream, roughly one extra instance — call it $25 a month, or noise. For a handler that waits 2.5 seconds on a model provider, thirteen Flask instances do the work of one async instance, which is $32.50 per million requests against $2.50. The longer you wait on someone else, the more the choice is worth.

Is migrating an earning Flask API worth the risk? Only if you can do it incrementally, which you can. Mount the Flask app under a prefix with a2wsgi, port read endpoints first, and keep the same process and deploy pipeline so rollback is one commit. A staged migration on a revenue-generating API is a series of small deploys, not a project — and if it turns into a project, stop and keep the WSGI mount indefinitely.

Does async make my API more expensive per request in any way? Slightly, in memory: an idle coroutine costs a few kilobytes, and you will hold more open database and HTTP connections at once. Budget for a connection pool sized to real concurrency rather than to thread count, and the extra RAM costs far less than the instances you stopped renting.

Can I keep Flask for the admin panel and run FastAPI for the paid endpoints? Yes, and this is often the right answer. Deploy them as two services behind the same domain, or mount Flask under /admin in the ASGI app. Keep authentication in one shared module so key rotation touches a single place — the pattern is in the guide to handling API authentication in Python.

Which framework is safer for a public API I plan to version and sell? FastAPI, because the OpenAPI document is generated from the handlers rather than maintained beside them. Customers integrate against a spec that cannot silently drift from the deployed behaviour, which makes deprecations announceable and breaking changes visible in a schema diff rather than in a support inbox.

Same track:

Other tracks: