I'd shipped several production APIs with Flask over the years and genuinely liked it, but a new project's requirement for real request validation and auto-generated documentation finally pushed me to actually try FastAPI instead of bolting extra libraries onto Flask the way I usually did. This is what actually happened building a real endpoint with it for the first time, including the parts that didn't match what the tutorials made it look like.
Getting a working server up took genuinely fewer lines than I expected coming from Flask.
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health_check():
return {"status": "ok"}
Running it with uvicorn main:app --reload gave me a live server with automatic reload, comparable to Flask's debug mode but noticeably faster to restart on file changes during my own testing.
Defining a request body as a Pydantic model meant invalid data got rejected automatically with a clear error response, before my own handler code ever ran, replacing a genuinely large amount of manual validation code I'd written by hand in every Flask project I'd built previously.
from pydantic import BaseModel
class CreateUser(BaseModel):
email: str
age: int
@app.post("/users")
def create_user(user: CreateUser):
return {"email": user.email, "age": user.age}
Sending a request with a string instead of an integer for age returned a genuinely useful, automatically generated error message pointing at the exact field, something I used to build manually with a validation library layered on top of Flask.
FastAPI generates interactive documentation automatically at a built-in route, and pulling it up for the first time to test my own endpoint, rather than reaching for a separate tool, was a genuine workflow change, I found myself testing new endpoints directly through that generated interface instead of writing a manual curl command every time.
I mixed a blocking database call inside an async def route handler, assuming the async keyword alone would make it non-blocking, and under concurrent load the whole server slowed down far more than expected since that blocking call was actually stalling the entire event loop rather than running in the background the way I'd assumed.
# this blocks the whole event loop despite being async
@app.get("/slow")
async def slow_route():
result = blocking_db_call() # not actually async
return result
Switching that specific route to a plain def instead of async def let FastAPI run it in a separate thread pool automatically, which fixed the concurrency problem immediately once I understood that async def only helps when everything inside it is genuinely non-blocking.
FastAPI's dependency injection system let me define a reusable authentication check once and attach it to any route that needed it, replacing a decorator pattern I'd built by hand in Flask that always felt slightly fragile.
from fastapi import Depends, HTTPException, Header
def verify_token(authorization: str = Header(...)):
if authorization != "Bearer secret-token":
raise HTTPException(status_code=401)
@app.get("/protected")
def protected_route(_: None = Depends(verify_token)):
return {"message": "authorized"}
FastAPI's built-in test client let me write tests that call routes directly in-process, without spinning up a real server, and the syntax felt close enough to the requests library I already knew that writing my first real test suite for this API took a genuinely short afternoon rather than the longer ramp-up I expected.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
A route that sent a confirmation email after creating a user didn't need to make the client wait for that email to actually send, and FastAPI's built-in background task support let me schedule that email dispatch to run after the response had already gone out, without setting up a full separate task queue for a job this small.
from fastapi import BackgroundTasks
def send_confirmation(email: str):
...
@app.post("/users")
def create_user(user: CreateUser, background_tasks: BackgroundTasks):
background_tasks.add_task(send_confirmation, user.email)
return {"email": user.email}
Defining a separate Pydantic response model, distinct from the request model, forced me to explicitly list which fields actually get returned to a client, and building this out for a user endpoint immediately flagged that my handler was about to return a hashed password field alongside the rest of the user object, purely because I'd been returning the raw database row without thinking about it.
class UserOut(BaseModel):
id: int
email: str
# password intentionally omitted
@app.get("/users/{user_id}", response_model=UserOut)
def get_user(user_id: int):
return db.get_user(user_id)
FastAPI filters the actual response down to exactly the fields declared on the response model, meaning even though the underlying object still technically has the password field, the client never sees it, a genuinely useful safety net I hadn't fully appreciated until it caught a real mistake of mine before it reached a client.
I'm not abandoning Flask for every project, its simplicity still wins for genuinely small scripts and internal tools, but for anything with real request validation needs or a team that benefits from automatically generated documentation, FastAPI solved problems I used to solve manually with extra libraries bolted onto Flask, and it did it with less code than I expected walking in.