PDAL vs laspy for Point Cloud Automation

Every Python point-cloud pipeline eventually picks a primary library, and the choice shapes everything built on top of it. PDAL is a C++ processing engine driven by JSON pipeline declarations, with a Python binding. laspy is a pure-Python LAS/LAZ reader and writer that hands you NumPy arrays and has no opinions about what you do with them.

They are not competitors in the way the framing suggests. PDAL is a processing framework; laspy is a file library. The interesting question is not which is better but which belongs at each point in a pipeline — and the answer is usually both, at different stages.

This page compares them on the axes that decide real pipelines: memory behaviour on large clouds, what each can express, how header control works, performance, and the operational cost of each dependency. It assumes the workflows in classifying point clouds with PDAL and Python and point cloud formats and interchange in Python.

Audience and prerequisites. Python 3.10+, and a working knowledge of both libraries’ basic use. The comparison assumes site-scale clouds — tens to hundreds of millions of points — where the differences matter; below ten million points, either library does anything you ask without complaint.

Prerequisites

Library / tool Minimum version Install command Notes
PDAL ≥ 2.5 conda install -c conda-forge pdal Conda strongly preferred; pip builds are fragile
pdal (Python) ≥ 3.2 pip install pdal Requires the matching PDAL library
laspy ≥ 2.5 pip install "laspy[lazrs]" Pure Python; lazrs adds LAZ support
numpy ≥ 1.24 pip install numpy Both return NumPy arrays
scipy ≥ 1.10 pip install scipy Neighbour queries when working in laspy

Conceptual architecture

PDAL’s model is a directed pipeline of stages: a reader, some filters, a writer, declared as JSON. The engine handles memory, streaming and parallelism, and many pipelines execute in streaming mode where points flow through in chunks and the whole cloud never exists in memory at once. That is the property that makes PDAL the right tool for a 400-million-point survey on a 16 GB machine.

laspy’s model is a file object and NumPy arrays. laspy.read() loads everything; laspy.open() gives a chunked iterator that lets you stream manually. There is no processing framework — you write the loops — and in exchange you have complete control and no abstraction to fight.

The practical division is that PDAL is better wherever the operation is one of its filters or wherever the data does not fit in memory, and laspy is better wherever you need to do something PDAL does not implement, or to manipulate the header directly.

Execution model of a PDAL pipeline against a laspy loop Two execution diagrams. The PDAL path shows a reader, three filters and a writer connected in a chain, with chunks of points flowing through and a note that peak memory is one chunk rather than the whole cloud. The laspy path shows a chunked reader feeding a Python loop containing arbitrary NumPy code, then a writer, with a note that memory is whatever the loop holds and the loop is the developer's responsibility. A summary states that PDAL owns the memory strategy and laspy hands it to you. PDAL — declarative pipeline, engine-managed memory reader outlier smrf range writer peak memory: one chunk, whatever the cloud's size provided every stage in the chain supports streaming laspy — arrays and a loop you own chunk iterator arbitrary NumPy anything you can write your accumulation writer peak memory: whatever your loop holds which is a feature when the operation is not one of PDAL's filters PDAL owns the memory strategy. laspy hands it to you.

Figure 1 — The architectural difference, and the one consequence that decides most choices.

Where PDAL wins

Clouds larger than memory. A streaming pipeline processes a 400-million-point file in a few gigabytes. The equivalent laspy implementation requires writing the chunking, and any neighbourhood operation requires writing the halo handling too — which is where the bugs live.

import json
import subprocess


def classify_stream(src: str, dst: str) -> None:
    """A pipeline that streams: peak memory is one chunk, not the cloud."""
    pipeline = {"pipeline": [
        src,
        {"type": "filters.assign", "value": "Classification = 0"},
        {"type": "filters.smrf", "window": 18.0, "threshold": 0.45},
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "forward": "all"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin", "--stream"],
                   input=json.dumps(pipeline), text=True, check=True)

Operations that are already implemented. Ground classification, outlier removal, height above ground, covariance features, gridding, reprojection, COPC writing — each is a stage and a few parameters. Reimplementing any of them correctly in NumPy is days of work with a worse result.

Format breadth. PDAL reads and writes far more than LAS: E57, PLY, text, databases, COPC, EPT. A conversion between two of them is a two-line pipeline.

Where laspy wins

Header control. Setting scale, offset, version, point format and CRS precisely, or reading a header without touching the points, is direct in laspy and awkward through PDAL’s abstraction. Every check in fixing LAS scale and offset precision loss is a laspy routine for this reason.

import laspy


def header_only(path: str) -> dict:
    """Read header fields without decompressing a single point."""
    with laspy.open(path) as fh:            # opens, does not read
        h = fh.header
        return {"points": h.point_count, "version": str(h.version),
                "point_format": h.point_format.id,
                "scales": h.scales.tolist(), "offsets": h.offsets.tolist(),
                "mins": h.mins.tolist(), "maxs": h.maxs.tolist()}

On a 6 GB LAZ this returns in milliseconds. The equivalent through a processing framework typically involves at least parsing metadata through a subprocess.

Anything PDAL does not implement. A bespoke statistic, a per-point calculation involving an external dataset, an unusual filter — these are a NumPy expression in laspy and either a custom C++ stage or a Python filter stage in PDAL, both of which cost more than the operation does.

Dependency weight. laspy plus lazrs installs with pip into any environment. PDAL wants conda, brings a substantial C++ dependency chain, and is the component most likely to complicate a container image — a real consideration for the workers described in containerising photogrammetry workers with Docker.

PDAL and laspy compared across six operational axes A six-row comparison. For clouds larger than memory, PDAL streams natively while laspy requires hand-written chunking. For standard processing operations, PDAL provides them as stages while laspy requires reimplementation. For header control, laspy is direct while PDAL is indirect through its abstraction. For format breadth, PDAL reads many formats while laspy reads only LAS and LAZ. For installation, laspy installs with pip anywhere while PDAL prefers conda and a C++ chain. For custom per-point logic, laspy is immediate while PDAL needs a custom stage. PDAL laspy larger than memory standard operations header control format breadth installation custom per-point logic streams natively hand-written chunking a stage and parameters reimplement it yourself indirect direct E57 · PLY · COPC · EPT · DB LAS and LAZ only conda, C++ chain pip, anywhere custom stage needed a NumPy expression No row makes one library the answer. Every row makes one of them the answer for that job.

Figure 2 — Six axes, and the split is close to even. Which is why most mature pipelines carry both.

The combination most pipelines converge on

The pattern that works is PDAL for bulk processing, laspy for header inspection and final validation. PDAL classifies, filters, reprojects and writes; laspy opens the result, checks the header, verifies the round trip, and reads metadata for the run record. Neither is asked to do the other’s job.

import json
import subprocess

import laspy


def process_and_verify(src: str, dst: str, expect_epsg: int) -> dict:
    """PDAL does the work; laspy proves it was done correctly."""
    pipeline = {"pipeline": [
        src,
        {"type": "filters.outlier", "method": "statistical", "mean_k": 12},
        {"type": "filters.range", "limits": "Classification![7:7]"},
        {"type": "filters.smrf", "window": 18.0},
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "forward": "all"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

    with laspy.open(dst) as fh:
        h = fh.header
        crs = h.parse_crs()
        problems = []
        if crs is None or crs.to_epsg() != expect_epsg:
            problems.append(f"CRS is {crs}, expected EPSG:{expect_epsg}")
        if float(h.scales.max()) > 0.005:
            problems.append(f"scale {h.scales.tolist()} coarser than 5 mm")
        return {"points": int(h.point_count), "problems": problems}

Performance, measured rather than assumed

The received wisdom is that PDAL is fast because it is C++ and laspy is slow because it is Python. That is true for per-point work and misleading overall, because most real operations are dominated by decompression and I/O, which both libraries delegate to the same compiled code.

Three regimes show up consistently on site-scale clouds.

Pure reading. Both spend their time in LAZ decompression. laspy with the lazrs backend and PDAL land within about twenty percent of each other, with the difference depending more on chunk size than on library.

Standard filters. PDAL wins decisively, and not by a small factor. A statistical outlier filter over a hundred million points is a matter of minutes in PDAL and — implemented as a KD-tree query per chunk in NumPy — closer to an hour. The gap is not the language; it is that PDAL’s implementation has been tuned for a decade and a hand-rolled one has not.

Bespoke arithmetic. laspy wins, because the PDAL equivalent is either a Python filter stage — which pays the same Python cost plus marshalling overhead — or a custom C++ stage nobody is going to write for one project.

import time
from contextlib import contextmanager


@contextmanager
def timed(label: str, results: dict):
    """Minimal timing helper for comparing two implementations honestly."""
    start = time.perf_counter()
    try:
        yield
    finally:
        results[label] = time.perf_counter() - start


def compare_read(path: str) -> dict:
    """Read the same file both ways and time it, including decompression."""
    import json
    import laspy
    import pdal

    out: dict = {}
    with timed("laspy", out):
        las = laspy.read(path)
        _ = las.x.mean()
    with timed("pdal", out):
        pipe = pdal.Pipeline(json.dumps({"pipeline": [path]}))
        pipe.execute()
        _ = pipe.arrays[0]["X"].mean()
    out["ratio"] = out["pdal"] / out["laspy"]
    return out

The point of measuring on your own data is that the answer depends on the file. A LAZ with many small chunks behaves differently from one with few large ones, and a cloud with twelve extra dimensions costs more to marshal than a bare one. A five-minute benchmark on a representative file settles a question that otherwise recurs in every design discussion.

Operational considerations that outlast the technical ones

Library choice has consequences beyond performance, and on a long-lived pipeline they usually matter more.

Reproducibility. A PDAL pipeline is JSON — it can be stored in a run manifest, diffed between runs, and replayed exactly. A laspy loop is code, which is reproducible only to the extent that the code is versioned with the run. Teams that care about being able to explain a result a year later find the JSON pipeline a genuine advantage, and the same argument appears in writing a manifest-driven batch runner for ODM.

Debuggability. When something inexplicable happens, laspy is pure Python: put a breakpoint in, look at the array, step through. PDAL’s internals are compiled, and the debugging surface is log verbosity and pipeline serialisation. For a subtle data problem, laspy will find it faster even in a pipeline that otherwise uses PDAL.

Environment stability. PDAL pulls GDAL, PROJ, GEOS and a compiled LAZ implementation. Those are the same dependencies the rest of a geospatial stack needs, so on a conda-managed environment the marginal cost is small — but in a pip-only environment, or a slim container, it is the dominant packaging problem. laspy adds essentially nothing.

Team knowledge. A JSON pipeline is readable by someone who does not write Python; a NumPy loop is not. On a team where operations staff need to adjust a classification parameter without a developer, that asymmetry is worth more than any benchmark.

None of these points to one library. What they point to is being deliberate: choose PDAL as the processing spine because of streaming, breadth and reproducibility, and keep laspy in the toolkit for headers, verification and anything bespoke, rather than drifting into a pipeline where the choice was made by whichever import happened first.

Parameter deep-dive

Consideration PDAL laspy Practical effect
Peak memory, 400 M points ~2 GB streaming Whole file, or manual chunks Decides whether a job runs at all
Neighbourhood operations Built in, halo handled Write it yourself Weeks of difference on a classifier
Header write control Via forward and writer options Field by field laspy for delivery, PDAL for bulk
Reading a header alone Subprocess to pdal info Milliseconds, no decompression Matters in an indexing loop
Threading Per-filter threads option Whatever you write PDAL’s is usually enough
Extra dimensions extra_dims declaration Direct array access laspy is clearer for bespoke fields
Install footprint Large, conda-oriented Small, pip Shapes container images
Debuggability JSON, opaque internals Pure Python, steppable laspy when something is unexplained

Verification and output inspection

A useful exercise when adopting either library is to prove they agree, on a small file, before trusting a pipeline built on one of them.

import json
import subprocess

import laspy
import numpy as np
import pdal


def cross_check(path: str, tol: float = 1e-6) -> dict:
    """Read the same file both ways and require the arrays to match."""
    las = laspy.read(path)
    a = np.column_stack([las.x, las.y, las.z])

    pipe = pdal.Pipeline(json.dumps({"pipeline": [path]}))
    pipe.execute()
    arr = pipe.arrays[0]
    b = np.column_stack([arr["X"], arr["Y"], arr["Z"]])

    if a.shape != b.shape:
        return {"agree": False, "reason": f"counts differ: {a.shape} vs {b.shape}"}
    order = np.lexsort((a[:, 2], a[:, 1], a[:, 0]))
    order_b = np.lexsort((b[:, 2], b[:, 1], b[:, 0]))
    worst = float(np.abs(a[order] - b[order_b]).max())
    return {"agree": worst < tol, "worst_difference": worst}

Sorting both before comparing is necessary because the two libraries do not guarantee the same point order after a read, and a naive element-wise comparison fails on a file where nothing is wrong.

A decision procedure

For a new pipeline, four questions settle the choice in about a minute.

Will any single input exceed available memory? If yes, PDAL is the spine, because hand-written chunking with correct halo handling for neighbourhood operations is a multi-week project with a long tail of edge cases. If no, either works and the remaining questions decide.

Is the operation already a PDAL stage? Classification, outlier removal, height above ground, reprojection, gridding, COPC writing and format conversion all are. Reimplementing any of them is a bad trade regardless of preference.

Does the job need precise header control or bespoke per-point arithmetic? Then laspy handles that part, whatever else the pipeline uses. Mixing is normal and costs nothing — they read and write the same files.

Who will maintain it? A JSON pipeline that an operations technician can adjust is worth more on a long-running service than a marginally faster Python loop only its author understands.

Written out as code, the policy is short enough to live in a project’s conventions document, which is where it belongs — the point is that the decision is made once and recorded, rather than re-argued on every new module.

Where each library is the obvious choice Three rows. PDAL is the obvious choice for declarative multi-stage processing, for anything that must stream a file larger than memory, and for format breadth including E57, COPC and database sources. laspy is the obvious choice for direct array access to point attributes, for NumPy and SciPy work over a cloud that fits in memory, and for writing a custom algorithm that does not decompose into pipeline stages. Both together is the common production answer, with PDAL reducing the cloud to a working subset and laspy operating on it. PDAL declarative multi-stage pipelines, streaming past memory, format breadth laspy direct array access, NumPy and SciPy work, custom algorithms in memory both, in sequence PDAL reduces to a working subset; laspy does the science on it The production answer is usually the third row, not an argument about the first two.

Figure 3 — The comparison resolves into a division of labour.

Troubleshooting

A PDAL pipeline that ran before now exhausts memory. A stage was added that cannot stream — filters.smrf and most neighbourhood filters are not streamable. Check with pdal info --pipeline-serialization and tile instead.

laspy reads a LAZ file but PDAL cannot. Usually a LAZ variant or a compressor version mismatch. Convert with laspy, then process with PDAL.

The two libraries report different point counts. One is respecting a withheld or classification flag the other is not. Compare the raw counts from the headers rather than from processed arrays.

PDAL will not install alongside an existing GDAL. This is the most common operational cost of PDAL. Use conda-forge for the whole geospatial stack, or containerise it separately.

A laspy loop is far slower than the equivalent PDAL pipeline. Expected, and usually acceptable for a one-off. If it is not, the operation probably exists as a PDAL stage already, and reaching for it is cheaper than optimising the loop.

A pipeline works on one machine and fails on another. Almost always a PROJ data directory difference rather than a library one. Both PDAL and laspy resolve coordinate reference systems through PROJ, and a missing or differently versioned grid file changes the result without changing the code.

Point Cloud Processing & 3D Deliverables