Fixing PDAL Pipeline JSON Schema Errors

PDAL aborts before it processes a single point: PDAL: Unable to parse pipeline, Couldn't create reader stage of type 'readers.laz', or Invalid option 'window_size' for stage 'filters.smrf'. Nothing was rasterized, and the traceback points at pipeline construction rather than execution. These are schema errors — the JSON you handed PDAL is well-formed text but does not describe a valid pipeline. This page maps each error class to its cause and gives one routine that validates a pipeline in Python before you ever call .execute().

Why PDAL rejects a pipeline before running it

A PDAL pipeline has a strict shape that is easy to violate in three ways, and the parser reports each differently. The first is the container form: PDAL accepts either a bare JSON array of stages, or an object with a single "pipeline" key whose value is that array. Wrapping the array in an object with any other top-level key, or nesting the "pipeline" key twice, yields Unable to parse pipeline because the parser cannot find the stage list where the schema says it lives. The second is stage identity: every stage names its type as "type": "readers.las", "filters.smrf", "writers.gdal", and PDAL looks that string up in a registry of compiled plugins. A typo, a wrong family (readers.laz does not exist — LAZ is read by readers.las), or a driver whose plugin was not built produces Couldn't create ... stage of type .... The third is option typing: each stage declares its options with expected value types, and passing a string where a number belongs ("resolution": "0.25"), an unknown option name (window_size instead of window), or the wrong JSON scalar triggers an invalid-option or conversion error.

Because these fail at construction, the fix is always to validate the pipeline object in isolation before running the expensive point processing — exactly the discipline the parent generating DSM and DTM from point clouds with PDAL workflow relies on so a schema typo never wastes a long run.

The shape a PDAL pipeline document must have The anatomy of a PDAL pipeline JSON. The document is an object with a single pipeline key whose value is an array of stages. A stage is either a bare string, interpreted as a filename whose extension selects the reader or writer, or an object with a type key naming the stage explicitly plus its options. Ordering is significant: the array is the execution order, readers first and writers last. Three common rejections are annotated: an array at the top level instead of an object, an unknown option key, and a type name that does not exist, each with the message PDAL emits. valid shape { "pipeline": [ … ] } an object at the top, never an array each element is a stage: "input.laz" — extension picks the reader {"type": "filters.smrf", "window": 18} "dtm.tif" — extension picks the writer array order is execution order readers first, writers last, filters between top-level array "expected object, got array" the outer braces are not optional unknown option key "Option 'windowsize' not recognized" option names are per-stage, not global unknown stage type "Couldn't create stage 'filters.smrf2'" check the plugin is present, not just the spelling All three are raised before a single point is read, which is why they are cheap to catch and worth validating in CI.

Figure 1 — PDAL validates the document against the stages it can actually construct, so the error messages name three different layers: JSON shape, stage existence, and option names.

Minimal reproducible solution

The routine below builds the pipeline as a native Python dict (so JSON syntax is never hand-typed), serialises it with json.dumps, constructs pdal.Pipeline, and calls .validate(). In recent PDAL the constructor already parses and checks the schema, so a bad stage name or malformed container raises immediately with a precise message; wrapping it lets you surface that message and pinpoint the offending stage instead of reading a bare traceback.

import json
import pdal


def validate_pipeline(spec: dict) -> pdal.Pipeline:
    """Construct and validate a PDAL pipeline dict, raising a clear error.

    Catches the three schema faults: wrong container form, unknown stage
    type, and mistyped/unknown option values -- all before execute().
    """
    # 1. Container form: must be {"pipeline": [ ...stages... ]}.
    if "pipeline" not in spec or not isinstance(spec["pipeline"], list):
        raise ValueError("pipeline must be an object with a 'pipeline' array")

    # 2. Every stage must name a type string.
    for i, stage in enumerate(spec["pipeline"]):
        if isinstance(stage, dict) and "type" not in stage and "filename" not in stage:
            raise ValueError(f"stage {i} has neither a 'type' nor an inferable filename")

    text = json.dumps(spec)                 # never hand-type JSON
    pipeline = pdal.Pipeline(text)          # raises on unknown stage / bad option
    pipeline.validate()                     # schema check without processing points
    return pipeline


good = {
    "pipeline": [
        {"type": "readers.las", "filename": "cloud.laz"},   # LAZ read by readers.las
        {"type": "filters.smrf", "window": 18.0},           # 'window', a float
        {"type": "writers.gdal", "filename": "dtm.tif",
         "resolution": 0.25, "output_type": "idw"},         # numbers, not strings
    ]
}
validate_pipeline(good)                     # returns a validated Pipeline

The two guard clauses catch the container and missing-type faults with a readable message; pdal.Pipeline(text) catches unknown stage names and bad options via PDAL’s own registry. Note the three corrections baked into the good example: LAZ files use readers.las (not a non-existent readers.laz), the SMRF option is window (not window_size), and resolution is the number 0.25 (not the string "0.25"). Build every pipeline as a dict and these classes of error largely disappear, because Python’s json.dumps guarantees valid syntax and correct scalar types.

Stage order changes the result, not just the validity Two pipelines with the same three stages in different orders. In the first, an outlier filter runs before the ground classifier, so isolated noise points are removed and the classifier sees clean data, producing a correct ground surface. In the second, the ground classifier runs first, so a low noise point beneath the true ground is classified as ground and pulls the surface down; removing it afterwards does not undo the classification. Both pipelines validate and run without error, which is why order faults are found by inspecting output rather than by reading logs. outlier removal before ground classification filters.outlier filters.smrf writers.gdal the classifier never sees the noise point — ground surface is correct ground classification before outlier removal filters.smrf filters.outlier writers.gdal a low noise point is classified as ground and drags the surface down; deleting it afterwards changes nothing Both pipelines validate, both exit zero. The only difference is in the raster.

Figure 2 — Schema validity says the pipeline can run, not that it computes what you meant. Order is the part of a PDAL pipeline that no validator will ever check for you.

Edge-case matrix

Input variant Error PDAL raises Expected fix
Array wrapped as {"stages": [...]} Unable to parse pipeline Rename the key to "pipeline"
Bare array [{...}, {...}] none — this form is valid Accept it, or wrap in {"pipeline": [...]} for tooling
"type": "readers.laz" Couldn't create reader stage of type 'readers.laz' Use readers.las; it reads LAZ transparently
"resolution": "0.25" (string) option conversion / invalid-option error Pass the JSON number 0.25
"window_size": 18 on filters.smrf Invalid option 'window_size' Correct to window
Trailing comma in hand-typed JSON Unable to parse pipeline (JSON syntax) Build the dict in Python; never hand-type
Driver plugin not built (e.g. writers.ept) Couldn't create ... stage of type ... Install the plugin build or use an available writer

The readers.laz mistake is the most common because the file extension implies a stage that does not exist — PDAL keeps LAS and LAZ under one readers.las reader and detects compression from the file itself. The trailing-comma case is why the routine builds a dict rather than storing a .json string in the repository.

Validating pipelines before they reach a cluster A two-lane comparison. Without validation, a pipeline is submitted directly to a batch queue, waits behind other jobs, loads a multi-gigabyte cloud, and only then fails on a mistyped option — an hour of wall clock for a one-character error. With validation, a dry-run parses and constructs every stage locally in well under a second, rejecting the same typo before submission; only pipelines that construct successfully are queued. A note observes that PDAL exposes this as a validate-only mode precisely because the two costs differ by four orders of magnitude. no pre-flight validation submit to queue wait 40 min load 40 GB cloud typo → fail roughly one hour of wall clock spent to discover a one-character error with a local dry run construct every stage locally typo → fail only valid jobs queued under a second, no points read — stage construction is what validates the option names The two costs differ by four orders of magnitude, which is why this belongs in CI rather than in a habit.

Figure 3 — Every schema error in this page is detectable without reading a single point. Constructing the pipeline is the validation, and it is fast enough to run on every commit.

Verification snippet

After validation passes, confirm PDAL resolved the exact stages you intended by inspecting the pipeline it parsed — a stage silently defaulting to the wrong driver is caught here, before execution.

import json
import pdal

pipeline = validate_pipeline(good)
# pipeline.pipeline is the normalised JSON PDAL actually parsed.
parsed = json.loads(pipeline.pipeline)
types = [s.get("type") for s in parsed["pipeline"] if isinstance(s, dict)]
assert "readers.las" in types, "reader stage did not resolve to readers.las"
assert "writers.gdal" in types, "writer stage missing after parse"
print("validated stages:", types)

If .validate() raised, the message already names the offending stage and option; if it passed but types lists a stage you did not intend, an option name collided with a stage default — re-read the stage’s option list and correct the key.

It is worth keeping pipelines in version control as files rather than building them as strings inside Python. A checked-in pipeline can be validated in CI, diffed when a result changes, and re-run months later exactly as it ran the first time. A pipeline assembled from f-strings at call time has none of those properties, and the schema errors it produces are correspondingly harder to reproduce because the document that failed no longer exists anywhere.

When to escalate

Schema validation gets the pipeline to run; it does not guarantee the result is correct. Return to generating DSM and DTM from point clouds with PDAL when:

  • The pipeline validates and executes but produces zero points or an empty raster, which is a filter-logic problem (a too-strict filters.range), not a schema one — the parent guide’s troubleshooting covers it.
  • A stage you need reports as an unknown type even though the name is correct, meaning the plugin was not compiled into your PDAL build; reinstall PDAL from conda-forge so the driver is present.
  • The error surfaces only inside filters.hag_delaunay or the SMRF options during de-vegetation, in which case the tuning context is in removing vegetation from DTM ground surfaces.

One habit removes most of these errors before they happen: start from a working pipeline for the same shape of job and change one stage at a time, validating after each change.

Generating DSM and DTM from Point Clouds with PDAL