FastAPI — Types Are the Contract
The Four Things One Type Hint Creates
Summary
In FastAPI, a type hint is not a comment; it is a contract that runs. Write one, and you get validation, serialization, documentation, and editor autocompletion all at once.
Why write the contract in code
An API spec that exists only as a document will inevitably drift from the code. No one updates the document at the same time as they make a field optional.
So the frontend ends up asking in chat, "can this field come back as null?", and the server receives an unexpected body and dies with a 500. The log holds only one KeyError line, so you cannot even tell who sent what wrong. If you start writing validation by hand inside handlers, that code ends up slightly different in every endpoint.
If you write the contract in the code with type hints, the documentation, validation, error responses, and client types all come from one place. There is no room left for them to drift apart.
So what is different
@app.post("/items")
def create(item: Item) -> ItemOut: ...
This one line does the following.
- Request validation: if the body is not shaped like
Item, it is rejected with 422 before it reaches the handler - Serialization: the return value is filtered through
ItemOutand turned into JSON - Documentation:
/openapi.jsonand/docsappear on their own - Type checking: mypy and your editor really check it
With Flask, you would pull out request.json, write if "name" not in body: by hand, write the same thing in the docs separately, and the two would start to drift.
422 is not 400
- 400 Bad Request: a rule that I defined was broken (insufficient balance, duplicate email)
- 422 Unprocessable Entity: the shape is wrong (a number arrived where a string should be)
FastAPI automatically returns 422 for a schema violation. The body says which field is wrong and why.
{"detail":[{"type":"int_parsing","loc":["body","qty"],
"msg":"Input should be a valid integer","input":"many"}]}
If you pass loc through to the client as it is, per-field error display on forms comes for free.
response_model is a device for "removing"
It is the most practical feature and the one people miss most often.
class User(BaseModel):
email: str
hashed_password: str # DB 모델에는 있다
class UserOut(BaseModel):
email: str # 나가는 쪽에는 없다
@app.get("/me", response_model=UserOut)
def me() -> User: ... # User 를 돌려줘도 UserOut 으로 걸러진다
Even if the handler mistakenly returns the whole object, the response does not contain hashed_password. Without this, someday someone will write return user and the hash will go out through the API. This is an accident that really happens often.
Following just one rule prevents half of these: never use the same class for the input model and the output model.
async def and def: this is where the server stops
FastAPI accepts both. But they behave completely differently.
| Declaration | Where it runs | If you block inside |
|---|---|---|
async def |
Directly on the event loop | The whole server stops |
def |
Sent to the thread pool | Only that thread stops |
@app.get("/slow")
async def slow():
time.sleep(1) # ❌ 이 1초 동안 모든 요청이 대기한다
Inside async def, you must not make a blocking call that you do not await. time.sleep, requests.get, a synchronous DB driver, and heavy CPU work all count.
There are two ways to fix it.
- Just declare it with
def→ FastAPI sends it to the thread pool for you - Use an asynchronous library →
httpx.AsyncClient,asyncpg
The number one trap of this framework is a beginner adding async "to make it fast" and instead serializing the server. When you are not sure, it is safer to write def.
Dependency injection
Depends is a device for declaring "this handler needs this".
def get_db():
con = connect()
try:
yield con # 핸들러가 쓰는 동안
finally:
con.close() # 응답을 보낸 뒤 정리된다
@app.get("/items")
def items(db = Depends(get_db)): ...
If you use yield, the cleanup code is guaranteed to run after the response. And the real value shows up in tests.
app.dependency_overrides[get_db] = lambda: FakeDB()
You swap it in without changing a single line of production code. No monkey patching is needed.
lifespan: on_event is a thing of the past
@asynccontextmanager
async def lifespan(app):
app.state.pool = await make_pool() # 시작할 때
yield
await app.state.pool.close() # 끝날 때
app = FastAPI(lifespan=lifespan)
@app.on_event("startup") is deprecated. Use lifespan in new code. It is the place to open connection pools and cache clients.
What bites you in practice
BackgroundTasks is not a queue. It runs in the same process after the response is sent. If the worker restarts, that job disappears. Use it only for things you can afford to lose, such as sending email, and send anything you cannot afford to lose to Redis or Kafka.
Number of workers. uvicorn --workers N starts N processes. Each uses its own memory and does not share global variables. If you keep an in-memory cache in a global, every worker holds a different value.
The size of the thread pool for synchronous endpoints is finite (40 by default). If all of the threads are blocked, the next request waits in the queue. def is not a cure-all; it is only a buffer.