Appearance
Zarr stores
A Zarr store is neither a file nor a service: it is a directory of compressed chunks with named dimensions, published as-is on object storage. So the query hands over an address and the name of one array inside it, and the panel builds the dataset and its layer.
sql
SELECT 'https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/gpcp-feedstock/gpcp.zarr' AS zarr_url,
'precip' AS zarr_variable;| Role | Detected from |
|---|---|
| Zarr URL | zarr_url, zarr, store_url |
| Zarr variable | zarr_variable, zarr_var |
| Zarr time label | zarr_time_label, zarr_label |
| Zarr levels | zarr_levels, zarr_pyramid |
| Zarr selectors | zarr_sel, zarr_select |
| Zarr time axis | zarr_time_dim, zarr_dim |
| Zarr ramp | zarr_colormap, zarr_ramp |
| Zarr range | zarr_rescale, zarr_range |
The first two go together: a store holds many arrays, so a URL without a variable is not a picture. Both must be present or neither role applies. As with the raster and WMS roles, none of them appears in the Field mapping editor — alias them in the query if your columns are named otherwise.
zarr_variable is deliberately narrow. A bare variable column is an ordinary thing to find in a long-format table, and claiming it would read a frame of unrelated measurements as a Zarr store.
A tile server does sit in between
Unlike a WMS, this needs one, and it is the Raster tile server panel option — the same option the raster path uses, because it means the same thing: the TiTiler this panel draws through. The server opens the store, decompresses the chunks, reprojects to Web Mercator and applies the colour ramp, so what reaches the browser is an ordinary PNG.
That division of labour is what makes this work at all:
- Compression. Practically every Zarr in the wild is written by xarray with
blosc. Nothing in the browser or in DuckDB's raster extension reads it;zarr-python, which TiTiler uses, does. - CORS. Many public stores send no CORS headers whatsoever, which would put them out of a browser's reach entirely. It does not matter here: the one fetching chunks is the server.
TiTiler 2.2.1 ships the /zarr router already, so a deployment that already renders COGs needs nothing new — with one exception, below.
What the query produces is a dataset with no rows — grafana-<refId>-zarr — holding the store, the variable and the colouring, alongside the ordinary table dataset your rows make.
Icechunk repositories
Some archives no longer publish a Zarr tree at all. dynamical.org's weather archives, among others, now ship Icechunk repositories: a transactional layer over Zarr that gives an archive git-like snapshots and atomic commits. That is worth a great deal for data that keeps arriving — a forecast writing twenty-five variables cannot be read half-updated, and every past version stays addressable — and it is why a growing number of public archives are moving.
A repository is not a tree of chunks, though. Its chunk objects are named by opaque identifiers and the map from array coordinates to object lives in a manifest, so the address of a chunk cannot be worked out from the array: it can only be looked up, after reading a pointer, then a snapshot, then the manifests that snapshot names. No ordinary Zarr reader does that, and the browser cannot either — the library that does is compiled to WebAssembly that needs cross-origin isolation, which a Grafana page does not have.
So this is the one case that asks something of the tile server: it must carry an Icechunk opener. The stock image has none; the one built in this repository's docker/titiler/ does, in a single small module.
Given such a server, nothing else changes — same roles, same panel options, same layer. Only the address does:
sql
SELECT 'stac+https://stac.dynamical.org/noaa-gfs-forecast/collection.json' AS zarr_url,
'temperature_2m' AS zarr_variableTwo forms are understood:
| Address | What it means |
|---|---|
icechunk+https://bucket…/name.icechunk | the repository, at exactly that location |
stac+https://…/collection.json | ask this STAC Collection where its repository is |
Prefer the second. Publishers roll repository versions — dynamical's GFS is on v0.2.7 as this is written — and a dashboard holding the bucket address breaks by itself on the next one, with a 404 that says nothing about versions. The catalogue always knows where today's is.
A forecast archive drawn end to end, with the query and what it costs: Forecasts from an Icechunk archive.
The colour range is not optional
A COG written for drawing carries its own sensible range. A scientific Zarr does not: it holds physical units — kelvin, mm/day, a reflectance — and nothing in it says which slice of that range deserves a colour.
Left empty, each tile is stretched over its own extremes, so neighbouring tiles disagree about what a colour means and the map reads as patchwork. Set Zarr value range to the range you actually want to see: 0,30 for mm/day of rain, 270,305 for sea-surface kelvin. The Raster colour ramp option above it is shared with the raster path.
A style per layer, when one panel is not enough
Those two options are one value for every layer the panel holds, which is the right answer while it holds one. It stops being the right answer at two: physical units differ, so a range that suits an aerosol optical depth of 0,1 leaves a field of kelvin flat — a uniform sheet, which reads as bad data rather than as bad styling.
So the query may carry its own, and it wins over the panel:
sql
-- A: smoke
'omaod550' AS zarr_variable, '0,1' AS zarr_rescale, 'purd' AS zarr_colormap
-- B: temperature at 2 m
't2m' AS zarr_variable, '270,315' AS zarr_rescale, 'rdylbu' AS zarr_colormapBoth are optional and both fall back on their own: leave a long interval ramp in the panel option, where it is pleasant to edit, and put only the range in the query beside the variable it describes.
zarr_colormap takes the same two spellings the panel option takes — a ramp name, or a whole ramp as TiTiler's interval JSON. Only the JSON form can fade the low end to nothing, which a field that is mostly near zero needs; every named ramp is opaque end to end, so such a field paints a sheet over the basemap in the palest tone it has. The intervals are in 0–255, not in the store's own units: the range above rescales the data onto the byte first, and the ramp colours bytes.
sql
'[[[0,8],[255,244,240,16]],[[8,16],[254,238,235,48]], … ]' AS zarr_colormapA ramp of any length is unpleasant to read inside a SELECT. A CTE keeps the query legible:
sql
WITH style AS (SELECT '[[[0,8],[255,244,240,16]], … ]' AS ramp)
SELECT …, style.ramp AS zarr_colormap FROM …, styleThe clock, and the label that goes with it
A store holds every moment in one place, so walking the timeline costs no query and no reload — the panel rewrites one parameter of the tile URL and the tiles on screen are refetched. Add a time column and the map's time widget walks it:
sql
SELECT dia::TIMESTAMPTZ AS time,
'https://.../gpcp.zarr' AS zarr_url,
'precip' AS zarr_variable
FROM …
ORDER BY timezarr_time_label exists because the match is literal. The label is handed to xarray's .sel, which compares exactly and offers no nearest-neighbour fallback. Left out, the panel spells the timestamp the way numpy stamps a datetime64[ns] — 2000-01-05T00:00:00.000000000 — which is right for a store stamped on the hour. It is wrong for one stamped at 09:00, as NASA's MUR SST is, and the symptom is every tile answering 500 with not all values found in index 'time'.
When the store's labels do not match, take them from the store and pass them through:
sql
strftime(dia, '%Y-%m-%dT09:00:00') || '.000000000' AS zarr_time_labelGET <server>/zarr/info?url=<store>&variable=<name> lists every label under band_descriptions, which is the reliable way to find out what a store answers to. It is not a small response — one store of 6 443 days answers with 1.2 MB — so read it once while building the dashboard rather than from the panel.
More than one axis to pin
A store is not obliged to have exactly one dimension beyond y and x. CarbonPlan's climate demo has a band and a month; give zarr_sel the pairs, comma separated, and each becomes its own sel:
sql
SELECT '…/tavg-prec-month' AS zarr_url,
'climate' AS zarr_variable,
'band=tavg,month=1' AS zarr_selTime is not listed there — the map's clock owns it, and it travels as the label above.
A pinned moment is a selector, not a time role
zarr_time_dim and zarr_time_label describe an axis the map's clock walks, and the panel only reads them from a query that returns a time column: with no calendar to build, the label never reaches the tile url and the server is asked for a cube it cannot draw — a 500 per tile.
When the moment is fixed by something else — a dashboard variable, a dropdown — it is not a time role at all. Put it in zarr_sel beside the other pinned axes:
sql
'init_time=' || $run || ',lead_time=' || $lead AS zarr_selWhen the temporal axis is not called time
A climate store often does not hold dates at all. CarbonPlan's demo keeps twelve months as month=1..12: integers, under a dimension named month. The map's clock still walks it — the query says which axis, and what each moment is called inside it:
sql
SELECT make_timestamp(2020, m, 1, 0, 0, 0)::TIMESTAMPTZ AS time, -- what the clock moves on
'month' AS zarr_time_dim, -- the axis in the store
m::TEXT AS zarr_time_label, -- what that axis answers to
…
FROM generate_series(1, 12) t(m)Naming the axis also switches off the timestamp fallback, deliberately: a store holding months answers to 7 and never to 1996-10-01T00:00:00.000000000, so guessing there would be a 500 on every tile. Name a dimension and you owe it labels.
Pyramids: what makes a store fast
This is the single biggest factor, and it is a property of the store rather than of the format.
A Zarr has no overviews unless it was written with them, so at a continental zoom the server reads native resolution. Chunks that span the whole time axis make it worse: reading one day pulls every day in the block. Measured on GPCP, whose chunks hold 200 days of the whole globe at 29 MB compressed apiece, tiles came back in 1.2 s to 17.5 s, and got slower the further you zoomed out.
A store written for maps behaves completely differently. Measured against the same server, on CarbonPlan's pyramided demo — six multiscales levels, 128×128 chunks, already in Web Mercator:
| Zoom | Pyramided store | GPCP, no pyramid |
|---|---|---|
| z=2 | 0.43 – 2.2 s | 2.8 – 9.0 s |
| z=3 | 0.53 – 2.0 s | 2.1 – 5.2 s |
| z=5 | 0.22 – 1.9 s | worse still |
Flat across zoom, and four to ten times quicker. Writing the same data locally both ways confirms the format itself is not the cost: a well-chunked Zarr read 0.06 s against a COG's 0.023 s, while the same data in one big chunk took 0.16 s.
Tell the panel how deep the pyramid is and each zoom asks for its own level:
sql
SELECT '…/tavg-prec-month' AS zarr_url,
'climate' AS zarr_variable,
6 AS zarr_levelszarr_levels is needed because the server will not do this by itself: titiler.xarray does not read the multiscales convention at all. What it does offer is a group parameter that selects a subgroup, and a pyramid written by ndpyramid names its groups 0, 1, 2 … by zoom — so the panel puts group={z} in the tile template and lets deck.gl fill in the zoom.
Declaring the depth also stops the map asking for levels the store does not have. Past the deepest one, deck reuses the tiles it already holds; without the count it would ask for group 6, 7, 8 and be answered 500 on every tile.
Leave zarr_levels out for a store with no pyramid — it answers at any zoom, just slowly.
Measuring, not only drawing
The same server answers questions about the store as well as painting it, over a polygon you draw on the map or a coordinate you click — and with the same variable, sel and group, so the number is about the slice on screen. That has a page of its own: Measuring imagery.
What is left when the store is right
A cache in front of the tile server is still worth having: nothing is reused between requests, so two tiles sharing a chunk fetch it twice.