This page covers the exact narrow problem of wiring a PostGIS geometry column through Model Mapping with GeoAlchemy2 into a FastAPI response — aligning three distinct layers that each fail silently when misconfigured: PostGIS spatial storage, SQLAlchemy 2.0 ORM type mapping, and Pydantic v2 JSON serialization.
Why the Naive Approach Fails
The most common mistake is declaring the geometry column without specifying geometry_type or srid, relying on GeoAlchemy2’s auto-created index, and then returning the ORM object directly from the endpoint:
# BROKEN: missing type/SRID, no serializer, naive column declaration
class Facility(Base):
__tablename__ = "facilities"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
location: Mapped[Geometry] = mapped_column(Geometry()) # SRID defaults to 0
class FacilitySchema(BaseModel):
id: int
location: object # FastAPI cannot serialize WKBElement
@app.get("/facilities/{id}")
def get_facility(id: int, db: Session = Depends(get_db)):
return db.get(Facility, id) # FastAPI raises TypeError on response buildThree things break here:
- SRID 0 — spatial functions like
ST_DWithinandST_Transformreject geometry without an explicit SRID, producingSRID mismatch (0 vs 4326)errors at query time rather than at insert time. - WKBElement serialization — FastAPI’s JSON encoder raises
TypeError: Object of type WKBElement is not JSON serializablebecause PostGIS returns geometry as raw binary (Well-Known Binary), not a Python dict. - Uncontrolled index —
Geometry()with defaults may create an unnamed GiST index you cannot reference in partial index definitions or composite spatial index strategies.
The architecture below shows how the three layers must connect:
Production-Ready Implementation
The following is a complete, copy-paste-ready setup. It maps a PostGIS POINT column, declares an explicit GiST index for spatial query performance, and serializes geometry safely for FastAPI endpoints.
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, field_serializer
from sqlalchemy import create_engine, Integer, String, Index
from sqlalchemy.orm import DeclarativeBase, Session, mapped_column, Mapped
from geoalchemy2 import Geometry
from geoalchemy2.shape import to_shape
from geoalchemy2.elements import WKTElement
import shapely.geometry
DATABASE_URL = "postgresql+psycopg2://user:pass@localhost:5432/gis_db"
engine = create_engine(DATABASE_URL, echo=False)
class Base(DeclarativeBase):
pass
# ── 1. SQLAlchemy model ───────────────────────────────────────────────────────
class Facility(Base):
__tablename__ = "facilities"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Explicit geometry type + SRID prevents silent SRID=0 mismatch errors.
# spatial_index=False lets us declare the index explicitly below.
location: Mapped[Geometry] = mapped_column(
Geometry(geometry_type="POINT", srid=4326, spatial_index=False)
)
__table_args__ = (
# Named index: easier to reference in EXPLAIN output and partial-index
# definitions. Use postgresql_using="gist" — B-tree cannot handle bbox ops.
Index("idx_facilities_location_gist", "location", postgresql_using="gist"),
)
# ── 2. Pydantic schema with GeoJSON serialization ─────────────────────────────
class FacilitySchema(BaseModel):
id: int
name: str
location: Optional[dict] = None
# Required for SQLAlchemy ORM object compatibility in Pydantic v2.
model_config = {"from_attributes": True}
@field_serializer("location")
def serialize_geometry(self, value: Optional[object]) -> Optional[dict]:
if value is None:
return None
# WKBElement → Shapely geometry → GeoJSON dict.
# Never pass WKBElement directly to json.dumps() — it raises TypeError.
geom = to_shape(value)
return shapely.geometry.mapping(geom)
# ── 3. FastAPI app and endpoints ──────────────────────────────────────────────
app = FastAPI(title="GeoAlchemy2 + FastAPI")
def get_db():
with Session(engine) as session:
yield session
@app.post("/facilities/", response_model=FacilitySchema, status_code=201)
def create_facility(name: str, lat: float, lon: float, db: Session = Depends(get_db)):
# WKTElement attaches the SRID before PostGIS receives the geometry,
# preventing the "Geometry SRID (0) does not match column SRID (4326)" error.
wkt_point = WKTElement(f"POINT({lon} {lat})", srid=4326)
facility = Facility(name=name, location=wkt_point)
db.add(facility)
db.commit()
# db.refresh() pulls the server-generated id and persisted geometry back
# into the instance. Without it, location may be stale or None in the response.
db.refresh(facility)
return facility
@app.get("/facilities/{facility_id}", response_model=FacilitySchema)
def get_facility(facility_id: int, db: Session = Depends(get_db)):
facility = db.get(Facility, facility_id)
if not facility:
raise HTTPException(status_code=404, detail="Facility not found")
return facilityConfiguration and Tuning
GiST index strategy. Pair this setup with the GiST index optimization patterns when your facilities table grows beyond a few thousand rows. For workloads that query only a subset of rows — for example, only active facilities — convert the index to a partial index:
Index(
"idx_active_facilities_gist",
"location",
postgresql_using="gist",
postgresql_where="status = 'active'",
)This reduces index size and write amplification for INSERT-heavy pipelines. See creating partial indexes for active map regions for the full pattern including ANALYZE requirements.
SQLAlchemy connection pool. The default pool size of 5 connections is sufficient for development but undersized for concurrent spatial workloads. Tune with:
engine = create_engine(
DATABASE_URL,
pool_size=10, # persistent connections
max_overflow=5, # burst headroom
pool_pre_ping=True, # detect stale connections before use
)work_mem for spatial sorts. ST_DWithin radius searches that order results by distance may spill to disk if work_mem is too low. Set it per-session for spatial endpoints:
db.execute(text("SET LOCAL work_mem = '32MB'"))Output format. The implementation returns GeoJSON, which is the standard consumed by Leaflet, MapLibre, and OpenLayers. If a downstream service expects Well-Known Text instead, replace shapely.geometry.mapping(geom) with geom.wkt and change the Pydantic field type from dict to str.
Verification Steps
After running Base.metadata.create_all(engine), confirm the index was created and the planner will use it:
-- Confirm GiST index exists
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'facilities'
AND indexname = 'idx_facilities_location_gist';
-- Insert a test row (adjust connection details)
INSERT INTO facilities (name, location)
VALUES ('Test', ST_SetSRID(ST_MakePoint(-73.985, 40.748), 4326));
-- Verify the planner uses the index for a spatial predicate
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name
FROM facilities
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(-73.985, 40.748), 4326)::geography,
1000
);
-- Expected: "Index Scan using idx_facilities_location_gist on facilities"
-- If you see "Seq Scan", run ANALYZE facilities; then re-check.Verify the FastAPI serializer from Python:
import requests
# POST a facility
r = requests.post(
"http://localhost:8000/facilities/",
params={"name": "Central Park", "lat": 40.785, "lon": -73.968}
)
assert r.status_code == 201
body = r.json()
assert body["location"]["type"] == "Point"
assert len(body["location"]["coordinates"]) == 2
print(body["location"])
# {"type": "Point", "coordinates": [-73.968, 40.785]}Reading the EXPLAIN ANALYZE output for spatial joins covers how to interpret cost estimates and bitmap heap scans when the index is used.
Gotchas Checklist
- Omitting
sridinGeometry()defaults the column to SRID 0. Spatial functions reject cross-SRID operations silently or with cryptic errors at query time. Always pass the target SRID explicitly. - Skipping
db.refresh()after commit leaves the ORM instance holding only the client-side values supplied during INSERT. Thelocationfield will be the rawWKTElementstring you passed in, not the fully resolvedWKBElementthe serializer expects — producing ato_shape()AttributeError. - Using
spatial_index=True(the default) alongside an explicitIndex(...)creates two indexes on the same column: one unnamed (from GeoAlchemy2) and one named (from your declaration). The duplicate wastes storage and write overhead. Setspatial_index=Falsewhenever you declare your own index. - Autocommit mode with psycopg2 bypasses SQLAlchemy’s transaction scope. If you switch to
AUTOCOMMITisolation for bulk inserts, removedb.commit()calls and use explicitBEGIN/COMMITin raw SQL or switch toengine.begin()context blocks. - SRID cast overhead in hot paths. If PostGIS must cast the input geometry’s SRID to match the column on every row during a bulk insert, query latency spikes. Batch inserts should attach the correct SRID at construction time (
WKTElement(..., srid=4326)) rather than relying on server-sideST_SetSRIDcalls in a trigger.
Related Topics
- Model Mapping with GeoAlchemy2 — parent reference covering declarative model setup, type coercion, and migration strategies
- SQLAlchemy and GeoAlchemy Integration Workflows — top-level guide to ORM session management, connection pooling, and hybrid geometry properties
- GiST Index Optimization — index creation, EXPLAIN plan analysis, and partial index strategies for spatial columns
- ST_DWithin Radius Searches — proximity query patterns that rely on the GiST index declared in this setup
- Handling Session Timeouts During Bulk Spatial Inserts — transaction scope and timeout management for high-volume geometry write workloads