← Back to the section

The URL is the first thing a user of your API sees. A well-designed URL is clear without documentation: GET /orders/{order_id}/items reads as "get the items of an order." A bad URL means questions from every new developer and mistakes during integration.

In this article we'll look at how to build URLs in FastAPI: which casing to choose, how to name resources, which HTTP method to use when, and how to avoid getting tangled up in nesting.

Path format: kebab-case and no trailing slashes

Imagine you're visiting a page in a browser: my-site.ru/my-page reads well, while my-site.ru/myPage or my-site.ru/my_page already raise questions. The same principles apply to REST API URLs.

Path format rules:

  • everything in lowercase;
  • words in a segment are separated by a hyphen: order-items, delivery-addresses;
  • no trailing slash;
  • no extensions like .json — the format is conveyed through the Accept header.
from fastapi import APIRouter

router = APIRouter(prefix="/api/v1", redirect_slashes=False)

@router.get("/order-items")
async def get_order_items(): ...

@router.get("/delivery-addresses")
async def get_delivery_addresses(): ...

Common mistakes:

/api/v1/OrderItems        # uppercase letters — no
/api/v1/order_items       # underscore — no
/api/v1/deliveryAddresses # camelCase — no
/api/v1/orders/           # trailing slash — no
/api/v1/orders.json       # extension in the path — no

Why redirect_slashes=False is mandatory

By default, FastAPI is configured so that a request to /orders/ is redirected to /orders with a 307 Temporary Redirect. This masks the mistake: the client thought the correct URL was the one with the slash and silently received a redirect. It's better to return an error right away so the developer fixes the URL.

router = APIRouter(prefix="/api/v1", redirect_slashes=False)

Utility endpoints outside the version

/health, /ready, /metrics are not part of the business API, so they live outside /api/v1:

from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
async def health(): return {"status": "ok"}

@app.get("/ready")
async def ready(): return {"status": "ok"}

They are not versioned and not protected by authentication — they exist for the infrastructure.

How to name resources

REST is built around resources — the entities the API works with. A resource is an order, a user, a product. The URL should name the resource, not the action performed on it.

Collections — in the plural:

@router.get("/orders")               # list of orders
@router.get("/orders/{order_id}")    # a single order
@router.get("/products")             # list of products

A singleton resource (always exactly one per context) — in the singular:

@router.get("/users/{user_id}/profile")   # a user's profile — there is only one

Names come from the domain: if the entity is called Order in the code and documentation, then it's /orders in the URL. Not /purchases, not /transactions — only the word used in the project.

Order    → /orders
Product  → /products
Customer → /customers

When names diverge across the code, documentation, and URLs, every developer on the team understands the API differently. Consistent terminology reduces the number of questions.

Which HTTP method to choose

In REST, the request method carries meaning: it says what you want to do with the resource. In FastAPI the method is set by a decorator.

@router.get("/orders", status_code=200)
async def get_orders(): ...                        # read a list

@router.post("/orders", status_code=201)
async def create_order(): ...                      # create an order

@router.put("/orders/{order_id}", status_code=200)
async def replace_order(order_id: str): ...        # full replacement

@router.patch("/orders/{order_id}", status_code=200)
async def update_order(order_id: str): ...         # partial update

@router.delete("/orders/{order_id}", status_code=204)
async def delete_order(order_id: str): ...         # delete
DecoratorWhat it doesSuccess code
@router.getreads a resource200
@router.postcreates a resource or runs a command201
@router.putfully replaces a resource200
@router.patchpartially updates a resource200
@router.deletedeletes a resource204

A common mistake: using GET for operations with a side effect. GET is for reading only — it must not change anything.

# Correct: a command with a side effect — POST
@router.post("/orders/{order_id}/cancel")
async def cancel_order(order_id: str): ...

# Incorrect: GET changes state
@router.get("/orders/{order_id}/cancel")
async def cancel_order(order_id: str): ...

Resource nesting

Sometimes one resource logically belongs to another: order items exist within the context of an order. In such cases nested URLs are used.

@router.get("/orders/{order_id}/items")
async def get_order_items(order_id: str): ...

@router.get("/orders/{order_id}/items/{item_id}")
async def get_order_item(order_id: str, item_id: str): ...

Two levels of nesting are fine. Three or more are already hard to read and maintain.

If you need to go beyond two levels, it's better to switch to a flat resource with a filter:

# Three levels — hard
# /users/{user_id}/orders/{order_id}/items/{item_id}

# Better: a flat resource with a parameter
@router.get("/items")
async def get_items(order_id: str = Query(alias="orderId")): ...

Path parameter names

With several nested resources, path parameters must have unique names, otherwise Swagger UI and Redoc won't be able to render the documentation correctly.

# Correct: unique names
@router.get("/orders/{order_id}/items/{item_id}")
async def get_order_item(order_id: str, item_id: str): ...

# Incorrect: identical names
@router.get("/orders/{id}/items/{id}")   # error in documentation tools

In short

  • The path is lowercase, words separated by a hyphen (order-items), with no trailing slash.
  • redirect_slashes=False is mandatory, otherwise FastAPI silently redirects erroneous URLs.
  • Collections — in the plural (/orders), singletons — in the singular (/profile).
  • Resource names come from the domain: one word — everywhere.
  • The method is set by a decorator: @router.get — read, @router.post — create or command.
  • GET must not change state — for commands with a side effect use POST.
  • Nesting — no deeper than two levels; deeper — a flat resource with a filter.
  • Path parameters across multiple levels must have unique names.
  • Versioning a REST API with FastAPI — how to use prefix="/api/v1" and move to v2.
  • Query parameters and pagination — filters, Query(alias=...), cursor pagination.
  • OpenAPI and antipatterns — operation_id, tags, common mistakes in the schema.