Cloud-Native Spatial Formats for Versioned Pipelines

Choosing a storage format decides how expensive your version history will be — part of Choosing Formats & Tools for Spatial Data Versioning. Cloud-native formats such as Cloud Optimized GeoTIFF (COG), Zarr, FlatGeobuf, and GeoParquet split a dataset into internally addressable pieces, which turns an expensive whole-file re-upload into a cheap re-upload of only the pieces that actually changed.

This guide explains what “cloud-native” means in practice, why internal chunking makes chunk-level versioning nearly free, and how to build a manifest workflow that commits only changed chunks. It closes with reliability patterns, a chunk-size tuning benchmark, and a troubleshooting table you can lift into your own pipeline.


Four cloud-native formats and their addressable unit A four-panel comparison. COG stores raster tiles plus overviews; Zarr stores an N-dimensional chunk grid; FlatGeobuf stores an in-file packed R-tree index over feature records; GeoParquet stores column chunks inside row groups. Each panel highlights the smallest unit that hashes independently for versioning. COG raster tiles + overviews unit: a 512×512 tile range read per tile Zarr N-D chunk grid unit: one chunk key e.g. band.2.4.1 FlatGeobuf packed R-tree + features in-file spatial index unit: a feature run contiguous by index order GeoParquet row groups + column chunks geom attrs unit: a column chunk within a row group Each highlighted unit hashes independently — only changed units re-upload chunk manifest

Prerequisites & Environment Setup

Before you build a chunk-versioned pipeline, confirm the toolchain and the invariants that keep chunk hashes stable across versions:

python -m pip install "rasterio>=1.3" "xarray>=2024.1" "zarr>=2.17" \
    numcodecs "pyarrow>=15" geopandas "dvc[s3]>=3.0"
gdalinfo --version
gdalinfo --formats | grep -E "COG|FlatGeobuf|Parquet"

These formats descend from the same lineage as pointer synchronization for raster datasets: the heavy binary lives in object storage, and Git tracks only a small manifest. The difference here is granularity — instead of one hash per multi-gigabyte file, you keep one hash per internal chunk.


Core Algorithmic Patterns

1. HTTP range reads and internal tiling

“Cloud-native” is a read-time property first. A COG places its image data in fixed tiles (typically 256×256 or 512×512) and appends reduced-resolution overviews, with a leading header that maps every tile to a byte offset. A client issues an HTTP range request for exactly the tiles that intersect a viewport, ignoring the rest of a file that may be tens of gigabytes. Zarr generalises this to N dimensions: each chunk is a separately addressable object keyed by its grid position (for example temp/2/4/1). FlatGeobuf packs a static R-tree at the head of the file so a reader can binary-search a bounding box and range-read only the matching feature records. GeoParquet splits data into row groups, and within each row group each column is a separately compressed chunk with min/max statistics for predicate pushdown.

The versioning payoff is a direct consequence: the same internal boundary that lets a reader fetch one tile also lets a writer replace one tile. The addressable unit for reads is the addressable unit for change.

2. Chunk-addressable versioning

Content-addressable storage keys each blob by the hash of its bytes. Apply it at chunk granularity and version comparison becomes set arithmetic. Given version A and version B of a store, compute the hash of every chunk in each, then:

  • Chunks whose hash appears in both A and B are unchanged — never re-uploaded.
  • Chunks in B but not A are new or modified — uploaded once.
  • Chunks in A but not B are deleted — dropped from the new manifest.

Complexity: hashing is O(n) in total bytes, but the transfer cost is O(changed bytes), not O(total bytes). For an edit touching k of N chunks, upload volume scales with k while the manifest scales with N. This is the entire economic argument for chunked formats in a version-control pipeline.

3. Per-chunk hashing and the manifest

The manifest is a small, sortable, text-serialisable record: one row per chunk holding its logical key, byte size, and content hash. Committed to Git, it is diffable — a reviewer sees exactly which tiles moved between two commits. The table below summarises where the addressable unit lives in each format.

Format Data kind Addressable unit In-file index Hash granularity
COG Raster Tile (e.g. 512×512) Tile offset table Per tile + per overview
Zarr N-D array Chunk object .zarray metadata Per chunk key
FlatGeobuf Vector Feature run Packed R-tree Per index page / run
GeoParquet Vector/table Column chunk Row-group footer Per column chunk

Production Workflow Implementation

The goal is a repeatable loop: build a chunk-aligned store, hash every chunk into a manifest, diff against the last committed manifest, and upload only the differences. The diagram shows the full path.

Chunk-manifest commit workflow A left-to-right pipeline. A chunked COG or Zarr store feeds a hasher that emits a manifest of chunk hashes. The manifest is diffed against the previous committed manifest. Unchanged chunks are skipped; changed chunks are uploaded to object storage; the new manifest is committed to Git. Chunked store COG / Zarr hash New manifest key → hash diff Manifest diff changed keys previous manifest (Git) unchanged chunks skipped put Object storage changed chunks only Git commit new manifest

Step 1: Build a chunk-aligned store

Write the raster as a tiled COG (or an equivalently chunked Zarr store) with the chunk grid, compression, and predictor pinned. These settings must never change between versions, or every chunk re-encodes.

import rasterio
from rasterio.shutil import copy as rio_copy

COG_PROFILE = {
    "driver": "COG",
    "blocksize": 512,          # fixed tile size — pin this forever
    "compress": "DEFLATE",     # deterministic codec
    "predictor": 2,            # horizontal differencing
    "overview_resampling": "average",
    "BIGTIFF": "IF_SAFER",
}

def build_cog(src_path: str, dst_path: str) -> None:
    """Rewrite a raster as a tiled COG with fixed, version-stable settings."""
    with rasterio.open(src_path) as src:
        rio_copy(src, dst_path, **COG_PROFILE)

Step 2: Enumerate and hash each internal chunk

For a COG, iterate the block windows the driver already defined; for Zarr, iterate the chunk keys under the array directory. Hash each chunk’s raw bytes with a streaming digest.

import hashlib
import rasterio

def hash_cog_tiles(path: str, algo: str = "sha256") -> dict[str, str]:
    """Return {block_key: hex_digest} for every internal tile of a COG."""
    manifest: dict[str, str] = {}
    with rasterio.open(path) as src:
        for band in range(1, src.count + 1):
            for (row, col), window in src.block_windows(band):
                data = src.read(band, window=window)
                h = hashlib.new(algo)
                h.update(data.tobytes())
                manifest[f"b{band}/{row}/{col}"] = h.hexdigest()
    return manifest

Step 3: Write the chunk manifest

Serialise the manifest to sorted JSON so identical stores always produce byte-identical manifests — a precondition for a clean Git diff.

import json
from pathlib import Path

def write_manifest(manifest: dict[str, str], out_path: str) -> None:
    """Persist a chunk manifest as sorted, deterministic JSON."""
    payload = {"algo": "sha256", "chunks": dict(sorted(manifest.items()))}
    Path(out_path).write_text(json.dumps(payload, indent=2, sort_keys=True))

Step 4: Diff against the previous version

Load the manifest committed with the previous version and compute the changed, added, and removed keys.

import json
from pathlib import Path

def diff_manifests(old_path: str, new: dict[str, str]) -> dict[str, set]:
    """Compare a new chunk manifest against the last committed one."""
    old = {}
    if Path(old_path).exists():
        old = json.loads(Path(old_path).read_text())["chunks"]
    old_keys, new_keys = set(old), set(new)
    changed = {k for k in old_keys & new_keys if old[k] != new[k]}
    return {
        "added": new_keys - old_keys,
        "removed": old_keys - new_keys,
        "changed": changed,
    }

Step 5: Commit and upload only changed chunks

Upload the blobs for added | changed, then commit the new manifest. Unchanged chunks are already in object storage under the same content hash, so they are never sent again.

import subprocess

def push_changed(diff: dict[str, set], bucket: str, blob_for) -> int:
    """Upload only added/changed chunk blobs; return the count pushed."""
    to_send = diff["added"] | diff["changed"]
    for key in sorted(to_send):
        local_path, remote_key = blob_for(key)  # your key → path mapping
        subprocess.run(
            ["aws", "s3", "cp", local_path, f"s3://{bucket}/{remote_key}"],
            check=True,
        )
    return len(to_send)
git add store.manifest.json
git commit -m "Version raster: 12 tiles changed, 748 unchanged (skipped upload)"

Code Reliability Patterns

Pin every encoding parameter. A single change to blocksize, compress, or predictor re-encodes all chunks and produces an all-new manifest, so guard the profile with an assertion before writing:

def assert_stable_profile(profile: dict, expected: dict) -> None:
    for key in ("blocksize", "compress", "predictor"):
        if profile.get(key) != expected.get(key):
            raise ValueError(
                f"Encoding drift on '{key}': {profile.get(key)} != {expected.get(key)} "
                "— this would re-hash every chunk and break deduplication."
            )

Verify before you delete. Before dropping a chunk that the diff marks as removed, confirm no other committed manifest still references its hash. Content-addressed deletion is safe only when the hash is unreferenced everywhere.

Snap edits to the chunk grid. When you know a bounding box was edited, restrict re-hashing to the intersecting chunks rather than rescanning the whole store. This keeps the hashing cost proportional to the edited region and mirrors the tolerance-snapping discipline used elsewhere in versioned spatial pipelines.

Fail closed on partial uploads. If the upload loop aborts midway, do not commit the manifest — a committed manifest that references a chunk missing from storage produces a silent read failure on the next checkout. Upload first, verify object presence, then commit.


Performance & Scale Considerations

Chunk size is the master dial. Smaller chunks localise edits (fewer wasted bytes per change) but inflate manifest size and per-request overhead; larger chunks cut overhead but re-upload more per edit. The table below shows a representative single-tile edit on a 40 GB, four-band COG (reference hardware: 16-core VM, NVMe, 10 Gbps).

Chunk (tile) size Chunks in manifest Full-store hash time Upload after 1-tile edit
256×256 ~640,000 ~95 s ~0.4 MB
512×512 ~160,000 ~70 s ~1.5 MB
1024×1024 ~40,000 ~58 s ~6 MB
2048×2048 ~10,000 ~52 s ~24 MB

The sweet spot for most editing workflows is a 512×512 or 1024×1024 tile: manifests stay a few megabytes, per-edit uploads stay small, and range reads remain efficient. For deep dives on how N-dimensional chunk shape steers which chunks turn dirty, see chunking Zarr arrays for incremental raster commits. For vector delivery, versioning FlatGeobuf streams for web delivery covers producing deterministic, diff-stable feature files.

Parallelise hashing. Chunk hashing is embarrassingly parallel — fan out block windows across a ProcessPoolExecutor to saturate cores. Batch small uploads. When a diff touches many tiny chunks, pack them into a single multipart upload rather than issuing thousands of PUT requests.


Troubleshooting & Failure Modes

Symptom Root cause Fix
Every chunk shows as changed after a trivial edit blocksize, codec, or predictor changed between writes Pin the encoding profile; assert it before writing (see reliability patterns)
Manifest diff is noisy / non-reproducible Non-deterministic serialisation (unsorted keys, embedded timestamps) Serialise sorted JSON; strip timestamps from headers
Checkout fails: chunk missing from storage Manifest committed before uploads finished Upload and verify object presence first, then commit the manifest
COG block windows differ from expected grid Driver retiled on rewrite (source was untiled) Rebuild with an explicit blocksize; confirm with gdalinfo block size
Deduplication ratio far below expectations Edits span chunk boundaries, dirtying neighbours Align edits to the chunk grid or reduce chunk size for hot regions
Overviews re-upload on every commit Overview resampling changed a single source tile Accept overview churn or regenerate overviews only on release builds

FAQ

What makes a spatial format “cloud-native”?

A cloud-native format supports HTTP range reads and stores data in internally addressable pieces — tiles, chunks, or row groups — alongside an index or overview pyramid. A reader can fetch a bounding box or a single band without downloading the whole file. COG, Zarr, FlatGeobuf, and GeoParquet all satisfy this, and that same internal addressing is exactly what makes chunk-level versioning cheap.

Why does internal chunking make versioning cheaper?

Because a small edit only rewrites the chunks it touches. If you hash each chunk independently and store a manifest of those hashes, a new version re-hashes every chunk but re-uploads only the ones whose hash changed. A 200 GB Zarr store where one tile changed pushes a few megabytes, not 200 GB — the transfer cost tracks the edited region, not the dataset size.

How do I keep chunk hashes stable across writes?

Fix the chunk grid, compression codec, and codec level up front, and never let the writer re-tile between versions. Any change to chunk shape, block size, or compression re-encodes every chunk and produces all-new hashes, defeating deduplication. Pin these parameters in a config committed alongside the data and assert them before every write.

Should I version the whole file or the chunk manifest?

Commit the manifest — a small text file listing chunk keys and hashes — to Git, and store the chunk blobs in content-addressed object storage. This mirrors pointer synchronization for raster datasets: Git holds cheap, auditable lineage while heavy binary chunks live in a bucket and are fetched on demand.

What chunk size should I choose?

Aim for 1–8 MB compressed per chunk. Smaller chunks give finer-grained deltas but inflate manifest size and per-request overhead; larger chunks reduce overhead but re-upload more data per edit. Tune to your edit locality: point edits favour smaller chunks, while full-scene refreshes tolerate larger ones.


Back to Choosing Formats & Tools for Spatial Data Versioning