Documenting GeoJSON Request Bodies in OpenAPI

Attach rich, valid GeoJSON examples to FastAPI request bodies with openapi_examples, Body(..., examples=...), and model json_schema_extra so Swagger UI shows a real Polygon and generated clients validate.

← 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

Example sources merging into the OpenAPI request bodyTwo example sources — model json_schema_extra and Body openapi_examples — merge in FastAPI's schema builder, land in different parts of openapi.json (components schema example versus requestBody examples), and both render in Swagger UI as a selectable example dropdown.model_configjson_schema_extra → schema.exampleBody(openapi_examples=)named, per-request examplesFastAPI schemabuilder/openapi.jsonrequestBody.content.examplesSwagger UI /docsexample dropdown, prefilled body

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.


Documentation lands in two very different places, and only some of it survives the trip to a generated client.

Where each documentation artefact shows upA comparison table. field description: Swagger UI yes, Generated client yes. becomes a docstring example on the model: Swagger UI yes, Generated client no. UI "try it" prefill examples on the route: Swagger UI yes, Generated client no. multiple named cases $ref component name: Swagger UI yes, Generated client yes. becomes the class name json_schema_extra: Swagger UI yes, Generated client partly. passes through verbatim Examples never reach a generated client, so anything a client author must know belongs in a description rather than an example.Where each documentation artefact shows upSwagger UIGenerated clientfield descriptionbecomes a docstringexample on the modelUI "try it" prefillexamples on the routemultiple named cases$ref component namebecomes the class namejson_schema_extra~passes through verbatimExamples never reach a generated client, so anything a client author must know belongs in a description rather than an example.

Key parameters & options

MechanismWhere it livesMultiple?Named + describedBest for
model_config["json_schema_extra"]["example"]On the Pydantic modelOne canonicalNoA default example reused across every route using the model
Body(..., examples=[{...}])Endpoint parameterYes (list)NoA couple of quick request samples, no metadata
Body(..., openapi_examples={...})Endpoint parameterYes (dict)Yes (summary, description)Public docs: labelled valid + counter-examples
Field(..., examples=[...])Individual model fieldYesNoIllustrating one member (e.g. a properties shape)

Notes that matter for geometry specifically:

  • openapi_examples keys become the dropdown labels — make them human-readable (polygon_zone, not ex2).
  • A value in openapi_examples is emitted verbatim into requestBody.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_extra accepts either a dict or a callable; use the callable form if you need to strip internal fields before publishing.

Inline schemas feel convenient at the point of writing and are paid for by every consumer.

Two ways to describe the same request bodyTwo panels. ✕ one inline schema per route: geometry described five times five generated classes, subtly different a fix has to be applied five times the UI shows five near-identical shapes ✓ one component, referenced: geometry described once one generated class, reused a fix propagates everywhere the UI links to a single definition The duplication is invisible in the source and glaring in the generated client, which is where it is discovered.Two ways to describe the same request body✕ one inline schema per routegeometry described five timesfive generated classes, subtly differenta fix has to be applied five timesthe UI shows five near-identical shapes✓ one component, referencedgeometry described onceone generated class, reuseda fix propagates everywherethe UI links to a single definitionThe duplication is invisible in the source and glaring in the generated client, which is where it is discovered.

Gotchas & failure modes

  • Example does not validate against its own schema. FastAPI does not check openapi_examples values, so a Polygon example with an unclosed ring or a Point with a single-element coordinates renders happily in /docs but 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. The invalid_axis_order counter-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 Geometry union but the body type is Feature, 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) vs examples (3.1) confusion. On OpenAPI 3.1, openapi_examples maps to the examples object; a stray top-level example key 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 /docs load. 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_extra example on a model flows into both the request schema and any response that uses the model, but openapi_examples on Body is request-only. If you want a documented response, attach the example to the model (or use responses= on the route decorator); a request-side example never appears under responses.
  • FeatureCollection bodies need a whole-collection example, not a per-feature one. Attaching an example to Feature documents a single feature; a route that accepts a FeatureCollection still 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"}}'
# -> 200

Guard 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 broken

← Back to OpenAPI Schema Generation for Spatial Types