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.

The records between the header and the points A LAS file laid out left to right: the public header, then a run of variable length records, then the point records, then extended VLRs after them. The VLR block is expanded to show four common records: the OGC WKT coordinate system, the extra bytes descriptor, a classification lookup table and a vendor-specific processing record. public header variable length records point records EVLRs LASF_Projection · 2112 — OGC WKT LASF_Spec · 4 — extra bytes descriptor LASF_Spec · 0 — classification lookup vendor id · 1000+ — processing history a record is identified by the pair (user_id, record_id) — the user_id namespaces the number, so 4 means one thing under LASF_Spec and something else entirely under a vendor’s own identifier.

# 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

bash
pdal info tile_0431.laz --metadata | python -m json.tool | grep -A4 vlr

laspy gives a friendlier view when you want to look at payloads:

python
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

json
{"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

json
{
  "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

python
"""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))
One record, six fields, 54 bytes of header The fixed part of a variable length record: two reserved bytes, a sixteen-byte user identifier, a two-byte record identifier, a two-byte payload length, and a thirty-two byte description. The payload follows, up to 65,535 bytes. Identity is the user identifier and record identifier together, not the number alone. reserved 2 B user_id 16 B record_id 2 B record_length_ 2 B description 32 B payload up to 65,535 B the record header, then the payload — repeated once per record identity is this pair — user_id namespaces record_id an extended record uses the same fields with a 64-bit length, and lives after the point block instead of before it, which is why LAS 1.2 cannot carry one at all.

# 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.

forward: all does not mean everything survives Five things a file carries, checked against two conversion targets. Writing to LAS 1.4 keeps all five. Writing down to LAS 1.2 keeps the points and the projection record, drops the extra-bytes descriptor and any extended records, and truncates classification codes above 31 — all without raising an error. → LAS 1.4 → LAS 1.2 point records kept kept projection VLR kept kept extra bytes VLR kept dropped EVLRs kept dropped classification > 31 kept truncated every red cell is a silent loss — the conversion exits zero and the file opens without complaint

# 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.