← Back to Core Geospatial API Architecture with FastAPI & PostGIS
Evolving spatial data models while keeping downstream clients functional is one of the most persistent challenges in geospatial platform engineering. As coordinate reference systems shift, geometry precision requirements tighten, and serialization standards mature, a deliberate versioning strategy becomes a structural necessity rather than an afterthought. This page walks through the decision points, implementation patterns, and production guardrails for versioning spatial endpoints in FastAPI backed by PostGIS — ensuring that spatial queries, schema evolution, and client routing remain predictable at scale.
Prerequisites & Environment
Before implementing versioned spatial endpoints, verify your stack meets these baselines:
| Dependency | Minimum version | Why it matters |
|---|---|---|
| FastAPI | 0.100+ | APIRouter prefix isolation, Pydantic v2 integration |
| Pydantic | 2.0+ | model_config, ConfigDict, and strict geometry validators |
| SQLAlchemy | 2.0+ | Async session support, asyncpg / psycopg3 drivers |
| PostGIS | 3.3+ | ST_AsGeoJSON, ST_Intersects, GIST index improvements |
| geojson-pydantic | 1.0+ | Typed GeoJSON geometry models with built-in validation |
| Shapely | 2.0+ | Optional local geometry validation before PostGIS round-trips |
PostGIS functions referenced in this page — ST_MakeEnvelope, ST_Intersects, ST_AsGeoJSON — require PostGIS 3.3+ and are only available once the postgis extension is active (CREATE EXTENSION IF NOT EXISTS postgis;).
Decision Matrix: Versioning Strategies for Spatial APIs
The diagram below maps the three mainstream versioning approaches against the concerns that matter most for GIS workloads: CDN cacheability, OGC routing compatibility, and debuggability.
URL path versioning wins for GIS workloads. It aligns with CDN caching strategies, simplifies routing in API gateways, avoids ambiguity when debugging spatial query failures across mixed client fleets, and integrates cleanly with the spatial resource modeling patterns already established in the core architecture. Header-based versioning forces every cache layer to handle Vary headers, which destroys tile and feature cacheability. Query parameters conflict with OGC-compliant spatial filter parameters like bbox and datetime, which makes them error-prone and semantically confusing.
Step-by-Step Implementation
1. Architect the Versioned Router Structure
Isolate each API version into dedicated FastAPI APIRouter instances. This prevents schema collisions and allows independent middleware, rate-limiting policies, and deprecation headers per version without touching the shared PostGIS service layer.
# app/routers/__init__.py
from fastapi import APIRouter
v1_router = APIRouter(prefix="/v1", tags=["GIS v1"])
v2_router = APIRouter(prefix="/v2", tags=["GIS v2"])Mount both routers in the main application. FastAPI automatically generates separate OpenAPI schemas per version, which eliminates cross-version documentation pollution:
# app/main.py
from fastapi import FastAPI
from app.routers import v1_router, v2_router
app = FastAPI(title="Geospatial API")
app.include_router(v1_router)
app.include_router(v2_router)For larger deployments where v1 and v2 have divergent dependency graphs or database pools, mount them as sub-applications using app.mount(). This allows fully independent lifespan events and middleware stacks per version.
2. Define Evolving Pydantic Schemas
Spatial schemas must explicitly declare geometry types, coordinate precision, and CRS metadata. As your platform matures you will need stricter validation or new geometry primitives. Rather than mutating existing models, create version-specific schemas that share a common base where the contract is stable.
# app/schemas/v1.py
from pydantic import BaseModel, Field
from typing import Optional
class FeatureV1(BaseModel):
id: str
geometry: dict # Raw GeoJSON dict — permissive for legacy clients
properties: dict
crs: Optional[str] = Field(default="EPSG:4326")# app/schemas/v2.py
from pydantic import BaseModel, Field, ConfigDict
from geojson_pydantic import Feature as GeoJSONFeature
from typing import Literal
class FeatureV2(GeoJSONFeature):
"""Strict GeoJSON Feature with enforced CRS and no extra fields."""
model_config = ConfigDict(extra="forbid")
# geojson-pydantic validates geometry type, coordinates structure,
# and required GeoJSON keys automatically.
crs: Literal["EPSG:4326", "EPSG:3857"] = "EPSG:4326"The v1 schema accepts any dictionary as geometry, which is safe for legacy clients that send non-standard extensions. V2 enforces full strict Pydantic geometry validation using geojson-pydantic, rejecting malformed rings or unknown geometry types at the boundary layer before they reach PostGIS.
This pattern prevents accidental field drift and makes breaking changes explicit at the schema level rather than at runtime.
3. Maintain PostGIS Query Compatibility
Database layer changes are the most common source of versioning friction. When altering column types (e.g., geometry to geography) or upgrading PostGIS, your ORM queries must remain version-aware. Use versioned repository classes to isolate query logic:
# app/repositories/spatial_v1.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import FeatureModel
async def get_features_v1(
session: AsyncSession,
bbox: tuple[float, float, float, float],
) -> list[FeatureModel]:
"""v1: uses ST_Intersects with a plain geometry envelope."""
stmt = select(FeatureModel).where(
FeatureModel.geom.ST_Intersects(
func.ST_MakeEnvelope(bbox[0], bbox[1], bbox[2], bbox[3], 4326)
)
)
result = await session.execute(stmt)
return result.scalars().all()# app/repositories/spatial_v2.py
from sqlalchemy import select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import FeatureModel
async def get_features_v2(
session: AsyncSession,
bbox: tuple[float, float, float, float],
limit: int = 100,
cursor: str | None = None,
) -> list[FeatureModel]:
"""v2: cursor-aware query — see spatial pagination strategies."""
envelope = func.ST_MakeEnvelope(bbox[0], bbox[1], bbox[2], bbox[3], 4326)
stmt = (
select(FeatureModel)
.where(FeatureModel.geom.ST_Intersects(envelope))
.order_by(FeatureModel.id)
.limit(limit)
)
if cursor:
stmt = stmt.where(FeatureModel.id > cursor)
result = await session.execute(stmt)
return result.scalars().all()The v2 repository adds cursor-based pagination — a pattern covered in detail in Spatial Pagination & Cursor Strategies — without touching the v1 path. Always validate that GIST indexes remain effective after schema migrations: PostGIS query planners are highly sensitive to geometry type changes, and a column alteration can silently flip a spatial query from an index scan to a sequential scan.
By decoupling repository implementations per version, you can safely deprecate legacy spatial functions (e.g., ST_AsText in v1) without disrupting active clients.
4. Handle Serialization and Format Negotiation
Early API versions default to verbose GeoJSON for maximum interoperability. Later versions may adopt binary or columnar formats to reduce latency and bandwidth. The GeoJSON vs GeoParquet Serialization decision matrix covers the trade-offs between text-based and binary spatial formats in detail.
When introducing new serialization options, expose them as distinct endpoints within the same versioned router rather than through content negotiation headers (which reintroduce the CDN caching problem):
# app/routers/v2_features.py
from fastapi import APIRouter, Depends
from fastapi.responses import Response
from app.repositories.spatial_v2 import get_features_v2
from app.schemas.v2 import FeatureCollectionV2
from app.deps import get_db_session
import pyarrow as pa
import pyarrow.parquet as pq
import io
router = APIRouter()
@router.get("/v2/features", response_model=FeatureCollectionV2)
async def list_features_geojson(
bbox: str,
cursor: str | None = None,
session=Depends(get_db_session),
):
"""GeoJSON response — broad client compatibility."""
xmin, ymin, xmax, ymax = map(float, bbox.split(","))
features = await get_features_v2(session, (xmin, ymin, xmax, ymax), cursor=cursor)
return build_feature_collection(features)
@router.get("/v2/features.parquet")
async def list_features_parquet(
bbox: str,
session=Depends(get_db_session),
):
"""GeoParquet binary response — optimised for analytics clients."""
xmin, ymin, xmax, ymax = map(float, bbox.split(","))
features = await get_features_v2(session, (xmin, ymin, xmax, ymax))
table = features_to_arrow_table(features)
buf = io.BytesIO()
pq.write_table(table, buf)
return Response(content=buf.getvalue(), media_type="application/vnd.apache.parquet")This keeps CDN caching working on both endpoints while making the format explicit in the URL.
Production Code Example: Full Versioned Route Pair
The following is a cohesive, copy-runnable example that wires together the router, schema, repository, and deprecation headers for a v1/v2 feature endpoint:
# app/main.py — complete versioned feature API
from fastapi import FastAPI, Depends, Header
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.deps import get_db_session
from app.repositories.spatial_v1 import get_features_v1
from app.repositories.spatial_v2 import get_features_v2
from app.schemas.v1 import FeatureV1
from app.schemas.v2 import FeatureV2
from typing import Annotated
import datetime
app = FastAPI(title="Geospatial API", version="2.0.0")
SUNSET_DATE = "2025-06-01" # ISO 8601 date after which v1 is retired
def add_deprecation_headers(response: JSONResponse, successor: str) -> JSONResponse:
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = SUNSET_DATE
response.headers["Link"] = f'<{successor}>; rel="successor-version"'
return response
@app.get("/v1/features", tags=["GIS v1"])
async def v1_list_features(
bbox: str,
session: AsyncSession = Depends(get_db_session),
):
xmin, ymin, xmax, ymax = map(float, bbox.split(","))
rows = await get_features_v1(session, (xmin, ymin, xmax, ymax))
body = [FeatureV1.model_validate(r.__dict__) for r in rows]
resp = JSONResponse(content=[f.model_dump() for f in body])
return add_deprecation_headers(resp, successor="/v2/features")
@app.get("/v2/features", tags=["GIS v2"])
async def v2_list_features(
bbox: str,
cursor: str | None = None,
session: AsyncSession = Depends(get_db_session),
):
xmin, ymin, xmax, ymax = map(float, bbox.split(","))
rows = await get_features_v2(session, (xmin, ymin, xmax, ymax), cursor=cursor)
body = [FeatureV2.model_validate(r.__dict__) for r in rows]
next_cursor = rows[-1].id if rows else None
return {
"type": "FeatureCollection",
"features": [f.model_dump() for f in body],
"next_cursor": next_cursor,
}Verification & Testing
Confirming version routing with curl
# Confirm v1 deprecation headers are present
curl -si "http://localhost:8000/v1/features?bbox=-74.01,40.70,-73.97,40.73" \
| grep -E "Deprecation|Sunset|Link"
# Expected:
# Deprecation: true
# Sunset: 2025-06-01
# Link: </v2/features>; rel="successor-version"
# Confirm v2 returns cursor for pagination
curl -s "http://localhost:8000/v2/features?bbox=-74.01,40.70,-73.97,40.73" \
| python3 -m json.tool | grep next_cursorConfirming PostGIS index usage after a schema migration
Run EXPLAIN ANALYZE to verify the GIST index is active after any column type change:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, ST_AsGeoJSON(geom)
FROM features
WHERE geom && ST_MakeEnvelope(-74.01, 40.70, -73.97, 40.73, 4326)
AND ST_Intersects(geom, ST_MakeEnvelope(-74.01, 40.70, -73.97, 40.73, 4326));The plan should show Index Scan using features_geom_idx on features. If you see Seq Scan, the index is missing or the geometry type changed from geometry(Polygon,4326) to an untyped geometry, which defeats the planner’s type-specific statistics. Re-create the index:
CREATE INDEX CONCURRENTLY features_geom_idx ON features USING GIST (geom);
ANALYZE features;Unit test skeleton
# tests/test_versioning.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.asyncio
async def test_v1_has_deprecation_headers():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.get("/v1/features?bbox=-74.01,40.70,-73.97,40.73")
assert r.headers.get("deprecation") == "true"
assert "sunset" in r.headers
@pytest.mark.asyncio
async def test_v2_returns_cursor():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.get("/v2/features?bbox=-74.01,40.70,-73.97,40.73")
body = r.json()
assert "next_cursor" in bodyFailure Modes & Edge Cases
GIST index silently dropped after
ALTER COLUMN— Changing a geometry column type withALTER TABLE features ALTER COLUMN geom TYPE geographydrops the existingGISTindex. Queries revert to sequential scans without any error. Always run\d featuresandEXPLAIN ANALYZEafter column migrations to confirm index presence.geometryvsgeographytype mismatch across versions — If v1 stored coordinates asgeometry(Point,4326)and v2 switches togeography, spatial predicates that mix the two types raiseoperator does not exist: geometry && geography. Keep the column type stable across versions and use explicit casts (::geography) only at the query layer.OpenAPI schema collision when routers share a model name — FastAPI deduplicates schema names by appending numeric suffixes if
FeatureV1andFeatureV2are both namedFeaturein their respective modules. Always use distinct class names or setmodel_config = ConfigDict(title="FeatureV2")explicitly to prevent collisions in generated OpenAPI JSON.CDN caching v1 and v2 responses at the same path fragment — If a proxy strips the version prefix before caching, v1 and v2 clients share the same cache key. Ensure your CDN or API gateway preserves the full path (including
/v1/or/v2/) as part of the cache key.Cursor invalidation after backfill migrations — If a v2 migration reassigns primary keys or re-sequences IDs during a bulk geometry correction, existing cursors issued before the migration become invalid. Return
410 Gonefor stale cursors rather than silently returning incorrect result windows.Timestamp serialization drift between versions — A common breakage is switching from Unix epoch integers (v1) to ISO 8601 strings with timezone offsets (v2) in temporal-spatial endpoints. Clients expecting integers break silently if they receive strings. Document timestamp format changes as explicit breaking changes in your migration guide.
Extra inputs are not permittedonFeatureV2with legacy clients —ConfigDict(extra="forbid")will reject requests from legacy clients that send deprecated proprietary fields. Keepextra="ignore"in v2 until the client fleet has migrated, then harden toextra="forbid"in v3.
Performance Notes
- Router isolation overhead is negligible. FastAPI’s
APIRouterresolves at startup, not per-request. Mounting ten versioned routers adds no measurable per-request latency. - Version-specific database sessions allow you to point v1 at a read replica and v2 at the primary, reducing write contention during migration windows. Wire this via
Dependsfactory functions rather than a shared session pool. - GIST index selectivity drops with
geographycolumns on large datasets. For bounding boxes covering more than ~5% of the indexed area, PostGIS may switch to sequential scans even with a healthy index. Partition large tables by tile or region boundary before introducing a new version to keep query plans predictable. - Deprecation header overhead. Adding four response headers per v1 request adds roughly 0.1 µs per response — immeasurable in practice but worth noting if you serve millions of small tile requests per second through a shared endpoint.
- Contract testing between versions prevents regressions. Run Schemathesis or a similar property-based API tester against both
/v1and/v2OpenAPI specs in CI. Schema regressions in spatial endpoints (e.g., a geometry field changing fromobjecttostringin the spec) will surface immediately rather than at client integration time.
FAQ
Why is URL path versioning preferred for spatial APIs over header versioning?
URL path versioning aligns with CDN caching strategies, simplifies API gateway routing, and makes the active spatial contract immediately visible in logs and debugging sessions. Header versioning adds ambiguity when tracing spatial query failures across middleware layers and forces Vary headers that break feature and tile cacheability.
How do I prevent GIST index invalidation when migrating PostGIS schemas between versions?
Use versioned repository classes that isolate spatial query logic per version. After any geometry column type change, run REINDEX on the affected GIST index and validate with EXPLAIN ANALYZE that the planner still uses the index rather than a sequential scan.
When should I sunset a spatial API version?
Add Deprecation and Sunset response headers from day one of the successor version. Monitor per-version traffic via OpenTelemetry traces and retire the old version once it falls below 5% of total requests, giving clients at least 90 days notice after the Sunset date is set.
Related
- Versioning Geospatial APIs Without Breaking Clients — contract testing, shadow routing, and gradual rollout patterns
- GeoJSON vs GeoParquet Serialization — choosing the right format for versioned spatial responses
- Spatial Pagination & Cursor Strategies — cursor-based pagination in versioned spatial endpoints
- Strict Pydantic Validation for Geometry — enforcing geometry contracts at the v2 schema boundary
- Spatial Resource Modeling Patterns — structuring FastAPI routers and PostGIS table hierarchies
← Back to Core Geospatial API Architecture with FastAPI & PostGIS