This guide is part of Raster Mosaicking & Tiling for Pipeline Outputs, within the broader Automated Vector & Raster Cleaning Workflows reference.

Building Virtual Raster Mosaics with GDAL VRT

A VRT presents many rasters as one without duplicating a byte. It is the cheapest possible mosaic, and its cheapness comes entirely from the fact that it defers everything to read time.

What a VRT Gets Right and Wrong

  • Right: instant to build. Twelve hundred scenes become one addressable raster in seconds, with no storage cost.
  • Right: always current. Reprocess a scene in place and the mosaic reflects it immediately, with no rebuild step.
  • Wrong: fragile references. Move the scenes, or let a signed URL expire, and the mosaic silently breaks.
  • Wrong: read amplification. A read spanning fifty scenes opens fifty files. Over object storage that is fifty round trips per window.

Version and Environment Compatibility

Component Version Note
GDAL >=3.4 gdal.BuildVRT with relativeToVRT, /vsis3/ support
rasterio >=1.3 Reads VRT transparently; WarpedVRT for the reprojecting case
Python 3.10+ Union syntax in the recipe
pip install "rasterio>=1.3" "gdal>=3.4" "geopandas>=1.0"

Where the Pixels Actually Come From

How a windowed read resolves through a VRT A read window drawn across a mosaic overlaps four scene footprints. The VRT resolves the window to those four sources and opens each one, reading only the overlapping part of each. The cost of the read is therefore the number of contributing scenes, not the size of the mosaic, which is why scene layout matters more than mosaic extent. mosaic extent Β· 1 240 scenes read window 4 sources opened, partial reads each scene_r0c0.tif β€” top-left corner only scene_r0c1.tif β€” top-right corner only scene_r1c0.tif β€” bottom-left corner only scene_r1c1.tif β€” bottom-right corner only Cost scales with contributing scenes, not with mosaic size β€” so a window that happens to sit on a four-way corner is four times the work of one inside a scene.

The build_scene_vrt Recipe

from __future__ import annotations

import logging
from pathlib import Path

import rasterio
from osgeo import gdal

logger = logging.getLogger(__name__)
gdal.UseExceptions()


def build_scene_vrt(
    sources: list[Path],
    vrt_path: Path,
    nodata: float | int = 0,
    resolution: str = "highest",
    relative: bool = True,
) -> Path:
    """Build a VRT over pre-ordered sources, failing loudly on incompatible inputs.

    `sources` must already be in painting order: GDAL paints later entries over
    earlier ones, so the priority winner belongs last in the list.
    """
    if not sources:
        raise ValueError("no sources supplied for the mosaic")

    # Every input must agree on CRS, band count and dtype β€” a plain VRT cannot reconcile them.
    with rasterio.open(sources[0]) as first:
        crs, count, dtype = first.crs, first.count, first.dtypes[0]
    for path in sources[1:]:
        with rasterio.open(path) as src:
            if (src.crs, src.count, src.dtypes[0]) != (crs, count, dtype):
                raise ValueError(
                    f"{path.name} differs from the mosaic contract "
                    f"({src.crs}, {src.count} bands, {src.dtypes[0]})"
                )

    options = gdal.BuildVRTOptions(
        resolution=resolution,
        srcNodata=nodata,
        VRTNodata=nodata,
        addAlpha=False,
        resampleAlg="nearest",
    )
    vrt = gdal.BuildVRT(str(vrt_path), [str(p) for p in sources], options=options)
    if vrt is None:
        raise RuntimeError(f"BuildVRT produced nothing for {vrt_path}")
    vrt.FlushCache()
    vrt = None  # close before rewriting paths

    if relative:
        _make_paths_relative(vrt_path)

    logger.info("built %s over %d sources", vrt_path.name, len(sources))
    return vrt_path


def _make_paths_relative(vrt_path: Path) -> None:
    """Rewrite absolute SourceFilename entries as paths relative to the VRT."""
    text = vrt_path.read_text()
    base = str(vrt_path.parent) + "/"
    text = text.replace(f'relativeToVRT="0">{base}', 'relativeToVRT="1">')
    vrt_path.write_text(text)

Key Implementation Notes

  • Painting order is the list order, and it is the opposite of intuition. GDAL paints sources in sequence, so the highest-priority scene must be last. Reverse the ranked list from the assignment step before passing it in.
  • The compatibility loop is not optional. BuildVRT will happily produce a mosaic from mixed CRSs and give you nonsense at read time; the explicit check turns that into a build-time error naming the offending file.
  • srcNodata and VRTNodata are both set. The first tells GDAL which source pixels to treat as transparent, the second declares what the mosaic reports. Setting only one produces gaps that read as zeros.
  • Relative paths make the mosaic movable. A VRT with absolute paths breaks the moment the directory is synced elsewhere, which is a common and confusing failure after a storage migration.
  • resolution="highest" is a decision, not a default. A mosaic mixing 10 m and 20 m sources will resample the coarse ones up; "average" or an explicit xRes/yRes may be more honest depending on the product.
  • The VRT is flushed and closed before rewriting. Editing the XML while GDAL still holds the dataset produces a file that is valid on disk and stale in memory.
Why relativeToVRT matters after a move The same VRT is copied from a staging directory to a published prefix along with its scenes. With absolute source paths the copied VRT still points at the old location and fails once that is cleaned up. With relative paths the copied VRT resolves against its new parent directory and continues to work unchanged. absolute paths /staging/mosaic.vrt β†’ /staging/scene_*.tif copy /published/mosaic.vrt β†’ still /staging/… breaks when staging is cleaned relativeToVRT="1" /staging/mosaic.vrt β†’ scene_*.tif copy /published/mosaic.vrt β†’ /published/scene_*.tif resolves against its new parent

When to Materialise Instead

A VRT stops being the right answer at three specific points, and recognising them early avoids an awkward migration later.

When to stop referencing and start materialising Three conditions under which a virtual mosaic stops being the right structure. Remote consumers pay a round trip per contributing scene. Volatile sources break the path promises the mosaic encodes. Dense repeated reads recompute the same assembly every time. Each condition points at materialised tiles as the replacement. signal why the VRT stops paying consumers are remote one round trip per contributing scene, per window sources move or expire the mosaic is a set of promises about paths the same window is read daily the assembly is recomputed on every read Any one of the three is enough; two of them together usually means the migration is overdue.

When the consumer is remote. A VRT over object storage means the reader needs credentials for every source and pays a round trip per contributing scene. A materialised COG per tile turns that into one range request.

When the sources are volatile. Signed URLs expire, lifecycle policies move objects to archive tiers, and scene files get reprocessed under new names. A VRT is a set of promises about paths, and every one of those events breaks a promise.

When reads are dense and repeated. A VRT read is recomputed every time. If the same window is requested daily by a dashboard, materialising it once is cheaper by the second day.

The pragmatic pattern is to use both: a VRT as the working view during processing and for analysts who have direct access to the scenes, and materialised tiles as the published product, as described in writing cloud-optimized GeoTIFFs with rio-cogeo.

Troubleshooting VRT Mosaics

Symptom Likely cause Fix
Mosaic reads all zeros in gaps VRTNodata not set Set both srcNodata and VRTNodata
Wrong scene wins in an overlap Sources passed in ranked rather than reverse-ranked order Reverse the list β€” later entries paint over earlier
BuildVRT succeeds, reads are nonsense Mixed CRSs among sources Add the compatibility check; reproject first
VRT broken after a copy Absolute source paths Rewrite with relativeToVRT="1"
Reads are extremely slow Window crosses many small scenes Materialise tiles, or build intermediate per-region VRTs

Integration Note

Build the VRT from the ranked manifest produced in raster mosaicking and tiling for pipeline outputs, reversing it so the priority winner paints last. In an orchestrated pipeline the VRT build is cheap enough to run at the end of every ingestion run rather than on a schedule of its own, and because it references rather than copies, rebuilding it is always safe.

Nested VRTs for Very Large Collections

A single VRT over tens of thousands of scenes becomes a problem in itself: the XML grows to tens of megabytes, GDAL parses all of it before serving any read, and every process that opens the mosaic pays that parse.

Nesting fixes it. Build one VRT per region β€” a UTM zone, a country, a year β€” and then a top-level VRT over those. A read resolves through two levels of routing instead of scanning one enormous source list, and each regional VRT can be rebuilt independently when its scenes change.

The nesting also matches how corrections arrive. Reprocessing a season of imagery for one country touches one regional VRT; the top-level file is untouched, and consumers holding a reference to it see the change without any coordination.

Keep the nesting shallow. Two levels handle collections into the hundreds of thousands; three is almost always a sign that the product wants materialised tiles rather than a deeper reference tree.