How to Run Spatial SQL Queries from Python with PostGIS
Problem statement
The data is in PostGIS. The code still reads all of it.
parcels = gpd.read_postgis("SELECT * FROM parcels", engine, geom_col="geom")
wards = gpd.read_postgis("SELECT * FROM wards", engine, geom_col="geom")
leith = wards[wards.name == "Leith"]
joined = gpd.sjoin(parcels, leith, predicate="within")
result = joined.groupby("ward")["area_m2"].sum()
Four million parcels crossed the network so that eight hundred could be kept. The spatial index the database maintains was never consulted. The machine running the script needs 6 GB of RAM to answer a question the database could have answered in 40 ms.
This is the most common way a PostGIS migration fails to pay off: the database becomes an expensive file server. The fix is not to abandon GeoPandas β it is to move the selection, the join and the aggregation into SQL and let GeoPandas receive the answer.
Quick answer
Push the filter, the join and the aggregation down; keep the modelling in Python:
import geopandas as gpd
from sqlalchemy import create_engine
engine = create_engine("postgresql+psycopg://user:pass@localhost:5432/gis")
sql = """
SELECT p.parcel_id,
w.name AS ward,
ST_Area(p.geom) AS area_m2,
p.geom
FROM parcels p
JOIN wards w ON ST_Within(p.geom, w.geom)
WHERE w.name = %(ward)s
AND p.surveyed >= %(since)s
"""
gdf = gpd.read_postgis(
sql, engine, geom_col="geom",
params={"ward": "Leith", "since": "2024-01-01"},
)
The rules of thumb:
| Do in SQL | Do in Python |
|---|---|
WHERE, LIMIT, bbox filters |
anything with scikit-learn in it |
| spatial and attribute joins | iterative or recursive logic |
GROUP BY and aggregation |
plotting and reporting |
ST_Intersection, ST_Buffer on many rows |
one-off geometry work on a handful |
| anything that reduces the row count | anything that needs a Python library |
And one rule that is not a rule of thumb: never build SQL with f-strings. Pass params and let the driver handle quoting.
The two shapes of the same analysis
Step-by-step solution
1. Parameterise, always
# never β a ward called O'Brien's Field breaks this, and worse things break it deliberately
sql = f"SELECT * FROM parcels WHERE ward = '{ward}'"
# psycopg / SQLAlchemy with a raw connection: %(name)s placeholders
gdf = gpd.read_postgis(
"SELECT * FROM parcels WHERE ward = %(ward)s AND surveyed > %(since)s",
engine, geom_col="geom",
params={"ward": ward, "since": since},
)
Placeholders are for values, not identifiers. A table or column name cannot be parameterised β if it must be dynamic, validate it against a known list rather than interpolating user input.
2. Use the spatial predicates that use the index
ST_Intersects, ST_Within, ST_Contains and ST_DWithin are index-accelerated: PostGIS filters by bounding box using the GIST index, then runs the exact test on the survivors. ST_Distance in a WHERE clause is not β it computes a distance for every row.
-- scans every row, computes 4 million distances
WHERE ST_Distance(p.geom, :point) < 500
-- uses the index, then tests exactly
WHERE ST_DWithin(p.geom, :point, 500)
ST_DWithin is the single most valuable substitution in spatial SQL. Same answer, different order of magnitude.
3. Confirm with EXPLAIN, not with a stopwatch
with engine.connect() as conn:
plan = conn.exec_driver_sql(
"EXPLAIN (ANALYZE, BUFFERS) " + sql, {"ward": "Leith", "since": "2024-01-01"}
).fetchall()
print("\n".join(r[0] for r in plan))
You are reading for two words:
Seq Scan on parcels β no index used; 4 million rows examined
Index Scan using parcels_geom_idx β the index did its job
A sequential scan on a large table almost always means a missing GIST index, a predicate that cannot use one, or an SRID mismatch that forced a cast.
4. Aggregate in the database when you do not need the geometry
If the answer is a table of numbers, do not fetch geometry at all β use pandas.read_sql, not read_postgis.
import pandas as pd
totals = pd.read_sql("""
SELECT w.name AS ward,
count(*) AS parcels,
sum(ST_Area(p.geom)) AS total_m2,
avg(ST_Area(p.geom)) AS mean_m2
FROM parcels p
JOIN wards w ON ST_Within(p.geom, w.geom)
GROUP BY w.name
ORDER BY total_m2 DESC
""", engine)
Twelve rows over the wire instead of four million. This is the biggest single win available and the one most often missed, because read_postgis is the function everyone reaches for.
5. Keep the CRS honest on the way back
read_postgis reads the SRID from the geometry and sets the GeoDataFrame's CRS from it β but only if the SRID is set. Geometries built inline in SQL often have SRID 0.
-- loses the SRID: the result has no CRS in Python
SELECT ST_Buffer(geom, 50) AS geom FROM parcels
-- keeps it
SELECT ST_SetSRID(ST_Buffer(geom, 50), ST_SRID(geom)) AS geom FROM parcels
Then assert on arrival:
gdf = gpd.read_postgis(sql, engine, geom_col="geom")
assert gdf.crs is not None, "query returned geometry with no SRID"
assert gdf.crs.to_epsg() == 27700
6. Stream results that do not fit in memory
# read_postgis has no chunksize; go through pandas and rebuild
from shapely import from_wkb
sql = "SELECT parcel_id, ward, ST_AsBinary(geom) AS wkb FROM parcels"
for chunk in pd.read_sql(sql, engine, chunksize=50_000):
gdf = gpd.GeoDataFrame(
chunk.drop(columns="wkb"),
geometry=from_wkb(chunk["wkb"]),
crs=27700,
)
process(gdf)
ST_AsBinary plus shapely.from_wkb is the manual version of what read_postgis does, and it is the way to get chunking. Set crs explicitly, because ST_AsBinary drops the SRID β ST_AsEWKB keeps it if you would rather not hard-code.
Code examples
Example 1: a spatial join that stays in the database
-- parcels tagged with their ward, plus distance to the nearest school
SELECT p.parcel_id,
w.name AS ward,
s.name AS nearest_school,
ST_Distance(p.geom, s.geom) AS school_m,
p.geom
FROM parcels p
JOIN wards w
ON ST_Within(p.geom, w.geom)
CROSS JOIN LATERAL (
SELECT name, geom
FROM schools
ORDER BY schools.geom <-> p.geom -- index-backed nearest neighbour
LIMIT 1
) s
WHERE w.name = %(ward)s;
The <-> operator is a KNN distance that the GIST index supports directly, so ORDER BY ... LIMIT 1 finds the nearest school without measuring all of them. There is no clean GeoPandas equivalent that is this efficient β sjoin_nearest is close, but it needs both layers in memory first.
Example 2: a reusable query layer
Inline SQL scattered through a codebase becomes unmaintainable. Give queries names and signatures.
# src/queries.py
from dataclasses import dataclass
import geopandas as gpd
import pandas as pd
PARCELS_IN_WARD = """
SELECT p.parcel_id, w.name AS ward, ST_Area(p.geom) AS area_m2, p.geom
FROM parcels p
JOIN wards w ON ST_Within(p.geom, w.geom)
WHERE w.name = %(ward)s
AND (%(min_area)s IS NULL OR ST_Area(p.geom) >= %(min_area)s)
"""
WARD_TOTALS = """
SELECT w.name AS ward, count(*) AS parcels, sum(ST_Area(p.geom)) AS total_m2
FROM parcels p JOIN wards w ON ST_Within(p.geom, w.geom)
GROUP BY w.name
"""
@dataclass
class Queries:
engine: object
def parcels_in_ward(self, ward: str, min_area: float | None = None) -> gpd.GeoDataFrame:
gdf = gpd.read_postgis(
PARCELS_IN_WARD, self.engine, geom_col="geom",
params={"ward": ward, "min_area": min_area},
)
if gdf.crs is None:
raise ValueError("query returned geometry without an SRID")
return gdf
def ward_totals(self) -> pd.DataFrame:
return pd.read_sql(WARD_TOTALS, self.engine)
The (%(min_area)s IS NULL OR ...) pattern gives one query an optional filter without string concatenation. It is the small trick that stops a query module growing an f-string.
Example 3: writing results back without a round trip
When the output is derived from data already in the database, keep it there.
from sqlalchemy import text
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS parcel_buffers (
parcel_id bigint PRIMARY KEY,
geom geometry(Polygon, 27700) NOT NULL
);
TRUNCATE parcel_buffers;
INSERT INTO parcel_buffers (parcel_id, geom)
SELECT parcel_id, ST_SetSRID(ST_Buffer(geom, 50), 27700)
FROM parcels
WHERE ward = :ward;
"""), {"ward": "Leith"})
Nothing crossed the network. For bulk geometry work on data that stays in the database, this beats reading, buffering in Python and writing back β often by a factor of ten.
Explanation
A GIST index on a geometry column stores each geometry's bounding box in an R-tree. When a query asks ST_Intersects(geom, :shape), PostGIS rewrites it internally as "bounding boxes overlap" β an index lookup that discards almost everything β followed by the exact geometric test on the remainder. That two-stage filter is why an intersects query over 40 million rows can return in milliseconds.
The rewrite only happens for predicates PostGIS knows are bounding-box-compatible. ST_Intersects, ST_Within, ST_Contains, ST_Overlaps, ST_Crosses and ST_DWithin qualify. ST_Distance(a, b) < d does not, because there is no bounding box that expresses it β hence the ST_DWithin substitution, which is the same question phrased so the index can answer it.
Two things silently disable the index:
- An SRID mismatch. Comparing a geometry in 27700 with a literal in 4326 does not fall back to a slow query β it raises
Operation on mixed SRID geometriesand the query fails. Transform the literal once, in the query, so both sides match. - A function wrapping the indexed column.
ST_Buffer(geom, 10)in aWHEREclause means the index ongeomcannot help, because the thing being tested is notgeom.
The wider point is about where the data has to travel. Every row a query returns must be serialised, sent over a socket, deserialised, and turned into Python objects. That per-row cost dwarfs the cost of the geometric test itself. So the fastest spatial query is almost always the one that returns the fewest rows β which is the same as saying: do the reducing work where the data already is.
Edge cases or notes
read_postgisneedsgeom_colto match your column name. The PostGIS convention isgeom; GeoPandas defaults togeometry.- A query returning zero rows gives a GeoDataFrame with no CRS, because there was no geometry to read an SRID from. Handle empties before asserting on
crs. ST_Areaon a geographic SRID returns square degrees. Store projected, or usegeographyand accept the slower spheroid maths.- Very long queries belong in
.sqlfiles, loaded at import. Multi-hundred-line strings inside Python are unreviewable. - Connection pooling matters under a scheduler. Create one engine at module level; do not build one per task.
LIMITwithoutORDER BYis non-deterministic. For sampling, useTABLESAMPLE SYSTEM (1).EXPLAIN ANALYZEruns the query. On aDELETEorUPDATE, wrap it in a transaction you roll back.- Keep transformation out of the join.
ST_Within(p.geom, ST_Transform(w.geom, 27700))transforms every ward for every parcel. Store both tables in one CRS instead.
Internal links
- How to connect GeoPandas to PostGIS β engines, drivers, connection strings
- How to write a GeoDataFrame to PostGIS β getting the data in with types and indexes intact
- PostGIS explained: when a spatial database beats a folder of files β why push work down at all
- PostGIS write fails on SRID or geometry type β the SRID mismatch that also breaks queries
- Spatial indexes explained: R-trees and why spatial joins are fast β what GIST is doing underneath
- How to perform a spatial join in Python (GeoPandas) β the in-memory equivalent, and when it is the right tool
- How to find the nearest point in GeoPandas β the Python version of the
<->example - Fixing memory errors in GeoPandas when working with large files β the symptom that sends people here
FAQ
Should I use read_postgis or read_sql?
read_postgis when you need the geometry, read_sql when you need numbers. Fetching geometry you then discard is the most expensive mistake in this whole workflow.
Why is my spatial query slow even with an index?
Run EXPLAIN ANALYZE. The usual causes are a missing GIST index, ST_Distance in the WHERE clause instead of ST_DWithin, an SRID mismatch forcing a per-row transform, or a function wrapped around the indexed column.
How do I pass a geometry as a query parameter?
Send it as WKT or WKB and let PostGIS parse it: ST_GeomFromText(%(wkt)s, 27700) with params={"wkt": shape.wkt}.
Can I use GeoPandas syntax against PostGIS directly?
No β GeoPandas operates on in-memory frames. Tools like ibis or GeoAlchemy2's ORM layer let you build queries in Python, but the work still happens as SQL.
Is sjoin in GeoPandas ever better than a SQL join?
Yes, when both layers are already in memory and small, or when the join needs a predicate PostGIS lacks. Otherwise the database wins, because it never has to load either side.
How do I stop SQL injection when the table name is dynamic?
You cannot parameterise identifiers. Validate against an allow-list of known table names, or use psycopg.sql.Identifier, which quotes them safely.
Why does my buffered result lose its CRS?
ST_Buffer returns geometry with the input's SRID in most versions, but geometry constructed from scratch has SRID 0. Wrap results in ST_SetSRID(..., ST_SRID(geom)) when in doubt, and assert on gdf.crs after reading.