API Versioning for GIS Endpoints

Version GIS endpoints in FastAPI without breaking clients. Compare URL path, header, and query-param strategies, then implement version-specific Pydantic schemas and a shared PostGIS service layer.

← 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:

DependencyMinimum versionWhy it matters
FastAPI0.100+APIRouter prefix isolation, Pydantic v2 integration
Pydantic2.0+model_config, ConfigDict, and strict geometry validators
SQLAlchemy2.0+Async session support, asyncpg / psycopg3 drivers
PostGIS3.3+ST_AsGeoJSON, ST_Intersects, GIST index improvements
geojson-pydantic1.0+Typed GeoJSON geometry models with built-in validation
Shapely2.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.

Versioning Strategy Decision Matrix for GIS EndpointsA table comparing three versioning strategies — URL path, header-based, and query parameter — across five criteria: CDN cacheability, OGC routing compatibility, debuggability, gateway routing simplicity, and recommended use case.CriterionURL Path /v2/featuresHeader Accept: vnd.v2Query Param ?version=2CDN cacheabilityExcellentPoor (Vary header)ModerateOGC routing compat.FullRequires adaptationConflicts w/ filtersDebuggability in logsImmediate (URL)Hidden in headersVisible, pollutingGateway routing simplicityStraightforwardComplex header matchingModerateOpenAPI doc isolationAutomatic per routerManual separationShared schemaVerdict for spatial APIsRecommendedAvoidAvoidGoodCautionAvoid

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_cursor

Confirming 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 body

Failure Modes & Edge Cases

  1. GIST index silently dropped after ALTER COLUMN — Changing a geometry column type with ALTER TABLE features ALTER COLUMN geom TYPE geography drops the existing GIST index. Queries revert to sequential scans without any error. Always run \d features and EXPLAIN ANALYZE after column migrations to confirm index presence.

  2. geometry vs geography type mismatch across versions — If v1 stored coordinates as geometry(Point,4326) and v2 switches to geography, spatial predicates that mix the two types raise operator does not exist: geometry && geography. Keep the column type stable across versions and use explicit casts (::geography) only at the query layer.

  3. OpenAPI schema collision when routers share a model name — FastAPI deduplicates schema names by appending numeric suffixes if FeatureV1 and FeatureV2 are both named Feature in their respective modules. Always use distinct class names or set model_config = ConfigDict(title="FeatureV2") explicitly to prevent collisions in generated OpenAPI JSON.

  4. 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.

  5. 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 Gone for stale cursors rather than silently returning incorrect result windows.

  6. 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.

  7. Extra inputs are not permitted on FeatureV2 with legacy clientsConfigDict(extra="forbid") will reject requests from legacy clients that send deprecated proprietary fields. Keep extra="ignore" in v2 until the client fleet has migrated, then harden to extra="forbid" in v3.


Performance Notes

  • Router isolation overhead is negligible. FastAPI’s APIRouter resolves 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 Depends factory functions rather than a shared session pool.
  • GIST index selectivity drops with geography columns 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 /v1 and /v2 OpenAPI specs in CI. Schema regressions in spatial endpoints (e.g., a geometry field changing from object to string in 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.


← Back to Core Geospatial API Architecture with FastAPI & PostGIS