QgsSpatialIndex vs setFilterRect — When to Use Each

A decision guide for PyQGIS spatial queries: when an in-memory QgsSpatialIndex beats provider-side QgsFeatureRequest.setFilterRect, by repetition, backend,…

TL;DR: Use setFilterRect for one-off bounding-box queries and for any database-backed layer, because the filter is pushed down to the provider’s native index. Build a QgsSpatialIndex when you fire many repeated queries against the same in-memory or file layer, or when you need nearestNeighbor() — the one-time build cost only pays off across repetition, and proximity ranking has no setFilterRect equivalent.

This page is part of the Spatial Indexing and Query Optimization in PyQGIS guide. That guide explains how the R-tree and the two-phase query work; this companion page is about the decision — given a concrete workload, which of the two tools you should reach for and why. If you are unsure what an R-tree candidate pass is, read the parent first; here we assume it and focus on trade-offs.

Both tools ultimately return a set of feature IDs whose bounding boxes overlap a rectangle. The difference is where the index lives and who owns it. setFilterRect hands your rectangle to the data provider and lets the provider decide how to satisfy it. QgsSpatialIndex builds a fresh R-tree in your Python process and answers every query from that snapshot. Choosing correctly is almost always a question of four variables: how many times you query, what backend the layer uses, whether the data mutates underneath you, and whether you need distance ranking.

The Decision at a Glance

The matrix below maps each of those variables to the tool that wins on it. Read a row, find your situation, and follow the column.

setFilterRect versus QgsSpatialIndex decision matrix A table comparing setFilterRect and QgsSpatialIndex across six rows: one-off versus repeated queries, in-memory versus database backend, nearest-neighbour, mutation and staleness, build cost, and memory. Each cell notes which tool is favoured for that criterion. Criterion setFilterRect QgsSpatialIndex Query count one-off vs repeated one-off wins repeated wins Backend DB vs memory/file DB push-down memory/file Nearest-neighbour not supported only option Mutating data staleness risk always live rebuild needed Build cost none full-layer pass Memory process footprint flat holds R-tree

No single column wins every row, which is the whole point: the tools are complementary, not ranked. setFilterRect is the default because it costs nothing to set up and never goes stale; you reach for QgsSpatialIndex deliberately when repetition or proximity tips the balance.

Two Equivalent Queries, Side by Side

The clearest way to internalise the difference is to write the same spatial query both ways. Both functions below return the feature IDs whose geometry envelope overlaps a search rectangle. They produce the same candidate set for a static layer — they differ only in mechanism and in what they cost when called repeatedly.

python
"""
spatial_query_two_ways.py

Two equivalent bounding-box queries in PyQGIS, plus a decision helper.
Compatible with QGIS 3.28+ (LTR). Runnable inside the QGIS Python console
or a standalone script after QgsApplication.initQgis().
"""
from __future__ import annotations

from qgis.core import (
    QgsFeatureRequest,
    QgsRectangle,
    QgsSpatialIndex,
    QgsVectorLayer,
)


def candidates_with_filter_rect(
    layer: QgsVectorLayer,
    search_rect: QgsRectangle,
) -> list[int]:
    """
    Return feature IDs overlapping search_rect using provider-side filtering.

    No index is built in Python. The rectangle is handed to the data
    provider via QgsFeatureRequest.setFilterRect(); for a GeoPackage or
    PostGIS layer the provider satisfies it with its own native index.

    Args:
        layer:       A valid QgsVectorLayer (any provider).
        search_rect: Query envelope in the layer's own CRS.

    Returns:
        Feature IDs whose bounding box intersects search_rect.
    """
    request = (
        QgsFeatureRequest()
        .setFilterRect(search_rect)   # pushed down to the provider
        .setNoAttributes()            # we only want IDs here
    )
    return [feature.id() for feature in layer.getFeatures(request)]


def candidates_with_spatial_index(
    index: QgsSpatialIndex,
    search_rect: QgsRectangle,
) -> list[int]:
    """
    Return feature IDs overlapping search_rect from a prebuilt in-memory index.

    The index must already have been populated from the layer. Each call is
    an O(log n) descent of the R-tree with no provider round-trip — cheap to
    repeat, but blind to any edits made after the index was built.

    Args:
        index:       A QgsSpatialIndex previously built from the layer.
        search_rect: Query envelope in the same CRS as the indexed geometries.

    Returns:
        Feature IDs whose bounding box intersects search_rect.
    """
    return index.intersects(search_rect)

Called once, candidates_with_filter_rect is the better choice: it does exactly one read and touches no memory beyond the matching rows. Called ten thousand times against the same static layer, candidates_with_spatial_index wins decisively — provided you built the index once beforehand:

python
# Build the index ONCE, then query it many times.
index = QgsSpatialIndex(layer.getFeatures(QgsFeatureRequest().setNoAttributes()))
for rect in ten_thousand_search_rects:
    ids = candidates_with_spatial_index(index, rect)
    ...  # process ids

That single build line is the entire trade. It reads the whole layer once. If you will not query enough times to earn that read back, skip it and use setFilterRect.

A Decision Helper You Can Call

The rule of thumb collapses cleanly into code. The helper below takes the shape of your workload and returns which tool to use, so you can wire it into a dispatcher or a code-review checklist rather than deciding by feel.

python
"""
Return a recommendation ("setFilterRect" or "QgsSpatialIndex") for a workload.
"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class QueryWorkload:
    """Describe the spatial query workload driving the choice."""

    query_count: int          # how many queries you will run against this layer
    is_database_backed: bool  # GeoPackage / PostGIS / other provider index?
    needs_nearest: bool       # do you need nearestNeighbor() ranking?
    data_mutates: bool        # will geometry change between queries?


def recommend_spatial_tool(w: QueryWorkload) -> str:
    """
    Recommend a PyQGIS spatial-query strategy for a workload.

    Decision order:
      1. Nearest-neighbour is only offered by QgsSpatialIndex.
      2. Mutating data favours setFilterRect (never stale) unless you
         accept rebuilding the index each cycle.
      3. Database-backed layers favour setFilterRect (provider push-down).
      4. Otherwise, repetition decides: build an index only if the one-time
         build pays off across many queries.

    Returns:
        Either "QgsSpatialIndex" or "setFilterRect".
    """
    if w.needs_nearest:
        return "QgsSpatialIndex"
    if w.data_mutates:
        return "setFilterRect"
    if w.is_database_backed:
        return "setFilterRect"
    # In-memory or file-based, static data: amortise the build over repetition.
    REPETITION_THRESHOLD = 25
    if w.query_count >= REPETITION_THRESHOLD:
        return "QgsSpatialIndex"
    return "setFilterRect"

The REPETITION_THRESHOLD is deliberately a soft constant, not a law. On a small in-memory layer of a few thousand features the crossover sits low, because the build is cheap; on a wide table where each feature drags many attributes, raise the threshold or, better, strip attributes during the build pass with setNoAttributes() so the read is geometry-only. The point of the helper is the ordering of the checks — nearest-neighbour and mutation short-circuit before repetition ever enters the picture.

Architecture Breakdown

Provider push-down: why setFilterRect is free on a database

setFilterRect is not a Python-side loop that discards non-matching rows. When the layer sits on a provider that maintains its own spatial index — a GeoPackage with an R*-tree, or a PostGIS table with a GiST index on the geometry column — QGIS translates the rectangle into a provider query and lets the database resolve it. Only the matching rows ever cross the boundary into your process. That is the meaning of push-down: the filtering work executes where the data and its index already live, not in Python.

This is precisely why rebuilding a QgsSpatialIndex over a database-backed layer is usually wasted effort — you would read the entire table into Python to construct an index that duplicates one the backend already keeps up to date. The exception is when you are hammering the same static extract so hard that even the provider round-trip latency dominates, but that is rare and worth measuring before you assume it. The choice of provider itself has downstream consequences for how much push-down you get; the GeoPackage vs PostGIS as a Processing Backend guide compares the two on exactly this kind of query behaviour. A plain memory layer or a shapefile, by contrast, has no server-side index worth speaking of, which is where an explicit QgsSpatialIndex earns its place.

Amortising the QgsSpatialIndex build over many queries

Building a QgsSpatialIndex reads every feature once to insert its envelope into the R-tree. Call that cost B. Each subsequent query then costs roughly — call it q. A single setFilterRect query on a non-indexed provider costs something closer to a scan — call it S. The index is worth building when B + k·q < k·S for your query count k; solving for k gives the break-even point. In plain terms: the more queries you run against the same unchanged data, the more the fixed build cost disappears into the average.

This is why the repetition axis dominates the decision for non-database layers. One query? Pay S once and move on. Thousands of queries against a static memory layer? Pay B once and enjoy q forever. The trap is building the index inside the query loop — a surprisingly common bug that pays B on every iteration and is strictly slower than setFilterRect would have been. Build once, above the loop, and hold the reference. If you need per-feature access after the candidate pass, combine the index with QgsFeatureRequest().setFilterFids(...), exactly as the feature iteration guidance in Optimizing Feature Iteration with QgsVectorLayer.getFeatures describes.

nearestNeighbor as the tie-breaker

When repetition and backend leave the decision genuinely balanced, one capability breaks the tie: proximity ranking. setFilterRect answers only one question — “which envelopes overlap this rectangle?” It has no notion of closest. To find the nearest feature to a point with setFilterRect you would have to invent a search radius, build a rectangle from it, and expand the rectangle and re-query whenever it comes back empty — an awkward, error-prone loop.

QgsSpatialIndex.nearestNeighbor(point, n) answers the proximity question directly, returning the n nearest feature IDs ranked by bounding-box distance in a single call. If your workload contains any “find the closest” step — snapping, spatial joins by proximity, service-area assignment — that requirement alone justifies building the index regardless of how the other axes fall. Note the ranking is by MBR distance, not exact geometry distance, so for large or concave features re-sort the returned candidates by true QgsGeometry.distance() when exact order matters. The parent guide covers that refinement in detail.

Production Best Practices

  • Default to setFilterRect. It is stateless, never stale, and free to set up. Only escalate to QgsSpatialIndex when a specific axis — repetition, no provider index, or nearest-neighbour — demands it.
  • Never build a QgsSpatialIndex inside the query loop. Build it once above the loop; building per-iteration is strictly slower than setFilterRect.
  • Trust the provider on databases. For GeoPackage and PostGIS, let setFilterRect push down to the native index rather than duplicating it in Python.
  • Rebuild the index after any write. A QgsSpatialIndex is a build-time snapshot; tie a rebuild to layer.editingStopped or rebuild before each batch when geometry can change.
  • Strip attributes during the build pass with QgsFeatureRequest().setNoAttributes() so indexing reads geometry only and stays memory-light on wide tables.
  • Keep both phases in mind. Whichever tool selects candidates, the returned set is a bounding-box superset — validate with an exact predicate such as geometry().intersects() before trusting a hit.
  • Measure before assuming. The repetition threshold shifts with feature count and attribute width; profile the real workload rather than guessing the crossover.

Related