# Streaming millions of TESSERA tiles over HTTP with Zarr v3

*2026-03-14 — note*


I've been working on making [TESSERA](https://anil.recoil.org/projects/tessera) map embeddings even easier to retrieve,
so that we can build [dynamic user interfaces](https://anil.recoil.org/notes/2026w10) in the browser or on mobile phones.

When we [first released](https://anil.recoil.org/notes/geotessera-python) GeoTessera last year, every 0.1°
tile was a pair of [numpy](https://numpy.org/) files; one quantized embedding and one scale
array.  That worked fine for grabbing a few tiles at a time, but our
[push for global coverage from 2017-2025](https://anil.recoil.org/notes/geotessera-python) is producing around 1.8 million
tiles per year, each weighing in at around 150MB!  Serving these over HTTP means a
million small directories on disk, and every client that wants a contiguous
region needs to discover, fetch and stitch dozens of them, and has a minimum download of 150MB.

To fix this, we need to rethink the structure of the storage entirely: it's quite tricky to support
both a small download (e.g. from a mobile phone) and also a large region from a cloud provider.
Luckily, there's a new cloud-native streaming format in town that's just the ticket, known
as [Zarr](https://zarr.dev).
Since the GeoTESSERA [0.7 release](https://anil.recoil.org/notes/geotessera-python-0-7) where we first added basic
Zarr support, I've been working on consolidating all our tiles into a
single sharded Zarr v3 store per year.

<div class="video-center"><iframe title="" width="100%" height="315px" src="https://crank.recoil.org/videos/embed/08aafc87-9aea-48e3-8c41-a2fe1b94fea4" frameborder="0" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe></div>

This post explains the **[TESSERA Zarr conventions proposal](https://github.com/ucam-eo/zarr-convention-tessera)** and why the
chunking size choices matter.  I'd also love to get feedback from experienced
geospatial gurus, so this post is also an RFC of sorts.

## Why Zarr v3?

[Zarr](https://zarr.dev/) is a format for large N-dimensional typed arrays
designed for cloud object stores.  It's great because it allows multidimensional
arrays to be accessed via HTTP, meaning that normal S3 or HTTP static servers are sufficient
for hosting large datasets.

I built a first prototype a few [weeks ago](https://anil.recoil.org/notes/2026w9) using Zarr v2, and mapped the existing
npy tile format we use to it. This collects up batches of 10m2 pixel embeddings into
larger tiles, which can be downloaded as a unit (of around 150MB each).
The [v3 specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html)
(released last year) brings a couple of important new features to improve this:

- **[Sharding](https://zarr-specs.readthedocs.io/en/latest/v3/codecs/sharding-indexed/index.html)** a single physical file can contain many logical chunks,
  indexed by an inline index.  This means a client can issue one [HTTP range request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests)
  to get the shard index, then a second byte range to get exactly the
  chunk it needs.  Without sharding, every logical chunk would be a separate file,
  and so reducing our minimum pixel size to save on downloads for small ROIs (e.g. for mobile devices)
  would be impractical and involve 100s of millions of tiny files.
  
- **[Codecs](https://zarr-specs.readthedocs.io/en/latest/v3/codecs/index.html)** formalise the chain of compression, transposition
  and serialisation applied to each chunk.  Sharding is one such codec, and we also use [Blosc](https://zarr-specs.readthedocs.io/en/latest/v3/codecs/blosc/index.html)/[Zstd](https://github.com/facebook/zstd) for all arrays, which gives us reasonable compression ratios on the int8 embeddings. We're never going to get amazing compression ratios of the TESSERA embeddings because they are high entropy (we reduce 1000s of dimensions into 128 during the training and inference process), but there's still some win to be had.

The Python [zarr](https://zarr.readthedocs.io/) library has reasonably solid v3 support now, and in my tests the wider ecosystem
such as [xarray](https://xarray.dev/), [dask](https://dask.org/), [rioxarray](https://corteva.github.io/rioxarray/) can all read these
v3 stores without issue. So I think we're good to use v3 features now\!

## The store layout

Each embeddings year gets a single Zarr store.  Within it, each [UTM zone](https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system) is a [group](https://zarr.readthedocs.io/en/latest/user-guide/groups/) that contains that particular strip of the planet's embeddings and scales. For clients to visualise what's going on, there's an optional global RGB preview group:

```
2024.zarr/
    zarr.json                 # root: version, year, conventions
    utm29/                    # one group per UTM zone
        embeddings            # int8    (H, W, 128)
        scales                # float32 (H, W)
        rgb                   # uint8   (H, W, 4)     [optional]
        easting               # float64 (W,)
        northing              # float64 (H,)
        band                  # int32   (128,)
    utm30/
        ...
    global_rgb/               # EPSG:4326 preview pyramid
        0/rgb                 # uint8   (H, W, 4)     level 0
        1/rgb                 #                       level 1
        2/rgb                 #                       level 2
        ...
```

Using **one store per year** rather than per zone allows us to use an experimental
[consolidated metadata](https://zarr.readthedocs.io/en/latest/user-guide/consolidated_metadata/) feature.
A single `zarr.consolidate_metadata()` call gives a client the full catalog of
zones, their spatial extents, and which arrays exist. This (I think) eliminates
the need for the [Parquet registry](https://geotessera.readthedocs.io/en/latest/geotessera.html#geotessera-registry-registry-management)
we currently maintain for TESSERA.

Like the current npy embeddings, each **zone group carries their own CRS** to minimise
coordinate skew.  Each zone has a `proj:code` attribute
(e.g. `EPSG:32630`) and a `spatial:transform` giving the affine matrix.
Southern hemisphere zones use the canonical northern-hemisphere EPSG code
with the 10,000,000m false northing subtracted, so the northing axis is
continuous.

**Coordinate arrays** (`easting`, `northing`, `band`) are small 1-D arrays
stored alongside the data, so an xarray `open_zarr` just works with labeled
axes. These are labeled as [dimension names](https://docs.xarray.dev/en/stable/internals/zarr-encoding-spec.html#dimension-encoding-in-zarr-formats)
so that xarray or other clients can pick them up automatically.

## Sharding and chunking

The TESSERA embeddings are quite large in aggregate, and that is where most of
the design time went.  TESSERA clients have three very different access patterns:

1. A single-pixel lookup or a small region-of-interest means that a user has a lon/lat and wants the 128-d
   embedding vector at that point.  This should be ~2KB over HTTP. This might be a mobile device aiming to do active learning, for example.
2. A regional subset means that a user wants a spatial rectangle (say, 100km2)
   of all 128 bands.  This should stream efficiently without reading the
   whole zone and mosaicing it in memory (a source of [memory problems](https://github.com/ucam-eo/geotessera/issues/117) currently). This might be a desktop analysis, or even a [satellite scanning a region](https://gazagnaire.org/blog/2026-02-25-satellite-software.html).
3. A scan of entire countries to do global analyses, which requires terabytes of downloads to retrieve the full set of embeddings.

We will come back to solve the third 'entire countries' problem later, via a new variant of the model we are training that uses [Matryoshka embeddings](https://huggingface.co/blog/matryoshka). However, the first two also pull in opposite directions but are needed for mobile clients vs chunkier desktop analysis tools.
Zarr v3 sharding resolves both by letting us create shards:

- Shard: 256 × 256 pixels  (aligned to tile boundaries)
- Chunk: 4 × 4 pixels  (inner chunk within each shard)

Each shard is a single file on disk or object in S3, containing a grid of
64×64 inner chunks plus a ~32KB shard index at the end.  To read a single
pixel the client has to:

1. Fetch the shard index via one HTTP range request of ~32KB, which is cacheable.
2. Compute which 4×4 inner chunk contains the pixel.
3. Fetch that chunk with another HTTP range request that's ~2KB for int8×128, and can reuse the previous HTTP connection via pipelining.

For a single pixel read, there's a bit of extra overhead from the index, but tolerable.
For a regional read, the client fetches whole shards and gets contiguous
256×256 blocks, which is efficient for downstream processing.

Another neat thing about Zarr is that we can have multiple data type arrays.
TESSERA uses a quantisation trick to compress the embeddings with a 'scale'
array, which is a float32 held alongside the 128-dimension int8 values. For this array we also use the same 256/4 sharding,
and also signal that there's no data via a NaN scale. This lets us skip the need for the [landmask TIFFs](https://dl2.geotessera.org/v1/global_0.1_degree_tiff_all/) we currently maintain.

After this the global RGB preview is plain sailing as its more like a
conventional visual map tile, and uses plain 512×512 chunks with no sharding
since the pyramid levels get small quickly and the access pattern is always
tile-aligned for map rendering. These previews can also be reprojected by the
client for dynamic maps.

## GeoZarr conventions

The [GeoZarr spec](https://github.com/zarr-developers/geozarr-spec) is still under active development, and has a [conventions mechanism](https://github.com/zarr-developers/zarr-specs/blob/main/docs/v3/conventions.rst) where stores declare which metadata schemas they follow.  We use three:

- **[proj:](https://github.com/zarr-conventions/geo-proj)** is the CRS
  information formerly held in our landmask TIFFs.  Each zone group carries a `proj:code` (e.g. `"EPSG:32630"`)
  and `proj:wkt2` for the full WKT2 string.
  
- **[spatial:](https://github.com/zarr-conventions/spatial)** is the affine spatial
  coordinate transforms.  Each zone group has `spatial:transform` (the 6-element
  affine), `spatial:dimensions`, `spatial:shape`, `spatial:bbox` and
  `spatial:registration`. This can be calculated fairly easily from the CRS, but included here
  so that clients that know the spatial Zarr convention can just query this and use it directly.
  
- **[multiscales](https://github.com/zarr-conventions/multiscales)** is the
  pyramid layout for the global preview, compatible with the approach used by
  [topozarr](https://github.com/developmentseed/topozarr).

These conventions are registered in the root [zarr.json](https://dl2.geotessera.org/zarr/v1/2024.zarr/zarr.json) attributes as an
array, following the [ZEP for conventions](https://zarr.dev/zeps/draft/ZEP0009.html).  This makes
the stores more self-describing as any Zarr-aware tool can read the conventions
list and know what metadata keys to expect.

In order to join the Zarr specification party, I've created **[zarr-convention-tessera](https://github.com/ucam-eo/zarr-convention-tessera)**
to crystallise the conventions I've used in TESSERA, such as the utm zone splitting and quantisation bands. Once we're happy with this
format, existing libraries like [geotessera](https://geotessera.readthedocs.io) and also the upcoming [OCaml geotessera](https://jon.recoil.org/notebooks/interactive_map.html)
can all switch to Zarr streaming instead.

<a href="https://tze.geotessera.org"> <figure class="image-center"><img src="/images/tessera-zarr-stream-1.webp" alt="The first TESSERA zarr prototype showing the multiscale pyramid. The vertical lines in the map are debug markers to delimit UTM zones. Conveniently Cambridge is split right down the middle of two!" title="The first TESSERA zarr prototype showing the multiscale pyramid. The vertical lines in the map are debug markers to delimit UTM zones. Conveniently Cambridge is split right down the middle of two!" loading="lazy" srcset="/images/tessera-zarr-stream-1.768.webp 768w, /images/tessera-zarr-stream-1.640.webp 640w, /images/tessera-zarr-stream-1.480.webp 480w, /images/tessera-zarr-stream-1.320.webp 320w, /images/tessera-zarr-stream-1.1920.webp 1920w, /images/tessera-zarr-stream-1.1600.webp 1600w, /images/tessera-zarr-stream-1.1440.webp 1440w, /images/tessera-zarr-stream-1.1280.webp 1280w, /images/tessera-zarr-stream-1.1024.webp 1024w"><figcaption>The first TESSERA zarr prototype showing the multiscale pyramid. The vertical lines in the map are debug markers to delimit UTM zones. Conveniently Cambridge is split right down the middle of two!</figcaption></figure> </a>

## Building the TESSERA Zarr stores

The `geotessera-registry` CLI now has [commands in development](https://github.com/ucam-eo/geotessera/pull/211) for this pipeline.
It's unfortunately a very computationally heavy job, since the input npy tiles have to be rearranged and rewritten one by one into the
Zarr format, and then the RGB pyramids calculated. This is reasonable to parallelise, but we're a little stuck on our university
storage cluster due to relatively slow network interconnects at the moment.

Figuring out this conversion bottleneck is top of my list next week; in particular, if you have any leads on a cloud storage
provider that may like to sponsor a petabyte or two of S3 storage, I'm all ears\!

One other useful thing is that we generate a STAC catalog that provides a standards-compliant discovery layer: one
[STAC](https://stacspec.org/) collection per year. This lets us use [tile servers](https://www.tunbury.org/2025/12/02/tessera-stac/) and
hopefully eventually [STAC-D pipelines](https://doi.org/10.1145/3759536.3763803) to integrate these into [planetary computing](https://anil.recoil.org/projects/plancomp) pipelines.

## What's next

We won't stop serving the npy files for some time, since we have a number of users already committed to those, and that workflow
is fine for regional analysis. However, I'm keen to unlock mobile workflows as there's a lot of demand for this (especially after the [TESSERA hackathon in Delhi](https://anil.recoil.org/notes/first-tessera-hackathon)), so we'll push
forward with Zarr. In particular thank you to [Deepak Cherian](https://cherian.net/) for giving me lots of Zarr advice on our [Zulip channel](https://eeg.zulipchat.com/#narrow/channel/527258-Tessera/topic/zarr.20file.20format/with/578418959).

While this spec is out for review, here's a sneak peek of [a TESSERA Zarr web viewer](https://tze.geotessera.org) that reads directly from the Zarr stores into the browser, with no server required. I'm also working on an access library in OxCaml using [Mark Elvers](https://www.tunbury.org/) [OCaml Zarr](https://github.com/mtelvers/ocaml-zarr) library so that we can use these from our native pipeline too. This would also make it much easier to integrate TESSERA into the [biodiversity monitoring standards framework](https://anil.recoil.org/notes/nas-rs-biodiversity-papers) that we've been working on.

I also discovered that there had been a very relevant [vector embeddings hackathon](https://www.clarku.edu/news/2026/03/12/sprinting-to-space-goddard-nasa-and-clarks-pathbreaking-work-in-geospatial-analytics/) held a few days ago at Clark University. They [came up](https://www.linkedin.com/feed/update/urn:li:ugcPost:7438619750936539136) with a [STAC for embeddings proposal](https://github.com/geo-embeddings/embeddings-stac-specification) that I've left an [query on](https://github.com/geo-embeddings/embeddings-stac-specification/issues/9) as well, to make sure our work is compatible.
Synopsis: How we restructured TESSERA's geospatial embeddings from millions of individual numpy files into sharded Zarr v3 stores for efficient HTTP streaming, enabling everything from single-pixel mobile lookups to regional-scale analysis with just a couple of range requests.
Words: 1858
DOI: 10.59350/tk0er-ycs46

Discussion:
- Bluesky: <https://bsky.app/profile/did:plc:nhyitepp3u4u6fcfboegzcjw/post/3mh3mmu5uhk24>
- LinkedIn: <https://www.linkedin.com/feed/update/urn:li:activity:7438876446242000896/>
- Mastodon: <https://amok.recoil.org/@avsm/116232459794836999>
- Twitter: <https://x.com/avsm/status/2033111456808313205>

## Related

- [Celebrating a year of Tessera embeddings and releasing GeoTessera 0.10](https://anil.recoil.org/notes/geotessera-a-year-on) (note, 2026-08-27)
- [.plan-26-33: Zarro rides out and evidence papers pour in](https://anil.recoil.org/notes/2026w33) (note, 2026-08-16)
- [.plan-26-32: Finally a use for serverless and found the Forester for the trees](https://anil.recoil.org/notes/2026w32) (note, 2026-08-09)
- [.plan-26-31: Sorting out Tessera and Evidence TAP infrastructure](https://anil.recoil.org/notes/2026w31) (note, 2026-08-02)
- [.plan-26-29: Perfect weather, imperfectly measured, precisely predicted](https://anil.recoil.org/notes/2026w29) (note, 2026-07-19)
- [.plan-26-28: What fun papers piled up while I was out at sea](https://anil.recoil.org/notes/2026w28) (note, 2026-07-12)
- [A scorching CNG London during Climate Action Week](https://anil.recoil.org/notes/cng-london-2026) (note, 2026-06-24)
- [Rewilding the Web: my workshop report from Edinburgh](https://anil.recoil.org/notes/rewilding-the-web-report) (note, 2026-05-30)
- [AI, science and the UK–EU relationship at the Royal Society](https://anil.recoil.org/notes/rs-eu-ai-science) (note, 2026-04-21)
- [.plan-26-16: Chennai, Cambridge, Belfast: a week on the wing](https://anil.recoil.org/notes/2026w16) (note, 2026-04-19)
- [.plan-26-15: Banyan trees, (anti)botnets and Bose-Einstein bases](https://anil.recoil.org/notes/2026w15) (note, 2026-04-12)
- [.plan-26-14: Tracking AI screen time and escaping to pen and paper](https://anil.recoil.org/notes/2026w14) (note, 2026-04-05)
- [.plan-26-13: Oxidised, standardised, and syndicated](https://anil.recoil.org/notes/2026w13) (note, 2026-03-29)
- [TESSERA now supports the Zarr geo-embeddings convention proposal](https://anil.recoil.org/notes/tessera-embeddings-convention) (note, 2026-03-27)
- [.plan-26-12: Zarr across space and TESSERA time](https://anil.recoil.org/notes/2026w12) (note, 2026-03-22)
- [.plan-26-11: Bins, bollards, bots and biodiversity boffins](https://anil.recoil.org/notes/2026w11) (note, 2026-03-15)
- [Tessera Zarr streaming preview](https://anil.recoil.org/videos/08aafc87-9aea-48e3-8c41-a2fe1b94fea4) (video, 2026-03-08)
- [.plan-26-10: Streaming TESSERA working, biodiversity action papers, and FPL takes off](https://anil.recoil.org/notes/2026w10) (note, 2026-03-08)
- [Connecting the dots for biodiversity action from the NAS/Royal Society Forum](https://anil.recoil.org/notes/nas-rs-biodiversity-papers) (note, 2026-03-07)
- [.plan-26-09: Browser TESSERA, package management and Docker in the CACM](https://anil.recoil.org/notes/2026w9) (note, 2026-03-01)
- [1st TESSERA/CoRE hackathon at the Indian AI Summit](https://anil.recoil.org/notes/first-tessera-hackathon) (note, 2026-02-19)
- [GeoTessera 0.7 out with efficient sampling and Zarr support](https://anil.recoil.org/notes/geotessera-python-0-7) (note, 2025-11-17)
- [GeoTessera Python library released for geospatial embeddings](https://anil.recoil.org/notes/geotessera-python) (note, 2025-08-31)
- [OxCaml Labs](https://anil.recoil.org/projects/oxcaml) (project, 2025-01-01)
- [TESSERA, a pixelwise geospatial foundation model](https://anil.recoil.org/projects/tessera) (project, 2025-01-01)
- [Remote Sensing of Nature](https://anil.recoil.org/projects/rsn) (project, 2023-01-01)
- [Planetary Computing](https://anil.recoil.org/projects/plancomp) (project, 2022-01-01)

---
Canonical: https://anil.recoil.org/notes/tessera-zarr-v3-layout
Type: note
License: CC BY 4.0 <https://creativecommons.org/licenses/by/4.0/>
Tags: tessera, spatial, zarr, ai, satellite
