Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
eefa424
chore(deps): bump the actions group with 3 updates (#4181)
dependabot[bot] Jul 27, 2026
d1533e1
docs: add blog
d-v-b Jul 27, 2026
0104cec
Update docs/blog/posts/3.3.0-release.md
d-v-b Jul 27, 2026
4a1a441
docs(blog): render post list and fill in release-post details
d-v-b Jul 27, 2026
ca9d16f
Merge remote-tracking branch 'origin/dev-blog' into dev-blog
d-v-b Jul 27, 2026
0b72757
feat: ZipStore accepts open binary file-like objects (#4187)
jhamman Jul 28, 2026
df8fce4
docs: drop concrete performance figures from the 3.3.0 release note
d-v-b Jul 28, 2026
02b54cc
docs: add runnable examples for the 3.3.0 performance features
d-v-b Jul 28, 2026
25324c4
docs: use slice(None) instead of Ellipsis in the pipeline example
d-v-b Jul 28, 2026
8e579a4
docs: update blog post
d-v-b Jul 28, 2026
8fdeea1
docs: add request for feedback to post
d-v-b Jul 28, 2026
0d33d1f
Merge branch 'main' into dev-blog
d-v-b Jul 28, 2026
732d576
docs: clarify latency claims
d-v-b Jul 28, 2026
ee07a61
Merge branch 'dev-blog' of https://github.com/d-v-b/zarr-python into …
d-v-b Jul 28, 2026
ef48a77
fix(examples): don't depend on pytest in the coalescing example
d-v-b Jul 28, 2026
6343988
docs: add changelog entry for the blog and examples
d-v-b Jul 28, 2026
cb93ef8
feat: add type-safe `get_array` and `get_group` methods to `AsyncGrou…
d-v-b Jul 28, 2026
8e5635a
docs(examples): measure gzip alongside zstd in the pipeline example
d-v-b Jul 28, 2026
ce1a844
Merge remote-tracking branch 'upstream/main' into dev-blog
d-v-b Jul 28, 2026
e345d18
Update 3.3.0-release.md
d-v-b Jul 28, 2026
05ce214
Update 3.3.0-release.md
d-v-b Jul 28, 2026
4a4eb43
Update 3.3.0-release.md
d-v-b Jul 28, 2026
2350202
Update .authors.yml
d-v-b Jul 28, 2026
3243ac2
docs(examples): rebuild the pipeline example on the real benchmark wo…
d-v-b Jul 28, 2026
c3f2498
Merge remote-tracking branch 'origin/dev-blog' into dev-blog
d-v-b Jul 28, 2026
e0f358f
docs(blog): apply Kyle's remaining review suggestions
d-v-b Jul 28, 2026
015732f
feat(zarr-metadata): Zarr metadata model layer (#4119)
d-v-b Jul 28, 2026
0c07bf5
fix: let open_like create arrays by default (#4146)
goutamadwant Jul 28, 2026
2fbacbd
Merge remote-tracking branch 'upstream/main' into dev-blog
d-v-b Jul 28, 2026
b6ccc69
Merge branch 'main' into dev-blog
d-v-b Jul 28, 2026
4174038
docs: bring the 4191 changelog entry up to date
d-v-b Jul 28, 2026
11cd09b
Merge remote-tracking branch 'origin/dev-blog' into dev-blog
d-v-b Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/needs_release_notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
pull-requests: write # Required to add labels to PRs
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: true
2 changes: 1 addition & 1 deletion .github/workflows/releases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
name: releases
path: dist
- name: Generate artifact attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/*
- name: Publish package to PyPI
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/zarr-metadata-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
path: dist

- name: Generate artifact attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/*

Expand All @@ -107,7 +107,7 @@ jobs:
path: dist

- name: Generate artifact attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/*

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/zarr-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ jobs:
- name: Sync test dependency group
run: uv sync --group test --python 3.11
- name: Run pyright
run: uv run --group test --with pyright pyright src
# Pinned to the last version that types PEP 661 sentinels in class
# attributes correctly; 1.1.405+ regressed (microsoft/pyright#11115).
# Unpin when the fix lands.
run: uv run --group test --with 'pyright==1.1.404' pyright src

zarr-metadata-complete:
name: zarr-metadata complete
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
persist-credentials: false

- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
3 changes: 3 additions & 0 deletions changes/3352.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fix `zarr.api.asynchronous.open_like` so it can create a new array by default when the
target path does not already exist. It now defaults to `mode="a"`; when using a read-only
store to open an existing array, pass `mode="r"` explicitly.
1 change: 1 addition & 0 deletions changes/4128.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `Group.get_array`, `Group.get_group`, `AsyncGroup.get_array`, and `AsyncGroup.get_group`: type-safe accessors that return the child array or group at a given path, raising `ArrayNotFoundError` / `GroupNotFoundError` if no node exists there, and `ContainsGroupError` / `ContainsArrayError` if the node is not of the requested kind. Unlike `Group.__getitem__`, which returns `Array | Group`, these methods have precise return types. Nested paths like `"subgroup/subarray"` are supported.
4 changes: 4 additions & 0 deletions changes/4187.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
`ZipStore` now accepts an open binary file-like object in place of a path, enabling
zip archives on remote storage (e.g. a file opened with `fsspec` or an
`obstore.ReadableFile`). Operations that require a filesystem location
(`clear`, `move`) raise `NotImplementedError` for file-object-backed stores.
14 changes: 14 additions & 0 deletions changes/4191.doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Added a blog section to the documentation, with a post covering two performance
highlights of the 3.3.0 release: the opt-in `FusedCodecPipeline` and byte-range
coalescing for partial reads of sharded arrays.

Added two runnable examples that accompany the post:
`examples/codec_pipeline_performance` compares the `BatchedCodecPipeline` and
`FusedCodecPipeline` on a sharded array across two stores and two codec
regimes, showing when the fused pipeline's thread pool helps and when it does
not, and `examples/sharding_coalescing` demonstrates how read coalescing
reduces the number of store requests when reading subregions of a sharded
array.

Also removed the hardware-specific speedup figures from the `FusedCodecPipeline`
release note, since they depend on the array layout, codec, and machine.
6 changes: 6 additions & 0 deletions docs/blog/.authors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
authors:
d-v-b:
name: Davis Bennett
description: Core developer
avatar: https://github.com/d-v-b.png
url: https://github.com/d-v-b
3 changes: 3 additions & 0 deletions docs/blog/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Blog

News, release highlights, and design notes from the Zarr-Python developers.
169 changes: 169 additions & 0 deletions docs/blog/posts/3.3.0-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
date: 2026-07-15
authors:
- d-v-b
categories:
- Release
---

# Zarr-Python 3.3.0

We're happy to announce the release of version 3.3.0 of Zarr-Python. It's been a while since our last release ([3.2.1](https://github.com/zarr-developers/zarr-python/releases/tag/v3.2.1) dropped in May of this year),
and we're bringing some exciting additions to the latest version. For the full release notes, see the [3.3.0 release notes](../../release-notes.md), otherwise stick around for an overview of two performance-centric highlights of this release.

<!-- more -->

## Faster low-latency storage

Relevant issues and pull requests:

- [#3524](https://github.com/zarr-developers/zarr-python/issues/3524) -- the performance report that started this work
- [#3885](https://github.com/zarr-developers/zarr-python/pull/3885) -- synchronous codec APIs and the `FusedCodecPipeline`

### The cost of async overhead

Zarr-Python 3.x uses async routines for fetching data and decoding chunks. In terms of code, this means our store (data fetching) and codec (chunk decoding) APIs are both async. This makes
I/O against high-latency storage backends like cloud object storage efficient. But for *low-latency* storage, like in-process memory or the file system, async routines add measurable overhead and offer no benefit. Async only adds value when there's work to be done while waiting for I/O to complete, but when I/O latency is low, it completes too quickly to run anything while waiting, and we are left paying the performance bill for obligatory async task scheduling that offered no value.

This performance problem became acute when Zarr-Python users reported that in-memory array indexing workloads ran *slower* in Zarr-Python 3.1.3 relative to Zarr-Python 2.18.7 ([#3524](https://github.com/zarr-developers/zarr-python/issues/3524)). Fortunately this performance regression had a straightforward fix (I don't say "easy" because it was a lot of work).

### Synchronous execution restores performance

If async overhead makes low-latency storage slow, does *removing* that overhead restore performance? Yes, it does!

In [#3885](https://github.com/zarr-developers/zarr-python/pull/3885) we defined synchronous versions of our storage and codec APIs -- the `SyncByteGetter` and `SyncByteSetter` protocols, plus a `get_ranges_sync` method on the `Store` ABC -- and then combined them in a new codec orchestration class called `FusedCodecPipeline`. The `FusedCodecPipeline` is an opt-in alternative to the default (the `BatchedCodecPipeline`) that gives large speedups for low-latency storage. It is currently marked [experimental](../../user-guide/experimental.md), so we may change it as we learn more; the default pipeline is untouched, and existing code keeps working unless you opt in.

The win here is *not* a faster compressor. It is the removal of async scheduling overhead (including some [nasty `asyncio.to_thread` overhead](https://github.com/python/cpython/issues/136084)), plus a few vectorized fast paths for dense, uncompressed shards. And we only expect this new pipeline to accelerate workloads targeting a subset of storage backends, namely any store with methods that advertise low latency.

On this author's 10-core Apple M4 laptop, the `FusedCodecPipeline` delivers the following results against memory-backed arrays:

- uncompressed writes are *~4 times faster*
- uncompressed reads are *~5 times faster*
- compressed writes are *~2 times faster*
- compressed reads are *~2 times faster*

These numbers came from a [runnable example](../../user-guide/examples/codec_pipeline_performance.md) that ships with the documentation. Run it yourself to get a sense of how the `FusedCodecPipeline` behaves on your system -- when and how you use it depends on your hardware, your array layout, and how your chunks are compressed. What's certain is that for in-memory arrays, and arrays saved to the local file system, the `FusedCodecPipeline` is worth a try.

Getting good numbers requires choosing the right level of thread-based parallelism for your workload, which is part of the configuration of the `FusedCodecPipeline`. For uncompressed chunks there's no CPU-bound work to do after fetching a chunk and so
thread-based parallelism is worse than useless and slows things down. But for compressed chunks, threading offers a substantial payoff.

### How to use it

Select the pipeline through the [runtime configuration](../../user-guide/config.md) by setting `codec_pipeline.path`. Set it globally to affect every array created or opened afterwards:

```python exec="true" session="blog-330" source="above"
import zarr

zarr.config.set(
{"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}
)
```

Or scope it to a block of code by using `zarr.config.set` as a context manager, which is the safer choice if you only want the new pipeline for part of your program:

```python exec="true" session="blog-330" source="above" result="ansi"
import numpy as np
import zarr
from zarr.storage import MemoryStore

with zarr.config.set(
{"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}
):
arr = zarr.create_array(
store=MemoryStore(),
shape=(1000, 1000),
chunks=(100, 100),
shards=(1000, 1000),
dtype="float32",
)
arr[:] = np.random.random((1000, 1000)).astype("float32")
result = arr[:]

print(result.shape)
```

Thread-based parallelism is configured separately, via `codec_pipeline.max_workers`. It defaults to `None`, meaning a pool sized to `os.cpu_count()`. Note that this setting is read *only* by the `FusedCodecPipeline` -- the default `BatchedCodecPipeline` ignores it, so tuning it without opting in above does nothing.

As noted, memory-backed and uncompressed workloads often do better with a single worker, which runs everything inline on the calling thread:

```python exec="true" session="blog-330" source="above"
import zarr

# No thread pool: run codec compute inline. Often best for uncompressed,
# memory-backed arrays, where there's no CPU-bound work to overlap.
zarr.config.set({"codec_pipeline.max_workers": 1})

# A fixed-size thread pool, which pays off once compression is in play.
zarr.config.set({"codec_pipeline.max_workers": 8})

# Or back to the default, sized to the number of CPUs.
zarr.config.set({"codec_pipeline.max_workers": None})
```

To return to the default pipeline, set `codec_pipeline.path` back to the batched implementation:

```python exec="true" session="blog-330" source="above"
import zarr

zarr.config.set(
{"codec_pipeline.path": "zarr.core.codec_pipeline.BatchedCodecPipeline"}
)
```

## Faster sharded reads

Relevant issues and pull requests:

- [#3004](https://github.com/zarr-developers/zarr-python/pull/3004) -- optimize partial shard reads
- [#3925](https://github.com/zarr-developers/zarr-python/pull/3925) -- `Store.get_ranges` for concurrent, coalesced multi-range reads
- [#3987](https://github.com/zarr-developers/zarr-python/pull/3987) -- control coalescing through `ArrayConfig` and the runtime config

### How sharding works

Chunks encoded with the `sharding_indexed` codec contain a secondary level of chunking, called subchunks. For example, if the `chunk_grid` field of the array metadata declares an "outer chunk" size of, say `(10, 10)`, a `sharding_indexed` codec in the `codecs` field could declare an "inner chunk" size of `(5, 5)`. Readers accessing such a chunk will observe a stored object (a stream of bytes) that decodes to an array with size `(10, 10)` (the "outer chunk"), which is comprised of four separate, contiguous byte ranges that each decode to a `(5, 5)` inner chunk. Each inner chunk occupies its own byte range in the outer chunk.

A reader can satisfy a request for all four inner chunks by issuing four separate byte-range requests, or by making a *single* request for a byte range that spans all four inner chunks. The latter option is nice because it cuts down on the number of requests we need. Historically Zarr-Python used this optimization when reading entire outer chunks; in 3.3.0, we use this optimization in more cases, resulting in more efficient I/O patterns for sharded reads.

### Interval equivalence

Byte ranges, being intervals, obey some combination rules: the values in two half-open intervals `[a, b), [b, c)` can be captured by the single interval `[a, c)`. That means a reader can get multiple inner chunks with *one* byte-range request by requesting a range of bytes starting with the first byte of the first subchunk and ending with the last byte of the last subchunk. When individual requests are expensive, this kind of optimization is worth a lot.

The requested inner chunks are not necessarily contiguous -- there might be a byte range gap between them. As long as that gap is not too big, its often efficient to fetch the entire byte range, gap included, and pick out the inner chunk byte ranges after I/O is done.

### Byte range coalescing

We call this procedure -- merging adjacent byte ranges -- "byte range coalescing", and it's a new performance optimization shipping in Zarr-Python 3.3.0. Unlike the `FusedCodecPipeline`, this one is on by default with base settings we think are good, so most users won't need to tune anything.

Two knobs control it, both documented in the [runtime configuration guide](../../user-guide/config.md). Nearby byte ranges in the same shard are merged into a single request when the gap between them is no larger than `array.sharding_coalesce_max_gap_bytes` (default 1 MiB) and the merged read stays within `array.sharding_coalesce_max_bytes` (default 16 MiB). The gap threshold is what trades wasted bytes against saved requests: raising it reads more data you didn't ask for, in exchange for fewer requests.

For a runnable demonstration -- counting the store requests saved and timing them against a store with simulated latency -- see the [sharded read coalescing example](../../user-guide/examples/sharding_coalescing.md).

You can set them globally, or per array by passing `config={...}` to [`zarr.create_array`][]:

```python exec="true" session="blog-330" source="above" result="ansi"
import zarr
from zarr.storage import MemoryStore

arr = zarr.create_array(
store=MemoryStore(),
shape=(1000, 1000),
chunks=(100, 100),
shards=(1000, 1000),
dtype="float32",
config={
"sharding_coalesce_max_gap_bytes": 4 * 1024**2, # 4 MiB
"sharding_coalesce_max_bytes": 64 * 1024**2, # 64 MiB
},
)
print(arr.shape)
```

## Tell us what you think

We hope these new features are helpful, and we would appreciate any feedback that helps us improve them, or any other aspect of Zarr-Python.

## Going faster

The updates in this release are just the first step of a larger performance-oriented direction for Zarr-Python. Landing these two enhancements taught us a *lot* about the performance-sensitive areas of the library. We can and will invest more time in performance tuning, e.g. by adding or changing abstractions, writing code for special cases, etc.

We plan to consider including compiled code that should enable significant performance improvements. The [`zarrs`](https://zarrs.dev/) project is an ecosystem of Zarr tools written in Rust, with [extremely high performance](https://book.zarrs.dev/#-zarrs-is-fast-). Is there a `zarrs` binding in Zarr-Python's future? I hope so! We are keenly observing development of [`zarrista`](https://developmentseed.org/zarrista/latest/) as a proof-of-concept for what a Python-`zarrs` binding layer might look like. Stay tuned!
2 changes: 1 addition & 1 deletion docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
concurrently. ([#3004](https://github.com/zarr-developers/zarr-python/issues/3004))
- Added a `subchunk_write_order` option to `ShardingCodec` to control the physical order of subchunks within a shard. Supported values are `morton`, `unordered`, `lexicographic`, and `colexicographic`. `unordered` makes no guarantee about subchunk layout. This setting affects only on-disk layout, not the data read back, and is not persisted in array metadata: it applies per codec instance and is not recovered when reopening a sharded array. ([#3826](https://github.com/zarr-developers/zarr-python/issues/3826))
- Added `SyncByteGetter` and `SyncByteSetter` runtime-checkable protocols and a `get_ranges_sync` method on the `Store` ABC. These let custom byte getters/setters opt into the synchronous codec pipeline's fast path for in-memory IO, which the sharding codec uses for its inner chunks. ([#3885](https://github.com/zarr-developers/zarr-python/issues/3885))
- Added `FusedCodecPipeline`, an opt-in codec pipeline that runs codec compute synchronously and in bulk (avoiding the per-chunk async scheduling overhead of the default `BatchedCodecPipeline`), giving large speedups for sharded arrays (up to ~24x writes / ~14x reads on many-chunks-per-shard layouts, more with compression) and no regressions on compute-bound workloads. The default `BatchedCodecPipeline` is unchanged for standard configurations, so existing code keeps working unless you opt in; enable the new pipeline with `zarr.config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"})`. ([#3885](https://github.com/zarr-developers/zarr-python/issues/3885))
- Added `FusedCodecPipeline`, an opt-in codec pipeline that runs codec compute synchronously and in bulk (avoiding the per-chunk async scheduling overhead of the default `BatchedCodecPipeline`), giving large speedups for sharded arrays. The default `BatchedCodecPipeline` is unchanged for standard configurations, so existing code keeps working unless you opt in; enable the new pipeline with `zarr.config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"})`. ([#3885](https://github.com/zarr-developers/zarr-python/issues/3885))
- Add `zarr.abc.store.Store.get_ranges` for concurrent, coalesced multi-range reads from a single key. The method is defined on the `Store` ABC with a default implementation built on `Store.get`, so every store inherits a working version; stores with native multi-range backends (e.g. `FsspecStore`) can override for efficiency. Coalescing knobs (`max_concurrency`, `max_gap_bytes`, `max_coalesced_bytes`) are passed as keyword arguments to `get_ranges`. Failures from underlying fetches surface as a `BaseExceptionGroup` (PEP 654); callers should use `except*` to filter for specific exception types such as `FileNotFoundError`. ([#3925](https://github.com/zarr-developers/zarr-python/issues/3925))
- Two new fields on `ArrayConfig` control how the sharding codec coalesces partial-shard reads: `sharding_coalesce_max_gap_bytes` (default 1 MiB) and `sharding_coalesce_max_bytes` (default 16 MiB). When reading multiple chunks from the same shard, nearby byte ranges are merged into a single request to the store if separated by no more than `sharding_coalesce_max_gap_bytes` and the merged read stays within `sharding_coalesce_max_bytes`. Defaults are seeded from the matching `array.sharding_coalesce_max_gap_bytes` / `array.sharding_coalesce_max_bytes` keys in [`zarr.config`][] at array-creation time, and can be overridden per array by passing `config={...}` to [`zarr.create_array`][]. ([#3987](https://github.com/zarr-developers/zarr-python/issues/3987))

Expand Down
7 changes: 7 additions & 0 deletions docs/user-guide/examples/codec_pipeline_performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
--8<-- "examples/codec_pipeline_performance/README.md"

## Source Code

```python exec="false" reason="pymdownx snippet include directive, not python source"
--8<-- "examples/codec_pipeline_performance/codec_pipeline_performance.py"
```
7 changes: 7 additions & 0 deletions docs/user-guide/examples/sharding_coalescing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
--8<-- "examples/sharding_coalescing/README.md"

## Source Code

```python exec="false" reason="pymdownx snippet include directive, not python source"
--8<-- "examples/sharding_coalescing/sharding_coalescing.py"
```
Loading
Loading