Lazy Loading Explained: Why xarray Reads Nothing Until You Ask
Problem statement
xr.open_zarr on a terabyte returns instantly. xr.open_dataset on a directory of a thousand GeoTIFFs prints a full description of a dataset it has not read.
Then one innocuous line β a .plot(), a .mean(), a print() of the values β takes twenty minutes and exhausts memory.
Lazy loading is why both happen. Understanding where the boundary lies between "building a description" and "actually reading" is the difference between a pipeline that scales and one that dies on the first real dataset.
Quick answer
Nothing is read until something forces it:
import xarray as xr
cube = xr.open_zarr("cube.zarr") # metadata only, instant
subset = cube.sel(time="2025-07") # still nothing read
mean = subset["reflectance"].mean("time") # still nothing read
values = mean.compute() # NOW it reads
Four operations that force a read, and are easy to trigger by accident:
array.values # materialises the whole array
array.compute() # explicit
float(array) # scalar conversion
array.plot() # needs numbers
np.asarray(array) # any numpy function
Step-by-step solution
1. Know what opening actually does
open_zarr and open_dataset read the metadata: dimensions, coordinates, dtypes, chunk shapes and attributes. That is kilobytes regardless of the array size.
The printed representation shows shapes and dtypes because those are metadata. It shows no values, which is the visible sign that nothing was read.
2. Know which operations stay lazy
Indexing, selection, arithmetic, reductions and reshaping all build a graph rather than executing:
cube.sel(time=slice("2025-06", "2025-09"))
cube["red"] / cube["nir"]
cube.mean("time")
cube.where(cube["scl"] != 9)
Each returns a new lazy object describing what to do. The cost is a graph node, not a byte of I/O.
3. Know which operations force a read
Anything needing actual numbers:
.values,.to_numpy(),np.asarray().compute(),.load(),.persist()float(),int(),bool()on a scalar.plot(),.to_netcdf(),.to_zarr()- printing a value, though not printing the object
The dangerous one is .values inside a loop. Each call re-executes the whole graph from the beginning, so a loop of a hundred iterations reads the data a hundred times.
4. Compute once, at the end
result = (cube.sel(time=window)
.where(cube["scl"].isin(GOOD))
.median("time"))
result.compute() # one pass over the data
Building the whole expression before computing lets the scheduler read each chunk once and apply everything to it. Computing intermediate steps forces a separate pass for each.
5. Use persist for something reused
masked = cube.where(mask).persist() # compute and keep in memory
a = masked.mean("time").compute()
b = masked.max("time").compute()
persist executes the graph and keeps the result in memory as chunks. Without it, a and b each re-read and re-mask everything.
Code examples
Example 1 β telling lazy from loaded
import xarray as xr
import numpy as np
def inspect_laziness(obj, name="array"):
"""Is this backed by dask, and how big would loading it be?"""
data = obj.data if hasattr(obj, "data") else obj
is_dask = hasattr(data, "dask")
nbytes = obj.nbytes if hasattr(obj, "nbytes") else data.nbytes
print(f" {name}: {'lazy (dask)' if is_dask else 'loaded (numpy)'}")
print(f" shape {tuple(obj.shape)}, dtype {obj.dtype}")
print(f" {nbytes / 1e9:.2f} GB if fully loaded")
if is_dask:
print(f" chunks {data.chunksize}, {data.npartitions} partitions")
print(f" {len(data.dask):,} tasks in the graph")
if nbytes > 8e9:
print(" ! do not call .values on this")
return is_dask
reflectance: lazy (dask)
shape (120, 4, 509, 543), dtype float32
0.53 GB if fully loaded
chunks (1, 4, 509, 543), 120 partitions
481 tasks in the graph
Printing the fully loaded size before computing is the habit that prevents most out-of-memory failures. Half a gigabyte is fine; the same code on a continental cube is not.
Example 2 β the pattern that reads once
import xarray as xr
import numpy as np
UNUSABLE = [0, 1, 3, 8, 9, 10]
def masked_composite(cube, red="red", nir="nir", scl="scl"):
"""Build the whole expression, then compute it once."""
usable = ~cube[scl].isin(UNUSABLE)
red_masked = cube[red].where(usable)
nir_masked = cube[nir].where(usable)
ndvi = (nir_masked - red_masked) / (nir_masked + red_masked)
result = xr.Dataset({
"ndvi_median": ndvi.median("time"),
"observations": usable.sum("time"),
})
print(f" graph has {len(result['ndvi_median'].data.dask):,} tasks "
"before compute")
computed = result.compute()
print(f" median NDVI {float(computed['ndvi_median'].median()):.3f}, "
f"observations {int(computed['observations'].min())}"
f"-{int(computed['observations'].max())}")
return computed
Both outputs are in one Dataset before .compute(), so the scheduler reads each chunk once and produces both. Computing them separately would double the I/O.
Example 3 β chunk-wise processing that never materialises
import xarray as xr
import numpy as np
def apply_chunkwise(cube, func, output_dtype="float32"):
"""Run a NumPy function on each chunk without loading the whole array."""
result = xr.apply_ufunc(
func, cube,
input_core_dims=[["time"]],
output_dtypes=[output_dtype],
dask="parallelized",
vectorize=False,
)
print(f" lazy result: {tuple(result.shape)}, "
f"chunks {result.data.chunksize}")
return result
def p90(values, axis=-1):
return np.nanpercentile(values, 90, axis=axis)
dask="parallelized" is what keeps this lazy. Without it, apply_ufunc computes the input first, which defeats the purpose β and the failure mode is memory exhaustion rather than an error.
input_core_dims tells dask which dimension the function consumes, so it can rechunk to make that dimension contiguous before applying the function.
Explanation
Why laziness is necessary, not clever
A continental Sentinel-2 cube is petabytes. Any API that reads on open is unusable for it.
Laziness inverts the model: describe the computation first, then execute only what the result requires. Selecting one month from ten years reads one month's chunks, because the selection happened before any I/O.
That is the same argument as for lazy evaluation in databases, and it has the same consequence: the cost of an operation is not where it appears in the code.
Why .values in a loop is so expensive
A lazy object holds a graph, not a cache. Calling .values executes the graph and returns an array; calling it again executes the graph again.
Inside a loop, that means re-reading and re-computing everything on every iteration. A ten-line loop can therefore take a hundred times longer than the same work done once outside it, with no visible difference in the code.
The fix is always the same: compute once outside the loop, or .persist() if the result is reused several times in different ways.
Why chunk alignment matters
The chunk shape decides how much data each task reads. If the stored chunks are (1, 4, 509, 543) β one time step each β then a reduction over time must touch every chunk, which is correct and unavoidable.
But if the dask chunks are set differently from the stored chunks, each task reads parts of several stored chunks, and each stored chunk is read by several tasks. The same bytes are fetched more than once.
Passing chunks= matching the storage layout, or a whole multiple of it, avoids that.
Why the graph itself can be a problem
Dask graphs are Python objects, and a graph with millions of tasks costs real memory and scheduling time.
The usual cause is very small chunks: a cube with a million chunks and ten operations gives ten million tasks. The scheduler then spends more time managing the graph than reading data.
If a computation is slow with low CPU and low network use, check the task count. It is usually a chunking problem rather than a compute one.
Edge cases or notes
- Opening reads metadata only, regardless of array size.
.values,.compute(),.plot()andfloat()all force a read..valuesinside a loop re-executes the whole graph every iteration.- Build the whole expression, then compute once.
.persist()for a result reused several ways.dask="parallelized"inapply_ufunc, or it computes the input first.- Align dask chunks with storage chunks.
- Check the task count when a computation is slow with idle CPU.
Internal links
- Chunked arrays and Zarr explained β where the chunks come from
- How to turn a STAC search into an xarray data cube β building a lazy cube from imagery
- How to choose chunk and tile sizes that actually help β sizing them
- Cloud-native geospatial explained β why partial reads are possible at all
- How to scale a GeoPandas job with dask-geopandas β the same laziness for tables
- dask-geopandas uses more memory or time than plain GeoPandas β when laziness costs more than it saves
- How to process a very large GeoPackage in chunks with Python β manual chunking
- How to profile a slow Python GIS script and find the real bottleneck β finding the accidental compute
FAQ
Why does opening a huge dataset take no time?
Because only the metadata is read β dimensions, dtypes, chunk shapes. That is kilobytes regardless of the array size.
What forces xarray to actually read the data?
.values, .compute(), .load(), .plot(), float() on a scalar, writing to a file, and any NumPy function applied to the object.
Why is my loop over an xarray object so slow?
You are probably calling .values inside it. Each call re-executes the whole graph, so the data is read once per iteration.
What is the difference between compute and persist?
compute returns a concrete result. persist executes the graph and keeps the result in memory as chunks, so several later operations reuse it instead of re-reading.
Should my dask chunks match the storage chunks?
Yes, or be whole multiples of them. Misaligned chunks cause each stored chunk to be fetched more than once.
Why is my computation slow with idle CPU?
Often too many tasks. Very small chunks produce enormous graphs, and the scheduler spends its time on bookkeeping rather than data.
Do I need dask for xarray?
No. Without chunks=, arrays are NumPy-backed and load on access. Use dask when the data is larger than memory.