REST Resources and Versioning
REST design in FastAPI is the discipline of making your URLs represent durable business objects, using HTTP methods to express what the client wants to do to those objects, and changing the public contract without surprising existing clients. The outcome of this lesson is a small but realistic pattern for resources, nested resources, representation schemas, and URL-based versions that you can apply before an API grows into many unrelated endpoints.
In this course’s API Design section, this topic sits after the mechanics of routing and validation. FastAPI makes it easy to create a path operation, but REST resource design decides whether the path will still make sense when mobile apps, scheduled jobs, partner integrations, and generated OpenAPI clients all depend on it.
Resources, Representations, and Versions
A REST resource is a conceptual thing with identity: an order, invoice, user, repository, or course lesson. The HTTP response is not the resource itself; it is a representation of the resource at a moment in time. That distinction matters because the same resource may have a compact list representation, a detailed read representation, and a creation request representation. In FastAPI those representations usually become separate Pydantic models even when they share several fields.
The usual anatomy is predictable. A collection URL such as /v1/courses represents a set of courses. A member URL such as /v1/courses/python-basics represents one course. GET reads, POST creates under a collection, PUT replaces a complete member, PATCH applies a partial change, and DELETE removes or deactivates. Actions that are not simple CRUD should still be modeled carefully. If the action creates a new domain object, promote that object to a resource; for example, POST /v1/orders/{order_id}/cancellations is often clearer than POST /v1/orders/{order_id}/cancel because the cancellation has time, reason, actor, and status.
Versioning is the compatibility policy for this contract. A version should not be a release number for every deploy. It is a promise about breaking changes. Adding an optional response field is usually compatible. Removing a field, changing its meaning, renaming a path parameter, changing an error shape, or making a formerly optional request field required can break clients and should move behind a new public version. FastAPI commonly implements this with routers: one APIRouter per resource area and a version prefix such as /v1 or /v2.
How FastAPI Resolves the Contract
At startup, FastAPI records each path operation with its HTTP method, path template, dependencies, request model, response model, status code, and OpenAPI metadata. At request time, Starlette matches the incoming method and path to a route. FastAPI then parses path parameters, query parameters, headers, cookies, and the body according to type annotations. Pydantic validates and converts the data before your endpoint function receives it. After your function returns, FastAPI serializes the result through the declared response_model, filtering fields that are not in the public representation.
This pipeline is why resource design and versioning belong in code structure, not just documentation. If CourseCreate, CourseReadV1, and CourseReadV2 are distinct models, the OpenAPI document and runtime serialization both enforce the difference. If you return raw dictionaries from many endpoints, version changes become informal and easier to break.
API Anatomy in Code
The first example creates a clean v1 course resource. Notice the plural collection name, stable slug identity, separate request and response models, and explicit 201 status for creation. The in-memory store keeps the example small; a real app would put the same boundary around a database repository.
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="Course API")
class CourseCreate(BaseModel):
title: str = Field(min_length=3, max_length=120)
level: str = Field(pattern="^(beginner|intermediate|advanced)$")
class CourseReadV1(BaseModel):
slug: str
title: str
level: str
courses: dict[str, CourseReadV1] = {}
@app.post("/v1/courses", response_model=CourseReadV1, status_code=status.HTTP_201_CREATED)
def create_course(payload: CourseCreate) -> CourseReadV1:
slug = payload.title.lower().replace(" ", "-")
if slug in courses:
raise HTTPException(status_code=409, detail="course already exists")
course = CourseReadV1(slug=slug, title=payload.title, level=payload.level)
courses[slug] = course
return course
@app.get("/v1/courses/{slug}", response_model=CourseReadV1)
def read_course(slug: str) -> CourseReadV1:
try:
return courses[slug]
except KeyError:
raise HTTPException(status_code=404, detail="course not found")
If a client posts {"title":"FastAPI Basics","level":"beginner"} to /v1/courses, the deterministic response status is 201 and the body is {"slug":"fastapi-basics","title":"FastAPI Basics","level":"beginner"}. A second identical request returns 409, which tells the client that the representation was valid but conflicts with existing state.
Progressive Example: Nested Resources
Nested resources are useful when the child cannot be identified without its parent or when the parent-child relationship is central to the operation. Lessons in a course fit that shape. The path /v1/courses/{course_slug}/lessons/{lesson_slug} says that the lesson slug is interpreted inside one course, not across the whole system.
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="Nested Course API")
class LessonCreate(BaseModel):
title: str = Field(min_length=3, max_length=120)
minutes: int = Field(ge=1, le=240)
class LessonRead(BaseModel):
course_slug: str
slug: str
title: str
minutes: int
course_slugs = {"fastapi-basics"}
lessons: dict[tuple[str, str], LessonRead] = {}
@app.post("/v1/courses/{course_slug}/lessons", response_model=LessonRead, status_code=status.HTTP_201_CREATED)
def create_lesson(course_slug: str, payload: LessonCreate) -> LessonRead:
if course_slug not in course_slugs:
raise HTTPException(status_code=404, detail="course not found")
lesson_slug = payload.title.lower().replace(" ", "-")
key = (course_slug, lesson_slug)
if key in lessons:
raise HTTPException(status_code=409, detail="lesson already exists")
lesson = LessonRead(course_slug=course_slug, slug=lesson_slug, title=payload.title, minutes=payload.minutes)
lessons[key] = lesson
return lesson
Posting {"title":"Path Operations","minutes":18} to /v1/courses/fastapi-basics/lessons returns 201 with course_slug included in the representation. Posting to /v1/courses/missing/lessons returns 404 because the parent resource does not exist. That is different from 422, which FastAPI uses when the request shape or field values fail validation before domain logic runs.
Progressive Example: Partial Updates
PATCH should mean partial change, not silent replacement. In FastAPI the usual pattern is an update model where every patchable field is optional, then model_dump(exclude_unset=True) to distinguish omitted fields from supplied values. That distinction is important because an omitted field means keep the current value, while a supplied null may mean clear the value if your schema allows it.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Patch Example")
class CourseRead(BaseModel):
slug: str
title: str
level: str
archived: bool = False
class CoursePatch(BaseModel):
title: str | None = Field(default=None, min_length=3, max_length=120)
level: str | None = Field(default=None, pattern="^(beginner|intermediate|advanced)$")
archived: bool | None = None
courses = {"fastapi-basics": CourseRead(slug="fastapi-basics", title="FastAPI Basics", level="beginner")}
@app.patch("/v1/courses/{slug}", response_model=CourseRead)
def patch_course(slug: str, patch: CoursePatch) -> CourseRead:
current = courses.get(slug)
if current is None:
raise HTTPException(status_code=404, detail="course not found")
changes = patch.model_dump(exclude_unset=True)
updated = current.model_copy(update=changes)
courses[slug] = updated
return updated
A request body of {"archived":true} changes only that field and returns {"slug":"fastapi-basics","title":"FastAPI Basics","level":"beginner","archived":true}. A body of {"level":"expert"} never reaches the function; Pydantic rejects it and FastAPI returns 422 with validation details.
Progressive Example: A Breaking v2 Representation
Now suppose clients need a richer course representation with a nested difficulty object instead of the old level string. Changing /v1/courses/{slug} in place would break any client that reads level. The safer design is to keep v1 stable and add v2 alongside it. Internally both versions can read the same domain record while presenting different public shapes.
from fastapi import APIRouter, FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Versioned Course API")
v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")
records = {"fastapi-basics": {"slug": "fastapi-basics", "title": "FastAPI Basics", "level": "beginner"}}
class CourseReadV1(BaseModel):
slug: str
title: str
level: str
class Difficulty(BaseModel):
code: str
label: str
class CourseReadV2(BaseModel):
slug: str
title: str
difficulty: Difficulty
def get_record(slug: str) -> dict[str, str]:
try:
return records[slug]
except KeyError:
raise HTTPException(status_code=404, detail="course not found")
@v1.get("/courses/{slug}", response_model=CourseReadV1)
def read_course_v1(slug: str) -> dict[str, str]:
return get_record(slug)
@v2.get("/courses/{slug}", response_model=CourseReadV2)
def read_course_v2(slug: str) -> CourseReadV2:
record = get_record(slug)
return CourseReadV2(
slug=record["slug"],
title=record["title"],
difficulty=Difficulty(code=record["level"], label=record["level"].title()),
)
app.include_router(v1)
app.include_router(v2)
GET /v1/courses/fastapi-basics returns a flat level. GET /v2/courses/fastapi-basics returns {"slug":"fastapi-basics","title":"FastAPI Basics","difficulty":{"code":"beginner","label":"Beginner"}}. The domain record did not need to fork; only the representation changed.
Design Choices and Trade-offs
URL versioning is easy to see in logs, caches, documentation, client code, and browser testing. Header-based versioning keeps URLs cleaner but makes manual debugging and shared links less obvious. Query-parameter versioning is simple but can be confused with filtering rather than contract selection. For most FastAPI services with generated OpenAPI clients, a path prefix is the most straightforward starting point.
Nested paths improve readability, but deep nesting becomes brittle. Two levels is usually enough: /courses/{course_slug}/lessons is clear, while /organizations/{org}/departments/{dept}/courses/{course}/lessons/{lesson}/comments/{comment} often exposes too much internal hierarchy. Prefer direct member URLs when the child has global identity.
PUT and PATCH also carry trade-offs. PUT is attractive when clients can send a complete representation and want idempotent replacement. PATCH is better for sparse edits from forms or automation. The risk with PATCH is ambiguous null and omitted fields, so the schema and tests must make that behavior explicit.
Failure Modes and Troubleshooting
A common symptom is a client receiving 404 for a URL that appears to exist. Check the method and route order first: GET /v1/courses and POST /v1/courses are different operations, and /v1/courses/{slug} will not match an extra trailing segment. Confirm the registered routes in /docs or /openapi.json, then correct the client URL or add the missing route.
Another symptom is unexpected 422 responses. The cause is usually validation before endpoint execution: wrong JSON shape, a path parameter type mismatch, a missing body, or a field that violates a Pydantic constraint. Reproduce the request with the smallest JSON body, inspect the loc and msg entries in the error response, then fix either the client payload or the declared model.
A more expensive failure is accidental breaking change. Symptoms include generated clients failing to deserialize responses or frontends showing blank fields after a deploy. Compare the previous and current OpenAPI documents, focusing on removed fields, renamed schemas, changed required arrays, and changed status codes. Restore compatibility in the existing version, then introduce the new shape under a new router prefix.
Duplicate creation bugs show up as two resources that should have had one identity. The cause is often slug generation outside a transaction or a missing uniqueness constraint. Add a datastore-level unique index, return 409 on conflict, and test repeated POST requests with the same natural key.
Security, Performance, and Reliability
Resource names can leak information if authorization is checked too late. A service may need to return the same 404 for a missing resource and a resource the caller cannot access. Version prefixes should not bypass authentication dependencies; include routers under shared dependencies or apply dependencies consistently per router.
Stable resource URLs improve cacheability for safe GET operations. Mutation endpoints should be idempotent where the business action allows it, especially when clients retry after network timeouts. For creation endpoints that cannot be naturally idempotent, consider an idempotency key so a retried POST does not create duplicates.
Hands-on Lab
Prerequisites: Python with FastAPI installed, plus pytest and FastAPI’s test client dependencies. Create a temporary file named app.py with the v2 router example above. Then add a test_app.py file that imports app, constructs TestClient(app), and checks three cases: v1 returns level, v2 returns difficulty.code, and a missing slug returns 404.
- Run
python -m pytestfrom the directory containing both files. - Open
/openapi.jsonthrough a running development server and verify that both/v1/courses/{slug}and/v2/courses/{slug}are present. - Change the v1 response model to remove
level, rerun the tests, and observe the v1 compatibility failure. - Rollback that change by restoring
leveltoCourseReadV1.
Verification is successful when the tests pass, v1 and v2 return different documented representations, and the rollback restores the original v1 behavior. Cleanup is simply deleting the temporary lab files if you do not want to keep them.
Assessment Exercises
- Design URLs and methods for publishing, archiving, and listing lessons in a course. Which operations are resources, and which are state changes on existing resources?
- A response field named
levelmust become a nested object. Explain whether this requires a new version and how you would keep the old clients working. - Write the expected status codes for creating a course successfully, creating the same course twice, sending an invalid level, and reading a missing course.
- Given a PATCH model with optional fields, explain the difference between an omitted field and a field explicitly sent as
null. - Compare URL, header, and query-parameter versioning for a team that relies on generated OpenAPI clients and production log analysis.
Summary
FastAPI REST design works best when resource identity, representation models, HTTP method semantics, and compatibility policy are explicit in the route structure. Use collections and members for durable domain objects, separate request and response schemas, reserve new versions for breaking contract changes, and keep older routers stable while new representations evolve beside them.
