What you'll learn
Quick Answer
FastAPI is a modern Python framework for building web APIs. You describe each request and response with standard type hints andPydanticmodels, and FastAPI validates incoming data, serializes responses to JSON, and generates interactive documentation for free. It runs on an ASGI server such as Uvicorn and lets route handlers be eitherdeforasync def.
Your first endpoint
A FastAPI application is an instance of the FastAPI class with route handlers attached by decorator. Save this as main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"status": "ok"}
FastAPI does not bundle a web server, so install one and run it:
pip install "fastapi[standard]" uvicorn
uvicorn main:app --reload
Open http://127.0.0.1:8000 and you get {"status": "ok"}. The return value is a plain dict; FastAPI serializes it to JSON and sets the content type. Now open http://127.0.0.1:8000/docs. That interactive Swagger UI page is generated from your code - every route, parameter, and model you declare appears there automatically, and you can send test requests from the browser. A second format lives at /redoc. The --reload flag restarts the server whenever you save a file; leave it off in production.
Type hints do the validation
Annotate a path parameter and FastAPI parses and checks it before your function runs:
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
item_id is declared int, so /items/42 hands you the integer 42, not the string "42". Any function argument that is not part of the path becomes a query parameter: q is optional and defaults to None.
Send a bad value and you get a precise error. GET /items/abc returns HTTP 422 Unprocessable Entity with a body that names the problem:
{
"detail": [
{
"type": "int_parsing",
"loc": ["path", "item_id"],
"msg": "Input should be a valid integer, unable to parse string as an integer"
}
]
}
You wrote no validation code. The type hint is the specification, and FastAPI both enforces it and documents it in /docs.
Request bodies with Pydantic models
For anything more complex than a few scalars, declare a Pydantic model. A parameter typed as a model is read from the JSON request body:
from pydantic import BaseModel, Field
class Course(BaseModel):
title: str
price: int = Field(gt=0)
published: bool = True
@app.post("/courses", status_code=201)
def create_course(course: Course):
return {"id": 1, **course.model_dump()}
POST /courses with {"title": "FastAPI", "price": 499} returns 201 and {"id": 1, "title": "FastAPI", "price": 499, "published": true} - the default filled in.
Break a rule and you get 422 before the handler runs. A price of -5 returns "Input should be greater than 0". Omit title entirely and the error loc is ["body", "title"] with type "missing". The model also shows up in /docs as an example schema, so anyone calling your API can see the exact shape you expect.
The async gotcha that blocks your server
FastAPI lets a handler be def or async def, and the difference matters more than beginners expect. An async def handler runs on the event loop. If you call a blocking function inside it - time.sleep, requests.get, a synchronous database driver, heavy CPU work - nothing else on that worker can make progress until it returns.
import time
@app.get("/blocking-async")
async def blocking_async():
time.sleep(1) # freezes the whole event loop
return {"done": True}
@app.get("/blocking-sync")
def blocking_sync():
time.sleep(1) # FastAPI runs this in a threadpool
return {"done": True}
Fire five requests at each route at once. The async def version handles them one after another and takes about five seconds in total. The plain def version finishes in about one second, because FastAPI runs synchronous handlers in a worker threadpool that never touches the loop.
The rule: use async def only when the body awaits async libraries the whole way down. If you are calling ordinary blocking code, a plain def handler is the safe - and often faster - choice.
Dependencies keep handlers small
The Depends mechanism lets a handler declare what it needs and have FastAPI supply it. A dependency is just a function:
from fastapi import Depends
def pagination(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit}
@app.get("/courses")
def list_courses(page: dict = Depends(pagination)):
return {"page": page, "total": 0}
GET /courses?limit=5 returns {"page": {"skip": 0, "limit": 5}, "total": 0}. The dependency's own parameters are merged into the route's query string and appear in the docs.
Dependencies compose and are cached within a single request, so a database session, the current authenticated user, or a settings object can each be a dependency that many routes share. Because a dependency is an ordinary function, a test can replace it through app.dependency_overrides and never touch a real database - override get_db with a function returning a fake and every route that depends on it uses the fake. This is how larger FastAPI codebases stay readable: handlers state intent, dependencies do the wiring.
Response models control what goes out
By default a handler's return value is serialized as-is. Set response_model and FastAPI filters the response through that model, dropping anything the model does not declare:
class UserIn(BaseModel):
username: str
password: str
class UserOut(BaseModel):
username: str
@app.post("/register", response_model=UserOut)
def register(user: UserIn):
return user # full object in, UserOut shape out
Register with a username and password and the response is {"username": "asha"}. The password field never leaves the server even though the handler returned the whole object. This is the safe default for any model backed by a database row, where columns like password hashes, internal flags, or soft-delete timestamps should stay hidden.
One note if you are following older tutorials: this is Pydantic v2. Methods were renamed - .dict() is now .model_dump() and .json() is .model_dump_json(). The old names still run but emit a deprecation warning.
