← Back to OpenAPI Schema Generation for Spatial Types
Attach real, valid GeoJSON examples to a FastAPI request body so Swagger UI shows a usable Polygon instead of "string", and so generated clients ship with example payloads that actually validate.
Context & when to use
A correct schema tells a consumer what shape a geometry must have; a good example tells them what a real one looks like. Without an explicit example, FastAPI synthesises a placeholder from the schema — for a GeoJSON body that means coordinates: [0] or "string", which is not valid GeoJSON and cannot be posted from the “Try it out” panel without hand-editing. For a Polygon, whose coordinates is a triply-nested array of closed rings, no consumer reconstructs a valid value from the schema alone. Examples are what make the docs operable.
Reach for this whenever a route accepts a Feature, a raw geometry, or a FeatureCollection — which is most write endpoints in a spatial API. It builds directly on the models and discriminated union from OpenAPI schema generation for spatial types; here we focus purely on the example layer that sits on top of that schema. There are three insertion points — model-level json_schema_extra, parameter-level Body(..., examples=...), and the richer openapi_examples — and they compose rather than compete.
A word on why examples deserve their own treatment for geometry specifically. Most request bodies are flat objects a consumer can guess — a name, an email, an integer. GeoJSON is not: a Polygon’s coordinates is a three-level array ([[[lon, lat], ...]]) whose innermost ring must be closed, and the axis order is a convention no type system encodes. A consumer staring at the schema sees “array of array of array of number” and has no way to produce a valid value on the first try. A single, correct, copy-pasteable example removes that friction entirely and is often the difference between an API that gets adopted and one that generates support tickets.
Preconditions: FastAPI 0.106+ (for openapi_examples on Body), Pydantic 2.5+, and the geometry models already defined. Every example you write must validate against the model, or it becomes a lie that generators may propagate.
Data flow: where each example surfaces
Runnable implementation
One file, three example mechanisms layered so Swagger UI shows a selectable set of valid GeoJSON bodies. The models are the discriminated Feature/Geometry types from the parent guide.
# app/routes/geojson_body.py
from typing import Literal, Annotated, Any, Union
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field
Position = Annotated[list[float], Field(min_length=2, max_length=3,
description="[longitude, latitude] (RFC 7946)")]
class Point(BaseModel):
type: Literal["Point"]
coordinates: Position
# (1) Model-level example: attached to the schema itself, so it appears
# wherever this model is referenced — responses included.
model_config = {
"json_schema_extra": {
"example": {"type": "Point", "coordinates": [-0.1278, 51.5074]}
}
}
class Polygon(BaseModel):
type: Literal["Polygon"]
# A ring must be closed: first position == last position, >= 4 positions.
coordinates: list[Annotated[list[Position], Field(min_length=4)]]
Geometry = Annotated[Union[Point, Polygon], Field(discriminator="type")]
class Feature(BaseModel):
type: Literal["Feature"]
geometry: Geometry
properties: dict[str, Any] | None = None
app = FastAPI()
@app.post("/zones")
async def create_zone(
feature: Feature = Body(
...,
# (2) openapi_examples: multiple *named* request examples. Each renders
# as an entry in the Swagger "Examples" dropdown. Unlike the older
# `examples=[...]` list, these carry a summary and description.
openapi_examples={
"point_poi": {
"summary": "Point of interest",
"description": "A single Point in EPSG:4326, lon before lat.",
"value": {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-0.1278, 51.5074]},
"properties": {"name": "Trafalgar Square", "kind": "poi"},
},
},
"polygon_zone": {
"summary": "Closed Polygon catchment",
"description": "Note the ring is closed — last == first position.",
"value": {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-0.130, 51.500], [-0.120, 51.500],
[-0.120, 51.510], [-0.130, 51.510],
[-0.130, 51.500],
]],
},
"properties": {"zone": "central", "kind": "catchment"},
},
},
"invalid_axis_order": {
"summary": "WRONG — lat/lon swapped (for contrast)",
"description": "Do not copy: coordinates are [lat, lon]; London lands off Somalia.",
"value": {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [51.5074, -0.1278]},
"properties": {},
},
},
},
),
):
"""Accept a GeoJSON Feature. The named examples above populate the
'Try it out' body; the point_poi and polygon_zone examples validate,
the invalid_axis_order one is documented deliberately as a counter-example."""
return {"received_type": feature.geometry.type, "properties": feature.properties}The three counterpart mechanisms, ranked by scope: json_schema_extra on the model is broadest (follows the schema everywhere), openapi_examples is per-endpoint and richest (named, described, multiple), and the older Body(..., examples=[...]) list is a lighter middle ground when you only need one or two unnamed samples and do not need summaries.
Key parameters & options
| Mechanism | Where it lives | Multiple? | Named + described | Best for |
|---|---|---|---|---|
model_config["json_schema_extra"]["example"] | On the Pydantic model | One canonical | No | A default example reused across every route using the model |
Body(..., examples=[{...}]) | Endpoint parameter | Yes (list) | No | A couple of quick request samples, no metadata |
Body(..., openapi_examples={...}) | Endpoint parameter | Yes (dict) | Yes (summary, description) | Public docs: labelled valid + counter-examples |
Field(..., examples=[...]) | Individual model field | Yes | No | Illustrating one member (e.g. a properties shape) |
Notes that matter for geometry specifically:
openapi_exampleskeys become the dropdown labels — make them human-readable (polygon_zone, notex2).- A
valueinopenapi_examplesis emitted verbatim intorequestBody.content."application/json".examples; it is not re-validated by FastAPI at schema-build time, which is exactly why an invalid example can slip through. json_schema_extraaccepts either a dict or a callable; use the callable form if you need to strip internal fields before publishing.
Gotchas & failure modes
- Example does not validate against its own schema. FastAPI does not check
openapi_examplesvalues, so aPolygonexample with an unclosed ring or aPointwith a single-elementcoordinatesrenders happily in/docsbut 422s the moment someone clicks “Execute”. Assert examples in a test (see Verification) so drift fails CI. - Coordinate order silently wrong.
[lat, lon]is structurally valid GeoJSON — two floats in an array — so nothing rejects it, but the geometry lands in the wrong hemisphere. Theinvalid_axis_ordercounter-example above exists to teach this; keep every real example in[lon, lat]per RFC 7946, matching the enforcement in validating WKT and GeoJSON with Pydantic v2. - Nested Feature example collapses to the geometry only. If you attach the example to the
Geometryunion but the body type isFeature, Swagger shows a bare geometry, not a full Feature. Attach the example at the level that matches the body parameter’s type. example(singular, 3.0) vsexamples(3.1) confusion. On OpenAPI 3.1,openapi_examplesmaps to theexamplesobject; a stray top-levelexamplekey you added by hand may be dropped. Let FastAPI emit the examples rather than hand-editing the document.- Giant Polygon example bloats
/openapi.json. A 5,000-vertex example makes the schema payload megabytes and slows/docsload. Keep example geometries small (a 4–6 vertex ring is plenty); real payloads can be large, documentation examples should not be. - Response examples are separate from request examples. A
json_schema_extraexample on a model flows into both the request schema and any response that uses the model, butopenapi_examplesonBodyis request-only. If you want a documented response, attach the example to the model (or useresponses=on the route decorator); a request-side example never appears underresponses. FeatureCollectionbodies need a whole-collection example, not a per-feature one. Attaching an example toFeaturedocuments a single feature; a route that accepts aFeatureCollectionstill shows a bare{}for the collection wrapper unless you attach a full{"type":"FeatureCollection","features":[...]}example at the body level. Document at the exact type the endpoint receives.
Verification
Confirm the example is present in the document and that it actually round-trips:
# 1. The named examples are in the request body
curl -s http://localhost:8000/openapi.json \
| jq '.paths."/zones".post.requestBody.content."application/json".examples | keys'
# -> ["invalid_axis_order", "point_poi", "polygon_zone"]
# 2. The valid Polygon example round-trips (should return 200)
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8000/zones \
-H "Content-Type: application/json" \
-d '{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-0.130,51.500],[-0.120,51.500],[-0.120,51.510],[-0.130,51.510],[-0.130,51.500]]]},"properties":{"zone":"central"}}'
# -> 200Guard every valid example against its schema in a test so a bad edit cannot ship:
# tests/test_examples_validate.py
from app.routes.geojson_body import Feature, app
def test_named_examples_validate():
route = next(r for r in app.routes if getattr(r, "path", "") == "/zones")
body_field = route.dependant.body_params[0].field_info
for name, ex in body_field.openapi_examples.items():
if name.startswith("invalid_"):
continue # counter-examples are meant to fail
Feature.model_validate(ex["value"]) # raises if the example is brokenRelated
- OpenAPI Schema Generation for Spatial Types — the discriminated-union models these examples decorate
- Generating Typed Clients from Spatial OpenAPI Schemas — where documented examples end up in the generated SDK
- Strict Pydantic Validation for Geometry — make sure the examples you document are the ones the validator accepts