Zarr v3 in Python: A Practical Guide to Chunked Array Storage, Sharding, and Cloud Pipelines (2026)
Zarr-Python 3 brings full v3 spec support, an async core, and chunk sharding for cloud object stores. A data-engineering walkthrough with chunking rules, migration steps, and pipeline tests you can actually run.
Zarr v3 in Python is the current stable specification for chunked, compressed, N-dimensional array storage. As of Zarr-Python 3.x, newly created arrays default to zarr_format=3, and the format adds sharding, an async core, a pluggable store ABC, and a codec entry-point system on top of the classic v2 model. If your pipelines write climate, imaging, genomics, or feature-store tensors into S3 or GCS, this is the flavour of Zarr you want in 2026. I run overnight ingest jobs on top of it, and this is the guide I wish I'd had before the first 3am pager.
Zarr-Python 3.0 (Jan 2025) and the 3.x line default to the Zarr v3 spec, keep read/write compatibility with v2, and require Python 3.11+.
Sharding (ZEP 2) decouples chunk size from the number of storage objects, so you can finally use 1 MB chunks on S3 without exploding request counts.
The async core (asyncio-based Store, Group, Array, Codec) is the reason concurrent cloud reads are dramatically faster than in v2.x.
Target 1–16 MB compressed chunks with Blosc/Zstd; align Dask chunks to Zarr chunks with align_chunks=True before writing.
Zarr isn't a Parquet replacement. It wins for N-D arrays and tensors, Parquet still wins for tabular analytics and Iceberg/Delta tables.
Migration from v2 is mostly re-writing arrays; direct Array(...) construction is banned, so use zarr.create_array/zarr.open_array.
What is Zarr v3 in Python?
Zarr is an open, community-maintained protocol for storing chunked, compressed, N-dimensional arrays. The v3 specification is the current stable version, and it adds extension points that v2 didn't have (custom codecs, chunk grids, data types, and stores), plus multi-language interoperability. In Python, the reference implementation is zarr-python; version 3.0 shipped in January 2025, and by mid-2026 the 3.x line is what every serious ingest job I've seen runs on.
A Zarr array is really two things on disk: a small JSON metadata document describing shape, dtype, chunk grid, codecs, and attributes, plus a bag of binary chunk objects laid out under keys like c/0/0/0. That layout maps beautifully to S3, GCS, and Azure Blob because "one chunk = one object" is exactly what object stores were built for. It also means your pipeline can append variables, resize dimensions, or reprocess a region without rewriting the whole dataset. That's the same reason I keep data contracts in front of every producer that touches these arrays.
Installation is one line, but pin your Python version. Zarr-Python 3 dropped support for Python 3.10 and below:
So, the v3 upgrade isn't just a version bump. It's a re-architecture. The 2.x series stopped receiving anything but security fixes six months after the 3.0 release, so if you're still on 2.x, this is your migration cue. Here's the summary I hand to new hires:
Aspect
Zarr v2 (2.18.x)
Zarr v3 (3.x)
Spec status
Legacy, still readable
Current default (zarr_format=3)
Metadata file
.zarray, .zattrs, .zgroup
Single zarr.json per node
Chunk key layout
0.0.0 (dot-separated)
c/0/0/0 (slash-separated)
Sharding (ZEP 2)
Not supported
First-class, via shards= kwarg
Async I/O
Sync only
Async core (Store, Group, Array, Codec)
Custom stores
Ad-hoc mapping
Formal Store ABC
Codec extensibility
Numcodecs only
Entry-point plugin interface (zarr.codecs)
Data types
NumPy dtypes
Zarr-specific dtype classes
Python support
3.9+
3.11+
Maintenance
End of life (post-2025)
Active
The most important line in that table for a data engineer is the async core. In v2, reading 1,000 chunks from S3 meant 1,000 blocking HTTP calls (unless you wrapped Dask around it). In v3, the Store, Group, Array, and Codec layers are all built on asyncio, so an fsspec-backed S3 store can issue those requests concurrently by default. Most user code still calls sync methods (the async layer is under the hood), but if you're writing a custom ingest worker, async def is now the fast path. The Zarr-Python 3 release notes have the full breakdown of the async API surface.
How does Zarr sharding work?
Sharding is the feature I dragged the team onto v3 for. In classic Zarr, one chunk maps to one object. That's fine when your array is (10k, 10k) with 1000×1000 chunks; you get 100 objects. It gets ugly when you have (10k, 10k, 1000) with 100×100×100 chunks, because you get one million objects, and S3 request billing starts to hurt at 3am when a scheduled reprocess kicks off.
Sharding, defined by ZEP 2, groups multiple chunks into a single storage object called a shard. Inside the shard, each chunk is compressed and indexed independently, so readers can seek to and fetch a single chunk without pulling the whole shard. Writes, however, happen at shard granularity: a shard is the unit of writing, a chunk is the unit of reading.
The math matters: for the array above, without sharding you'd have 1,000,000 chunks-as-objects. With shards of 1000×1000×1000, you have 100 objects on S3 while still letting a downstream reader fetch a single 100×100×100 chunk. The GET-request bill on my ingest went down by roughly 4 orders of magnitude the week we rolled this out. Honestly, that was the single line item that paid for the migration.
How to choose chunk and shard sizes
Nine times out of ten, "my Zarr is slow" turns out to be bad chunk sizing. The rules I follow, calibrated against a few thousand production runs:
Chunk shape should match the access pattern. If downstream code always slices along time, make the time dimension the fastest-varying with a small chunk length; if it slices spatially, do the opposite.
Uniform chunks across all dims. Non-uniform chunks (v3 supports them, but I avoid them) confuse Dask alignment and break most rechunker heuristics.
Shard size = 10–1000× chunk size. Enough to keep object count sane, small enough that a rewrite of one shard doesn't kill your job.
Compression: Blosc + Zstd level 3 for numeric data is my default. LZ4 if you're CPU-bound.
A quick sanity check I run before shipping any pipeline change:
That single assert has caught more prod bugs than I want to admit, usually a colleague passing chunk shapes in the wrong dtype units, or forgetting that uint8 and float64 have an 8× size difference.
Building a Zarr pipeline with xarray, Dask, and S3
Real pipelines almost never touch zarr directly. They go through xarray.Dataset.to_zarr with a Dask-backed dataset. Both xarray and Dask have had v3 support since early 2025, so this is the layout I use for anything larger than "fits on a laptop":
import xarray as xr
import numpy as np
import pandas as pd
import dask.array as da
# Build a chunked dataset: (time, lat, lon)
n_time, n_lat, n_lon = 8760, 721, 1440 # one hourly year at 0.25 deg
temperature = da.random.normal(
288, 15, size=(n_time, n_lat, n_lon), chunks=(24, 180, 360)
).astype("float32")
ds = xr.Dataset(
{"t2m": (("time", "lat", "lon"), temperature)},
coords={
"time": pd.date_range("2026-01-01", periods=n_time, freq="1h"),
"lat": np.linspace(-90, 90, n_lat),
"lon": np.linspace(-180, 180, n_lon, endpoint=False),
},
)
# Zarr v3 write to S3, with sharding and chunk alignment.
ds.to_zarr(
"s3://mybucket/era5/t2m.zarr",
mode="w",
zarr_format=3,
encoding={
"t2m": {
"chunks": (24, 180, 360),
"shards": (168, 720, 1440),
"compressors": [{"name": "blosc", "configuration": {"cname": "zstd", "clevel": 3}}],
}
},
align_chunks=True, # rechunk Dask to match Zarr before write
safe_chunks=True, # refuse writes that would corrupt neighbours
storage_options={"anon": False, "region_name": "us-east-1"},
)
Two flags matter more than the others. align_chunks=True rechunks the Dask array before it hits Zarr, so each Dask task maps to exactly one shard, meaning no torn writes across shard boundaries. safe_chunks=True (the default) refuses writes that would violate the many-to-one Dask→Zarr chunk mapping, which is the guardrail that prevents parallel workers from silently overwriting each other. Turn it off only if you own the parallelism yourself and know what you're doing. I don't.
For truly large-scale rebuilds (petabyte-class), the Apache Beam + xarray-beam combo is what I reach for; it handles the rechunker plumbing and works with the same zarr_format=3 switch. But for daily incrementals under ~5 TB, plain xarray + Dask on a modest cluster is enough. My colleagues on GeoPandas-driven geospatial pipelines reach for Zarr the moment they leave the vector world.
Zarr vs Parquet vs HDF5: when to use which
This is the top "People Also Ask" question, and the answer is: they solve different problems. Parquet is tabular columnar. HDF5 is single-file hierarchical scientific. Zarr is cloud-native N-dimensional array. I use all three, sometimes in the same job.
Dimension
Zarr v3
Parquet
HDF5
Data model
N-D chunked arrays
Columnar tabular
Hierarchical N-D arrays
Storage layout
Many objects (or shards) in a directory
One or many files, row groups
Single file
Cloud object stores
Native (S3/GCS/Azure)
Native, via Iceberg/Delta
Awkward (single-file locking)
Parallel writes
Yes, per-shard
Yes, per-file
Fragile without MPI-HDF5
Random N-D slicing
Excellent
Poor
Excellent (local)
Ecosystem
xarray, Dask, PyTorch loaders
Arrow, Spark, DuckDB, Iceberg
h5py, netCDF4
Best use case
Tensors, climate, imaging, features
Analytics, warehouses, lakehouses
Scientific HPC, legacy datasets
My rough decision tree: if it's rows × columns and lands in a warehouse, reach for Parquet (probably inside Iceberg or Delta Lake). If it's N-dimensional and lives on object storage, Zarr. If it's an on-prem HPC job that reads a single file with MPI, HDF5. Mixing is fine and common: use Parquet for the manifest/index of a dataset, Zarr for the underlying tensor blobs.
Migrating from Zarr v2 to v3
The migration is less scary than it sounds because Zarr-Python 3 can still read v2 stores. The pain is on the write side and in code changes.
Pin your version.pip install "zarr>=3.0,<4". Confirm with zarr.__version__.
Replace direct Array(...) construction. The v3 API forbids it. Use zarr.create_array or zarr.open_array. This is the most common CI failure I see.
Migrate stores. Read v2, write v3:
src = zarr.open_group("s3://bucket/legacy.zarr", zarr_format=2)
dst = zarr.open_group("s3://bucket/v3.zarr", zarr_format=3, mode="w")
for name, arr in src.arrays():
dst.create_array(
name=name, shape=arr.shape, chunks=arr.chunks,
dtype=arr.dtype, shards=None, # add later once you profile
)[:] = arr[:]
Add sharding gradually. Start with no shards, verify correctness, then re-write with shards once you've measured the object-count problem.
Update codec configs. Numcodecs still works, but if you were using compressor=, note it's now compressors=[...] (a pipeline). The official Zarr 3.0 migration guide has the exhaustive list.
Bump downstream libs. xarray ≥ 2024.11, Dask ≥ 2024.12, netCDF4 ≥ 1.7 all have proper v3 support.
Testing Zarr pipelines so they don't break at 3am
Pipelines fail silently more than they fail loudly, and Zarr writes are worse than most because a bad chunk shape or a mis-aligned shard can look fine until three months later when someone queries the boundary. The tests I now consider non-negotiable:
import zarr
import numpy as np
import pytest
def test_zarr_chunks_are_uniform_and_sane(store_path):
z = zarr.open_array(store_path)
chunk_mb = np.prod(z.chunks) * z.dtype.itemsize / 1e6
assert 0.5 <= chunk_mb <= 32, f"Chunk size {chunk_mb:.1f} MB out of range"
def test_zarr_shards_align_with_chunks(store_path):
z = zarr.open_array(store_path)
if z.shards is None:
pytest.skip("no shards configured")
for shard_dim, chunk_dim in zip(z.shards, z.chunks):
assert shard_dim % chunk_dim == 0, "Shard must be integer multiple of chunk"
def test_zarr_metadata_is_v3(store_path):
z = zarr.open_array(store_path)
assert z.metadata.zarr_format == 3
def test_roundtrip_deterministic(store_path, sample_slice):
z = zarr.open_array(store_path)
a = z[sample_slice]
b = z[sample_slice]
np.testing.assert_array_equal(a, b)
def test_no_nan_in_hot_variable(store_path):
z = zarr.open_array(store_path)
# Cheap probe: first + last chunk
assert not np.isnan(z[:z.chunks[0]]).any()
assert not np.isnan(z[-z.chunks[0]:]).any()
Run those in CI before every deploy of a producer. The two that have caught the most real bugs are the chunk-size sanity check (someone always mis-remembers the units) and the shard-alignment check (a rebase silently changed the encoding dict). They're the Zarr equivalent of producer-side data contracts, and they cost nothing to run.
Production pitfalls I've hit
The list you'll wish you'd read six months earlier:
Mixed v2/v3 metadata in the same store. If a v2 writer touches a v3 store (or vice versa), the metadata files land side-by-side and readers get confused. Enforce zarr_format at the producer.
Consolidated metadata drift.zarr.consolidate_metadata is a v2-era optimisation; in v3 it's optional, and the on-disk copy can drift after appends. Regenerate it as a pipeline step or drop it.
fsspec caching hides staleness. A common one: s3fs caches directory listings and returns yesterday's chunk list. Pass skip_instance_cache=True in read-after-write flows.
Sharded stores + partial writes. If your job crashes mid-shard, that shard is now torn. Restart with a shard-level idempotent key, not a chunk-level one.
Python 3.10 clusters. Zarr-Python 3 needs 3.11+. Airflow images that still ship 3.10 will fail with a cryptic import error. I hit this exact bug shipping a scheduled backfill and lost half a day to it.
Silent uint16 overflow. Zarr won't stop you from writing float32 into a uint16 array; the codec cast rounds hard. Add a dtype assertion to your pipeline tests.
Blosc thread contention. Blosc uses its own thread pool. If you also run Dask with 32 workers, you get 32×N Blosc threads and the box thrashes. Set numcodecs.blosc.set_nthreads(1) inside Dask workers.
If Zarr is going to fit into your pipeline stack, treat it the way you'd treat any other producer surface: schema tests, chunk-size assertions, and a nightly integrity job. The format is genuinely great in 2026 (I ship far fewer boundary bugs than I did on the v2/HDF5 mix), but it rewards discipline. Point it at S3, run the tests, and get a decent night's sleep.
Frequently Asked Questions
Do I need to migrate from Zarr v2 to Zarr v3?
You don't have to migrate immediately, since Zarr-Python 3.x still reads v2 stores. But v2 is no longer actively maintained after mid-2025, and features like sharding, the async core, and the codec plugin system are v3-only. For any new pipeline started in 2026, write v3.
Does Zarr work with Amazon S3, Google Cloud Storage, and Azure Blob?
Yes. Zarr's chunk/object layout maps directly onto object stores. Use fsspec plus the store-specific driver (s3fs, gcsfs, adlfs). Sharding makes cloud storage particularly friendly by keeping the number of objects manageable.
What chunk size should I use in Zarr?
Aim for 1–16 MB compressed per chunk. Below 1 MB, per-request overhead on S3/GCS dominates; above 16 MB, you waste bandwidth on partial reads. Match chunk shape to your dominant access pattern (small along dimensions you slice heavily, larger along dimensions you always take in full).
Is Zarr faster than Parquet?
Different jobs. For N-D array slicing, Zarr is dramatically faster because Parquet's row-group model wasn't designed for random tensor access. For SQL-style tabular analytics, Parquet wins; its columnar layout with Arrow readers is unmatched. Use both where each fits.
Can xarray write directly to Zarr v3?
Yes. Since xarray 2024.11, Dataset.to_zarr(..., zarr_format=3) produces v3 stores and supports the shards= option in the encoding dict. Use align_chunks=True and safe_chunks=True for parallel Dask writes.
Benchmark Cohere Rerank 3.5, BGE v2-m3, Jina Reranker v2, and ColBERT v2 for RAG in Python. Runnable code, NDCG@10 results, latency, and $/1M queries so you can pick the right reranker.
DataFusion is an Apache Arrow-native, Rust query engine you install via pip as datafusion-python. Learn install, SQL and DataFrame APIs, UDFs, Substrait, Ballista, and how it stacks up against DuckDB and Polars in 2026.
uv is Astral's Rust-based Python package manager that replaces pip, pip-tools, pyenv, pipx, and Poetry with one tool that resolves and installs dependencies 10-100x faster. This 2026 guide covers uv.lock, PEP 723 scripts, workspaces, PyTorch/CUDA installs, and Jupyter integration.