Serving Mosaics with MosaicJSON

A mosaic definition maps tile indices to the files covering them, so many COGs serve as one layer:

from cogeo_mosaic.mosaic import MosaicJSON
from cogeo_mosaic.backends import MosaicBackend

mosaic = MosaicJSON.from_urls(scene_urls, minzoom=8, maxzoom=14)
with MosaicBackend("s3://example-bucket/mosaics/ndvi_2026.json", mosaic_def=mosaic) as m:
    m.write(overwrite=True)

No merged raster is created, nothing is duplicated, and adding a scene is a change to one small JSON file. This page belongs to serving raster tiles with TiTiler in Visualization, Tiling & Web Delivery.


What the Definition Actually Contains

Tiles to files, not pixels to files The definition holds an index from quadkey to a list of scene URLs. A tile in the interior of one scene maps to a single file. A tile on a scene boundary maps to two or three, which are read in list order until enough valid pixels are found. One small JSON file describes the whole archive 36NYF 36NYG 36NZF interior tile — one file "03201...": ["s3://.../36NYF.tif"] one open, one set of range reads boundary tile — several files "03203...": ["36NYF.tif", "36NYG.tif", "36NZF.tif"] read in order until the tile is filled Boundary tiles are a small fraction of any archive, so the average cost is close to the single-file case.

The index is keyed by quadkey at a chosen zoom, and tiles at higher zooms inherit their parent’s file list. That is why minzoom and maxzoom matter: the definition’s size grows with the indexing zoom, and a mosaic indexed too finely becomes a very large JSON file for no benefit.


Environment & Setup

Package Version pin Used for
cogeo-mosaic >=7.0 Building, reading and writing mosaic definitions
titiler.mosaic >=0.18 The mosaic tile endpoints
pystac-client >=0.7 Querying a catalog to assemble the scene list
rio-tiler >=6.4 Reading tiles from each member file
pip install "cogeo-mosaic>=7.0" "titiler.mosaic>=0.18" "pystac-client>=0.7"

Complete Working Example

from datetime import datetime

import pystac_client
from cogeo_mosaic.backends import MosaicBackend
from cogeo_mosaic.mosaic import MosaicJSON


def scenes_from_catalog(api_url: str, collection: str, bbox: list[float],
                        start: str, end: str, *, max_cloud: float = 20.0) -> list[str]:
    """Scene asset URLs, sorted so the least cloudy is first."""
    client = pystac_client.Client.open(api_url)
    search = client.search(
        collections=[collection], bbox=bbox,
        datetime=f"{start}/{end}",
        query={"eo:cloud_cover": {"lt": max_cloud}},
    )
    items = sorted(search.item_collection(),
                   key=lambda it: it.properties.get("eo:cloud_cover", 100))
    return [it.assets["visual"].href for it in items]


def build_mosaic(urls: list[str], out_uri: str, *,
                 minzoom: int = 8, maxzoom: int = 14, name: str = "") -> dict:
    mosaic = MosaicJSON.from_urls(urls, minzoom=minzoom, maxzoom=maxzoom)
    mosaic.name = name or f"mosaic-{datetime.utcnow():%Y%m%d}"
    with MosaicBackend(out_uri, mosaic_def=mosaic) as backend:
        backend.write(overwrite=True)
    return {
        "tiles_indexed": len(mosaic.tiles),
        "files": len({u for lst in mosaic.tiles.values() for u in lst}),
        "bounds": mosaic.bounds,
    }


if __name__ == "__main__":
    urls = scenes_from_catalog(
        "https://earth-search.aws.element84.com/v1", "sentinel-2-l2a",
        bbox=[33.5, -0.5, 35.5, 1.5], start="2026-06-01", end="2026-06-30")
    print(build_mosaic(urls, "s3://example-bucket/mosaics/june2026.json"))

The sort is the pixel selection rule. Sorting by ascending cloud cover means the clearest scene covering each tile is read first and wins; sorting by descending date means the most recent wins. Neither is universally right — a change product wants recency, a basemap wants clarity — and the choice belongs next to the mosaic rather than in someone’s memory. The same reasoning applies to the compositing rules discussed in seamless mosaicking and edge blending.


Updating Without Rebuilding

Merging rather than rebuilding An existing definition covering fifty scenes is merged with a small definition built from three new scenes. Only tiles the new scenes touch gain entries, and the merged result is written as a new version so viewers are never served a partially written file. Merge, then publish a new version existing: 50 scenes 12,400 tiles indexed new: 3 scenes 740 tiles indexed merge by quadkey new files prepended or appended june2026_v2.json new object, atomic swap Whether new files go first or last in each list is the pixel selection rule again.
from cogeo_mosaic.backends import MosaicBackend
from cogeo_mosaic.mosaic import MosaicJSON


def merge_mosaics(existing_uri: str, new_urls: list[str], out_uri: str,
                  *, prefer_new: bool = True) -> int:
    with MosaicBackend(existing_uri) as backend:
        current = backend.mosaic_def

    addition = MosaicJSON.from_urls(new_urls, minzoom=current.minzoom,
                                    maxzoom=current.maxzoom)
    tiles = dict(current.tiles)
    for quadkey, urls in addition.tiles.items():
        prior = tiles.get(quadkey, [])
        tiles[quadkey] = (urls + prior) if prefer_new else (prior + urls)

    current.tiles = tiles
    with MosaicBackend(out_uri, mosaic_def=current) as backend:
        backend.write(overwrite=True)
    return len(tiles)

Writing to a new object rather than overwriting is what makes the update safe. A viewer that requests a tile mid-write against an overwritten object can read a truncated file; publishing ..._v2.json and then repointing the layer gives an atomic swap, and the previous version remains available if the new one is wrong.


Verification

import httpx
from cogeo_mosaic.backends import MosaicBackend

with MosaicBackend("s3://example-bucket/mosaics/june2026.json") as b:
    m = b.mosaic_def
    assert m.tiles, "definition is empty"
    files = {u for lst in m.tiles.values() for u in lst}
    print(f"{len(m.tiles)} tiles, {len(files)} files, bounds {m.bounds}")
    multi = sum(1 for lst in m.tiles.values() if len(lst) > 1)
    print(f"{multi / len(m.tiles):.1%} of tiles need more than one file")

r = httpx.get("https://tiles.example.org/mosaic/tiles/WebMercatorQuad/10/604/512.png",
              params={"url": "s3://example-bucket/mosaics/june2026.json",
                      "rescale": "0,3000"}, timeout=30)
r.raise_for_status()
Overlap drives the cost of every tile A tiled archive with edge-only overlap reads about 1.1 files per tile on average. An archive of overlapping acquisitions from several dates reads around 2.5, and a stack of every acquisition over the same area reads eight or more, which makes rendering several times slower and usually means the mosaic should be pre-filtered. Average files opened per tile tiled, edge overlap 1.1 several dates 2.5 every acquisition 8+ Pre-select one scene per area when the bottom bar describes your archive.

The multi-file fraction is the number to watch. For a tiled archive with little overlap it should be well under 20%; a figure near 100% means the scenes overlap heavily, every tile reads several files, and the layer will be slow. That usually calls for pre-selecting one scene per area rather than letting the mosaic arbitrate at render time.

Check the bounds too. A mosaic whose bounds span the whole world usually contains one scene with a broken footprint — often a file whose CRS is mislabelled — and that single entry makes low-zoom tiles attempt to read everything.


Common Errors

Low-zoom tiles time out

minzoom is lower than the archive can support, so a single tile covers dozens of files. Raise minzoom, or build a separate overview mosaic from downsampled copies.

One scene dominates the layer

It sorted first everywhere because its cloud-cover property is missing and defaulted low. Filter out items lacking the sort key before building.

The definition file is enormous

maxzoom is too high, so the index carries far more quadkeys than needed. Index at the zoom where scenes are distinguishable and let higher zooms inherit.

A tile returns 404 inside the coverage area

That quadkey has no entry, usually because the scene footprint in the catalog is wrong. Rebuild from the files themselves rather than from catalog geometry.


Frequently Asked Questions

Q: How does a mosaic decide which scene wins where they overlap? By list order: the first file covering a tile is read first, and by default its valid pixels win. That makes the sort you apply before building the definition the pixel selection rule, so sort by cloud cover or by date deliberately rather than accepting file order.

Q: Does a mosaic read every file for every tile? No. The definition maps each tile index to only the files that cover it, so a tile in the middle of one scene reads one file. That is the whole point: a thousand-scene archive behaves as one layer without a merged file existing.

Q: How do I add new scenes without rebuilding everything? Build a definition for the new scenes and merge it into the existing one, or rebuild from a catalog query if the archive is small enough that a rebuild takes seconds. Either way, write a new version rather than editing in place so viewers are never served a half-written file.

Q: Can one mosaic mix products with different value ranges? Technically yes and practically no. Every member is rendered with the same rescale parameters, so mixing a reflectance product with an index product produces a layer where half the tiles are wrong. Keep one mosaic per product type.