Zarr v3 vs COG for Multidimensional Data
Three questions decide it: how many axes do readers slice, must the ecosystem open it directly, and does the dataset grow by appending? Everything else — file size, compression, tooling preference — is secondary and usually cancels out. This page turns the choice into a short set of tests rather than a matter of taste, extending Zarr and chunked array storage for raster and Cloud-Optimized GeoTIFF for raster workloads.
Quick Reference
| Test | Answer → COG | Answer → Zarr | Primary Use Case |
|---|---|---|---|
| Axes readers slice | Two (y, x) | Three or more (t, band, z…) | Scene delivery vs cube analytics |
| Direct ecosystem access | Required | Not required | Desktop GIS and tile servers |
| Growth pattern | Reprocessed in whole scenes | Appended along an axis | Rolling archives |
| Per-object granularity | One file per scene | One object per chunk | Object count management |
| Overviews / pyramids | Built in | Separate arrays or absent | Zoomed-out browsing |
The Three Tests, in Order
Test one: how many axes get sliced. This is the decisive one. A COG is a two-dimensional structure with a band list attached; it can chunk in y and x and nothing else. Ask for one spectral band across a whole scene and it reads interleaved data; ask for one pixel across ten thousand dates and it opens ten thousand files. Zarr chunks along every axis, so a time-series read touches a handful of objects. If a third axis is genuinely part of the query, that settles it.
Test two: who opens the file. COG’s decisive advantage is that it is a GeoTIFF. Every desktop GIS, every tile server, every raster library, and every third party you might publish to opens it without any new dependency. A Zarr store needs a reader that speaks Zarr, which the scientific Python stack does and much of the wider geospatial ecosystem still does not. If the deliverable must be openable by an arbitrary consumer, this test overrides test one.
Test three: how the data grows. A rolling archive that gains a time step each day appends one chunk row to a Zarr array — a metadata update and some new objects. The COG equivalent is a new file per date, which is fine, or an extension of an existing file, which is a rewrite. Where appending along an axis is the natural operation, Zarr models it and COG does not.
Generating Both from One Source
Where test two forces the issue, the honest answer is usually to publish both rather than to compromise. The cost is storage and one extra pipeline stage.
# Requires: xarray>=2024.7, rioxarray>=0.17, zarr>=3.0, rio-cogeo>=5.3 (Python 3.10+)
from __future__ import annotations
from pathlib import Path
import xarray as xr
from rio_cogeo.cogeo import cog_translate
from rio_cogeo.profiles import cog_profiles
def publish_cube(dataset: xr.Dataset, variable: str, zarr_target: Path) -> None:
"""The analytical copy: chunked along every axis readers slice."""
if variable not in dataset:
raise KeyError(f"{variable!r} not in the dataset")
array = dataset[variable]
if "time" not in array.dims:
raise ValueError("a cube without a time axis does not need Zarr")
dataset.to_zarr(
zarr_target, mode="w", consolidated=True,
encoding={variable: {"chunks": (365, 128, 128)}},
)
def publish_scenes(dataset: xr.Dataset, variable: str, cog_dir: Path) -> list[Path]:
"""The ecosystem copy: one COG per time step, openable by anything."""
cog_dir.mkdir(parents=True, exist_ok=True)
profile = cog_profiles.get("deflate")
profile.update(blockxsize=512, blockysize=512)
written: list[Path] = []
for stamp in dataset.time.values:
slice_ = dataset[variable].sel(time=stamp)
if slice_.rio.crs is None:
raise ValueError("each scene must carry a CRS before export")
date = str(stamp)[:10]
staging = cog_dir / f".{date}.tmp.tif"
target = cog_dir / f"{variable}_{date}.tif"
try:
slice_.rio.to_raster(staging, driver="GTiff")
cog_translate(
staging, target, profile,
overview_level=5, overview_resampling="average",
quiet=True, in_memory=False,
)
finally:
staging.unlink(missing_ok=True)
written.append(target)
return written
Validation
Prove the choice with the query that motivated it, not with a size comparison. The two formats are within a few per cent on size for the same data and codec; they differ by orders of magnitude on the read that matters.
# Requires: xarray>=2024.7, rioxarray>=0.17 — time the query that drove the decision
import time
import xarray as xr
import rioxarray # noqa: F401 (registers the .rio accessor)
import glob
start = time.perf_counter()
cube = xr.open_zarr("temperature.zarr", consolidated=True)
series_zarr = cube["t2m"].sel(lat=51.45, lon=-2.58, method="nearest").load()
zarr_seconds = time.perf_counter() - start
start = time.perf_counter()
values = []
for path in sorted(glob.glob("cogs/t2m_*.tif")): # one open per date
with xr.open_dataarray(path, engine="rasterio") as scene:
values.append(float(scene.sel(x=-2.58, y=51.45, method="nearest").values))
cog_seconds = time.perf_counter() - start
print(f"20-year point series — Zarr {zarr_seconds:.2f}s · COGs {cog_seconds:.1f}s")
Expected ranges on a 7,300-date archive: Zarr in single-digit seconds, per-date COGs in the tens of minutes, and a single-date continental read where the ordering reverses — COG in well under a second, Zarr a few seconds depending on chunk shape.
Edge Cases and Caveats
Object count on fine chunk grids. A 4 GB Zarr store cut into 128 KB chunks is 32,000 objects, and listing, lifecycle rules, and per-request charges all scale with that. Zarr v3’s sharding lets many chunks share one object, which is the direct answer; without it, keep chunks in the megabyte band as chunk shape selection recommends, and watch the request cost model.
Overviews are not free in Zarr. A COG carries its pyramid internally and every reader uses it automatically. A Zarr store has no built-in notion of overviews: you build a multiscale group of separate arrays and consumers must know to use it. If zoomed-out browsing matters, that is real work, and it is work COG has already done.
A Zarr store chunked like a COG. Writing one chunk per date reproduces exactly the COG read profile while giving up the ecosystem — the worst of both. If the chunk grid ends up one-date-deep, that is the signal that test one was answered wrongly and COG was the right format all along.
Frequently Asked Questions
Can a COG store more than two dimensions?
It can hold multiple bands, and that covers a spectral axis of modest size. What it cannot do is chunk along that axis, so a query for one band across all pixels still reads interleaved data, and a query along a time axis means opening one file per time step. Bands are a list, not a dimension you can slice efficiently, which is exactly the distinction that decides between the two formats.
Is Zarr v3 different enough from v2 to matter for this decision?
For the format choice, no — both are chunked N-dimensional arrays in object storage. Version 3 tightens the specification, makes the codec pipeline explicit and extensible, standardises consolidated metadata, and adds sharding so many small chunks can share one object. Sharding is the practically important one, because it removes the main operational objection to fine chunk grids, which was the object count.
Should I convert an existing COG archive to Zarr?
Only if the queries you cannot currently answer are the reason. A COG archive that serves spatial windows well is not improved by conversion, and it loses the ecosystem support that made it useful. The case for conversion is specific: readers want time series at points, the archive has thousands of dates, and every such query currently opens thousands of files. Convert for that, and keep the COGs for everything else.
Related
- Zarr and Chunked Array Storage for Raster — parent guide: the chunked-array model in full
- Cloud-Optimized GeoTIFF for Raster Workloads — the other side of this comparison
- Validating COG Structure in CI — making sure the COG half of a dual-publish is genuinely cloud-optimized
- Choosing Zarr Chunk Shapes for Time-Series Raster — the decision that follows once Zarr is chosen