Filtering Points with filters.expression
TL;DR: {"type": "filters.expression", "expression": "Classification == 2 && Z > 100 && ReturnNumber == NumberOfReturns"} keeps points for which the boolean expression is true. It supports ==, !=, <, >, <=, >=, &&, ||, !, parentheses and arithmetic on dimensions — which filters.range cannot do — and the same syntax powers the where option available on most PDAL filters.
# Context and Motivation
This guide is part of Pipeline Filtering Logic. For years, filters.range was the way to subset points, and its compact syntax — "Classification[2:2],Z[100:]" — is still fine for simple bounds. It becomes awkward quickly: ranges on the same dimension are OR-ed while ranges on different dimensions are AND-ed, there is no arithmetic, and conditions involving two dimensions (last return, for example, means ReturnNumber == NumberOfReturns) cannot be expressed. filters.expression accepts an ordinary boolean expression instead, and the same expression language appears in the where option that lets any filter operate on a subset while passing the rest through untouched.
# Prerequisites and Assumptions
- PDAL 2.2 or newer. The expression syntax and the
whereoption arrived in the 2.x series; check withpdal --options filters.expression. - Dimension names spelled exactly as PDAL reports them (
pdal info --schema); they are case-sensitive. - An idea of which points you need at each point in the pipeline — expressions are cheap, but filtering early saves every later stage work.
# Step-by-Step Implementation
# Step 1 — Write the condition as a boolean
Think in terms of “keep if”. Classification == 2 || Classification == 9 keeps ground and water.
# Step 2 — Use dimension arithmetic when needed
Expressions can compute: (Z - HeightAboveGround) < 250 keeps points whose ground elevation is below 250 m, useful after filters.hag_nn.
# Step 3 — Prefer where for partial processing
To apply a filter to only some points while keeping the rest, add "where" to that filter instead of splitting the pipeline. filters.assign with where is the common case.
# Step 4 — Test on a small tile
Run with --verbose 4 or read pipeline.log to see how many points passed each stage.
# Step 5 — Keep expressions readable
Break complex conditions into two stages rather than one long expression; each stage’s point count then documents what it removed.
# Complete Working Example
{
"pipeline": [
"tiles/t_0431.laz",
{ "type": "filters.expression",
"expression": "Classification != 7 && Classification != 18" },
{ "type": "filters.hag_nn", "count": 2 },
{ "type": "filters.expression",
"expression": "ReturnNumber == NumberOfReturns && HeightAboveGround > 2.0 && HeightAboveGround < 60.0" },
{ "type": "filters.assign",
"value": ["UserData = 1 WHERE Intensity > 3000"] },
{ "type": "writers.las", "filename": "out/t_0431_last_elevated.laz",
"extra_dims": "HeightAboveGround=float", "minor_version": 4, "dataformat_id": 6 }
]
}The Python equivalent, with a count after each filter to see what each removed:
import json
import pdal
base = ["tiles/t_0431.laz"]
steps = [
{"type": "filters.expression", "expression": "Classification != 7 && Classification != 18"},
{"type": "filters.hag_nn", "count": 2},
{"type": "filters.expression",
"expression": "ReturnNumber == NumberOfReturns && HeightAboveGround > 2.0 && HeightAboveGround < 60.0"},
]
for i in range(len(steps) + 1):
n = pdal.Pipeline(json.dumps({"pipeline": base + steps[:i]})).execute()
label = steps[i - 1].get("expression", steps[i - 1]["type"]) if i else "read"
print(f"{n:>12,} {label}")# A Small Library of Useful Expressions
Most production pipelines reuse the same handful of conditions. Keeping them as named constants in your pipeline-building code — rather than retyping them — avoids subtle differences between projects.
| Purpose | Expression |
|---|---|
| Drop low and high noise | Classification != 7 && Classification != 18 |
| Ground and water only | Classification == 2 || Classification == 9 |
| First returns (surface) | ReturnNumber == 1 |
| Last and single returns | ReturnNumber == NumberOfReturns |
| Single returns only | NumberOfReturns == 1 |
| Vegetation above 2 m | Classification >= 3 && Classification <= 5 && HeightAboveGround > 2 |
| Near-nadir returns | ScanAngleRank >= -15 && ScanAngleRank <= 15 |
| Exclude withheld points | Withheld == 0 |
| One flightline | PointSourceId == 1104 |
| Plausible elevations | Z > -100 && Z < 4500 |
Two of these deserve a note. The scan-angle condition uses ScanAngleRank, which is what PDAL exposes for older point formats; for PDRF 6 and later, PDAL provides the finer-grained scan angle under the same dimension name in degrees, so check the values in your data with pdal info --stats before relying on the bounds. And the withheld condition requires a point format and reader that expose the flag as a dimension; on older formats withheld points may be carried in the classification bits instead.
# Key Parameter Table
| Element | Syntax | Example |
|---|---|---|
| Comparison | == != < > <= >= |
Intensity >= 1500 |
| Logical | && || ! |
!(Classification == 2) |
| Grouping | ( ) |
(Classification == 3 || Classification == 4) && Z > 10 |
| Arithmetic | + - * / |
Z - HeightAboveGround < 250 |
| Dimension compare | dim op dim | ReturnNumber == NumberOfReturns |
where option |
on most filters | "where": "Classification == 1" |
# Verification
- Count after each stage, as in the Python loop, and sanity-check the numbers against expectations.
- Invert and add. Running the expression and its negation (
!(...)) should produce counts that sum to the input count. - Spot check values. Read the output and assert the condition holds for every point.
p = pdal.Pipeline(json.dumps({"pipeline": ["out/t_0431_last_elevated.laz"]})); p.execute()
a = p.arrays[0]
assert (a["ReturnNumber"] == a["NumberOfReturns"]).all()
assert ((a["HeightAboveGround"] > 2.0) & (a["HeightAboveGround"] < 60.0)).all()# Gotchas and Edge Cases
Shell quoting. && and || mean something to the shell. When passing an expression on the command line as an override, quote the whole argument in single quotes.
Floating point equality. Z == 100.5 rarely matches because Z is a double reconstructed from scaled integers. Use ranges for continuous dimensions and equality for integer ones such as Classification and ReturnNumber.
Unknown dimensions fail late. An expression that names a dimension not yet in the table — HeightAboveGround before filters.hag_nn — fails when the stage runs. Order stages so dimensions exist before they are referenced.
where is not a filter. A where clause on a filter restricts what that filter operates on; it does not remove non-matching points. Use filters.expression when you want points gone.
# Frequently Asked Questions
What is the difference between filters.range and filters.expression?
filters.range uses compact bound syntax with implicit OR within a dimension and AND across dimensions. filters.expression takes an explicit boolean expression with logical operators, parentheses, arithmetic and comparisons between dimensions, which covers far more cases.
How do I select last returns in PDAL?
Use an expression comparing two dimensions: ReturnNumber == NumberOfReturns. filters.range cannot express that directly; filters.returns with the groups option last and only is another route.
Does a where clause remove points?
No. It limits which points the filter acts on and passes the others through unchanged. To remove points, use filters.expression or filters.range.
Are expressions slow on large tiles?
No. They are evaluated per point in compiled code and stream, so the cost is small compared with neighbourhood stages. Filtering early usually makes the whole pipeline faster.
# Related
- Pipeline Filtering Logic — filter costs and ordering
- Removing Noise Classes Before Processing — the most common expression
- Cropping a Point Cloud to a Polygon Boundary — spatial rather than attribute filtering
- Assigning Classification with Conditional filters.assign — expressions in WHERE clauses
- Which PDAL Filters Break Streaming Mode — expressions stream; neighbourhood filters do not