How to Choose Chunk and Tile Sizes That Actually Help

Problem statement

Chunk and tile size is the parameter people copy from an example and never revisit, and it decides more about performance than any other choice in a cloud-native pipeline.

Two measurements from the same 4096 Γ— 4096 COG with 512-pixel tiles show why:

window size    requests    bytes fetched    bytes per pixel
      1 px            4        470.8 kB          470,793
    256 px            4        470.8 kB              7.2
    512 px            5      1,585.3 kB              6.0
   2048 px            8      9,619.6 kB              2.3

Reading one pixel and reading 65,536 pixels cost exactly the same, because both touch one tile. The tile is the unit of transfer, and every sizing decision follows from that.

Quick answer

Work backwards from the access pattern:

def suggest_tile_size(typical_window_px, dtype_bytes=4, bands=1,
                      target_bytes=(1e6, 10e6)):
    """A tile that covers the typical read without much waste."""
    for candidate in (128, 256, 512, 1024, 2048):
        tiles = max(1, -(-typical_window_px // candidate)) ** 2
        fetched = tiles * candidate ** 2 * dtype_bytes * bands
        useful = typical_window_px ** 2 * dtype_bytes * bands
        chunk_bytes = candidate ** 2 * dtype_bytes * bands
        ok = target_bytes[0] <= chunk_bytes <= target_bytes[1]
        print(f"  {candidate:5d} px  {tiles:4d} tiles  "
              f"{chunk_bytes / 1e6:6.2f} MB/tile  "
              f"{fetched / useful:5.1f}x amplification  "
              f"{'ok' if ok else ''}")
    128 px    16 tiles    0.07 MB/tile    1.0x amplification
    256 px     4 tiles    0.26 MB/tile    1.0x amplification
    512 px     1 tiles    1.05 MB/tile    1.0x amplification  ok
   1024 px     1 tiles    4.19 MB/tile    4.0x amplification  ok
   2048 px     1 tiles   16.78 MB/tile   16.0x amplification

Two constraints: the chunk should be a few megabytes, and it should not be much larger than the typical read.

Small chunks paying per-request overhead and large chunks fetching unused data, with a few megabytes as the balance point.
Too small costs requests; too large costs bytes. The minimum is broad and a few megabytes wide.

Step-by-step solution

1. Establish the typical read

Not the largest, not the smallest β€” the one that happens most. A tile server reads 256 Γ— 256; an analysis pipeline reads a study area; a time-series extraction reads a pixel.

Those want different layouts, which is why "what chunk size should I use" has no answer without the access pattern.

2. Apply the size constraint

A few megabytes per chunk, uncompressed:

  • Under about 1 MB: per-request overhead dominates. Over HTTP that is a round trip per chunk; on object storage it is also a billed request.
  • Over about 100 MB: any partial read wastes most of what it fetched, and memory per task grows.

For a float32 single band, 512 Γ— 512 is 1.05 MB and 1024 Γ— 1024 is 4.19 MB. Both are reasonable; 128 Γ— 128 at 0.07 MB is not.

3. Apply the alignment constraint

An unaligned window straddles more tiles than an aligned one. A 512-pixel window at offset 0 touches one 512-tile; the same window at offset 256 touches four.

The measurement shows it: the 512-pixel read at offset 1792 β€” three and a half tiles in β€” cost 1.59 MB, against 470 kB for a read wholly inside one tile.

Where you control the reader, align the windows. Where you do not, choose tiles somewhat larger than the typical window so a straddling read touches two rather than four.

4. For n-dimensional data, choose the shape as well as the size

A cube of (time, band, y, x) has the same total chunk size for (1, 4, 512, 512) and (120, 4, 46, 46), and they behave completely differently:

chunk one date's map one pixel's history
(1, 4, 509, 543) 1 read 120 reads
(120, 4, 64, 64) 72 reads 1 read

If both patterns matter, either store two copies or compromise at something like (30, 4, 128, 128).

5. Measure the amplification, not the time

bytes fetched / bytes used

Time varies with network, cache and load. Amplification is a property of the layout and the query, and it is the number to design against. Above about 10 for your typical read, the layout is wrong.

A window aligned to the tile grid touching one tile, and the same window offset by half a tile touching four.
The same window can cost one tile or four. Alignment is free and frequently forgotten.

Code examples

Example 1 β€” measuring amplification for a real access pattern

import numpy as np


def amplification(shape, chunks, queries, dtype_bytes=4):
    """Bytes fetched divided by bytes used, per query shape."""
    print(f"  array {shape}, chunks {chunks}, "
          f"{np.prod(chunks) * dtype_bytes / 1e6:.2f} MB per chunk")
    results = {}
    for name, query in queries.items():
        touched = 1
        for dim, (size, chunk) in enumerate(zip(shape, chunks)):
            span = query.get(dim, size)
            touched *= int(np.ceil(span / chunk))
        used = np.prod([query.get(d, s) for d, s in enumerate(shape)])
        fetched = touched * np.prod(chunks)
        results[name] = fetched / max(used, 1)
        print(f"    {name:26} {touched:6,} chunks  "
              f"{fetched * dtype_bytes / 1e6:9.1f} MB fetched  "
              f"{results[name]:8.1f}x")
    return results

Running this for the two or three queries you actually make, over three or four candidate chunk shapes, produces a table that decides the question in a minute.

Example 2 β€” aligning windows to the tile grid

import numpy as np
from rasterio.windows import Window


def align_window(window, block_w, block_h):
    """Expand a window outward to whole tiles."""
    col0 = int(window.col_off // block_w) * block_w
    row0 = int(window.row_off // block_h) * block_h
    col1 = int(np.ceil((window.col_off + window.width) / block_w)) * block_w
    row1 = int(np.ceil((window.row_off + window.height) / block_h)) * block_h
    return Window(col0, row0, col1 - col0, row1 - row0)


def read_aligned(ds, window, band=1):
    """Read whole tiles, then slice to the requested window."""
    block_h, block_w = ds.block_shapes[band - 1]
    outer = align_window(window, block_w, block_h)
    data = ds.read(band, window=outer)

    r0 = int(window.row_off - outer.row_off)
    c0 = int(window.col_off - outer.col_off)
    print(f"  requested {int(window.width)}x{int(window.height)}, "
          f"read {int(outer.width)}x{int(outer.height)} "
          f"({outer.width * outer.height / (window.width * window.height):.1f}x)")
    return data[r0:r0 + int(window.height), c0:c0 + int(window.width)]

Reading the aligned outer window and slicing is often faster than reading the requested window, because GDAL fetches the same tiles either way and the aligned read avoids partial-block handling. The printed ratio shows how much of the read was unavoidable.

Example 3 β€” picking a chunking for a cube from its queries

import numpy as np


def choose_cube_chunks(shape, dtype_bytes=4, target_mb=(2, 20),
                       weights=None):
    """Score candidate chunk shapes against weighted query patterns."""
    n_time, n_band, height, width = shape
    weights = weights or {"map": 0.5, "pixel_series": 0.3, "window_series": 0.2}

    candidates = []
    for t in (1, 5, 30, n_time):
        for s in (64, 128, 256, min(height, width)):
            chunk = (t, n_band, min(s, height), min(s, width))
            mb = np.prod(chunk) * dtype_bytes / 1e6
            if not target_mb[0] <= mb <= target_mb[1]:
                continue
            cost = (
                weights["map"] * np.ceil(n_time / t) ** 0 *
                np.ceil(height / chunk[2]) * np.ceil(width / chunk[3]) +
                weights["pixel_series"] * np.ceil(n_time / t) +
                weights["window_series"] * np.ceil(n_time / t) *
                np.ceil(64 / chunk[2]) * np.ceil(64 / chunk[3])
            )
            candidates.append((cost, chunk, mb))

    candidates.sort()
    for cost, chunk, mb in candidates[:6]:
        print(f"  {str(chunk):28} {mb:6.2f} MB  weighted cost {cost:8.1f}")
    return candidates[0][1] if candidates else None

Weighting the query patterns by how often they occur is what turns an argument into a calculation. If maps are half your reads and pixel series a third, the optimum is not the shape that is best at either.

Explanation

Why the tile is the unit of transfer

Chunks and tiles are compressed independently, so a reader cannot decompress part of one. The minimum transfer for any element is its whole containing chunk.

That is why the measurement shows 1 pixel and 256 Γ— 256 pixels costing the same 470.8 kB. It is also why per-pixel access patterns are pathological: a thousand scattered pixels can cost a thousand chunks.

Every sizing rule is a consequence: make chunks small enough that a targeted read is targeted, and large enough that the per-chunk overhead is amortised.

Why very small chunks fail

Each chunk costs a request, a compression header and a metadata entry. Over HTTP the request is a round trip β€” 100 ms against a remote store.

Reading a 4096 Γ— 4096 raster in 128-pixel tiles is 1,024 requests. At 100 ms each, serially, that is 102 seconds for 67 MB of data. The same raster in 512-pixel tiles is 64 requests.

Object stores also bill per request, so tiny chunks cost money as well as latency.

Why very large chunks fail

A 4096 Γ— 4096 single-chunk raster requires reading all 67 MB to obtain any pixel. That is the pathological case that tiling was invented to avoid.

Less obviously, large chunks hurt parallelism. A task processes a chunk, so chunk size sets the granularity of the work. Eight workers and four chunks means half the workers are idle.

Why alignment is free performance

An unaligned window straddles chunk boundaries in both dimensions, so it can touch four chunks where an aligned one touches one β€” a factor of four in bytes for the same output.

The measured example: a 512-pixel read at offset 1792 cost 1.59 MB, against 470 kB for a read inside one tile.

Where you generate the windows β€” a tiling job, a batch extraction β€” aligning them is a few lines and often the single largest available improvement.

Three chunk shapes for a data cube scored on how many reads a map and a pixel time series each cost.
The middle row costs four reads for both. It is worse at each task and better overall.

Edge cases or notes

  • A 1-pixel read costs a whole chunk. Read blocks, not points.
  • A few megabytes per chunk is the broad optimum.
  • Align windows to the chunk grid where you control them.
  • Chunk shape matters as much as size for n-dimensional data.
  • Amplification, not time, is the metric. Time varies with the network.
  • Chunk size sets parallel granularity β€” too few chunks starves workers.
  • Tile dimensions must be multiples of 16 in GeoTIFF.
  • Compressed chunk sizes vary; design against the uncompressed size.

FAQ

What chunk size should I use?

A few megabytes uncompressed β€” 512 Γ— 512 for a single-band float32 raster is 1.05 MB and a good default.

Why does reading one pixel cost so much?

Because chunks are compressed independently, so the minimum transfer is one whole chunk. One pixel and a 256 Γ— 256 window both cost 470 kB on a 512-tile COG.

Does chunk shape matter as well as size?

For n-dimensional data, enormously. Time-chunked and space-chunked cubes of identical chunk size differ by two orders of magnitude on the same query.

What is read amplification?

Bytes fetched divided by bytes used. It is a property of the layout and the query, unlike time, and it is the right number to design against.

Should I align my read windows to the tile grid?

Yes where you can. An unaligned window can touch four tiles where an aligned one touches one.

Are smaller chunks safer?

No. Below about a megabyte, per-request overhead and task count dominate. A 4096-pixel raster in 128-pixel tiles is 1,024 requests.

How does chunk size affect parallelism?

Chunks are the unit of work. Too few chunks and workers sit idle; too many and the scheduler spends its time on bookkeeping.