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
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
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()
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.
Related
- Serving Raster Tiles with TiTiler — the parent topic on deployment and caching.
- Querying STAC Catalogs Programmatically — assembling the scene list a mosaic indexes.
- Merging Tiles with rasterio merge — the alternative that produces an actual merged file.
- Building a Mosaic from a STAC Search — the analytical counterpart to this display-side mosaic.