Choosing Tile Sizes for Raster Partitioning

Three tile sizes appear in a raster pipeline and they are routinely confused: the internal block a file is written in, the processing tile a task works on, and the delivered tile a client requests. They answer different questions — storage layout, memory and duration, and client experience — and setting them equal is a coincidence rather than a design. Choosing each from its own constraint takes an hour and removes most of the surprises about memory, throughput and egress that follow.

When to Use This Pattern

  • A new raster product is being designed and no size has been chosen yet.
  • Tasks are running out of memory or taking wildly different times.
  • Egress is higher than the output size suggests, which usually means the read granularity is wrong.
  • A processing tile has been inherited from a tutorial and nobody has checked whether it fits.

Complete Working Example

Compute each size from its own constraint, then verify by measurement.

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class SizePlan:
    internal_block: int      # how the file is written
    processing_tile: int     # what one task handles
    delivered_tile: int      # what a client asks for


def plan_sizes(bands: int, bytes_per_px: int, worker_mem_gb: float,
               concurrency: int, target_seconds: float,
               px_per_second: float, headroom: float = 1.3) -> SizePlan:
    # 1. Memory bound. A warp holds source, destination and one working copy.
    per_task_bytes = (worker_mem_gb * 1024 ** 3) / (concurrency * headroom)
    max_px = per_task_bytes / (3 * bands * bytes_per_px)
    mem_side = int(max_px ** 0.5)

    # 2. Duration bound: aim for a task of a few minutes.
    time_side = int((target_seconds * px_per_second) ** 0.5)

    processing = _round_to_power_of_two(min(mem_side, time_side))
    return SizePlan(
        internal_block=512,          # request-count vs waste, near-universal answer
        processing_tile=processing,  # the binding constraint of the two above
        delivered_tile=256,          # what map clients expect; not a processing choice
    )


def _round_to_power_of_two(n: int) -> int:
    p = 256
    while p * 2 <= n:
        p *= 2
    return p


plan = plan_sizes(bands=4, bytes_per_px=2, worker_mem_gb=8, concurrency=4,
                  target_seconds=300, px_per_second=1_800_000)
# SizePlan(internal_block=512, processing_tile=4096, delivered_tile=256)

The processing tile is then used consistently, and the delivered tile is a separate step:

@task(retries=2, timeout_seconds=900, tags=["tile"])
def process(window: Window, src_uri: str, dest: Path, plan: SizePlan) -> Path:
    with rasterio.open(src_uri) as src:
        data = src.read(window=window, boundless=True)
    warped = warp(data, window)
    with rasterio.open(
        dest, "w", driver="GTiff", tiled=True,
        blockxsize=plan.internal_block, blockysize=plan.internal_block,
        compress="deflate", predictor=2, **profile_from(warped),
    ) as dst:
        dst.write(warped)
    return dest


@task(timeout_seconds=1800)
def cut_delivery_tiles(mosaic: Path, plan: SizePlan, out: Path) -> int:
    """Delivered tiles come from the mosaic, not from the processing grid."""
    return cut(mosaic, size=plan.delivered_tile, out=out)
Three sizes, three constraintsThe internal block is chosen from request count, the processing tile from memory and duration, and the delivered tile from what map clients expect. They are rarely the same number.sizechosen fromtypicalinternal blockrequests vs wasted bytes512 pxprocessing tilememory and duration4096 pxdelivered tilewhat clients request256 pxSetting all three to 256 gives tasks that last four seconds and a fan-out sixteen times wider than it needs to be.Setting all three to 4096 gives clients a megabyte of data to render a corner of a screen.
The three numbers are independent because the constraints are independent; any coincidence between them is a coincidence.

The min(mem_side, time_side) in the planner encodes something worth stating: the processing tile is bounded by two constraints and only one of them binds, and which one binds tells you what to change. If memory is the binding constraint, the fix is a larger worker or lower concurrency; if duration binds, the fix is accepting shorter tasks or optimising the operation. Computing both and taking the smaller makes that visible instead of leaving it as a number somebody picked.

Parameter & Option Reference

Setting Typical Spatial notes
Internal block 512 Balances request count against bytes fetched beyond the window. 256 doubles the requests.
Processing tile 2048–8192 From memory ÷ concurrency and a target duration of a few minutes.
Delivered tile 256 (raster), 512 (vector) A client-side expectation, not a processing decision.
Overview levels to ~256 px Missing overviews turn every coarse read into a full-resolution one.
Compression deflate + predictor 2 Good ratio on continuous data; the predictor matters more than the level.
Boundless reads on So an edge tile does not need special handling and every task is identical.
Power-of-two sizes yes They align with overview levels, so a coarse read maps to a whole number of blocks.
Where the processing tile fitsAt 512 pixels tasks last seconds and orchestration dominates. At 16384 they exceed the worker's memory. Between 2048 and 8192 both constraints are satisfied.cost5122048819216384workable bandorchestration overheadmemoryTwo curves that cross, and the answer is the flat region between them rather than either extreme.
The band is wide, which is reassuring: the choice does not need to be precise, only inside it.

Verification & Testing

def test_processing_tile_fits_in_memory(plan) -> None:
    px = plan.processing_tile ** 2
    est_mb = 3 * px * BANDS * BYTES_PER_PX / 1e6
    assert est_mb * CONCURRENCY < WORKER_MB * 0.75, (
        f"{est_mb:.0f} MB per task x {CONCURRENCY} exceeds the worker"
    )


def test_typical_task_lands_in_the_target_band(sample_windows) -> None:
    times = [measure(process.fn, w) for w in sample_windows[:20]]
    p50 = statistics.median(times)
    assert 60 < p50 < 900, f"median task is {p50:.0f}s — outside the 1-15 min band"


def test_output_is_written_with_the_internal_block(tmp_path, plan) -> None:
    out = process.fn(WINDOW, SRC, tmp_path / "t.tif", plan)
    with rasterio.open(out) as ds:
        assert ds.profile["tiled"]
        assert ds.block_shapes[0] == (plan.internal_block, plan.internal_block)


def test_delivered_tile_is_independent_of_processing(plan) -> None:
    assert plan.delivered_tile == 256, "clients expect 256; processing size must not leak"


def test_edge_tiles_take_similar_time(sample_windows) -> None:
    interior = [measure(process.fn, w) for w in sample_windows if not w.on_edge]
    edges = [measure(process.fn, w) for w in sample_windows if w.on_edge]
    assert max(edges) < statistics.median(interior) * 3, "edge handling is not boundless"

The edge-tile test catches a specific and annoying asymmetry. Without boundless reads, a tile at the raster’s edge covers a partly-empty window and the code either special-cases it or reads a smaller array — either of which makes edge tiles behave differently from interior ones in memory, timing and output shape. Boundless reads make every task identical regardless of position, which removes a class of “only the coastal tiles fail” bug that is otherwise very tedious to track down.

Why edge tiles read boundlesslyA window at the raster's edge extends past the data. A boundless read fills the outside with nodata and returns the full array; a plain read returns a smaller one.raster extentrequested windowboundless: full array, nodata outsideplain read: a smaller array, and adifferent code path for the edgeEvery tile then has the same shape, the same memory profile and the same duration, wherever it is.
Uniformity across positions is worth a small amount of wasted computation at the edges, because it removes a whole category of positional bug.

The three sizes also have different revision cadences, which is a practical reason to keep them separate in code as the SizePlan does. The delivered tile is effectively frozen once clients exist, because changing it invalidates every cached tile in every browser and every proxy between you and them. The internal block changes only when the storage or access pattern changes, which is rare. The processing tile, by contrast, is expected to move: a new worker class, a change in concurrency, a source that gains bands. Holding all three in one constant means the cheap change drags the expensive ones with it, which is usually enough to stop anyone making the cheap change at all.

One more consideration applies when the operation needs a buffer — a focal filter, a hillshade, anything reading neighbouring pixels. The buffer is added to the processing tile on every side, so a 512-pixel tile with a 32-pixel buffer processes 576 pixels square and discards the frame, wasting about a quarter of the work. The same buffer on a 4096-pixel tile wastes three per cent. Where a buffer is required, it is a strong argument for the larger end of the workable band, and it is worth including in the memory arithmetic rather than discovering as an unexpected overshoot.

Common Pitfalls

  • One size for all three roles. The constraints are unrelated; equality is a coincidence and usually a costly one.
  • Choosing the processing tile from the delivered tile. 256-pixel tasks last seconds and the run becomes orchestration.
  • Non-power-of-two sizes. They stop aligning with overview levels, so a coarse read spans partial blocks.
  • Ignoring band count and data type. A four-band 16-bit tile is eight times the memory of a single-band 8-bit one at the same dimensions.
  • No boundless reads. Edge tiles then behave differently from interior ones in shape, memory and time.
  • Skipping overviews on intermediates. Any coarse read of them becomes a full-resolution read.

Frequently Asked Questions

Why is 512 the usual internal block?

It is the point where request count and wasted bytes balance for typical window sizes. At 256 a window spans four times as many blocks, which is four times the requests; at 1024 each block fetched carries more data outside the window than inside it for small reads.

Does the processing tile have to be square?

No, and rectangles are sometimes better — a source stored in strips is cheaper to read in wide, short windows that align with them. Square is a good default because it minimises the perimeter-to-area ratio, which is what governs edge overlap when a task needs a buffer.

How does this interact with the orchestration partition?

The processing tile is usually the partition, or a small batch of them is. Where the two differ, the partition is the coarser unit and the processing tile is the loop inside it; see partitioning strategies for spatial workloads.

What about vector tiles?

The delivered size is 512 by convention rather than 256, and the processing partition is governed by feature count rather than pixels. The three-sizes framing still holds; only the units change. See partitioning vector data by H3 cell.

Should the size change per layer?

The processing tile, yes — it depends on band count, data type and operation cost, which differ per layer. The internal block and delivered tile are better kept uniform, because consumers and readers benefit from consistency more than from per-layer optimisation.

Partitioning Strategies for Spatial Workloads