Backend engineers and GIS platform architects building location-aware services face a set of structural decisions that plain CRUD patterns never surface: where spatial computation belongs, how to keep OGC schemas stable under evolving standards, and how to deliver binary or streaming geometry payloads without exhausting memory. This reference covers the full architectural stack — from PostGIS configuration and async FastAPI wiring through serialisation strategy, cursor-based pagination, production hardening, and the failure modes that sink spatial APIs in the first month of real traffic.
Architectural Blueprint: Three-Tier Spatial Design
A production spatial API divides cleanly into three tiers. Blurring the boundaries between them is the single most common cause of lock contention, unpredictable query plans, and serialisation bottlenecks.
FastAPI owns Tiers 1 and 3 because of its async runtime, Pydantic v2 validation, and OpenAPI documentation generation. PostGIS owns Tier 2 because C-level spatial algorithms cannot be meaningfully replicated in application code. The architecture succeeds when these tiers communicate through well-defined contracts rather than leaking concerns across boundaries — the exact principle behind spatial resource modelling patterns, which keeps geometries, attributes, and temporal metadata decoupled from routing logic.
Database Layer: PostGIS as the Spatial Engine
PostGIS is not a passive data store. It is the computational core of the architecture, and misconfiguring it is far more damaging than a slow Python endpoint.
Geometry vs Geography: Choosing the Right Column Type
| Factor | geometry | geography |
|---|---|---|
| Coordinate system | Planar (Cartesian) | Ellipsoidal (WGS 84) |
| Distance accuracy | Approximation for large spans | True geodesic distance |
| Supported functions | Full ST_ catalogue | Subset (no ST_Buffer area accuracy) |
| Query throughput | Faster — no ellipsoid math | 10–40 % overhead |
| Best for | Regional/local datasets, tile pipelines | Global tracking, cross-continental joins |
Never mix the two types in the same join or WHERE clause without an explicit ::geometry or ::geography cast. Implicit casting silently disables index usage and corrupts spatial predicates on large polygons.
Indexing Strategy
A single GIST index on a geometry column rarely covers production workloads. Layer indexes by access pattern:
-- Standard GIST for geometry predicates
CREATE INDEX idx_parcels_geom
ON parcels USING GIST (geom);
-- Partial index — only active records, smaller and faster
CREATE INDEX idx_active_parcels_geom
ON parcels USING GIST (geom)
WHERE status = 'active';
-- Functional index — pre-project to WGS 84 for API output
CREATE INDEX idx_parcels_geom_4326
ON parcels USING GIST (ST_Transform(geom, 4326));
-- BRIN index for append-only GPS time series (enormous tables)
CREATE INDEX idx_gps_pings_geom_brin
ON gps_pings USING BRIN (geom)
WITH (pages_per_range = 128);After bulk loads, always run ANALYZE parcels; before opening the endpoint to traffic. Monitor index bloat weekly with pg_stat_user_indexes and rebuild if idx_blks_read / idx_blks_hit climbs above 0.05.
Connection Pooling and Async Compatibility
FastAPI’s async runtime pairs with asyncpg (direct driver) and SQLAlchemy 2.0’s AsyncSession. Run PgBouncer in transaction pooling mode in front of PostGIS — spatial queries hold locks longer than typical OLTP workloads, so session pooling wastes connections.
Recommended configuration for a single API replica:
# pgbouncer.ini — spatial workload settings
pool_mode = transaction
max_client_conn = 200
default_pool_size = 25 # 20–40 per replica is the practical ceiling
server_idle_timeout = 30 # reclaim connections after idle spatial reads
query_wait_timeout = 10 # fail-fast rather than queue-buildSet statement_timeout = '8s' at the database level to prevent runaway ST_Union or ST_DWithin aggregations from exhausting the pool.
Application Layer: FastAPI Integration Patterns
Dependency Injection for Spatial Validation
Attach geometry validators as FastAPI dependencies so malformed payloads fail before touching the database:
from fastapi import Depends, HTTPException
from pydantic import BaseModel, field_validator
from shapely.wkt import loads as wkt_loads
from shapely.validation import make_valid
class GeometryPayload(BaseModel):
wkt: str
srid: int = 4326
@field_validator("wkt")
@classmethod
def must_be_valid_geometry(cls, v: str) -> str:
try:
geom = wkt_loads(v)
except Exception:
raise ValueError("Unparseable WKT geometry")
if not geom.is_valid:
fixed = make_valid(geom)
return fixed.wkt # auto-repair rather than reject for minor issues
return v
@field_validator("srid")
@classmethod
def must_be_known_srid(cls, v: int) -> int:
allowed = {4326, 3857, 27700, 32632}
if v not in allowed:
raise ValueError(f"SRID {v} not in allowed set {allowed}")
return v
async def get_db_session():
async with AsyncSession(engine) as session:
yield session
@app.post("/features/")
async def create_feature(
payload: GeometryPayload,
session: AsyncSession = Depends(get_db_session),
):
...This pattern produces structured 422 Unprocessable Entity responses for invalid geometries before a single database round-trip. For the full Pydantic v2 validation approach — including WKT vs GeoJSON handling and coordinate-bound enforcement — see strict Pydantic validation for geometry.
Async Execution and Avoiding Event-Loop Blocking
ST_Union, ST_ConvexHull, and complex ST_Intersects predicates are CPU-bound inside PostGIS. They do not block FastAPI’s Python event loop because the work executes on the database server — but they do hold asyncpg connection slots. Two mitigations:
- Prepared statements for repeated bounding-box shapes — reduces planning overhead by ~30 % on parameterised spatial filters.
- Query plan caching — SQLAlchemy’s
text()with bound parameters allows PostGIS to reuse cached query plans across requests with different bounding boxes but identical predicate shapes.
from sqlalchemy import text
BBOX_QUERY = text("""
SELECT id, ST_AsGeoJSON(geom)::json AS geometry, properties
FROM features
WHERE geom && ST_MakeEnvelope(:xmin, :ymin, :xmax, :ymax, 4326)
AND ST_Intersects(geom, ST_MakeEnvelope(:xmin, :ymin, :xmax, :ymax, 4326))
ORDER BY id
LIMIT :limit
""")
@app.get("/features/")
async def list_features(
xmin: float, ymin: float, xmax: float, ymax: float,
limit: int = 100,
session: AsyncSession = Depends(get_db_session),
):
result = await session.execute(
BBOX_QUERY,
{"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax, "limit": limit},
)
return {"features": [dict(row) for row in result.mappings()]}Note the double predicate: && uses the GIST index for a fast bounding-box pre-filter, then ST_Intersects applies the exact geometric test only to the candidates that survive. Removing the && clause disables index usage entirely.
Data Contracts and Serialisation
How you serialise spatial data determines bandwidth consumption, client rendering latency, and downstream pipeline compatibility. The choice is not binary — well-designed endpoints negotiate format per request.
Format Selection Matrix
| Format | Size vs GeoJSON | Parse speed | Human-readable | Streaming | Best use |
|---|---|---|---|---|---|
| GeoJSON | 1× (baseline) | Moderate | Yes | Chunked | Web map clients, third-party tools |
| FlatGeobuf | 0.3–0.5× | Fast (columnar) | No | Yes (seekable) | Internal microservices, CDN tiles |
| GeoParquet | 0.1–0.2× | Very fast | No | Column-selective | Analytics pipelines, bulk exports |
| WKB (raw) | 0.2–0.3× | Very fast | No | Yes | Database-to-database transfer |
Expose format negotiation via the Accept header or a ?format= query parameter. Use the GeoJSON vs GeoParquet serialisation decision matrix to choose the right approach for each endpoint family, and reserve StreamingResponse for exports that may return millions of features.
from fastapi.responses import StreamingResponse
import json
async def stream_geojson(session, bbox):
async def feature_generator():
yield '{"type":"FeatureCollection","features":['
first = True
async for row in await session.stream(BBOX_QUERY, bbox):
if not first:
yield ","
yield json.dumps({"type": "Feature", "geometry": row.geometry, "properties": row.properties})
first = False
yield "]}"
return StreamingResponse(feature_generator(), media_type="application/geo+json")OGC Compliance
Align response schemas with RFC 7946 to guarantee interoperability with QGIS, Mapbox GL JS, and OGC API Features consumers. At minimum:
typemust be a valid GeoJSON geometry type string — never a custom alias.coordinatesmust be[longitude, latitude]order (not lat/lon) unless the CRS is explicitly declared otherwise.- Feature
idmust be a string or number, not a composite object. propertiesmust be a JSON object ornull, never an array.
Use ST_AsGeoJSON(geom, 6) (six decimal places ≈ 0.1 m precision) rather than ST_AsText to avoid a Python-side WKT-to-GeoJSON conversion step.
Performance and Scalability
Query Plan Guidance
Run EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) on every new spatial query before deploying it. Watch for three anti-patterns:
- Seq Scan on a large table — missing or disabled GIST index. Check
enable_seqscanis not set tooffglobally (a common “debug” setting left in production). - Nested Loop on unindexed join column — a spatial join missing an index on the non-geometry join key (e.g.
tenant_id). Add a composite index:(tenant_id, geom)usingGIST (geom)with a standard btree ontenant_idas a separate index, then let the planner intersect them. - Hash Aggregate on large geometry sets —
ST_Unionwithout a priorST_Simplifypre-pass. Simplify geometries to the resolution required by the client before aggregating.
Caching Hooks
Spatial query results are cacheable when the underlying dataset changes infrequently. Layer caches at two levels:
- PostGIS materialized views — refresh on a schedule for analytical endpoints (e.g., pre-computed administrative boundaries).
- Redis geometry cache — store
ST_AsGeoJSONoutput keyed by(feature_id, srid). Invalidate on write. Avoid caching raw WKB; cache the serialised form the endpoint would return.
import hashlib, json
from redis.asyncio import Redis
async def cached_feature(feature_id: int, redis: Redis, session: AsyncSession):
cache_key = f"feature:{feature_id}:geojson"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
row = await session.execute(
text("SELECT ST_AsGeoJSON(geom, 6)::json AS geometry FROM features WHERE id = :id"),
{"id": feature_id},
)
result = row.mappings().one()
await redis.setex(cache_key, 3600, json.dumps(result["geometry"]))
return result["geometry"]Concurrency Limits
Spatial aggregations (ST_Union, ST_ConvexHull) should never share a connection pool with lightweight metadata queries. Separate endpoints into two FastAPI routers backed by different database pools: a lightweight pool (50 connections) for point-lookup and bounding-box queries, and a heavy pool (5–10 connections) for aggregation and export endpoints. This prevents one slow ST_Union from blocking a hundred tile requests.
Pagination, Versioning, and Production Readiness
Cursor-Based Spatial Pagination
Offset pagination (LIMIT n OFFSET m) is unsuitable for spatial datasets: it triggers a full sequential scan to reach offset m, produces duplicates during concurrent inserts, and returns different rows as the dataset shifts. Replace it with keyset pagination using a stable spatial ordering.
Three strategies are covered in depth in Spatial Pagination & Cursor Strategies:
- Z-order (Morton) cursors: Map
(x, y)coordinates to a 1D integer key. Range scans on the Z-order index are fast and deterministic. - Tile-based pagination: Align pages with map tile boundaries (
/features?tile=z/x/y). Each response corresponds to exactly one tile, eliminating overlap. - Temporal-spatial cursors: Combine
(created_at, id)for real-time tracking endpoints where insert order is the natural traversal direction.
A minimal keyset implementation:
@app.get("/features/page/")
async def paginate_features(
after_id: int = 0,
limit: int = 100,
session: AsyncSession = Depends(get_db_session),
):
result = await session.execute(
text("""
SELECT id, ST_AsGeoJSON(geom, 6)::json AS geometry, properties
FROM features
WHERE id > :after_id
ORDER BY id
LIMIT :limit
"""),
{"after_id": after_id, "limit": limit},
)
rows = result.mappings().all()
next_cursor = rows[-1]["id"] if len(rows) == limit else None
return {"features": [dict(r) for r in rows], "next_cursor": next_cursor}For the full implementation including Z-order cursors and tile-aligned paging, see implementing cursor-based pagination for spatial queries.
API Versioning for GIS Endpoints
OGC standards evolve. GeoJSON, OGC API Features, 3D coordinate support, and SRID conventions change between specification versions. Enforce URL-based versioning (/v1/features, /v2/features) and route each version to its own FastAPI router, sharing only the database session dependency:
from fastapi import APIRouter
v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")
@v1_router.get("/features/")
async def features_v1(...): ... # GeoJSON with EPSG:4326
@v2_router.get("/features/")
async def features_v2(...): ... # GeoJSON + GeoParquet negotiation, SRID-awareWhen migrating spatial schemas, use PostgreSQL views to keep the v1 endpoint pointing at a stable projection of the new table. Deprecation guidance for each version lives in API Versioning for GIS Endpoints.
Security: Rate Limiting and Row-Level Security
Spatial queries are expensive. A single malicious or buggy ST_DWithin call with a 1,000 km radius can hold a connection for seconds. Layer defences:
- Per-endpoint rate limiting — use Redis sliding-window counters. Apply strict limits (5 req/s) to complex spatial joins and looser limits (200 req/s) to tile and metadata endpoints.
- PostgreSQL row-level security — enforce tenant isolation at the database row level so application bugs cannot leak cross-tenant geometry:
ALTER TABLE features ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON features
USING (tenant_id = current_setting('app.tenant_id')::uuid);Set app.tenant_id in the asyncpg connection initialisation block, not in the query itself — this ensures the RLS policy is active even when raw SQL bypasses the ORM.
Observability and Monitoring Signals
Standard HTTP metrics are insufficient for spatial APIs. Add these signals:
| Signal | What to measure | Alert threshold |
|---|---|---|
ST_ function duration | p95 execution time per function | > 500 ms |
| Geometry vertex count | Average vertices per inbound payload | > 10,000 |
| GIST index hit rate | idx_blks_hit / (idx_blks_hit + idx_blks_read) | < 0.95 |
| Connection pool saturation | Active connections / max connections | > 0.85 |
ST_IsValid failure rate | Invalid geometry rejections per minute | > 1 % of requests |
Export via Prometheus and set Grafana alerts on p95 ST_DWithin durations and pool saturation. The auto_explain PostgreSQL extension (with auto_explain.log_min_duration = '1s') captures query plans for slow spatial operations without manual EXPLAIN ANALYZE instrumentation.
Health Checks
A health-check endpoint must verify that PostGIS extensions are loaded, not just that the database connection works:
@app.get("/health")
async def health(session: AsyncSession = Depends(get_db_session)):
result = await session.execute(
text("SELECT PostGIS_Version() AS postgis, ST_AsText(ST_MakePoint(0,0)) AS point")
)
row = result.mappings().one()
return {"status": "ok", "postgis": row["postgis"], "probe": row["point"]}This distinguishes “PostgreSQL is up” from “PostGIS extension is installed and functional” — a distinction that matters after major upgrades.
Failure Modes and Common Misconfigurations
The following mistakes appear repeatedly in production spatial APIs. Each has caused measurable outages or data corruption.
Using
OFFSETpagination on spatial tables. Full table scans on millions of geometry rows. Replace with keyset pagination before going to production.Mixing
geometryandgeographyin a single query without explicit casts. PostgreSQL applies an implicit cast that disables index usage and returns incorrect distance values. Always audit cross-type comparisons withEXPLAINand watch for “Cast” nodes in the plan.Omitting
ST_IsValidchecks beforeST_UnionorST_Intersection. Invalid input geometries cause these functions to returnNULLsilently. Add aWHERE ST_IsValid(geom)guard or runST_MakeValidduring ingestion.Setting PgBouncer to session pooling mode with spatial APIs. Long-lived spatial transactions hold session connections, defeating the pool. Always use transaction pooling for PostGIS workloads.
Indexing
geographycolumns with a plain GIST index without specifying the cast operator class. UseUSING GIST (geom geography_ops)explicitly — the default operator class is forgeometry.Returning raw
ST_AsBinary(WKB) in GeoJSON endpoints without conversion. WKB is binary; embedding it in a JSON string produces base64 or garbled output. Always useST_AsGeoJSONfor JSON responses.Not pinning PostGIS version in Docker images. Minor PostGIS releases sometimes change spatial algorithm outputs (e.g.,
ST_SimplifyPreserveTopologytolerance handling). Pin versions inFROM postgis/postgis:16-3.4to prevent silent algorithmic regressions after image rebuilds.Skipping
ANALYZEafter bulk geometry inserts. The query planner relies on table statistics that go stale after large loads. An un-analyzed table causes the planner to underestimate row counts and choose sequential scans over index paths.
Related
- Spatial Resource Modelling Patterns — how to structure FastAPI routers and PostGIS tables around spatial domain entities
- GeoJSON vs GeoParquet Serialisation — format decision matrix and streaming strategy for spatial responses
- Spatial Pagination & Cursor Strategies — keyset, Z-order, and tile-based pagination for geometry endpoints
- API Versioning for GIS Endpoints — URL versioning, view-backed migrations, and deprecation patterns
- Advanced Spatial Endpoint Implementation & Data Contracts — strict Pydantic v2 validators, async bulk uploads, and bounding-box query patterns
- Securing Geospatial APIs — JWT spatial scope claims, PostGIS row-level security, and rate limiting for spatial endpoints
- Deploying & Operating Geospatial APIs — containerizing PostGIS and FastAPI, CI/CD with spatial integration tests, and edge tile delivery