Iterating PDAL Arrays in Chunks from Python
TL;DR: For a streamable pipeline, for arr in pdal.Pipeline(spec).iterator(chunk_size=1_000_000): ... yields NumPy structured arrays of up to a million points each, so Python code can compute statistics, histograms or per-point results over a tile of any size with memory bounded by the chunk. Check pipeline.streamable first; a non-streamable stage makes the whole run load everything.
# Context and Motivation
This guide is part of Streaming Mode Execution in PDAL. Streaming on the command line bounds memory for pipelines that end in a writer. But much Python work does not end in a writer: you want a histogram of heights, a count per class, a per-cell density grid or a set of points to hand to a model. pipeline.execute() followed by pipeline.arrays loads everything, which is exactly what fails on a 3 GB tile on a 4 GB worker. The Python bindings’ iterator gives you the points in chunks instead, driving PDAL’s streaming engine from a Python loop.
# Prerequisites and Assumptions
python-pdal3.x, which providesPipeline.iterator().- A pipeline in which every stage streams — readers,
filters.range,filters.expression,filters.assign,filters.reprojection,filters.cropand similar. See which PDAL filters break streaming mode. - Python work that can be expressed as an accumulation over chunks: counts, sums, histograms, per-cell grids, filtered outputs written incrementally.
# Step-by-Step Implementation
# Step 1 — Build a pipeline without a writer
The iterator returns points to Python, so a writer is optional. Keep only the streamable reading and filtering stages.
# Step 2 — Confirm it streams
Construct the pipeline and assert pipeline.streamable. If false, the iterator still works but loads everything first.
# Step 3 — Choose a chunk size
One million points is a sensible default: large enough to amortize Python overhead, small enough to keep memory around 50–100 MB depending on dimensions.
# Step 4 — Accumulate per chunk
Update running totals — counts, sums, histograms, grid cells — inside the loop. Never append whole chunks to a list; that rebuilds the full array in memory.
# Step 5 — Finalize after the loop
Compute means, percentiles from histograms, or write accumulated grids once the iteration ends.
# Complete Working Example
A height histogram, class counts and a 10 m point-count grid over a large tile, in bounded memory:
"""Chunked statistics over a large LAZ with pdal.Pipeline.iterator."""
from __future__ import annotations
import json
import numpy as np
import pdal
SRC = "big/t_0431_60M.laz"
CELL = 10.0
spec = {"pipeline": [
{"type": "readers.las", "filename": SRC},
{"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
]}
pipeline = pdal.Pipeline(json.dumps(spec))
assert pipeline.streamable, "a stage blocks streaming; the iterator would load everything"
# Grid extent from the header, read cheaply before iterating.
info = pdal.Pipeline(json.dumps({"pipeline": [SRC]})).quickinfo["readers.las"]
b = info["bounds"]
x0, y0 = b["minx"], b["miny"]
cols = int(np.ceil((b["maxx"] - x0) / CELL))
rows = int(np.ceil((b["maxy"] - y0) / CELL))
edges = np.arange(-50.0, 3000.0, 0.5)
z_hist = np.zeros(len(edges) - 1, dtype=np.int64)
class_counts = np.zeros(256, dtype=np.int64)
grid = np.zeros((rows, cols), dtype=np.int32)
total = 0
for chunk in pipeline.iterator(chunk_size=1_000_000):
total += len(chunk)
z_hist += np.histogram(chunk["Z"], bins=edges)[0]
class_counts += np.bincount(chunk["Classification"], minlength=256)
c = np.clip(((chunk["X"] - x0) / CELL).astype(int), 0, cols - 1)
r = np.clip(((chunk["Y"] - y0) / CELL).astype(int), 0, rows - 1)
np.add.at(grid, (r, c), 1)
cdf = np.cumsum(z_hist) / z_hist.sum()
median_z = edges[np.searchsorted(cdf, 0.5)]
print(f"{total:,} points, median Z ≈ {median_z:.1f} m")
print({k: int(v) for k, v in enumerate(class_counts) if v})
print(f"density: mean {grid.mean() / CELL**2:.1f} pts/m², max cell {grid.max() / CELL**2:.1f}")quickinfo reads the header only, so the grid can be sized before any points move.
# Writing a Filtered Subset as You Go
Sometimes the Python work decides which points to keep — a model score above a threshold, a custom geometric test — and the kept points must end up in a file. Collecting them in a list would rebuild the cloud in memory; writing them per chunk keeps memory flat. laspy’s writer accepts point records chunk by chunk, so the pattern is to open one writer before the loop and append inside it.
import laspy
header = laspy.LasHeader(point_format=6, version="1.4")
header.scales = [0.01, 0.01, 0.01]
header.offsets = [x0, y0, 0.0]
with laspy.open("out/high_points.laz", mode="w", header=header) as writer:
for chunk in pdal.Pipeline(json.dumps(spec)).iterator(chunk_size=1_000_000):
keep = chunk[chunk["Z"] > 250.0]
if len(keep) == 0:
continue
rec = laspy.ScaleAwarePointRecord.zeros(len(keep), header=header)
rec.x, rec.y, rec.z = keep["X"], keep["Y"], keep["Z"]
rec.classification = keep["Classification"]
writer.write_points(rec)The writer updates the header’s point count and bounds when it closes, so the output is a valid LAZ file however many chunks contributed to it.
# Key Parameter Table
| Setting | Typical value | Effect |
|---|---|---|
chunk_size |
1,000,000 | Points per yielded array; memory ≈ chunk × bytes per point |
prefetch |
0 (default) | Chunks prepared ahead of consumption; more trades memory for overlap |
pipeline.streamable |
must be True |
Otherwise the iterator loads the whole cloud first |
| histogram bin width | 0.1–1 m | Resolution of percentiles computed from the histogram |
| grid cell | 1–10 m | Size of per-cell accumulators; memory is rows × cols |
# Verification
- Totals match the header. The sum of chunk lengths should equal the reader’s point count minus points removed by filters. Compare with
pdal info --summaryand a class count. - Same answer as the in-memory path. On a small tile, compute the statistics both ways — iterator and
execute()plusarrays— and assert equality. - Flat memory. Measure peak RSS while iterating; it should not grow with tile size. See measuring peak memory of a PDAL pipeline.
small = {"pipeline": ["tiles/t_small.laz"]}
p = pdal.Pipeline(json.dumps(small)); p.execute()
full = np.bincount(p.arrays[0]["Classification"], minlength=256)
chunked = sum(np.bincount(c["Classification"], minlength=256)
for c in pdal.Pipeline(json.dumps(small)).iterator(chunk_size=100_000))
assert np.array_equal(full, chunked)# Gotchas and Edge Cases
Exact percentiles need all the data. A median over chunks cannot be computed exactly without holding every value. Histograms give percentiles to within one bin width, which is usually enough; if you need exact values, write the one dimension you care about to a memory-mapped array.
Neighbourhood work across chunk edges. Chunks are arbitrary slices of the point stream, not spatial tiles. Anything that needs neighbours — local slope, outlier tests — cannot be done correctly per chunk. Use PDAL’s blocking filters in a separate pass for that.
Appending chunks defeats the purpose. all_points.append(chunk) in the loop rebuilds the whole cloud in Python memory. If you need a filtered subset, write it incrementally with laspy or a PDAL writer rather than collecting it.
Non-streamable readers. A reader that must index or sort its source before yielding points, or a remote source read through an unindexed format, may block. LAZ, LAS and COPC stream.
# Frequently Asked Questions
What does pdal.Pipeline.iterator return?
A Python iterator that yields NumPy structured arrays, each holding up to chunk_size points with the pipeline’s dimensions as named fields. Memory is bounded by the chunk rather than by the file.
Does the iterator work with non-streamable filters?
It runs, but the pipeline executes in standard mode, loading all points before yielding the first chunk. Check the streamable property first if bounded memory is the goal.
How big should chunks be?
Around one million points balances Python loop overhead against memory. Smaller chunks increase overhead; much larger ones raise memory without speeding things up much.
Can I compute a median height with chunks?
Approximately, from a histogram accumulated over chunks, with error up to one bin width. Exact medians need all values at once or a two-pass approach.
# Related
- Streaming Mode Execution in PDAL — the engine behind the iterator
- Running a PDAL Pipeline in Streaming Mode — streaming for writer-terminated pipelines
- Which PDAL Filters Break Streaming Mode — keeping the pipeline streamable
- Chunked Reading of Large LAS Files with laspy — the laspy equivalent
- Counting Points per Class with PDAL — one accumulation in detail