Running a PDAL Pipeline in Streaming Mode

TL;DR: Build a chain of per-point stages, guard on pipeline.streamable, then call pipeline.execute_streaming(chunk_size=100_000) — or pdal pipeline --stream on the command line — and confirm with ru_maxrss that peak memory sits near the chunk working set instead of tracking the input file size.

# Context and Motivation

This recipe is part of Streaming Mode Execution in PDAL, which explains the pull-loop execution model and which stages participate in it. Here the focus is narrower: taking a pipeline you already have and actually running it in streaming mode, with enough instrumentation that you can tell whether it worked.

The motivation is nearly always a memory ceiling. A worker with 4 GB of RAM cannot read a 6 GB tile conventionally, no matter how simple the filtering is, because standard execution materialises the whole cloud before the first filter runs. Retiling the input is one answer and a laborious one. Streaming is the other, and for a chain of range filters and a reprojection it takes about four lines of change. The catch is that nothing about the ordinary API tells you which mode you got, so the recipe below spends as much effort on verification as on execution.

The whole change, side by side Two short call sequences. The conventional one constructs a pipeline and calls execute, then reads pipeline.arrays. The streaming one constructs the same pipeline, asserts pipeline.streamable, calls execute_streaming with a chunk size, and works with the returned count. The pipeline JSON is identical in both. conventional streaming p = pdal.Pipeline(spec) — no capability check — n = p.execute() arr = p.arrays[0] p = pdal.Pipeline(spec) assert p.streamable n = p.execute_streaming(100_000) — no arrays; there is no array — the pipeline JSON is byte-for-byte identical on both sides; only the execution call differs

# Prerequisites and Assumptions

Requirement Detail
PDAL 2.3+ (the Python execute_streaming API)
Python 3.9+ with the pdal bindings installed
Input tile LAS, LAZ or COPC — all stream; a text reader also streams but slowly
Filter chain per-point stages only; see which filters break streaming
Platform resource.getrusage for peak RSS is POSIX; on Windows use psutil.Process().memory_info().peak_wset

The example assumes a tile in a projected metric CRS. Nothing about streaming requires that, but the reprojection stage in the chain does need to know what it is transforming from, which the spatial reprojection guide covers in detail.

# Step-by-Step Implementation

# Step 1 — Build the chain from per-point stages only

Streaming is a property of the whole chain, so the design work happens before any execution call. Keep classification, sorting and neighbourhood filters out of this pipeline.

json
{
  "pipeline": [
    {"type": "readers.las", "filename": "tile_0431.laz"},
    {"type": "filters.range", "limits": "Z[-30:5000]"},
    {"type": "filters.range", "limits": "Classification![7:7]"},
    {"type": "filters.reprojection", "out_srs": "EPSG:6318"},
    {"type": "writers.las", "filename": "tile_0431_clean.laz", "compression": "laszip", "forward": "all"}
  ]
}

# Step 2 — Ask the pipeline whether it streams

python
import pdal

pipeline = pdal.Pipeline(spec)
if not pipeline.streamable:
    raise RuntimeError("chain contains a blocking stage — cannot stream")

This costs nothing and converts the failure mode from “used 30 GB in production” into a message at startup.

# Step 3 — Choose a chunk size deliberately

The default of 10,000 points is safe but leaves throughput on the table. A hundred thousand is the value worth writing down: about 35 MB of working set on a typical 40-byte point layout, and close to the throughput ceiling.

# Step 4 — Execute and capture the count

python
count = pipeline.execute_streaming(chunk_size=100_000)

The returned integer is the number of points the writer accepted. There is no pipeline.arrays afterwards, because no complete array was ever assembled.

# Step 5 — On the command line, use --stream

bash
pdal pipeline clean.json --stream --verbose 4

If any stage blocks, this exits non-zero and names it. There is no fallback, which is exactly what you want in a CI check.

# Complete Working Example

Save as stream_run.py. It runs the pipeline, records peak memory, and cross-checks the streaming result against a conventional run on a capped subset of the same file.

python
"""Run a PDAL pipeline in streaming mode and prove the memory claim."""
from __future__ import annotations

import argparse
import json
import logging
import resource
import time
from pathlib import Path

import pdal

LOG = logging.getLogger("stream_run")


def spec(src: Path, dst: Path, out_srs: str, count: int | None = None) -> str:
    reader = {"type": "readers.las", "filename": str(src)}
    if count is not None:
        reader["count"] = count
    return json.dumps({
        "pipeline": [
            reader,
            {"type": "filters.range", "limits": "Z[-30:5000]"},
            {"type": "filters.range", "limits": "Classification![7:7]"},
            {"type": "filters.reprojection", "out_srs": out_srs},
            {
                "type": "writers.las",
                "filename": str(dst),
                "compression": "laszip",
                "minor_version": 4,
                "dataformat_id": 6,
                "forward": "all",
            },
        ]
    })


def peak_rss_mb() -> float:
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0


def stream(src: Path, dst: Path, out_srs: str, chunk: int) -> dict:
    pipeline = pdal.Pipeline(spec(src, dst, out_srs))
    if not pipeline.streamable:
        raise RuntimeError("pipeline is not streamable; inspect the stage list")

    started = time.perf_counter()
    written = pipeline.execute_streaming(chunk_size=chunk)
    elapsed = time.perf_counter() - started

    return {
        "mode": "streaming",
        "chunk_size": chunk,
        "points": written,
        "seconds": round(elapsed, 2),
        "peak_rss_mb": round(peak_rss_mb(), 1),
    }


def equivalence_check(src: Path, out_srs: str, sample: int = 200_000) -> None:
    """Run both modes over the same capped subset and compare counts."""
    plain = pdal.Pipeline(spec(src, Path("/tmp/_plain.laz"), out_srs, count=sample))
    plain_n = plain.execute()

    streamed = pdal.Pipeline(spec(src, Path("/tmp/_stream.laz"), out_srs, count=sample))
    streamed_n = streamed.execute_streaming(chunk_size=25_000)

    if plain_n != streamed_n:
        raise AssertionError(
            f"mode mismatch: standard kept {plain_n}, streaming kept {streamed_n}"
        )
    LOG.info("equivalence check passed on %d sampled points", sample)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("src", type=Path)
    ap.add_argument("dst", type=Path)
    ap.add_argument("--out-srs", default="EPSG:6318")
    ap.add_argument("--chunk", type=int, default=100_000)
    ap.add_argument("--check", action="store_true", help="cross-check against standard mode first")
    args = ap.parse_args()

    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

    if args.check:
        equivalence_check(args.src, args.out_srs)

    result = stream(args.src, args.dst, args.out_srs, args.chunk)
    LOG.info("done: %s", json.dumps(result))
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()

Running it against a 2.1 GB tile on a worker with a 1 GB memory limit succeeds, which is the whole point:

bash
python stream_run.py tile_0431.laz tile_0431_clean.laz --chunk 100000 --check

# Key Parameter Table

Parameter Type Default Guidance
chunk_size int 10000 100,000 is the general-purpose value; below 10,000 the overhead dominates
count on the reader int all points Cap the input for a fast smoke test without retiling
--stream CLI flag off Fails loudly rather than falling back; use it in CI
--verbose int 0–8 0 At 4 or above, PDAL logs which stage refused to stream
compression on the writer string none laszip costs CPU per chunk but keeps the output small

# Verification

Three assertions turn “it ran” into “it streamed and produced the right answer”.

Peak memory is flat across input sizes. Run the same command against a 200 MB tile and a 2 GB tile. Peak RSS should differ by a few megabytes, not by an order of magnitude.

bash
/usr/bin/time -v python stream_run.py small.laz small_out.laz 2>&1 | grep "Maximum resident"
/usr/bin/time -v python stream_run.py big.laz big_out.laz 2>&1 | grep "Maximum resident"

Counts match standard mode. That is what --check does above, on a capped subset so the comparison is affordable.

The output header is complete. pdal info tile_0431_clean.laz --metadata should report the new CRS, a point count equal to the returned integer, and a bounding box consistent with the reprojected extent.

What the memory trace looks like when it worked Resident memory sampled through two runs of the same 2.1 gigabyte tile. The conventional run climbs steadily for the first third as the reader materialises the cloud, plateaus above three gigabytes, and falls only at the end. The streaming run rises to about thirty-five megabytes in the first second and stays there for the whole run, with a small sawtooth as each chunk is allocated and released. standard — 3.2 GB plateau streaming — 35 MB sawtooth 0 2 GB 4 GB elapsed seconds resident set if your streaming trace has a ramp in it, something in the chain is accumulating — start with the writer

# Gotchas and Edge Cases

Checking streamable and then calling execute(). The guard passes, the run is conventional, and nothing warns you. Grep your own code for execute( in any function that also mentions streaming.

A chunk size larger than the file. Perfectly legal and completely pointless: one chunk means one buffer holding everything, which is standard mode with extra steps. On small tiles the memory graph will look wrong for exactly this reason.

writers.gdal in the chain. Rasterization accumulates cells over the whole input, so the pipeline will not stream. Split it into a streaming point-domain pass and a second rasterizing pass — the approach described in splitting a blocking pipeline into two passes.

Reading from S3 without tuning GDAL. Streaming from an object store works well, but the default virtual-filesystem settings issue a directory listing on every open. Set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR before the run — the S3 and cloud storage I/O guide covers the rest of the environment.

Four ways to think you are streaming when you are not Four failure rows, each pairing a symptom with the cause. Flat throughput with high memory means execute was called instead of execute_streaming. Memory tracking the file means a blocking stage was added after the capability check. A single chunk means the chunk size exceeds the point count. Slow reads with low memory means the object store, not the pipeline, is the bottleneck. what you observe what it actually is memory tracks the file size execute() was called, not execute_streaming() streamable is False after an edit a blocking filter was added to the chain one chunk, memory looks wrong chunk_size exceeds the tile point count low memory but very slow object-store latency, not the pipeline

# Frequently Asked Questions

Why does execute_streaming return a number instead of arrays?

Because no complete array ever exists. Streaming keeps one chunk in memory at a time, so there is nothing to hand back except the total the writer accepted. Code that needs pipeline.arrays needs standard execution and the memory that comes with it.

Does the --stream flag fall back to standard mode?

No. It fails and names the blocking stage. That refusal is the useful behaviour — a silent fallback is how a job ends up needing thirty gigabytes in production having passed on a small tile in testing.

How much memory should a streaming run actually use?

Roughly chunk_size × bytes-per-point × live buffers, plus a few tens of megabytes for PDAL, GDAL and PROJ themselves. At 100,000 points and a 40-byte layout that is about 35 MB — and it should not move when the input grows.

Can I stream directly from S3?

Yes, and it is one of the better reasons to stream. A LAZ object read through the GDAL virtual filesystem arrives in ranges, and a streaming pipeline consumes them as they land, so neither the object nor the decoded cloud is ever fully resident.