Your First Endpoint
The fastest way to understand FastAPI is to build something and poke it. This lesson walks through creating a complete, tested endpoint from scratch — not a toy "hello world", but a realistic pattern you will repeat hundreds of times.
Learning Focus
By the end of this lesson you can: define GET and POST routes, use path and query parameters, return typed responses, and read the auto-generated OpenAPI documentation.
The Anatomy of a Route
Every FastAPI route has four parts:
app/main.py
from fastapi import FastAPI
app = FastAPI()
# 1) HTTP Method decorator
# 2) Path string
# 3) Optional metadata (tags, summary, status_code)
# 4) Handler function with type hints
@app.get("/items/{item_id}", tags=["items"], summary="Get one item")
async def get_item(item_id: int) -> dict:
return {"id": item_id, "name": "Widget"}
@app.get— HTTP GET. Use@app.post,@app.put,@app.patch,@app.deletefor other methods."/items/{item_id}"— path with a path parameteritem_id.item_id: int— FastAPI reads the type hint and validates/coerces the path segment toint.-> dict— the return type hint drives the response schema in OpenAPI docs.
Your First GET Endpoint
app/main.py
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Annotated
app = FastAPI(title="Items API", version="1.0.0")
# In-memory store for this example
items_db: dict[int, dict] = {
1: {"id": 1, "name": "Keyboard", "price": 99.99},
2: {"id": 2, "name": "Monitor", "price": 349.00},
}
@app.get("/items/", tags=["items"])
async def list_items(skip: int = 0, limit: int = 10) -> list[dict]:
"""Return a paginated list of items."""
values = list(items_db.values())
return values[skip : skip + limit]
@app.get("/items/{item_id}", tags=["items"])
async def get_item(item_id: int) -> dict:
"""Return a single item by ID."""
if item_id not in items_db:
return {"error": "not found"}
return items_db[item_id]
test-get.sh
uvicorn app.main:app --reload
# List all items
curl http://localhost:8000/items/
# → [{"id": 1, "name": "Keyboard", ...}, ...]
# Get item by ID (path param)
curl http://localhost:8000/items/1
# → {"id": 1, "name": "Keyboard", "price": 99.99}
# Pagination (query params)
curl "http://localhost:8000/items/?skip=1&limit=1"
# → [{"id": 2, "name": "Monitor", ...}]
Your First POST Endpoint
POST endpoints need a request body. Define a Pydantic model for it:
app/main.py
from pydantic import BaseModel, Field
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
price: float = Field(..., gt=0)
class ItemResponse(BaseModel):
id: int
name: str
price: float
@app.post("/items/", status_code=201, tags=["items"])
async def create_item(item: ItemCreate) -> ItemResponse:
"""Create a new item."""
new_id = max(items_db.keys(), default=0) + 1
new_item = {"id": new_id, **item.model_dump()}
items_db[new_id] = new_item
return ItemResponse(**new_item)
test-post.sh
# Create a valid item
curl -X POST http://localhost:8000/items/ \
-H "Content-Type: application/json" \
-d '{"name": "Mouse", "price": 49.99}'
# → {"id": 3, "name": "Mouse", "price": 49.99}
# Send invalid data — FastAPI rejects automatically
curl -X POST http://localhost:8000/items/ \
-H "Content-Type: application/json" \
-d '{"name": "", "price": -10}'
# → 422 Unprocessable Entity with field-level error details
Reading the Auto-Generated Docs
With your server running, open:
http://localhost:8000/docs— Swagger UI, interactive "Try it out" buttonhttp://localhost:8000/redoc— ReDoc, cleaner read-only layouthttp://localhost:8000/openapi.json— raw OpenAPI 3.1 spec
Everything you see — schemas, examples, descriptions — is generated directly from your Python type hints and docstrings. No extra maintenance.
Returning Proper HTTP Status Codes
Always use the correct status code:
| Operation | Status Code | FastAPI |
|---|---|---|
| Successful GET / list | 200 | Default |
| Successful resource creation | 201 | status_code=201 |
| No content (DELETE) | 204 | status_code=204 |
| Not found | 404 | Raise HTTPException |
| Validation error | 422 | Automatic |
app/main.py
from fastapi import HTTPException
@app.delete("/items/{item_id}", status_code=204, tags=["items"])
async def delete_item(item_id: int) -> None:
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
del items_db[item_id]
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| Query param treated as path param | Wrong annotation position | Path params go in the URL string {name}; query params are function args with defaults |
None returned instead of 404 | Forgot to raise HTTPException | Always raise, never return None for missing resources |
status_code=201 but DELETE 204 returning body | Body returned with 204 | Return None (no body) for 204 responses |
Both GET /items/ and GET /items/{id} conflict | Route order matters | FastAPI matches in declaration order; place /items/me before /items/{id} |
pydantic.ValidationError in console, 500 returned | Model field constraints violated server-side | Validate on input, not on output |
Hands-On Practice
app/practice.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Practice API")
products: dict[int, dict] = {}
_counter = 0
class ProductIn(BaseModel):
name: str = Field(..., min_length=2)
price: float = Field(..., gt=0)
in_stock: bool = True
class ProductOut(BaseModel):
id: int
name: str
price: float
in_stock: bool
@app.get("/products/", response_model=list[ProductOut])
async def list_products():
return list(products.values())
@app.get("/products/{pid}", response_model=ProductOut)
async def get_product(pid: int):
if pid not in products:
raise HTTPException(404, detail=f"Product {pid} not found")
return products[pid]
@app.post("/products/", response_model=ProductOut, status_code=201)
async def create_product(body: ProductIn):
global _counter
_counter += 1
record = {"id": _counter, **body.model_dump()}
products[_counter] = record
return record
@app.delete("/products/{pid}", status_code=204)
async def delete_product(pid: int) -> None:
if pid not in products:
raise HTTPException(404, detail=f"Product {pid} not found")
del products[pid]
run-practice.sh
uvicorn app.practice:app --reload --port 8001
# Create two products
curl -s -X POST http://localhost:8001/products/ \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "price": 19.99}'
curl -s -X POST http://localhost:8001/products/ \
-H "Content-Type: application/json" \
-d '{"name": "Gadget", "price": 49.99, "in_stock": false}'
# List them
curl -s http://localhost:8001/products/
# Delete the first
curl -s -X DELETE http://localhost:8001/products/1 -w "\nStatus: %{http_code}\n"
# → Status: 204
# Verify it's gone
curl -s http://localhost:8001/products/1 -w "\nStatus: %{http_code}\n"
# → Status: 404