Reading and Writing LAS VLRs with PDAL
TL;DR: Read variable length records with pdal info --metadata or laspy’s header.vlrs, write them with forward: "all" on the writer to preserve what came in, and add your own with writers.las vlrs — a JSON array of records with user_id, record_id and base64 data.
# Context and Motivation
This guide is part of LAS/LAZ File Structure, which describes the binary layout. Variable length records are the extension mechanism inside that layout: a list of typed blobs sitting between the public header and the point records, where everything the fixed header cannot express ends up.
That includes things you cannot afford to lose. The coordinate reference system lives in a VLR. So does the extra-bytes descriptor that names and types every custom dimension in the file. So do classification lookup tables, flight-line metadata and whatever the vendor’s processing software chose to record about how the cloud was produced. A conversion that drops VLRs produces a file that opens perfectly and has forgotten what its own dimensions are called — which is why forward: "all" appears in nearly every writer configuration on this site.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.4+ for vlrs on writers.las |
laspy |
2.x, for inspecting records outside a pipeline |
| A file with records worth keeping | most delivered tiles have at least the projection VLR |
| base64 | VLR payloads in pipeline JSON are base64-encoded bytes |
# Step-by-Step Implementation
# Step 1 — See what is there
pdal info tile_0431.laz --metadata | python -m json.tool | grep -A4 vlrlaspy gives a friendlier view when you want to look at payloads:
import laspy
with laspy.open("tile_0431.laz") as fh:
for vlr in fh.header.vlrs:
print(vlr.user_id, vlr.record_id, len(vlr.record_data), vlr.description)# Step 2 — Preserve what you did not create
{"type": "writers.las", "filename": "out.laz", "forward": "all", "extra_dims": "all"}forward copies header fields and records from the source; without it a conversion is a quiet amputation.
# Step 3 — Add your own record
{
"type": "writers.las",
"filename": "out.laz",
"vlrs": [{
"description": "processing run id",
"user_id": "PYLIDAR",
"record_id": 1200,
"data": "cnVuPTIwMjYtMDgtMDdUMDk6MTQ6MjJa"
}]
}Choose a user_id that is yours and a record_id above 1000 so it cannot collide with the reserved ranges.
# Step 4 — Prefer an EVLR for anything large
Records before the point block are limited to 65,535 bytes. Anything bigger — a full processing log, a large lookup table — belongs in an extended record after the points, which LAS 1.4 supports and 1.2 does not.
# Complete Working Example
"""Copy a tile, preserving its VLRs and stamping a provenance record of our own."""
from __future__ import annotations
import base64
import json
from pathlib import Path
import laspy
import pdal
def provenance_vlr(run_id: str, pipeline_sha: str) -> dict:
payload = json.dumps({"run_id": run_id, "pipeline_sha256": pipeline_sha}).encode()
return {
"description": "pythonlidar provenance",
"user_id": "PYLIDAR",
"record_id": 1200,
"data": base64.b64encode(payload).decode("ascii"),
}
def convert(src: Path, dst: Path, run_id: str, pipeline_sha: str) -> int:
spec = json.dumps({"pipeline": [
{"type": "readers.las", "filename": str(src)},
{"type": "filters.range", "limits": "Classification![7:7]"},
{"type": "writers.las", "filename": str(dst), "compression": "laszip",
"minor_version": 4, "dataformat_id": 6,
"forward": "all", "extra_dims": "all",
"vlrs": [provenance_vlr(run_id, pipeline_sha)]},
]})
return pdal.Pipeline(spec).execute()
def audit(path: Path) -> list[tuple[str, int, int]]:
with laspy.open(str(path)) as fh:
return [(v.user_id, v.record_id, len(v.record_data)) for v in fh.header.vlrs]
if __name__ == "__main__":
src, dst = Path("tile_0431.laz"), Path("tile_0431_clean.laz")
before = audit(src)
convert(src, dst, run_id="2026-08-07T09:14:22Z", pipeline_sha="9f2c…")
after = audit(dst)
lost = {(u, r) for u, r, _ in before} - {(u, r) for u, r, _ in after}
assert not lost, f"records dropped by the conversion: {sorted(lost)}"
print(json.dumps({"before": before, "after": after}, indent=2))# Key Parameter Table
| Option | Stage | Meaning |
|---|---|---|
forward |
writers.las |
Which header fields and records to copy from the source; all is the safe default |
vlrs |
writers.las |
Array of records to add, each with user_id, record_id, description, base64 data |
extra_dims |
writers.las |
Custom dimensions to write, which also writes the extra-bytes descriptor record |
minor_version |
writers.las |
Must be 4 for extended records after the point block |
record_id |
per record | Below 1000 is reserved under LASF_Spec; use your own namespace above it |
# Verification
Nothing was dropped. The assertion in the example compares the record set before and against after, keyed by (user_id, record_id).
The projection record survived. pdal info --metadata should still report a spatial reference on the output. Losing it is the most consequential VLR failure and the easiest to miss — see coordinate reference systems.
Your record round-trips. Read it back with laspy, base64-decode, and parse. A record that writes without error and cannot be decoded is worse than no record.
# Gotchas and Edge Cases
forward: "all" does not forward everything. It copies what the writer can legitimately carry into the target version. Writing a 1.4 file down to 1.2 drops extended records with no error, because there is nowhere to put them.
Record identifiers are only unique within a user identifier. Two vendors may both use record 1001 for different things. Always match on the pair.
LAZ compresses the points, not the records. A large VLR is stored uncompressed, so a megabyte of embedded metadata is a megabyte in every copy of the tile.
# Frequently Asked Questions
What is actually stored in a VLR?
Anything the fixed header cannot express: the coordinate reference system as OGC WKT or GeoTIFF keys, the extra-bytes descriptor that names and types custom dimensions, classification lookup tables, and vendor processing history. Losing them produces a file that opens fine and no longer knows what its own dimensions are called.
Why did my conversion lose its VLRs?
Because the writer was not told to forward them. Without forward set, writers.las builds a header from what the pipeline computed and carries nothing else across. Setting forward to all, together with extra_dims all, is the safe default for any archival conversion.
What is the difference between a VLR and an EVLR?
Position and size. Variable length records sit between the header and the point block and are capped at 65,535 bytes of payload. Extended records sit after the points, have a 64-bit length field, and exist only in LAS 1.4 — so writing a 1.4 file down to 1.2 discards them.
How do I choose a record id that will not collide?
Use a user id that belongs to you rather than LASF_Spec or LASF_Projection, and a record id above 1000. Identifiers are only unique within a user id, so the pair is what matters and matching on the number alone will eventually surprise you.
# Related
- LAS/LAZ File Structure — the binary layout these records live inside
- Converting LAS to LAZ with PDAL — the conversion where forward matters most
- How to Parse LAS Headers with Python — reading the fixed header that precedes the records
- Coordinate Reference Systems — the record whose loss costs the most
- Metadata and Header Sync — keeping the header honest across a pipeline