Vector and Raster Data Access Patterns in PyQGIS

Production-ready PyQGIS patterns for vector feature iteration and raster block reading: QgsFeatureRequest, QgsRasterIterator, memory management, and GDAL…

Efficient data access is the operational foundation of every PyQGIS automation task. Building plugins, processing pipelines, or custom geoprocessing tools requires moving past basic layer loading and adopting structured access patterns that align with QGIS’s underlying C++ architecture. Understanding how QGIS interfaces with GDAL/OGR providers, manages memory during iteration, and maps pixel coordinates to geographic space determines whether scripts run in seconds or grind to a halt. This guide is part of the PyQGIS Core Architecture & Data Handling guide, which covers the full stack from SIP bindings to provider internals.

Prerequisites

Before implementing these patterns, confirm your environment meets the following baseline:

  • QGIS 3.28 LTR or newer — PyQGIS API stability is guaranteed across LTR releases; QgsFeatureRequest flag names changed in 3.16.
  • Python 3.10+ — access via the QGIS Python console, a Processing script, or a standalone PyQGIS environment.
  • Working knowledge of GDAL/OGR data models, QGIS layer providers, and basic spatial indexing concepts.
  • Validation datasets: a GeoPackage or Shapefile for vector workflows; a tiled GeoTIFF or Cloud Optimized GeoTIFF (COG) for raster workflows.
  • Familiarity with QgsVectorLayer, QgsRasterLayer, QgsDataProvider — skim the QgsProject and layer registry patterns first if you are new to layer lifecycle management.

Provider Architecture and Internals

PyQGIS data access is mediated entirely through the provider abstraction layer. Neither QgsVectorLayer nor QgsRasterLayer owns raw data; they delegate every read operation to a QgsDataProvider subclass that talks to the underlying storage driver — OGR for vector formats, GDAL for raster formats, and bespoke C++ drivers for PostGIS or WFS. Understanding this indirection is essential for reasoning about performance, locking behaviour, and error propagation.

PyQGIS Provider Architecture Python script calls QgsVectorLayer or QgsRasterLayer, which delegates to QgsDataProvider, which in turn calls OGR (for vector) or GDAL (for raster) to read from disk or network storage. Python Script QgsApplication QgsVectorLayer getFeatures(request) QgsRasterLayer dataProvider().block() QgsDataProvider (OGR / GDAL) Storage GeoPackage / SHP GeoTIFF / COG PostGIS / WFS SIP binding boundary: Python objects wrap C++ pointers — lifetime management is manual

SIP Ownership and the Pointer Wrapper Contract

Every QgsFeature, QgsGeometry, and QgsRasterBlock object that Python receives is a thin SIP-generated wrapper around a C++ heap allocation. QGIS does not use Python reference counting to manage the underlying C++ object’s lifetime. This means:

  • Holding a reference to a feature iterator beyond its natural scope can pin provider file handles.
  • Assigning a feature’s geometry to a standalone variable and then deleting the feature does not free the geometry’s C++ backing — and can produce a dangling pointer if the provider clears its buffer.
  • The garbage collector will eventually reclaim the Python shell, but the timing is non-deterministic and insufficient for tight resource loops.

Practical rule: treat every PyQGIS object as if it were an with-block resource. Consume it immediately, extract the scalar values you need, and let the wrapper go out of scope.

The Standardized Access Lifecycle

Every data access operation in PyQGIS follows the same five-stage lifecycle. Deviating from this sequence causes provider locks, memory fragmentation, or silent data truncation:

PyQGIS Access Lifecycle Five sequential stages: Resolve layer reference, Validate provider state, Configure access parameters, Execute extraction, Release and clean up. 1. Resolve layer reference 2. Validate provider state 3. Configure access parameters 4. Execute extraction 5. Release & clean up

Step-by-Step Implementation

Step 1: Resolve the Layer Reference

Retrieve the target layer from the active project registry or instantiate it directly. Within plugin contexts, prefer the project registry over hardcoded filesystem paths — this respects user-defined layer aliases and integrates with QgsProject and layer registry state management so that your scripts adapt to the user’s current workspace without manual path resolution.

python
from qgis.core import QgsProject, QgsVectorLayer, QgsRasterLayer

def resolve_vector_layer(layer_name: str) -> QgsVectorLayer | None:
    """
    Retrieve a named vector layer from the active project.

    Returns None if the layer is not found or is not a vector type.
    """
    layers = QgsProject.instance().mapLayersByName(layer_name)
    if not layers:
        return None
    layer = layers[0]
    return layer if isinstance(layer, QgsVectorLayer) else None

Step 2: Validate Provider State

Always confirm layer.isValid() returns True before attempting any read operations. Invalid layers indicate missing drivers, corrupted metadata, path resolution failures, or unsupported CRS definitions. Surface actionable diagnostics via layer.dataProvider().error().message():

python
from qgis.core import QgsVectorLayer, QgsMessageLog, Qgis

def assert_layer_valid(layer: QgsVectorLayer, context: str = "data access") -> bool:
    """
    Validate layer and provider state. Logs an error message on failure.

    Returns True if the layer is ready for read operations.
    """
    if not layer.isValid():
        error_msg = layer.dataProvider().error().message()
        QgsMessageLog.logMessage(
            f"[{context}] Layer invalid: {error_msg}",
            tag="VectorRasterAccess",
            level=Qgis.Critical,
        )
        return False
    return True

Logging errors early via QgsMessageLog (rather than raising bare exceptions) integrates cleanly with QGIS’s message panel and avoids crashing the host application in plugin contexts.

Step 3: Configure Access Parameters — Vector

Use QgsFeatureRequest to constrain every vector read. Never iterate without filters or attribute subsets unless full dataset extraction is genuinely required. Explicit constraints bypass unnecessary cache population and reduce I/O overhead.

python
from qgis.core import QgsFeatureRequest, QgsVectorLayer, QgsRectangle

def build_request(
    layer: QgsVectorLayer,
    fields: list[str],
    extent: QgsRectangle | None = None,
) -> QgsFeatureRequest:
    """
    Build a minimal QgsFeatureRequest for efficient feature extraction.

    Args:
        layer:  Source vector layer.
        fields: Attribute names to retrieve (empty list = all fields).
        extent: Optional spatial filter; applies a bounding-box pre-filter.

    Returns:
        A configured QgsFeatureRequest ready for layer.getFeatures().
    """
    request = QgsFeatureRequest()

    if fields:
        # setSubsetOfAttributes with field names requires the QgsFields object
        request.setSubsetOfAttributes(fields, layer.fields())

    if extent and not extent.isNull():
        request.setFilterRect(extent)

    return request

When your analysis only needs bounding boxes or attribute values, add request.setFlags(QgsFeatureRequest.NoGeometry) to skip geometry parsing entirely — this eliminates WKB deserialization overhead and can cut iteration time by 40–70% on geometry-heavy tables.

Step 4: Execute Extraction — Vector Iteration

Run the iteration within a controlled scope. Features are yielded incrementally from the provider; the full feature set is never loaded into memory at once:

python
from collections.abc import Generator
from qgis.core import QgsVectorLayer, QgsFeatureRequest, QgsFeature

def iter_features(
    layer: QgsVectorLayer,
    request: QgsFeatureRequest,
) -> Generator[QgsFeature, None, None]:
    """
    Yield features from a vector layer using the supplied request.

    The caller is responsible for extracting scalar values from each feature
    before advancing the iterator — do not store QgsFeature references
    beyond a single iteration step.
    """
    for feature in layer.getFeatures(request):
        yield feature

Extract scalar values (feature["field_name"], feature.geometry().asWkt()) within the loop body and immediately append them to a plain Python list. This breaks the SIP reference chain and allows the C++ feature buffer to be recycled.

Step 5: Execute Extraction — Raster Block Reading

Raster workflows require a fundamentally different approach. QgsRasterBlock gives direct access to raw pixel arrays without loading the entire dataset into memory. Always align block extents to the file’s native tile grid:

python
from qgis.core import QgsRasterLayer, QgsRectangle

def read_raster_block(
    layer: QgsRasterLayer,
    band: int,
    extent: QgsRectangle | None = None,
) -> list[float]:
    """
    Read a single raster band into a flat list of float values.

    Reads the full layer extent if no extent is supplied. For large rasters
    prefer tile-aligned sub-extents and call this function in a loop.

    Returns an empty list if the block is invalid or out-of-range.
    """
    provider = layer.dataProvider()
    target_extent = extent if extent else layer.extent()

    # Compute pixel dimensions for the requested extent
    x_res = layer.rasterUnitsPerPixelX()
    y_res = layer.rasterUnitsPerPixelY()
    cols = max(1, round(target_extent.width() / x_res))
    rows = max(1, round(target_extent.height() / y_res))

    block = provider.block(band, target_extent, cols, rows)
    if not block.isValid():
        return []

    return [block.value(row, col) for row in range(rows) for col in range(cols)]

For multi-band imagery, iterate range(1, provider.bandCount() + 1) — QGIS band indices are one-based. Always call block.isValid() before accessing pixel data; an invalid block on a corrupted tile will segfault if you read from it directly.

Step 6: Release and Clean Up

PyQGIS uses a hybrid memory model: Python wrappers around C++ pointers. Failing to release references causes dangling pointers, provider locks, or memory leaks. In standalone scripts, always tear down the application context:

python
import sys
from qgis.core import QgsApplication

def teardown_qgis(app: QgsApplication) -> None:
    """
    Release all provider locks and GDAL/OGR handles.

    Call this exactly once, after all processing is complete,
    in standalone (non-plugin) scripts.
    """
    app.exitQgis()
    sys.exit(0)

In plugin contexts, never call exitQgis() — the host application manages the lifecycle. Wrap resource-heavy operations in try/finally blocks to guarantee cleanup when exceptions occur mid-iteration.

Advanced Patterns

Hybrid Spatial + Expression Filtering

Combining a bounding-box filter with an OGR expression pushes the filter down to the storage driver, eliminating the Python-layer evaluation overhead entirely for supported formats (GeoPackage, PostGIS, Shapefile with index):

python
from qgis.core import QgsFeatureRequest, QgsRectangle, QgsVectorLayer

def hybrid_filtered_request(
    layer: QgsVectorLayer,
    extent: QgsRectangle,
    expression: str,
    fields: list[str],
) -> QgsFeatureRequest:
    """
    Build a request that applies both a spatial bounding-box filter and
    an attribute expression. OGR-capable providers push both filters to
    the driver, avoiding full table scans.

    Example:
        request = hybrid_filtered_request(
            layer, canvas_extent, "population > 10000", ["id", "name", "population"]
        )
    """
    return (
        QgsFeatureRequest()
        .setFilterRect(extent)
        .setFilterExpression(expression)
        .setSubsetOfAttributes(fields, layer.fields())
    )

This is the highest-throughput read pattern for spatially selective queries. For purely attribute-driven queries against large tables, build a spatial index once and pass the resulting candidate IDs as a setFilterFids() call instead.

Streaming COG Tiles Over HTTP

Cloud Optimized GeoTIFFs leverage HTTP range requests to fetch only the required tiles. PyQGIS supports COG streaming natively through GDAL’s Virtual File System layer. For remote rasters the setup is identical to local file access — GDAL’s VSI machinery is transparent:

python
from qgis.core import QgsRasterLayer

def open_remote_cog(url: str, layer_name: str = "remote_cog") -> QgsRasterLayer:
    """
    Open a Cloud Optimized GeoTIFF over HTTP(S) using GDAL's VSI layer.

    The GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR environment variable should
    be set before this call to suppress slow directory listing on remote paths.

    Args:
        url: Full HTTPS URL to the COG file.

    Returns:
        A QgsRasterLayer backed by GDAL's HTTP/VSI driver.
    """
    # GDAL VSI prefix instructs the driver to use range requests
    vsi_path = f"/vsicurl/{url}"
    return QgsRasterLayer(vsi_path, layer_name)

Set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR in your environment before calling this — without it, GDAL issues a directory listing request that blocks on most HTTP servers and dramatically slows the first tile fetch.

Chunked Processing for Large Datasets

For datasets that exceed available RAM, break the spatial extent into tiles and process each tile independently:

python
from qgis.core import QgsRasterLayer, QgsRectangle

def iter_raster_tiles(
    layer: QgsRasterLayer,
    tile_cols: int = 256,
    tile_rows: int = 256,
) -> list[QgsRectangle]:
    """
    Generate a list of tile-aligned QgsRectangle extents for chunked processing.

    Tile dimensions default to 256x256 pixels, matching the most common
    GeoTIFF internal tile size and maximising OS page cache hits.
    """
    full = layer.extent()
    x_res = layer.rasterUnitsPerPixelX()
    y_res = layer.rasterUnitsPerPixelY()
    tile_width = tile_cols * x_res
    tile_height = tile_rows * y_res

    tiles = []
    y = full.yMaximum()
    while y > full.yMinimum():
        x = full.xMinimum()
        while x < full.xMaximum():
            tile = QgsRectangle(
                x, max(y - tile_height, full.yMinimum()),
                min(x + tile_width, full.xMaximum()), y,
            )
            tiles.append(tile)
            x += tile_width
        y -= tile_height
    return tiles

Pair tile iteration with memory management and garbage collection discipline: call del block after processing each tile and monitor resident set size between chunks to catch accumulating wrappers before they exhaust available RAM.

Pitfalls and Debugging

  • Provider lock on repeated opens: Opening the same file via two separate QgsVectorLayer instances can cause OGR to apply read locks, especially on Shapefile format. Reuse the same layer instance across operations or open in read-only mode (QgsVectorLayer(path, name, "ogr") does not acquire a write lock by default, but some drivers differ).

  • Silent geometry truncation on wide extents: When setFilterRect() is combined with layers in a different CRS than the filter extent, QGIS reprojection of the filter rectangle can clip legitimate features at CRS boundary artefacts. Always supply the filter extent in the layer’s own CRS, or explicitly define a coordinate transformation before constructing the request.

  • block.isValid() returns False without an error message: This typically means the requested extent does not intersect the raster’s actual data extent, or that cols or rows computed to zero due to floating-point rounding. Add assert cols > 0 and rows > 0 checks before calling provider.block().

  • getFeatures() returns zero features despite visible data: The most common cause is a non-null setFilterRect() whose extent is in a different CRS than the layer’s native CRS. The filter is applied in layer CRS space — if the extent is in EPSG:4326 and the layer is in a projected CRS, the bounding box will look like a near-zero rectangle to the provider.

  • Memory growth during long feature iteration loops: Python’s reference counting does not immediately release SIP wrappers. Avoid storing QgsFeature objects in lists — extract the values you need (fid, attribute dict, WKT string) and let the feature reference drop. Use tracemalloc snapshots at iteration checkpoints to verify that the heap is not growing monotonically.

  • Thread-safety violations with QgsTask: QgsVectorLayer.getFeatures() and QgsDataProvider.block() are not thread-safe. If you call them from a worker thread, you must either use QgsTask with its own copy of the layer path (instantiate a fresh QgsVectorLayer inside the task’s run() method) or acquire the provider lock via the provider’s internal mutex. Never share a single layer instance across threads.

  • COG range requests failing silently: If a remote COG returns empty blocks, confirm that the server supports HTTP range requests (Accept-Ranges: bytes in the response headers). Not all object-storage configurations enable this; some require a bucket-level setting to allow partial GETs.

Conclusion

Mastering data access in PyQGIS requires shifting from ad-hoc scripting to deliberate, architecture-aware development. By respecting the provider lifecycle, constraining requests with QgsFeatureRequest, aligning raster reads to native tile boundaries, and releasing SIP wrappers promptly, you can build tools that scale from single-file desktop plugins to enterprise processing pipelines processing terabytes of spatial data. The patterns in this guide are the operational backbone of the optimizing feature iteration with QgsVectorLayer.getFeatures() deep-dive, which extends these foundations with iterator caching, batch fetching, and provider-specific optimizations.


Related