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.

Four positions, four different answers A zone polygon with four test points: one strictly inside, one exactly on the boundary, one just outside, and a small polygon overlapping the edge. A table beneath shows which of ST_Intersects, ST_Contains, ST_Within and ST_Covers returns true for each. The boundary is where the predicates disagree zone A inside B on the edge C outside D straddling Intersects Contains Covers A inside true true true B on the edge true false true C outside false false false D straddling true false false Row B is the whole argument. Whether it belongs to the zone is a business rule, not a technical detail.

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:

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

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

Choosing by what should count as a match Three cases with their predicate. Any touching or overlap at all wants ST_Intersects. Strict containment with the boundary excluded wants ST_Contains. Containment with the boundary included wants ST_Covers, which is the usual answer for assigning points to areas. Say what should count, then pick the predicate "any overlap at all, including a shared edge" ST_Intersects(a, b) — symmetric, no argument-order trap "entirely inside, and the boundary does not count" ST_Contains(container, contained) — or ST_Within with the arguments swapped "entirely inside, and the boundary does count" — the usual business rule ST_Covers(container, contained) Row counts by predicate on the same join Joining 8.2 million customers to 340 delivery zones: ST_Intersects returns 8,204,118 rows because boundary points match twice, ST_Covers with a lateral limit returns 8,204,110, and ST_Contains returns 8,204,096 because eight boundary customers match nothing. Same data, same join, three row counts ST_Intersects 8,204,118 — eight customers counted twice ST_Covers + LATERAL 8,204,110 — every customer exactly once ST_Contains 8,204,096 — eight customers assigned to nothing Fourteen rows out of eight million. Whether that matters is a question about your domain, not your database.

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:

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

Keeping 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_Contains and ST_Within are the same test, arguments reversed. A swap produces no error and wrong rows.
  • ST_Intersects is 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_ContainsProperly is 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.