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:

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

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

SQLAlchemy hybrid property routing When a hybrid property is accessed on a model instance, SQLAlchemy calls the Python method (which may use Shapely or pyproj). When the same property appears in a query clause, SQLAlchemy substitutes the SQL expression (which calls PostGIS functions like ST_Area). Both paths return the same logical value. @hybrid_property area_sqkm parcel_obj.area_sqkm (instance access) to_shape(self.boundary) transform(proj, geom) geom.area / 1_000_000 Parcel.area_sqkm > 50 (query clause) ST_Area( boundary::geography ) / 1000000 routes to routes to Python / Shapely PostGIS SQL

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.

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

python
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_000

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

python
    @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_000

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

python
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 SQL

The second query compiles to the following SQL. Verify this by enabling SQLAlchemy’s echo or using compile() in a test:

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

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

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

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

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

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:

python
@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_000

For 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():

python
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_000

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

python
# 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:

sql
-- 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)

python
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)

python
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

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

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