Problem Statement
Three predicates can express “these two geometries are related”, they perform identically, and they return different rows. Choosing between them is a modelling decision that is routinely made by accident — usually by copying whichever one appeared in the first search result. This page makes the choice explicit for spatial joins: what each predicate says about boundaries, which pairs are inverses, and how to test the difference rather than assume it.
Why the Naive Approach Fails
The failure is not an error message. It is a row count that is quietly wrong in a way nobody notices until a customer on a boundary is billed twice, or a parcel adjacent to a flood zone is excluded from a report it should have been in.
Production-Ready Implementation
Write the choice down where the next reader will see it. A comment naming the rule is worth more than the predicate itself:
-- Assignment rule: a customer exactly on a zone border BELONGS to that zone.
-- ST_Covers, not ST_Contains — see ticket OPS-4412.
SELECT c.id AS customer_id, z.id AS zone_id
FROM customers c
JOIN LATERAL (
SELECT z.id
FROM zones z
WHERE z.geom && c.geom -- index prefilter
AND ST_Covers(z.geom, c.geom) -- boundary counts as inside
ORDER BY z.priority, z.id -- deterministic when zones overlap
LIMIT 1
) z ON true;And in SQLAlchemy, where argument order is easy to get backwards:
from sqlalchemy import select, func
# ST_Covers(container, contained) — the container comes first, as in SQL.
stmt = (
select(Customer.id, Zone.id)
.join(Zone, func.ST_Covers(Zone.geom, Customer.geom))
)
# The inverse spelling, identical in meaning:
stmt_equivalent = (
select(Customer.id, Zone.id)
.join(Zone, func.ST_CoveredBy(Customer.geom, Zone.geom))
)Picking one spelling and using it consistently across a codebase removes an entire class of review question. Most teams settle on the container-first form because it reads in the same order as the SQL.
Configuration and Tuning Knobs
There is very little to tune here, which is the point: all of these predicates use the same index path and the same recheck. Two things do affect the outcome.
Geometry validity. All of these predicates assume valid input. On an invalid polygon the answer is undefined rather than merely wrong, and it can differ between GEOS versions — which produces the memorable failure mode of a report that changes when the database is upgraded. See geometry validity and repair.
Coordinate precision. Two geometries digitised independently rarely share an edge exactly. A point “on the boundary” in the source data may be a nanometre outside it in floating point, in which case ST_Covers and ST_Contains agree — and the case you carefully handled never arises, while a different one does. ST_DWithin(a, b, 0.0000001) is the pragmatic tolerance-based alternative where exactness is unattainable.
Verification Steps
Test with geometry you construct deliberately, not with a sample of production data:
WITH zone AS (
SELECT ST_GeomFromText('POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))', 4326) AS g
), probes(label, g) AS (
VALUES ('inside', ST_GeomFromText('POINT(5 5)', 4326)),
('boundary', ST_GeomFromText('POINT(10 5)', 4326)),
('outside', ST_GeomFromText('POINT(11 5)', 4326))
)
SELECT p.label,
ST_Intersects(z.g, p.g) AS intersects,
ST_Contains(z.g, p.g) AS contains,
ST_Covers(z.g, p.g) AS covers
FROM zone z, probes p;
-- label | intersects | contains | covers
-- ----------+------------+----------+--------
-- inside | t | t | t
-- boundary | t | f | t
-- outside | f | f | fKeeping that query as a test asserts the behaviour your code depends on, and it will fail loudly if a future PostGIS release changes it — which is exactly the kind of change nobody reads release notes carefully enough to catch.
Gotchas Checklist
ST_ContainsandST_Withinare the same test, arguments reversed. A swap produces no error and wrong rows.ST_Intersectsis symmetric; the others are not. That makes it the safe default when the relationship genuinely is “these touch”.- A polygon does not contain its own boundary under
ST_Contains. This surprises people every time, and it is the source of most duplicate or missing assignments. ST_ContainsProperlyis stricter still — it excludes any shared boundary at all, including interior touching. It is rarely what an application wants but occasionally exactly right for topology checks.- All of these ignore SRID mismatches at your peril. A predicate between geometries in different SRIDs raises an error, which is the good outcome; between geometries wrongly labelled with the same SRID, it silently returns nonsense.
Boundary Semantics, and Why They Bite in Production
The three predicates differ only on the boundary, which is exactly where real data lives. Administrative areas share borders. Parcels touch. A GPS point snapped to a road that forms a district edge lands on the line, not beside it. So the boundary case is not an edge case; on a typical dataset it accounts for a small but persistent fraction of rows, and it is the fraction that generates support tickets.
Underneath, all four predicates are shorthand for patterns in the DE-9IM matrix that describes how the interiors, boundaries, and exteriors of two geometries relate. ST_Contains(A, B) requires that B’s interior intersect A’s interior and that no part of B lie in A’s exterior — and, critically, that B not lie entirely within A’s boundary. That last clause is why a point exactly on the edge of a polygon is not contained by it, and it is why ST_Covers exists: it drops the clause and returns true for the boundary case.
The practical rule is short. Use ST_Intersects when any spatial relationship at all counts, which covers most joins. Use ST_Covers when you mean “inside, boundary included”, which is what non-specialists almost always mean when they say contains. Reserve ST_Contains and ST_Within for the cases where excluding the boundary is a deliberate requirement you can articulate.
A second consequence shows up in aggregation. If districts share a border and you assign points to districts with ST_Intersects, every point on a shared edge is assigned to both districts and your totals exceed the row count. The fix is not a different predicate but a tie-break: DISTINCT ON (point_id) ordered by district id, or a join on ST_Covers combined with a deterministic choice. Deciding this once, at the schema level, prevents two teams from computing different totals from the same table.
Related Topics
- Spatial Joins — parent topic: join strategy and index interaction
- Point-in-Polygon Joins at Scale — where this choice determines the row count
- Geometry Validity and Repair — why invalid input makes all of these undefined
- Bounding Box Filtering — the prefilter every one of them shares