Hybrid properties bridge the gap between Python object attributes and PostGIS SQL expressions. As part of the broader SQLAlchemy and GeoAlchemy integration workflows, this technique lets you define a single property—such as area_sqkm or centroid—that behaves correctly whether it is accessed on a loaded ORM instance or compiled into a SELECT, WHERE, or ORDER BY clause. The database handles the heavy lifting; Python never fetches raw geometry bytes just to compute a metric.
This page covers the full implementation workflow: validating your infrastructure, wiring up @hybrid_property with correct SRID and unit handling, writing the companion SQL expression, verifying the generated SQL with EXPLAIN ANALYZE, and diagnosing the failure modes that most commonly trip teams up in production.
Prerequisites and Infrastructure Validation
Before writing hybrid properties, confirm the following baseline is in place. Attempting to compile a GeoAlchemy2 expression against a database that is missing PostGIS, or filtering on a spatial metric without the right index, produces obscure errors that are hard to attribute.
Required Python packages
sqlalchemy>=2.0
geoalchemy2>=0.14
shapely>=2.0
pyproj>=3.4 # for instance-level projected-CRS area calculations
psycopg2-binary # or asyncpg for async sessions
Verify PostGIS and the geometry column
Run these checks against your target database before deploying:
-- 1. Confirm PostGIS is installed
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'postgis';
-- 2. Confirm the geometry column exists with the expected type and SRID
SELECT f_table_name, f_geometry_column, type, srid, coord_dimension
FROM geometry_columns
WHERE f_table_name = 'parcels';
-- 3. Confirm a GiST index exists on the geometry column
-- (required for ST_DWithin and && bounding-box operators used alongside hybrids)
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'parcels'
AND indexdef ILIKE '%gist%';If the GiST index is missing, create it before running spatial queries in production. Hybrid expressions that delegate to PostGIS functions can still run without a GiST index, but any WHERE clause that also uses bounding-box filtering or ST_DWithin radius searches will fall back to a sequential scan on large tables.
CREATE INDEX CONCURRENTLY idx_parcels_boundary_gist
ON parcels USING GIST (boundary);
ANALYZE parcels;How the Dual-Layer Pattern Works
The diagram below shows how SQLAlchemy routes a single property name through two different code paths depending on where it appears.
The compiler intercepts the property during query generation and substitutes the Python method with the SQL fragment returned by the @expression decorator. No geometry bytes cross the network until you explicitly load rows.
Core Execution Workflow
Step 1 — Define the Declarative Model
Set up your ORM base and declare the geometry column with explicit srid and geometry_type. Explicit parameters enforce database-level constraints, prevent silent SRID coercion, and give GeoAlchemy2 enough information to generate correct DDL during Alembic migrations.
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import Column, Integer, String
from geoalchemy2 import Geometry
class Base(DeclarativeBase):
pass
class Parcel(Base):
__tablename__ = "parcels"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
# Explicit SRID=4326 and geometry_type prevents silent coercion errors
boundary = Column(
Geometry(geometry_type="POLYGON", srid=4326),
nullable=False,
)Review model mapping with GeoAlchemy2 for column type conventions, WKB serialization behavior, and how to wire this model into FastAPI or Flask correctly.
Step 2 — Attach @hybrid_property with Correct Unit Handling
The most common mistake is calling ST_Area on a geometry(POLYGON, 4326) column and treating the result as square metres. That column stores coordinates in degrees; ST_Area on planar geometry returns area in the square of the SRID’s unit, which is square degrees for EPSG:4326—a physically meaningless value. The fix is to cast to geography, which always computes spheroidal area in square metres.
At the instance level, project the geometry into an equal-area CRS using pyproj before calling Shapely’s .area attribute.
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import func
from geoalchemy2.shape import to_shape
from shapely.ops import transform
import pyproj
# Build the transformer once at module load time; reuse across instances
_wgs84_to_equal_area = pyproj.Transformer.from_crs(
"EPSG:4326", "EPSG:6933", always_xy=True
).transform
class Parcel(Base):
__tablename__ = "parcels"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
boundary = Column(
Geometry(geometry_type="POLYGON", srid=4326),
nullable=False,
)
@hybrid_property
def area_sqkm(self) -> float:
"""Area in km² computed on a loaded instance using an equal-area CRS.
Projects from WGS84 to EPSG:6933 (World Equal Area) before calling
Shapely .area so the result is in square metres, then converts to km².
"""
geom = to_shape(self.boundary) # WKBElement → Shapely geometry
projected = transform(_wgs84_to_equal_area, geom)
return projected.area / 1_000_000 # m² → km²
@area_sqkm.expression
@classmethod
def area_sqkm(cls):
"""SQL expression: cast to geography triggers spheroidal ST_Area in m².
The division by 1 000 000 converts to km² inline, so ORDER BY and
WHERE clauses see the same scale as the Python instance method.
"""
return func.ST_Area(
func.cast(cls.boundary, "geography")
) / 1_000_000Step 3 — Add a Perimeter Hybrid Property
The same pattern applies to any PostGIS scalar function. ST_Perimeter on a geography cast returns perimeter in metres.
@hybrid_property
def perimeter_km(self) -> float:
"""Perimeter in km computed on a loaded instance."""
geom = to_shape(self.boundary)
projected = transform(_wgs84_to_equal_area, geom)
return projected.length / 1_000 # m → km
@perimeter_km.expression
@classmethod
def perimeter_km(cls):
"""SQL expression: ST_Perimeter on geography returns metres."""
return func.ST_Perimeter(
func.cast(cls.boundary, "geography")
) / 1_000Step 4 — Query Using the Hybrid Expressions
Once defined, the hybrid properties work transparently in ORM queries. SQLAlchemy routes instance access to the Python method and query usage to the SQL expression—you do not need to call anything special.
from sqlalchemy import select
from sqlalchemy.orm import Session
with Session(engine) as session:
# Filter: area_sqkm > 50 compiles to a WHERE clause with ST_Area
large_parcels = session.scalars(
select(Parcel).where(Parcel.area_sqkm > 50)
).all()
# Project specific columns — PostGIS computes both metrics in one query
ranked = session.execute(
select(Parcel.name, Parcel.area_sqkm, Parcel.perimeter_km)
.order_by(Parcel.area_sqkm.desc())
.limit(20)
).all()
# Access on a loaded instance calls the Python method
parcel = session.get(Parcel, 1)
print(parcel.area_sqkm) # uses to_shape + pyproj, no extra SQLThe second query compiles to the following SQL. Verify this by enabling SQLAlchemy’s echo or using compile() in a test:
SELECT parcels.name,
(ST_Area(CAST(parcels.boundary AS geography)) / 1000000) AS area_sqkm,
(ST_Perimeter(CAST(parcels.boundary AS geography)) / 1000) AS perimeter_km
FROM parcels
ORDER BY area_sqkm DESC
LIMIT 20;PostGIS evaluates both functions at the C level. No geometry bytes travel over the wire until the scalar results are returned.
Step 5 — Integrate with Session Boundaries
Hybrid properties that access self.boundary require the instance to be within an active session with the geometry column loaded. When using session management patterns that expire attributes on commit, or when using session.expunge(), load the geometry explicitly before detachment:
from sqlalchemy.orm import Session, load_only
with Session(engine) as session:
# Eager-load boundary so the hybrid property works after session closes
parcel = session.scalars(
select(Parcel)
.where(Parcel.id == 42)
.options(load_only(Parcel.id, Parcel.name, Parcel.boundary))
).one()
session.expunge(parcel)
# Safe to call after session close: boundary is already in memory
print(parcel.area_sqkm)Performance Considerations
EXPLAIN ANALYZE Output
Run EXPLAIN (ANALYZE, BUFFERS) against your hybrid-driven queries to confirm the planner is not choosing a sequential scan on the geometry column:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT name,
ST_Area(boundary::geography) / 1000000 AS area_sqkm
FROM parcels
WHERE ST_Area(boundary::geography) / 1000000 > 50;Expected output for a table with a GiST index and up-to-date statistics:
Seq Scan on parcels (cost=0.00..1240.00 rows=12 width=64)
Filter: ((st_area((boundary)::geography) / '1000000'::double precision) > '50'::double precision)
A sequential scan here is expected for ST_Area-based filters, because GiST indexes cover spatial operators (&&, ST_DWithin), not scalar function results. To make range filters on computed area use an index, create a functional index:
-- Functional index on the same expression as the hybrid property
CREATE INDEX idx_parcels_area_sqkm
ON parcels ((ST_Area(boundary::geography) / 1000000));
ANALYZE parcels;After this index exists, re-run EXPLAIN and confirm the planner switches to an Index Scan when the selectivity is high enough (typically when the filtered fraction is under ~10% of the table).
Relevant GUC Settings
| Setting | Recommended value | Effect on spatial queries |
|---|---|---|
work_mem |
32–128 MB | Prevents on-disk sorts when ordering by area or perimeter |
random_page_cost |
1.1 (SSD) | Helps planner prefer index scans over sequential scans |
effective_cache_size |
50–75% of RAM | Tells the planner how much OS page cache is available |
default_statistics_target |
500 for geometry columns | Improves cardinality estimates for spatial predicates |
Set default_statistics_target per-column for geometry-heavy tables:
ALTER TABLE parcels
ALTER COLUMN boundary SET STATISTICS 500;
ANALYZE parcels;Common Failure Modes and Fixes
Missing .expression Decorator
Symptom: AttributeError: Neither 'InstrumentedAttribute' object nor 'Comparator' object has an attribute 'area_sqkm' when using the property in a WHERE clause.
Cause: Only the instance method is defined; the SQL expression counterpart is absent.
Fix: Add the @<property_name>.expression decorator with a method that returns a SQLAlchemy ColumnElement. Both must be present for the property to work in both contexts.
SRID Mismatch in Cross-Table Expressions
Symptom: ERROR: Operation on two geometries with different SRIDs when joining two tables in a hybrid expression.
Diagnosis SQL:
SELECT f_table_name, f_geometry_column, srid
FROM geometry_columns
WHERE f_table_name IN ('parcels', 'zones');Fix: Add an explicit ST_Transform in the expression:
@area_sqkm.expression
@classmethod
def area_sqkm(cls):
# Transform to EPSG:4326 first if the stored SRID could vary
return func.ST_Area(
func.ST_Transform(cls.boundary, 4326).__cast__("geography")
) / 1_000_000For a detailed treatment of SRID alignment across joins, see reading EXPLAIN ANALYZE output for spatial joins.
WKBElement / WKTElement AttributeError on Instance Access
Symptom: AttributeError: 'WKBElement' object has no attribute 'area' when calling the Python method.
Cause: GeoAlchemy2 returns a WKBElement or WKTElement from the column, not a native Shapely object. Calling .area directly on a WKBElement fails.
Fix: Always wrap with to_shape():
from geoalchemy2.shape import to_shape
@hybrid_property
def area_sqkm(self) -> float:
geom = to_shape(self.boundary) # converts WKBElement → shapely.geometry.Polygon
projected = transform(_wgs84_to_equal_area, geom)
return projected.area / 1_000_000Also review the type coercion and serialization patterns page for a full reference on GeoAlchemy2 element types and when each appears.
OOM on Large Result Sets
Symptom: Memory spikes or MemoryError when calling session.scalars(...).all() on a query that loads thousands of full geometry objects.
Cause: .all() materializes the entire result set into memory. If the SELECT includes the raw boundary column (triggered by loading full Parcel instances rather than projecting scalars), you fetch MB-scale geometry for every row.
Fix: Project only the scalar metrics, or use yield_per() for streaming:
# Option A: project scalars — geometry never crosses the wire
results = session.execute(
select(Parcel.id, Parcel.name, Parcel.area_sqkm)
).all()
# Option B: stream full instances in batches of 500
for parcel in session.scalars(select(Parcel).yield_per(500)):
process(parcel.area_sqkm)For batch spatial operations under memory pressure, combine yield_per() with batch processing patterns from spatial joins.
Planner Choosing Sequential Scan Despite Functional Index
Symptom: EXPLAIN still shows Seq Scan after creating the functional index.
Cause: Statistics are stale, or the planner estimates the filtered fraction is too large for the index to pay off.
Fix sequence:
-- 1. Update statistics
ANALYZE parcels;
-- 2. Force planner reconsideration in your session
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT name FROM parcels
WHERE (ST_Area(boundary::geography) / 1000000) > 50;
-- 3. Re-enable after testing
SET enable_seqscan = on;If the Index Scan appears when seqscan is disabled, the planner is making a cost-based decision. Adjust random_page_cost downward if you are on SSD storage, or increase default_statistics_target to give the planner better cardinality data.
Verification
After implementation, run this checklist to confirm everything is wired correctly:
1. Expression compilation test (no database required)
from sqlalchemy import select
from sqlalchemy.dialects import postgresql
stmt = select(Parcel).where(Parcel.area_sqkm > 50)
compiled = stmt.compile(dialect=postgresql.dialect())
sql_text = str(compiled)
assert "ST_Area" in sql_text, "ST_Area missing from compiled SQL"
assert "geography" in sql_text, "geography cast missing"
assert "1000000" in sql_text, "unit conversion missing"
print("Compilation OK:", sql_text[:120])2. Round-trip accuracy test (requires a live PostGIS connection)
import pytest
from sqlalchemy.orm import Session
def test_area_consistency(engine, sample_parcel_id):
"""Instance value and SQL value should agree within floating-point tolerance."""
with Session(engine) as session:
parcel = session.get(Parcel, sample_parcel_id)
instance_area = parcel.area_sqkm
sql_area = session.scalar(
select(Parcel.area_sqkm).where(Parcel.id == sample_parcel_id)
)
# Allow 0.5% tolerance: pyproj equal-area vs PostGIS spheroidal differ slightly
assert abs(instance_area - sql_area) / sql_area < 0.005, (
f"Instance ({instance_area:.4f}) and SQL ({sql_area:.4f}) diverge by "
f"more than 0.5%"
)3. Index usage check
-- After creating the functional index and running ANALYZE,
-- this should show Index Scan (not Seq Scan) for selective queries
EXPLAIN (ANALYZE, BUFFERS)
SELECT name, (ST_Area(boundary::geography) / 1000000) AS area_sqkm
FROM parcels
WHERE (ST_Area(boundary::geography) / 1000000) BETWEEN 1 AND 5
ORDER BY area_sqkm;Async Compatibility
When using asyncpg with SQLAlchemy’s AsyncSession, the @expression method is evaluated at compile time—which is synchronous—so no changes are needed for async compatibility. The instance-level method (using to_shape and pyproj) runs in your application code. If you call it inside an async def, ensure it does not block the event loop: pyproj transforms are CPU-bound but fast for individual geometries; for bulk processing, offload to asyncio.to_thread.
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
async def get_large_parcels(session: AsyncSession):
result = await session.scalars(
select(Parcel).where(Parcel.area_sqkm > 50)
)
return result.all()The Parcel.area_sqkm > 50 filter compiles to SQL at the select() call (synchronous), then the compiled query is dispatched asynchronously to asyncpg.
Related Topics
- SQLAlchemy and GeoAlchemy Integration Workflows — parent section covering the full ORM integration stack
- Model Mapping with GeoAlchemy2 — column type conventions, SRID enforcement, WKB serialization
- Type Coercion and Serialization — WKBElement vs WKTElement, to_shape patterns
- Session Management for Spatial Data — session scoping, expunge, DetachedInstanceError prevention
- Advanced GiST Indexing and Optimization — functional indexes, index-only scans, EXPLAIN plan interpretation