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.
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.
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.
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.
Internal links
- Cloud-optimised GeoTIFF explained β where tile size is set
- Chunked arrays and Zarr explained β chunk shape for n-dimensional data
- How to write a cloud-optimised GeoTIFF in Python β applying the choice
- How to read a COG from a URL without downloading the whole file β aligning reads
- Reading a COG from a URL is slow or downloads everything β when the layout is wrong
- Lazy loading explained: why xarray reads nothing until you ask β chunk size and task count
- How to scale a GeoPandas job with dask-geopandas β partition sizing for tables
- How to process a very large GeoPackage in chunks with Python β chunking without a chunked format
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.