Read about how Iris loads and saves Zarr stores. |
Tags: topic_load_save
Zarr I/O Handling in Iris#
Zarr stores an array as many independently compressed chunks under a prefix, rather than as one file. Iris reads Zarr version 2 and version 3 stores natively, through zarr-python.
zarr is an optional dependency, and Iris requires at least version
3.1.0. Install it to load Zarr stores:
conda install -c conda-forge "zarr>=3.1.0"
Iris without it installs and runs normally, raising an ImportError that
names the package and its minimum required version if you ask it to load a
store.
Loading#
A store is a directory, or a URL, so pass it where you would pass a file:
cubes = iris.load("/data/forecast.zarr")
Iris recognises a local store by the metadata file at its root – zarr.json
for version 3, .zgroup for version 2 – not by the .zarr suffix, so a
directory that merely has the suffix is not mistaken for one.
A store holding UGRID mesh data loads as a mesh cube, the same as the
equivalent netCDF file would. iris.mesh.load_meshes() reads a store
too.
Note
A Zarr attribute may be any JSON value, so a cube loaded from a store may carry one that netCDF cannot hold. Saving that cube as netCDF fails. See Attributes.
Saving#
A store is a directory, or a URL, so pass it where you would pass a file:
iris.save(cubes, "/data/forecast.zarr")
Iris writes Zarr version 3 stores. A whole CubeList
goes into one store, which is opened once. Saving through netCDF-c’s NCZarr
driver writes version 2 instead, covered below in NCZarr.
Warning
A save that is interrupted leaves a partial store behind, and Iris cannot roll it back. The damage is quiet – see What an interrupted save leaves behind.
Unlike the netCDF saver, this one does not overwrite. iris.save raises
if the target already exists, because replacing a store means deleting many
objects one at a time, and an interrupted replacement destroys what was there
without completing what replaces it. Say so explicitly to opt in:
iris.save(cubes, "/data/forecast.zarr", mode="w") # replace
iris.save(more_cubes, "/data/forecast.zarr", mode="a") # add what is not there
mode="a" adds a new array or group to a store that already holds others.
It never extends, rewrites, resizes or re-chunks an array that is already
there. Saving a cube whose own data variable already exists in the target
group is an error rather than an overwrite, so saving the same cube twice with
mode="a" raises rather than duplicating it. Appending to data is not
supported, for Zarr or for netCDF: deciding whether incoming data is
compatible with what a store already holds is a data-model problem, and the
storage format does not solve it.
Everything else a cube carries – its coordinates, bounds and cell measures –
is not matched against what the store already holds. Adding a cube to a store
that already carries a latitude array writes a second one, named
latitude_0, rather than reusing the first. Deciding whether two
coordinates are “the same” across unequal values, differing units and
different calendars is its own piece of work, so Iris does not attempt it.
Storage keywords#
The Zarr saver takes zarr-python’s vocabulary, not netCDF’s:
iris.save(
cube,
"out.zarr",
chunks=(1, 50, 50),
shards=(4, 100, 100),
compressors=[zarr.codecs.BloscCodec(cname="zstd")],
)
chunks, shards, compressors, filters and serializer are
passed through as zarr-python defines them. A netCDF keyword such as
zlib=True raises TypeError naming the Zarr replacement where there
is one, and saying there is none where there is not. order raises too: it
is zarr-python’s own keyword, but it has no effect on a version 3 array.
For a cube holding lazy data, chunks defaults to the cube’s own lazy
chunking. Real data has no chunking of its own to inherit, and gets
zarr-python’s automatic chunking instead. Passing chunks explicitly
overrides either case. The default is also dropped when you pass shards: a
shard holds a whole number of inner chunks, and zarr-python picks the inner
chunk in that case.
Deferred saving#
compute=False returns a dask.delayed.Delayed that writes the lazy
data when computed, and consolidates the store’s metadata after the last chunk:
result = iris.save(cubes, "deferred.zarr", compute=False)
result.compute()
Consolidated metadata#
Iris writes a consolidated metadata document at the store’s root when the save
completes, which makes a remote store usable: a reader fetches one object
instead of one per array. Pass consolidate_metadata=False to decline.
When Iris adds to a store that already carries one, it rewrites it – or, if you declined consolidation for that save, removes it. A consolidated document that does not mention what was just written is what a default reader believes, so a stale one hides data; having none at all is slower to read but tells the truth.
What an interrupted save leaves behind#
An interrupted save leaves a partial store, and Iris offers no rollback. Zarr has no multi-object transaction: a store’s metadata and its chunks are separate objects, so a save interrupted between them leaves the two disagreeing, and a missing chunk is not an error – it reads back as the fill value, with no warning. The usual mitigation, writing to a temporary name and renaming on success, is not adopted: a directory rename is atomic on a local filesystem but object stores have no equivalent, so the guarantee would evaporate exactly where Zarr is most used. Callers who need transactional writes want Icechunk.
Meshes#
A cube with an unstructured MeshXY saves correctly: the
store carries the full CF-UGRID structure, the same mesh, topology and
connectivity variables a netCDF save would write.
Remote stores#
A URL with one of the s3, gs, az or abfs schemes, ending in
.zarr, is passed to zarr-python unaltered:
cubes = iris.load("s3://noaa-gfs-bdp-pds/forecast.zarr")
Iris adds no store library of its own and handles no credentials. Install the
one your scheme needs – s3fs, gcsfs, adlfs – and configure it as
that package documents. zarr-python raises an ImportError naming the
package if it is missing.
Groups#
A Zarr store may hold several groups, and they need not describe the same
thing. Iris loads one group per call, like xarray, rather than merging
them: iris.load reads the root group, and a store whose root holds no data
variables raises a message naming the groups that do.
To read another group, call the Zarr loader directly:
from iris.cube import CubeList
import iris.fileformats.zarr
cubes = CubeList(
iris.fileformats.zarr.load_cubes(
"/data/sentinel.zarr", group="measurements/reflectance/r10m"
)
)
Saving takes a group more directly: iris.save itself takes a group=
keyword, naming a /-separated path within the store:
iris.save(cubes, "/data/sentinel.zarr", group="measurements/reflectance/r10m")
Passing group= implies mode="a", because writing a group into a store
means the store may already hold others. iris.load and iris.load_cube
have no equivalent keyword: reading a group other than the root means calling
the Zarr loader directly, as above.
Dimension names#
A netCDF dimension has a name, one length, and a meaning shared across the whole file. A Zarr dimension name promises less. An array need not name its dimensions at all, and the same name in two arrays guarantees nothing – not even the same length. Both are permitted by the specification.
So Iris treats two arrays as sharing a dimension only when they name it identically and it is the same length in both. Anything else loads as an anonymous dimension, which is an ordinary Iris cube dimension with no coordinate on it – a dimension the store never named, for instance.
The pair is matched array by array, so one name used at two lengths does not
spoil both. Given a store whose t is 4 long in one data array and 5 in
another, a coordinate array named t and 4 long attaches to the first and
not to the second: the first cube gets its coordinate, and only the second
gets an anonymous dimension. The load also warns, naming the dimension and
the sizes found, because nothing else would explain the difference.
A coordinate left with nothing to attach to is not discarded. It goes to
iris.loading.LOAD_PROBLEMS, and the load warns that not every object
was parsed.
Version 2 stores have no dimension-name field. They carry names in an
_ARRAY_DIMENSIONS attribute instead – the convention xarray established
– which Iris reads, so dimensions are named on both versions. The attribute
describes the store rather than the data, so it does not itself reach
cube.attributes.
Missing data#
Zarr returns a plain array and applies no masking, so Iris does it, from the
CF attributes: _FillValue and missing_value for sentinels,
valid_min/valid_max/valid_range for a range, and
scale_factor/add_offset for packed data.
A Zarr array also carries a fill_value in its metadata. That says what
unwritten storage reads back as, not which values are missing, and
zarr-python defaults it to zero. Iris reads it as missing data only on a
version 2 store whose array declares no CF attribute – which is what version
2 writers meant by it, and what xarray does.
That one case can mask a genuine zero, so Iris warns when it happens, naming
the variable. Setting a _FillValue attribute on the array prevents both
the masking and the warning.
On save, Iris writes the missing-value declaration as well as the storage fill:
a masked cube gets a _FillValue attribute even when it carried none of its
own, because on version 3 the attribute is what declares missing data and the
storage fill_value is not. Without it, every masked point would read back
unmasked. Pass fill_value= to choose the sentinel; the default is the same
one netCDF uses for that dtype.
A floating _FillValue is written as the base64 of a little-endian
float64, which is what xarray and the wider Zarr ecosystem read. This
deviates from CF section 2.5.1, which asks for a value of the variable’s own
type. The deviation is deliberate and necessary: a plain JSON number there
makes the whole store unreadable by default xarray, which is a worse outcome
than a convention deviation that every reader understands.
Chunking#
Iris chooses Dask chunks from the store’s own chunking, as it does for
netCDF, and CHUNK_CONTROL works the same
way for both formats.
A Zarr chunk is the atomic unit of storage: reading any part of one fetches
and decompresses the whole object, so a Dask chunk smaller than a store chunk
costs a re-read per division. Iris does not adjust its chunking to avoid
that, any more than it does for netCDF, but it warns when a store chunk is
larger than Dask’s target chunk size – where the cost starts to be worth
your attention. Use CHUNK_CONTROL to choose the chunking yourself.
A sharded array is chunked on its inner chunks, not its shards. Zarr’s sharding codec is indexed, so a reader fetches only the inner chunks it needs.
zarr-python fetches chunks concurrently, and Dask’s worker count multiplies
with that concurrency. If a remote store rate-limits you, lower either Dask’s
worker count or zarr-python’s own zarr.config setting
async.concurrency.
Attributes#
A Zarr attribute value may be any JSON literal, including many things CF cannot express, such as multidimensional arrays, dictionaries or lists of lists. Iris carries such a value onto the cube unchanged and does not interpret it. See #7288 for the wider question of how the CF attribute model should map onto JSON.
Note
A cube carrying such an attribute cannot be saved as netCDF, because
netCDF attribute values are scalars, strings and one-dimensional arrays.
The netCDF library raises TypeError: illegal data type for attribute
b'stac_discovery', which does not name the cube. Delete the attribute
before saving:
del cube.attributes["stac_discovery"]
See #7329.
Saving attributes converts them#
Zarr stores its attributes as JSON, which has one number type and no concept
of a NumPy dtype. So saving converts: a NumPy scalar becomes the Python
number it holds, a NumPy array becomes a list, bytes become text through
ASCII, and a list or tuple is converted element by element.
The conversion is not reversible, and a round trip through a store does not return the dtype you wrote:
cube.attributes["sensor_count"] = np.int32(3)
iris.save(cube, "out.zarr")
loaded = iris.load_cube("out.zarr")
type(loaded.attributes["sensor_count"]) # <class 'int'>
The value survives; its width does not. Compare attribute values rather than their types when checking a round trip, and do not rely on an attribute to carry a dtype – the data’s own dtype is unaffected by any of this, and round-trips exactly.
An attribute Iris cannot convert is refused, by name:
TypeError: Cannot save attribute 'probe': a value of type object has no
JSON form. Convert it to a number, a string, or a list of either.
It is not silently stringified: an attribute that cannot be read back as
what you wrote is worse than a save that stops and tells you which attribute
is the problem. The refusal happens as the attribute is written, so the
rejected value never reaches the store – but the save as a whole has
started, and what it has already written stays there. See
what an interrupted save leaves behind;
a retry needs mode="w", because the default refuses an existing store.
NCZarr#
Iris also reads and writes Zarr through netCDF-c’s NCZarr driver, reached by way of a URL fragment:
iris.load("file:///data/forecast.zarr#mode=nczarr,file")
NCZarr implements Zarr version 2, and writes scalar variables as length-one
arrays. The native reader described above handles both versions, and is
what a plain path or URL – one with no #mode= fragment – reaches.