Building spatial aggregations on top of Hybrid Properties for Geometry is what keeps a vessel-tracking API from dragging thousands of ais_positions rows into Python just to compute one convex track or bounding envelope. This page shows how to pair @hybrid_property and hybrid_method with ST_Collect, ST_Union, ST_Extent, and ST_Centroid so every aggregation is compiled into the SQL SELECT list and executed by PostGIS. The scenario throughout is a fleet service that must return, per vessel, a merged track, a centroid, and a bounding box — all in SRID 4326.
Why the naive approach fails
The obvious implementation fetches every position for a vessel and folds the geometries in Python. It is correct but ruinous: it ships the entire point history over the wire, deserialises each WKB blob into a Shapely object, and rebuilds the aggregate that PostGIS could have produced in a single pass.
# SLOW: pulls every ais_positions row into Python and aggregates client-side
from geoalchemy2.shape import to_shape
from shapely.ops import unary_union
def vessel_track_bbox(session, mmsi: int):
rows = session.scalars(
select(AisPosition).where(AisPosition.mmsi == mmsi)
).all() # could be 100k+ WKBElements
points = [to_shape(r.geom) for r in rows] # 100k Shapely objects in memory
merged = unary_union(points) # CPU-bound union in Python
return merged.bounds # (minx, miny, maxx, maxy)For a vessel with a month of one-second AIS fixes this materialises millions of objects and can exhaust memory before it ever returns the four numbers the caller wanted. The database can compute the same envelope with ST_Extent while touching only the index and the heap once.
Production-ready implementation
The fix is to give each derived geometry a hybrid definition with an @expression companion, so the same attribute name works as a Python fallback on a loaded instance and as pushed-down SQL inside a query. Here is the complete, copy-paste model plus the queries that aggregate in the database.
from datetime import datetime
from sqlalchemy import Integer, BigInteger, String, DateTime, ForeignKey, Index, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method
from geoalchemy2 import Geometry
from geoalchemy2.shape import to_shape
class Base(DeclarativeBase):
pass
class Vessel(Base):
__tablename__ = "vessels"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
mmsi: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
name: Mapped[str] = mapped_column(String(120))
positions: Mapped[list["AisPosition"]] = relationship(back_populates="vessel")
class AisPosition(Base):
__tablename__ = "ais_positions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
mmsi: Mapped[int] = mapped_column(BigInteger, ForeignKey("vessels.mmsi"), index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
geom: Mapped[object] = mapped_column(
Geometry(geometry_type="POINT", srid=4326, spatial_index=False)
)
vessel: Mapped["Vessel"] = relationship(back_populates="positions")
__table_args__ = (
Index("ais_positions_geom_gist", "geom", postgresql_using="gist"),
)
# ── centroid: no arguments, so hybrid_property fits ──────────────────
@hybrid_property
def centroid(self):
# Instance fallback: only valid on a single loaded point
return to_shape(self.geom).centroid
@centroid.expression
def centroid(cls):
# SQL side: aggregate centroid of all matched points, SRID preserved
return func.ST_Centroid(func.ST_Collect(cls.geom)).label("centroid")
# ── bounding envelope of a group of points ───────────────────────────
@hybrid_property
def track_extent(self):
return to_shape(self.geom).bounds
@track_extent.expression
def track_extent(cls):
# ST_Extent returns a box2d; wrap in ST_SetSRID(ST_Envelope(...)) for a
# real geometry the ORM can decode as a polygon in SRID 4326.
return func.ST_SetSRID(
func.ST_Envelope(func.ST_Collect(cls.geom)), 4326
).label("track_extent")
# ── parameterised merge: needs a target SRID, so hybrid_method ───────
@hybrid_method
def merged_track(cls, srid: int = 4326):
# ST_Collect bundles points into a MultiPoint without dissolving;
# optionally reproject the collection into a projected CRS for length.
collected = func.ST_Collect(cls.geom)
if srid != 4326:
collected = func.ST_Transform(collected, srid)
return collected.label("merged_track")With the model in place, the aggregations run entirely in PostGIS. Each query returns one aggregate geometry per vessel via GROUP BY:
def fleet_centroids(session):
"""One centroid geometry per vessel, computed in the database."""
stmt = (
select(
AisPosition.mmsi,
AisPosition.centroid, # -> ST_Centroid(ST_Collect(geom))
)
.group_by(AisPosition.mmsi)
)
for mmsi, centroid_wkb in session.execute(stmt):
pt = to_shape(centroid_wkb) # WKBElement -> shapely Point (4326)
yield mmsi, (round(pt.x, 5), round(pt.y, 5))
def fleet_bounding_boxes(session):
"""One envelope polygon per vessel, computed in the database."""
stmt = (
select(AisPosition.mmsi, AisPosition.track_extent)
.group_by(AisPosition.mmsi)
)
return {
mmsi: to_shape(env).bounds
for mmsi, env in session.execute(stmt)
}
def merged_track_utm(session, mmsi: int, utm_srid: int = 32631):
"""Merge one vessel's points into a MultiPoint reprojected to UTM 31N."""
stmt = (
select(AisPosition.merged_track(utm_srid))
.where(AisPosition.mmsi == mmsi)
)
return to_shape(session.execute(stmt).scalar_one())ST_Collect is the workhorse for gathering AIS points because it only bundles them into a MultiPoint — it never dissolves geometry, so it stays cheap even for enormous point sets. Reserve ST_Union for the case where dissolved boundaries genuinely matter, such as merging overlapping port_zones polygons into a single coverage footprint:
def dissolved_port_coverage(session, srid: int = 4326):
"""True geometric union of overlapping port zones — boundaries dissolved."""
stmt = select(func.ST_Union(PortZone.geom).label("coverage"))
return to_shape(session.execute(stmt).scalar_one())Because UTM zone 31N (EPSG:32631) is a projected, metre-based CRS, the reprojected merged_track can feed a downstream ST_Length for track distance without the degree-based distortion you would get by measuring in raw SRID 4326.
Configuration and tuning knobs
work_mem—ST_CollectandST_Unionaccumulate geometry in the aggregate state. SetSET LOCAL work_mem = '128MB'for fleet-wide merges so the aggregate does not spill to a temp file.spatial_indexand the GiST index — the explicitais_positions_geom_gistindex keeps anyWHEREpredicate (for example anST_Intersectsagainst a port zone) index-aware even while theSELECTlist aggregates. Aggregation itself is a heap scan, but pre-filtering the group is not.ST_ExtentvsST_Envelope—ST_Extentreturns abox2dwith no SRID, which the ORM cannot decode as a geometry. Wrap it (or useST_Envelope(ST_Collect(...))as above) and re-attach SRID 4326 withST_SetSRIDsoto_shapesucceeds.GROUP BYcardinality — grouping bymmsiproduces one row per vessel; make suremmsiis indexed so the grouping does not force a full sort of the position table.- Statement timeout — a fleet-wide
ST_Unionover millions of points is analytical, not transactional. Route it to a replica or raisestatement_timeoutfor that session so it does not trip the OLTP budget.
Verification steps
Confirm the aggregation is compiled into SQL rather than executed in Python by printing the compiled statement before it runs:
stmt = (
select(AisPosition.mmsi, AisPosition.centroid)
.group_by(AisPosition.mmsi)
)
print(stmt.compile(compile_kwargs={"literal_binds": True}))
# Expect ST_Centroid(ST_Collect(ais_positions.geom)) in the SELECT list,
# NOT a bare ais_positions.geom that would force client-side aggregation.Then confirm the plan and the numeric result at the database:
-- The aggregate should scan the index/heap once, not per group-member
EXPLAIN (ANALYZE, BUFFERS)
SELECT mmsi,
ST_AsText(ST_Centroid(ST_Collect(geom))) AS centroid
FROM ais_positions
WHERE mmsi = 244660000
GROUP BY mmsi;
-- Sanity-check the envelope SRID survived the round-trip
SELECT ST_SRID(ST_SetSRID(ST_Envelope(ST_Collect(geom)), 4326)) AS srid
FROM ais_positions
WHERE mmsi = 244660000; -- expect 4326A healthy run shows a single aggregate node in the plan and an SRID of 4326 on the returned envelope. If the compiled SQL still contains a bare geom column with no ST_ wrapper, the @expression companion is missing and the aggregation silently reverted to the Python getter.
Gotchas checklist
- A
hybrid_propertywithout@expressionruns in Python. SQLAlchemy quietly uses the instance getter, so the aggregation happens after the rows are fetched — exactly what you were trying to avoid. Always pair the getter with an@expression. ST_Extenthas no SRID. It returns abox2d; decoding it as a geometry raises. Re-wrap withST_Envelope/ST_SetSRIDand re-attach SRID 4326.- Confusing
ST_CollectwithST_Union.ST_Collectbundles (cheap, keeps overlaps);ST_Uniondissolves (expensive, merges boundaries). UsingST_Unionto gather AIS points wastes CPU for no benefit. - Parameterised aggregations need
hybrid_method, nothybrid_property. A property cannot take a target SRID or tolerance argument; forcing one leads to closures over module-level state that break under concurrency. - Forgetting
GROUP BYmixes aggregates and columns. Selectingmmsialongside an aggregate without grouping raises a grouping error in PostgreSQL — every non-aggregated column in theSELECTmust appear inGROUP BY.
Related Topics
- Hybrid Properties for Geometry — parent reference on instance-vs-expression hybrids for spatial columns
- SQLAlchemy & GeoAlchemy Integration Workflows — the full ORM-to-PostGIS integration stack
- Type Coercion and Serialization — decoding the aggregate WKBElement values these queries return
- Session Management for Spatial Data — sizing work_mem and routing analytical aggregations off the primary