Test Whether Two Geometries Intersect in PyQGIS

intersects() returns a boolean and looks like the simplest method in the geometry API. It is also the one most often used where a different predicate was meant, because its definition is broader than the everyday word suggests: two shapes that merely share a boundary point intersect, even though their interiors never meet. Spatial joins that double-count along shared edges almost always trace back to that single fact.

This page is a focused recipe within Geometry Operations and Spatial Predicates in PyQGIS. It covers the basic test, the boundary cases that make it surprising, how to choose between intersects, contains, within and overlaps, and how to make the test fast over a whole layer.

When intersects() returns trueFive small panels each show a blue shape and a teal shape in a different arrangement: fully apart, corner touching, edge touching, partially overlapping and one inside the other. Beneath each is the boolean result of intersects, which is false only for the fully separated pair.Only complete separation returns falseapartFalsecorner touchTrueshared edgeTrueoverlappingTruecontainedTruethe orange marks show the only shared points in the touching cases

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • Two layers, or two geometries, in the same CRS. Predicates compare raw coordinates and do not reproject.
  • Valid geometry — GEOS predicates can raise on self-intersecting input.

The basic test

Both geometries must be QgsGeometry objects, and the method is symmetric: a.intersects(b) and b.intersects(a) always agree.

from qgis.core import QgsProject

parcels = QgsProject.instance().mapLayersByName("parcels")[0]
zones = QgsProject.instance().mapLayersByName("flood_zones")[0]

zone_geometry = next(zones.getFeatures()).geometry()

hits = [
    feature["parcel_id"]
    for feature in parcels.getFeatures()
    if feature.geometry().intersects(zone_geometry)
]
print(len(hits), "parcel(s) touch the flood zone")

Breakdown: feature.geometry() returns the geometry handle; calling intersects() on it does the full GEOS test. A list comprehension is fine here because the predicate is cheap, but note that every feature is read from the provider — the prefiltering section below is what makes this scale.

If the two layers are in different CRSs the answer will be silently wrong rather than an error, because the coordinate ranges simply never overlap. Guard for it:

if parcels.crs() != zones.crs():
    raise ValueError(
        f"CRS mismatch: {parcels.crs().authid()} vs {zones.crs().authid()}"
    )

Breakdown: Comparing QgsCoordinateReferenceSystem objects with != is reliable. Raising rather than reprojecting silently is the right default in a script someone else will run — a mismatch usually means the wrong layer was picked, not that a transform is needed.

Choose the right predicate

intersects() answers "do these share any point at all". Most real questions are narrower, and picking the specific predicate makes the intent readable and the result correct.

QuestionPredicateNote
Is any part of the parcel in the zone?intersects()Includes a boundary-only touch
Is the parcel entirely in the zone?within()True for identical geometries too
Does the zone completely hold the parcel?contains()The inverse of within()
Do they share area without either containing the other?overlaps()Excludes touching and containment
Do they meet only at a boundary?touches()Interiors must not meet
Are they completely separate?disjoint()The negation of intersects()
for feature in parcels.getFeatures():
    geometry = feature.geometry()
    if geometry.within(zone_geometry):
        category = "wholly inside"
    elif geometry.intersects(zone_geometry):
        category = "partly inside"
    else:
        category = "outside"
    print(feature["parcel_id"], category)

Breakdown: Testing within() first and intersects() second encodes the specificity — every geometry that is within another also intersects it, so the reverse order would classify everything as "partly inside". This three-way split is the shape most real classification code takes.

Prefilter with bounding boxes

An exact predicate is a real geometric computation. A bounding-box comparison is four number comparisons. Running the cheap test first discards the overwhelming majority of non-matches for almost nothing.

Cheap test first, exact test secondTwelve candidate features enter a bounding box comparison. Nine are rejected immediately because their boxes do not overlap the query box. Three survive and are passed to the exact intersects test, of which two are confirmed true and one is a false positive rejected at the exact stage.The box test is an approximation — it never replaces the exact one12 candidatesboundingBox().intersects(box)4 number comparisonsrejects 9intersects()full GEOS test3 candidates2 real hits1 was a box-onlya diagonal line's box can overlap a shape the line never touches

from qgis.core import QgsFeatureRequest

zone_box = zone_geometry.boundingBox()
request = QgsFeatureRequest().setFilterRect(zone_box)

hits = [
    f["parcel_id"]
    for f in parcels.getFeatures(request)
    if f.geometry().intersects(zone_geometry)
]

Breakdown: setFilterRect() pushes the box test down to the provider, so features outside it are never read at all — a much bigger saving than filtering in Python. The exact intersects() still runs on what survives, because a bounding box is a coarse approximation: a long diagonal line has a large box that overlaps shapes the line itself misses entirely. For repeated queries against many geometries, promote this to a QgsSpatialIndex — see Build and Use a Spatial Index in PyQGIS.

Test many pairs efficiently

When both sides are layers, prepare the query geometry once. GEOS can build an internal index for a geometry that will be tested repeatedly, and QgsGeometry exposes it through a prepared form used automatically when you call the predicate in a tight loop against the same object.

from qgis.core import QgsFeatureRequest, QgsGeometry

zone_geometries = [f.geometry() for f in zones.getFeatures()]
merged = QgsGeometry.unaryUnion(zone_geometries)
box = merged.boundingBox()

request = QgsFeatureRequest().setFilterRect(box)
affected = [
    f["parcel_id"]
    for f in parcels.getFeatures(request)
    if f.geometry().intersects(merged)
]
print(len(affected), "parcel(s) affected by any zone")

Breakdown: Unioning the zones once turns n × m comparisons into n × 1. That is almost always the right move when the question is "does this touch any of them" rather than "which one does it touch". unaryUnion() merges the whole collection in a single optimised pass rather than pairwise. If you do need to know which zone, keep them separate and use a spatial index instead.

Point in polygon: the special case

Point-in-polygon is the most common predicate question of all, and it has one genuine ambiguity: a point lying exactly on the boundary. within() excludes it, intersects() includes it, and which you want depends on whether the polygons tile a surface.

How each predicate treats a boundary pointA polygon is drawn with three points: one clearly inside, one sitting exactly on an edge, and one outside. A table beneath records that within is true only for the interior point, intersects is true for both the interior and boundary points, and contains behaves identically to within.A point on the boundary is the only ambiguous caseA · insideB · on the edgeC · outsidepredicateABCpoint.within(poly)point.intersects(poly)poly.contains(point)

from qgis.core import QgsFeatureRequest, QgsProject, QgsSpatialIndex

points = QgsProject.instance().mapLayersByName("incidents")[0]
districts = QgsProject.instance().mapLayersByName("districts")[0]

index = QgsSpatialIndex(
    districts.getFeatures(), flags=QgsSpatialIndex.FlagStoreFeatureGeometries
)
names = {f.id(): f["district_name"] for f in districts.getFeatures()}

for point in points.getFeatures():
    geometry = point.geometry()
    assigned = None
    for did in index.intersects(geometry.boundingBox()):
        if geometry.within(index.geometry(did)):
            assigned = names[did]
            break
    print(point["incident_id"], assigned or "unassigned")

Breakdown: Using within() rather than intersects() means a point sitting exactly on a shared district boundary is assigned to neither district instead of both — which is what you want when the districts tile a surface and each incident must be counted once. The break stops at the first containing polygon, which is safe precisely because within() cannot match two neighbours. If unassigned boundary points are unacceptable, fall back to intersects() for those and pick deterministically, for example the district with the lowest feature ID, so repeated runs agree.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical predicate API and semantics.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Faster GEOS backend for repeated predicates; results unchanged.

The predicate methods on QgsGeometry have been stable throughout 3.x, and their semantics follow the OGC simple-features definitions, so they match PostGIS behaviour as well.

Troubleshooting

  • Everything returns false. The two layers are in different CRSs, so their coordinate ranges do not overlap. Compare authid() on both.
  • Adjacent parcels are counted twice in a join. They share a boundary, which intersects() treats as a hit. Use overlaps() if a shared edge should not count.
  • A geometry appears to intersect itself. within() and intersects() are both true for identical geometries. Exclude self-matches by comparing feature IDs in a self-join.
  • A GEOS exception is raised. One of the inputs is invalid. Repair it with makeValid() first.
  • The test is slow on large layers. No bounding-box prefilter. Add QgsFeatureRequest().setFilterRect() or build a spatial index.
  • Results differ from the GUI's spatial join. The GUI defaults to a specific predicate set; check which predicates were ticked rather than assuming it used intersects.

Conclusion

intersects() is the broadest of the spatial predicates: it is true for a shared corner, a shared edge, a partial overlap and full containment alike. Choosing the narrower predicate that matches your actual question makes the code self-documenting and avoids the double-counting that shared boundaries otherwise cause. For performance, prefilter with a bounding box and union the query side when you only need "any".

Frequently Asked Questions

Do two polygons that share only a border intersect? Yes. intersects() is true whenever the geometries share any point, including a single boundary point. If a shared edge should not count, use overlaps(), which requires the interiors to share area.

Does intersects() reproject the geometries? No. It compares raw coordinates and assumes both are in the same CRS. A mismatch produces False for every pair with no warning, so check crs().authid() on both layers before testing.

Is a bounding-box test enough on its own? No. It is an approximation that produces false positives — a diagonal line's box covers a large area the line never touches. Use it to shorten the candidate list, then run the exact predicate.

What is the difference between contains and within? They are inverses. a.contains(b) and b.within(a) mean the same thing. Both are true when the geometries are identical, because a shape contains itself.

Is disjoint just the opposite of intersects? Yes, exactly — a.disjoint(b) is always the negation of a.intersects(b). Use whichever reads more naturally in the surrounding code.