← 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
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 / Header | Version | Accepted values | Notes |
|---|---|---|---|
bbox query param | v1 | lat_min,lon_min,lat_max,lon_max | Adapter swaps to lon/lat internally |
bbox query param | v2 | lon_min,lat_min,lon_max,lat_max | RFC 7946 / OGC standard order |
crs query param | both | EPSG:4326, OGC:CRS84, EPSG:3857 | Stored as-is; service runs ST_Transform |
geometry_format | both | geojson, wkt | Controls serialization at response layer |
Deprecation header | v1 | true | Signals end-of-life per draft IETF spec |
Sunset header | v1 | RFC 7231 HTTP-date | Exact removal date; RFC 8594 §3 |
Content-CRS header | v2 | OGC URN string | Eliminates implicit axis-order assumptions |
Link: rel=successor-version | v1 | /v2/features | Guides 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
Sunsetheader on v1. WithoutSunset, 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
FeatureV1andFeatureV2inherit from a common base and you add amodel_validatorto 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 inEPSG:4326(lat/lon in older drivers). Without an explicitST_Transformand aContent-CRSecho header, the client cannot tell which axis order it received. Always echo the effective CRS in the response header. - Precision rounding via
ST_AsGeoJSONdefault. 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_confignot propagating to nested models. IfGeometryV2is a nested BaseModel insideFeatureV2and you forgetConfigDict(from_attributes=True)on the nested class, serialisation from asyncpgRecordobjects will raise aValidationErrorthat 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.
Related
- API Versioning for GIS Endpoints — strategy overview: path vs. header versioning, deprecation lifecycle, and OpenAPI multi-version generation
- Bounding Box Spatial Index Queries —
ST_MakeEnvelope,ST_Within,ST_Intersectspatterns and GiST index configuration - Spatial Pagination & Cursor Strategies — version-safe cursor token design for paginated feature collections
- Strict Pydantic Validation for Geometry — Pydantic v2 validators for GeoJSON and WKT at the request boundary
← Back to API Versioning for GIS Endpoints