Run the PDAL Algorithms from PyQGIS

QGIS wraps PDAL as an ordinary Processing provider, which means every LiDAR operation is a processing.run() call with a dictionary of parameters — the same shape as a buffer or a clip. That is a much better deal than it sounds. You get progress reporting, cancellation, temporary output handling and a stable identifier that survives version upgrades, none of which you get from shelling out to the pdal binary yourself.

This recipe belongs to Point Cloud & LiDAR Workflows in PyQGIS. It covers finding out what your build actually ships, the parameter conventions that differ from the native: algorithms, the expression syntax trap, and how to chain several steps without writing intermediate files to disk.

The pdal: family, grouped by what it is forThe wrapped PDAL algorithms fall into four groups. Inspection reports what is in a file. Conversion changes format, projection or index. Reduction cuts the number of points by filtering, thinning, clipping or tiling. Export turns points into raster or vector products that the rest of QGIS understands.Four jobs, eighteen algorithmsinspectpdal:infopdal:boundarypdal:densityanswers what isin the file, cheaplyconvertpdal:convertformatpdal:createcopcpdal:reprojectpdal:assignprojectionrun once, keep the resultreducepdal:filterpdal:clippdal:thinbyradiuspdal:tile · pdal:mergethe expensive stepsexportpdal:exportrasterpdal:exportrastertinpdal:exportvectorhands over to therest of QGISa typical pipeline touches one from each group, in that orderinfo → createcopc → clip and filter → exportraster

Prerequisites

Find out what your build ships

Algorithm availability varies with the QGIS version and, occasionally, with how a distribution packaged PDAL. Rather than trusting a list in an article, ask the registry.

from qgis.core import QgsApplication

registry = QgsApplication.processingRegistry()
for alg in registry.algorithms():
    if alg.id().startswith("pdal:"):
        print(f"{alg.id():34} {alg.displayName()}")

Breakdown: algorithms() returns every registered algorithm across every provider, so filtering on the identifier prefix is the cheapest way to enumerate one family. If this prints nothing, Processing has not been initialised (in a standalone script you must call Processing.initialize() yourself) or the build has no PDAL provider. To go one level deeper on a single algorithm, registry.algorithmById("pdal:exportraster").parameterDefinitions() lists every parameter with its name, type and default — which is more reliable than any documentation, because it is the code.

The parameter conventions that differ

Three conventions in this family will catch you out if you are used to the native: algorithms.

INPUT takes a path or a layer. Most native: algorithms want a QgsMapLayer or a source string; the PDAL ones accept a QgsPointCloudLayer, a file path, or a .vpc manifest, and resolve all three to a file on disk. That is convenient, and it means a layer with a subset string applied has that subset ignored — the algorithm reads the file, not your view of it.

Filter expressions are PDAL syntax. This is the single biggest trap in the family.

Two expression languages, one attribute nameA point cloud layer's subset string is QGIS expression syntax with single equals, IN lists and AND. A PDAL algorithm's filter expression uses doubled equals, doubled ampersands and no IN operator. The same attribute names appear in both, which is why the mistake is so easy to make and so hard to see.The same filter, written two different wayslayer.setSubsetString(...)QGIS expression syntaxClassification = 2Classification IN (2, 9)Z > 40 AND Intensity < 900a view — the file is unchangedFILTER_EXPRESSIONPDAL expression syntaxClassification == 2Classification == 2 || Classification == 9Z > 40 && Intensity < 900decides what is written to the outputa single equals in the right-hand column matches nothing and raises nothing

Outputs are paths, and the extension chooses the driver. OUTPUT on pdal:exportraster writes whatever GDAL infers from the extension; on pdal:filter it writes a point cloud whose format follows the extension too, so .copc.laz gives you an indexed result and .laz gives you an unindexed one. TEMPORARY_OUTPUT works but produces a file in the temporary folder rather than an in-memory layer, because there is no such thing as a memory point cloud.

A single call, with feedback

import processing
from qgis.core import QgsProcessingFeedback


class Progress(QgsProcessingFeedback):
    def setProgress(self, value):
        print(f"\r{value:5.1f}%", end="", flush=True)

    def pushInfo(self, info):
        print(f"\n{info}")


feedback = Progress()

result = processing.run("pdal:exportraster", {
    "INPUT": "/data/indexed/survey.vpc",
    "RESOLUTION": 1.0,
    "TILE_SIZE": 1000,
    "FILTER_EXPRESSION": "Classification == 2",
    "OUTPUT": "/data/output/dtm.tif",
}, feedback=feedback)

print("\nwrote", result["OUTPUT"])

Breakdown: Subclassing QgsProcessingFeedback is the supported way to see inside a long job; the base class does nothing, and the default processing.run() feedback prints to the QGIS log rather than your console. pushInfo carries the messages PDAL itself emits, including the stage names, which is how you find out that a job is spending its time reprojecting rather than gridding. The same object exposes cancel() and isCanceled(), which is what makes a plugin's stop button work — see reporting progress and cancelling a processing algorithm.

Chaining without littering the disk

A real pipeline is several steps, and writing every intermediate to a named file leaves you with a folder of step1.laz, step2.laz files that nobody dares delete. Chaining through TEMPORARY_OUTPUT keeps the mess inside the session.

Only the last step gets a nameReproject, clip and thin each write a temporary point cloud that is fed directly into the next stage. Only the final export names a file on disk, so the intermediate products are cleaned up with the session and never accumulate in the output folder.Four stages, one file at the endpdal:reprojectinto the project CRSpdal:clipto the study boundarypdal:thinbyradius0.4 m minimum spacingpdal:exportraster/data/output/dtm.tifTEMPORARY_OUTPUT — three files in the session temp folder, gone at exitthe only artefactthin before exporting, not after — the grid is what costs, and it costs per point

reprojected = processing.run("pdal:reproject", {
    "INPUT": "/data/indexed/survey.vpc",
    "CRS": "EPSG:27700",
    "OUTPUT": "TEMPORARY_OUTPUT",
}, feedback=feedback)["OUTPUT"]

clipped = processing.run("pdal:clip", {
    "INPUT": reprojected,
    "OVERLAY": "/data/vector/study_area.gpkg",
    "OUTPUT": "TEMPORARY_OUTPUT",
}, feedback=feedback)["OUTPUT"]

processing.run("pdal:exportraster", {
    "INPUT": clipped,
    "RESOLUTION": 1.0,
    "FILTER_EXPRESSION": "Classification == 2",
    "OUTPUT": "/data/output/dtm.tif",
}, feedback=feedback)

Breakdown: Each run() returns a dictionary whose OUTPUT is the path Processing chose, and passing that straight into the next call is all "chaining" means here — there is no pipeline object to build. Order matters for cost rather than correctness: clipping early throws away the points you were never going to use, so the reprojection and the grid both do less work. The same structure, and the same reasoning about what to do first, appears in chaining a buffer and a clip.

Failing usefully across many tiles

A survey-wide job is a loop, and the interesting design question is what happens when tile forty-one is corrupt. The two useless answers are stopping the whole run and swallowing the error silently; the useful one is recording the failure and carrying on, so a four-hour job produces thirty-nine good outputs and a list of two to look at.

from qgis.core import QgsProcessingException

failures = []

for tile in tiles:
    target = os.path.join(out_dir, os.path.basename(tile).replace(".copc.laz", ".tif"))
    if os.path.exists(target):
        continue
    try:
        processing.run("pdal:exportraster", {
            "INPUT": tile,
            "RESOLUTION": 1.0,
            "FILTER_EXPRESSION": "Classification == 2",
            "OUTPUT": target,
        }, feedback=feedback)
    except QgsProcessingException as error:
        failures.append((tile, str(error)))

for tile, message in failures:
    print("FAILED", os.path.basename(tile), "-", message.splitlines()[0])

Breakdown: QgsProcessingException is the only exception type these algorithms raise for an operational failure — a missing input, an unwritable output, a PDAL stage error — so catching it specifically leaves genuine programming errors (a typo in a parameter name raises a plain KeyError from Processing's own validation) free to crash the script, which is what you want. The os.path.exists skip at the top makes the loop restartable: re-running after a crash picks up where it stopped instead of redoing three hours of work. Taking only the first line of the message keeps the summary readable, since PDAL errors often carry a multi-line stage trace.

One caveat specific to this family: a failed run may still have created a partial output file. Where that matters, write to a temporary name and rename on success, so a half-written raster never looks like a finished one to the next run's existence check.

Reading pdal:info as data

pdal:info is the diagnostic that resolves most point cloud confusion, and it returns a JSON file rather than a layer.

import json

info_path = processing.run("pdal:info", {
    "INPUT": "/data/indexed/tile_0345.copc.laz",
    "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]

with open(info_path) as handle:
    info = json.load(handle)

print(info.get("stats", {}).get("statistic", [])[:3])

Breakdown: The exact shape of the JSON follows PDAL's own --info output and has grown fields between PDAL releases, so read it defensively with .get() rather than indexing straight in. What you are usually after is the per-attribute statistics block and the SRS block; logging both at the start of a batch run turns "the output looks wrong" into a diff between two runs.

QGIS version compatibility

pdal: algorithms appeared in 3.32 and the identifiers and parameter names used here are unchanged through 3.44. Two differences are worth knowing: virtual point cloud inputs need 3.34 or newer, and some builds before 3.34 name the virtual-cloud builder differently, which is exactly why the registry enumeration above is the right way to check rather than assuming. If algorithmById("pdal:info") returns None, the build has no PDAL provider and no amount of parameter fiddling will help.

Troubleshooting

  • The output raster is entirely nodata. The filter expression matched nothing — check for = where PDAL wants ==, and check the classes really exist with statistics().classesOf("Classification").
  • processing.run raises QgsProcessingException: Unknown algorithm. No PDAL provider in this build, or Processing was never initialised in a standalone script.
  • The job ignores the subset string I set on the layer. Expected: these algorithms read the file, not the layer view. Put the condition in FILTER_EXPRESSION instead.
  • A clip returns an empty cloud. The overlay and the cloud are in different CRSs. These algorithms do not reproject the overlay for you.
  • Memory use climbs until the process is killed. Lower TILE_SIZE so the algorithm works in smaller chunks, and thin before you grid.
  • Progress jumps from 0 to 100 with a long pause between. Some PDAL stages report no intermediate progress. pushInfo output still tells you which stage is running.

Conclusion

Enumerate the family from the registry rather than from memory, remember that filter expressions are PDAL's language and not QGIS's, chain through TEMPORARY_OUTPUT so only the final product gets a name, and always pass a feedback object on jobs that will take more than a few seconds. With those four habits the PDAL algorithms behave like every other part of Processing, which is the whole point of them being wrapped this way.

Frequently Asked Questions

Can I run a raw PDAL pipeline JSON from PyQGIS? Not through the Processing provider — it builds its own pipelines. For a stage the wrapped algorithms do not expose, run the pdal executable with subprocess, accepting that you lose progress and cancellation. Keep that on the edge of your code rather than in the middle of it.

Why is pdal:exportrastertin slower than pdal:exportraster? It triangulates the points and interpolates across the facets rather than binning them into cells, which is far more work and produces a continuous surface with no holes. Use it when ground returns are sparse and the binned output is full of gaps.

Do these algorithms run in the Model Designer and the batch dialog? Yes — they are ordinary Processing algorithms, so they appear in models and batch runs, and a model containing them can be executed from Python exactly as described in running a graphical model from Python.

How do I run these on a machine with no display? The same way as any other algorithm, either through a headless QGIS application or qgis_process. Nothing in the PDAL family needs a canvas — see using the qgis_process command line runner.