Versioning Geospatial APIs Without Breaking Clients

How to use URL path versioning and Pydantic v2 adapters to evolve FastAPI + PostGIS endpoints safely — handling axis-order shifts, CRS URNs, and geometry format changes without breaking existing map clients.

← Back to API Versioning for GIS Endpoints · Core Geospatial API Architecture

Use URL path versioning (/v1/, /v2/), a shared PostGIS service layer, and version-specific Pydantic v2 adapters to let your spatial endpoints evolve without invalidating existing map clients.

Context & When to Use

Spatial endpoints carry implicit contracts that ordinary CRUD APIs rarely face. Even a minor upgrade — a PostGIS minor release, an OGC compliance fix, or a field rename — can silently break downstream clients through axis-order shifts, coordinate-precision changes, or CRS representation differences. Locking every version behind a shared database path while isolating presentation logic at the routing layer lets you adopt modern standards at your own pace.

This pattern applies whenever two or more distinct consumer groups depend on the same PostGIS data source but need different response shapes. Typical triggers include: a legacy WMS/WMTS front-end that assumes lat, lon order; a new RFC 7946–compliant mobile SDK that requires lon, lat; or a migration from a flat properties map to a typed attributes object. If only one consumer exists and you can update client and server atomically, path versioning adds unnecessary complexity — a feature-flag on the response model is sufficient.

The preconditions are: FastAPI ≥ 0.111, Pydantic v2 (pydantic>=2.0), and asyncpg or SQLAlchemy + GeoAlchemy2 for async PostGIS access. Review the foundational routing conventions in API Versioning for GIS Endpoints before proceeding — that page establishes the header vs. path trade-off analysis and the deprecation timeline model this page builds on.

Why spatial versioning is harder than standard REST versioning

Categories of Breaking Changes in Geospatial APIsFour boxes arranged in a 2×2 grid, each describing a type of breaking change specific to spatial endpoints: axis-order shifts, precision drift, parameter ambiguity, and cursor invalidation.Axis-order shiftEPSG:4326 historically: lat, lon.OGC / RFC 7946 modern: lon, lat.Legacy renderers break silently whenthe order flips between API versions.Precision driftPostGIS upgrades alter ST_AsGeoJSONrounding. Client-side topology checksthat rely on exact float equality failwithout a version-isolated response.Parameter ambiguityUntyped bbox strings and CRS URNsaccepted by v1 allow both lat/lon andlon/lat — clients make incompatibleassumptions about the interpretation.Cursor invalidationSpatial pagination tokens tied toR-tree or BRIN indexes break whenthe indexing strategy changes, makingexisting cursors return wrong results.

Runnable Implementation

The structure below separates concerns into three layers: a version-agnostic PostGIS service, a shared parameter-normalization dependency, and isolated per-version routers. Each router owns its Pydantic response model; the service executes one modern PostGIS query path for both.

# requirements: fastapi>=0.111, pydantic>=2.0, asyncpg>=0.29
from fastapi import FastAPI, APIRouter, Query, Depends, Response, HTTPException
from pydantic import BaseModel, Field, ConfigDict, model_validator
from typing import Optional, List, Dict, Any
from enum import Enum

app = FastAPI(title="Geospatial Feature API", version="2.0.0")

# ── Shared PostGIS Service (version-agnostic) ────────────────────────────────
# In production replace this stub with asyncpg or SQLAlchemy + GeoAlchemy2.
# ST_Transform normalises all incoming geometries to EPSG:4326 internally;
# ST_AsGeoJSON(geom, 6) caps precision at 6 decimal places (~11 cm accuracy).
async def fetch_features_postgis(
    bbox_wsen: Optional[str],   # lon_min,lat_min,lon_max,lat_max  (internal standard)
    crs: str,
    limit: int,
    offset: int,
) -> List[Dict[str, Any]]:
    # Simulate: SELECT id, ST_AsGeoJSON(geom, 6) AS geom, props FROM features
    #   WHERE ST_Within(geom, ST_MakeEnvelope($1,$2,$3,$4, 4326))
    #   ORDER BY id LIMIT $5 OFFSET $6
    return [
        {
            "id": "feat_001",
            "geom": {"type": "Point", "coordinates": [-122.419400, 37.774900]},
            "properties": {"name": "San Francisco", "area_sqkm": 121.4},
        }
    ]

# ── Parameter Normalisation Dependency ───────────────────────────────────────
class GeometryFormat(str, Enum):
    GEOJSON = "geojson"
    WKT = "wkt"

async def normalize_spatial_params(
    bbox: Optional[str] = Query(
        None,
        description="v2: lon_min,lat_min,lon_max,lat_max  |  v1 (legacy): lat_min,lon_min,lat_max,lon_max",
    ),
    crs: Optional[str] = Query("EPSG:4326"),
    geometry_format: Optional[GeometryFormat] = Query(GeometryFormat.GEOJSON),
    version: str = "v2",  # injected by each router
) -> Dict[str, Any]:
    """Translate legacy v1 lat/lon-first bbox into the v2 lon/lat internal standard."""
    out: Dict[str, Any] = {"crs": crs, "format": geometry_format, "bbox_wsen": None}
    if bbox:
        parts = [float(p) for p in bbox.split(",")]
        if len(parts) != 4:
            raise HTTPException(400, "bbox must be 4 comma-separated floats")
        if version == "v1":
            # v1 clients sent lat_min, lon_min, lat_max, lon_max → swap to lon/lat
            lat_min, lon_min, lat_max, lon_max = parts
            out["bbox_wsen"] = f"{lon_min},{lat_min},{lon_max},{lat_max}"
        else:
            out["bbox_wsen"] = bbox  # already lon/lat
    return out

# ── v1 Router (Legacy — maintained for backward compatibility) ────────────────
router_v1 = APIRouter(prefix="/v1", tags=["v1 (deprecated)"])

class FeatureV1(BaseModel):
    """GeoJSON Feature shape used by legacy v1 clients."""
    model_config = ConfigDict(from_attributes=True)
    id: str
    type: str = "Feature"
    geometry: Dict[str, Any]
    properties: Dict[str, Any]

def _v1_params(
    bbox: Optional[str] = Query(None),
    crs: Optional[str] = Query("EPSG:4326"),
    geometry_format: Optional[GeometryFormat] = Query(GeometryFormat.GEOJSON),
) -> Dict[str, Any]:
    # Wrap the shared dependency and inject version="v1"
    import asyncio
    return asyncio.get_event_loop().run_until_complete(
        normalize_spatial_params(bbox, crs, geometry_format, version="v1")
    )

@router_v1.get(
    "/features",
    response_model=List[FeatureV1],
    summary="List spatial features (deprecated v1 — lat/lon bbox order)",
)
async def get_features_v1(
    bbox: Optional[str] = Query(None),
    crs: Optional[str] = Query("EPSG:4326"),
    geometry_format: Optional[GeometryFormat] = Query(GeometryFormat.GEOJSON),
    limit: int = Query(50, le=1000),
    offset: int = Query(0, ge=0),
    response: Response = None,
):
    # Normalise bbox in-handler so we can pass version="v1"
    params = await normalize_spatial_params(bbox, crs, geometry_format, version="v1")
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "Sat, 31 Jan 2026 23:59:59 GMT"  # RFC 8594
    response.headers["Link"] = '</v2/features>; rel="successor-version"'
    raw = await fetch_features_postgis(
        bbox_wsen=params["bbox_wsen"], crs=params["crs"],
        limit=limit, offset=offset,
    )
    return [
        {"id": r["id"], "type": "Feature", "geometry": r["geom"], "properties": r["properties"]}
        for r in raw
    ]

# ── v2 Router (Current) ───────────────────────────────────────────────────────
router_v2 = APIRouter(prefix="/v2", tags=["v2"])

class GeometryV2(BaseModel):
    type: str
    coordinates: List[Any]

class FeatureV2(BaseModel):
    id: str
    geometry: GeometryV2
    attributes: Dict[str, Any]   # 'properties' renamed to 'attributes' in v2
    metadata: Dict[str, Any] = Field(default_factory=dict)

@router_v2.get("/features", response_model=List[FeatureV2])
async def get_features_v2(
    bbox: Optional[str] = Query(None),
    crs: Optional[str] = Query("EPSG:4326"),
    geometry_format: Optional[GeometryFormat] = Query(GeometryFormat.GEOJSON),
    limit: int = Query(100, le=5000),
    offset: int = Query(0, ge=0),
    response: Response = None,
):
    params = await normalize_spatial_params(bbox, crs, geometry_format, version="v2")
    # Explicit CRS header eliminates client-side guessing (OGC API Features §7.14)
    response.headers["Content-CRS"] = "<http://www.opengis.net/def/crs/EPSG/0/4326>"
    raw = await fetch_features_postgis(
        bbox_wsen=params["bbox_wsen"], crs=params["crs"],
        limit=limit, offset=offset,
    )
    return [
        {
            "id": r["id"],
            "geometry": r["geom"],
            "attributes": r["properties"],
            "metadata": {"crs": params["crs"], "format": params["format"]},
        }
        for r in raw
    ]

app.include_router(router_v1)
app.include_router(router_v2)

The key architectural decision is that fetch_features_postgis owns all PostGIS logic. Both routers call the same function; breaking changes in presentation (field names, coordinate order, response envelope) stay isolated in the router and Pydantic model layers. When you add /v3/, you extend that layer without touching the query path.

For context on how ST_MakeEnvelope and bounding-box query patterns interact with spatial indexing, see the Bounding Box Spatial Index Queries guide. If any endpoint serves paginated feature collections, wire its cursor tokens to Spatial Pagination & Cursor Strategies — token format is a version-sensitive contract and must be treated the same way as field names.

Key Parameters & Options

Parameter / HeaderVersionAccepted valuesNotes
bbox query paramv1lat_min,lon_min,lat_max,lon_maxAdapter swaps to lon/lat internally
bbox query paramv2lon_min,lat_min,lon_max,lat_maxRFC 7946 / OGC standard order
crs query parambothEPSG:4326, OGC:CRS84, EPSG:3857Stored as-is; service runs ST_Transform
geometry_formatbothgeojson, wktControls serialization at response layer
Deprecation headerv1trueSignals end-of-life per draft IETF spec
Sunset headerv1RFC 7231 HTTP-dateExact removal date; RFC 8594 §3
Content-CRS headerv2OGC URN stringEliminates implicit axis-order assumptions
Link: rel=successor-versionv1/v2/featuresGuides clients to the current endpoint

CDN cache keys must be prefixed with the version string (v1:features:bbox=…, v2:features:bbox=…). Without this isolation a stale v1 response can be served to a v2 client after a cache miss — coordinate-order differences make this a silent data corruption scenario. See the Redis Caching for Spatial Queries patterns for version-aware key construction.

Gotchas & Failure Modes

  • Forgetting the Sunset header on v1. Without Sunset, external tooling (API gateways, developer portals, SDK generators) cannot auto-generate deprecation warnings. Add it the moment you ship v2 — not at sunset time.
  • Sharing the Pydantic model between versions. If FeatureV1 and FeatureV2 inherit from a common base and you add a model_validator to the base, both versions run it. An axis-order fix in the validator silently changes v1 response shapes. Keep models entirely separate.
  • CRS URN mismatch between query and response. A client sends crs=OGC:CRS84 (lon/lat by definition), but your service stores in EPSG:4326 (lat/lon in older drivers). Without an explicit ST_Transform and a Content-CRS echo header, the client cannot tell which axis order it received. Always echo the effective CRS in the response header.
  • Precision rounding via ST_AsGeoJSON default. The default precision in PostGIS 3.3+ changed from 9 to 15 significant digits. A bbox filter that snapped to 6-digit coordinates in v1 may return marginally different feature sets in v2 if your query uses the geometry directly. Pin precision: ST_AsGeoJSON(geom, 6).
  • Pydantic v2 model_config not propagating to nested models. If GeometryV2 is a nested BaseModel inside FeatureV2 and you forget ConfigDict(from_attributes=True) on the nested class, serialisation from asyncpg Record objects will raise a ValidationError that looks like a missing-field error, not a config error.

Verification Snippet

After starting the API locally (uvicorn main:app --reload), confirm both versions behave correctly:

# v1 endpoint — check for Deprecation + Sunset headers, legacy response shape
curl -si "http://localhost:8000/v1/features?bbox=37.7,-122.5,37.8,-122.4&limit=5" \
  | grep -E "^(Deprecation|Sunset|Link|Content-Type|\{)"

# Expected headers:
# Deprecation: true
# Sunset: Sat, 31 Jan 2026 23:59:59 GMT
# Link: </v2/features>; rel="successor-version"

# v2 endpoint — confirm Content-CRS and lon/lat-first geometry
curl -si "http://localhost:8000/v2/features?bbox=-122.5,37.7,-122.4,37.8&limit=5" \
  | grep -E "^(Content-CRS|content-type)"

# Expected:
# Content-CRS: <http://www.opengis.net/def/crs/EPSG/0/4326>

# Contract test: coordinates[0] (longitude) should be negative for SF
curl -s "http://localhost:8000/v2/features?limit=1" \
  | python3 -c "import sys,json; f=json.load(sys.stdin)[0]; lon=f['geometry']['coordinates'][0]; assert lon < 0, f'Expected negative longitude, got {lon}'; print('lon/lat order correct')"

For a deeper look at how EXPLAIN ANALYZE surfaces index usage in bounding-box queries, the Reading EXPLAIN ANALYZE for Spatial Query Optimization walkthrough shows the exact plan difference between a sequential scan and a GiST index hit.


← Back to API Versioning for GIS Endpoints