Skip to main content

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.delete for other methods.
  • "/items/{item_id}" — path with a path parameter item_id.
  • item_id: int — FastAPI reads the type hint and validates/coerces the path segment to int.
  • -> 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" button
  • http://localhost:8000/redoc — ReDoc, cleaner read-only layout
  • http://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:

OperationStatus CodeFastAPI
Successful GET / list200Default
Successful resource creation201status_code=201
No content (DELETE)204status_code=204
Not found404Raise HTTPException
Validation error422Automatic
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

PitfallCause / SymptomFix
Query param treated as path paramWrong annotation positionPath params go in the URL string {name}; query params are function args with defaults
None returned instead of 404Forgot to raise HTTPExceptionAlways raise, never return None for missing resources
status_code=201 but DELETE 204 returning bodyBody returned with 204Return None (no body) for 204 responses
Both GET /items/ and GET /items/{id} conflictRoute order mattersFastAPI matches in declaration order; place /items/me before /items/{id}
pydantic.ValidationError in console, 500 returnedModel field constraints violated server-sideValidate 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

What's Next