In Django Ninja, why does /products/{product_id} answer 422 for a non-integer id while /products/{int:product_id} answers 404?
answer
- two layers inspect the segment
- the resolver matches first
- the default converter accepts anything
- validation reports, matching refuses
basics
~20 sNinja turns {product_id} into Django's default str converter, which matches 'abc', so Pydantic validation of product_id: int fails with 422. With {int:product_id}, Django's int converter does not match, so the resolver returns 404 and Ninja never runs.
solid answer
~40 sA Ninja path becomes a regular Django URL pattern: `{product_id}` becomes `<product_id>`, which uses Django's default `str` converter and matches any segment. The request reaches Ninja, which validates `product_id: int` with Pydantic and returns `422` with an error located at `path.product_id`. Writing `{int:product_id}` puts Django's `int` converter in the pattern, so `/products/abc` never matches, the resolver answers `404`, and the operation is never called. So the choice is a contract: 422 reports invalid input on an existing endpoint, 404 says no such path and lets other patterns match. Ninja also rewrites `{uuid:...}` to its own string converter so Pydantic, not Django, converts UUIDs.
code
python · 15 linesfrom ninja import Router
router = Router()
@router.get("/by-id/{product_id}")
def product_by_id(request, product_id: int):
# GET .../by-id/abc -> 422, detail located at ["path", "product_id"]
return {"id": product_id}
@router.get("/strict/{int:product_id}")
def product_strict(request, product_id: int):
# GET .../strict/abc -> 404 from Django's URL resolver; this function never runs
return {"id": product_id}go deeper
Remember that Ninja path placeholders become Django URL patterns, and that a converter like int changes which requests match.
Explain the two layers, Django's resolver then Ninja's Pydantic validation, and why each produces a different status for the same bad value.
Choose the contract deliberately per API, keep it consistent, and recognise converter mismatches when diagnosing unexpected 404s.
Set the API-wide convention for malformed identifiers, weighing structured 422 errors for clients against 404 semantics and route disambiguation.
## Two layers look at a path parameter A Django Ninja operation path such as `/products/{product_id}` is turned into an ordinary Django URL pattern: Ninja replaces `{` and `}` with `<` and `>`, so the pattern becomes `products/<product_id>`. That gives **two separate layers** a chance to reject a bad value: - **Django's URL resolver** matches the request path against the pattern. A bare `<product_id>` uses Django's default `str` converter, which matches any non-empty segment without a slash. - **Ninja's validation** then converts the captured string to the annotated type — `product_id: int` — with Pydantic, and reports failures. Which layer rejects `/api/products/abc` decides the status code the client sees. ## The two spellings compared | Operation path | Django pattern | Request `/api/products/abc` | Request `/api/products/42` | |---|---|---|---| | `/products/{product_id}` with `product_id: int` | `products/<product_id>` | Matches; Ninja validation fails: **422** with an error located at `["path", "product_id"]` | 200, `product_id == 42` | | `/products/{int:product_id}` with `product_id: int` | `products/<int:product_id>` | Django's `int` converter does not match: **404** from Django's resolver, Ninja never runs | 200, `product_id == 42` | Neither is wrong; they express different contracts: - **422** says "this endpoint exists, but your input is invalid", and the body tells the client which parameter failed. - **404** says "no such resource path", which suits values that are part of the resource's identity and lets another pattern — say `/products/{str:slug}` defined after it — match instead. ## Other converters and special cases - **`{slug:sku}`** uses Django's `slug` converter, which matches only ASCII letters, digits, hyphens and underscores — a cheap way to keep malformed SKUs from ever reaching the operation. - **`{path:value}`** uses Django's `path` converter, which also matches slashes — useful for file-like keys such as `/files/{path:key}`. - **`{uuid:item_id}`** is rewritten by Ninja to its own `uuidstr` converter. Django's built-in `uuid` converter would hand the view a `UUID` object; Ninja keeps the value a string so Pydantic performs the conversion and validation, consistent with every other parameter. - **Nested routers** can carry parameters in their prefix, as in `products_router.add_router("/{product_id}/images/", images_router)`; operations in `images_router` must declare `product_id: int = Path(...)`, because the name is not in their own path string. - **Parameter names must match** the placeholders. A placeholder with no matching function parameter produces a warning that the field is in the view path but not in the view signature. ## What the client actually receives The two paths do not only differ in status code; they differ in **who writes the response**: - **The 422 is Ninja's.** It is rendered by the API's renderer as JSON with a `detail` list, so a client can show which parameter failed, and the API's exception handlers can reshape it. - **The 404 is Django's.** The resolver found no pattern, so the project's `handler404` answers — by default Django's "Not Found" page (the technical 404 page when `DEBUG` is on), not JSON — and none of the API's exception handlers run. - **Monitoring sees different things.** A 422 comes from a matched URL pattern, so it can be attributed to that endpoint; a converter 404 matched no pattern and belongs to no endpoint. For a JSON API this is often the deciding argument: a converter-based 404 is the one response on the endpoint that is not in the API's own format. ## Choosing in practice 1. Use **plain `{name}`** when you want every bad value reported as a structured 422 — typical for public APIs whose clients parse error bodies. 2. Use a **converter** such as `{int:name}` when the value is part of the URL's identity and a non-matching value genuinely means "not found", or when two routes need to be told apart by shape. 3. Whatever you choose, apply it consistently across the API, because clients and monitoring treat 404 and 422 very differently: one is a missing resource, the other a client bug. ## A related trap A 404 for a malformed id is easy to misread in logs as a missing database row. When an inventory endpoint suddenly "loses" products, check whether the path pattern even matched before looking at the query — a converter mismatch never reaches the operation, so no application log line is written for it.
- Why does Ninja rewrite {uuid:item_id} instead of using Django's uuid converter?Django's `uuid` converter returns a `UUID` object to the view. Ninja replaces `{uuid:...}` with its own registered `uuidstr` converter so the captured value stays a string and Pydantic converts and validates it like every other parameter, keeping the annotation the single source of the type.
- How does an operation in a nested router read a parameter from the router's prefix?The name appears in the mount prefix, not in the operation's own path, so inference cannot see it. Declare it explicitly with `Path(...)`, for example `product_id: int = Path(...)` in an operation of a router mounted at `/{product_id}/images/`.
saying these in an interview costs you the question
- Believing Ninja returns 404 whenever a path parameter fails type conversion
- Assuming {int:id} and id: int give the same response for bad input
- Expecting Django's uuid converter to hand Ninja a UUID object
- Reading a converter 404 in logs as a missing database row