Converting LAS to LAZ with PDAL

TL;DR: Point a PDAL pipeline at your .las file and write it with writers.las using compression: true, a .laz filename, forward: "all", and extra_dims: "all" — the header, every VLR, the point format, and any custom dimensions carry through losslessly, and the round trip back to LAS is byte-identical.

# Context and Motivation

This guide is part of LAS/LAZ File Structure, which lays out the binary anatomy that compression has to preserve. Conversion sounds trivial — swap an extension — but the details that get dropped along the way are what turn a routine compression into a corrupted deliverable.

LAZ is LAS with LASzip compression applied to the point records; the two formats share an identical header structure and point layout. Because the codec is lossless, the appeal is obvious: a survey tile shrinks to a fraction of its uncompressed size with no loss of precision, which is why almost every archive, download portal, and cloud bucket stores LiDAR as LAZ. The trap is that a careless conversion preserves the coordinates while quietly discarding the things that make the file usable — the coordinate reference system in a VLR, the custom per-point dimensions a classifier wrote into the Extra Bytes record, or the exact scale and offset that downstream tools expect. PDAL’s writers.las handles the compression itself in one flag, but faithful conversion depends on two more settings that tell it to carry the full header and every extra dimension across the boundary. Get those right and the LAZ is a perfect stand-in for the LAS; get them wrong and the loss is invisible until something downstream fails to find its CRS or its custom attribute.

What carries across a LAS to LAZ conversion with forward:all and extra_dims:all On the left a LAS file is drawn as a stack of four parts: Public Header Block, VLRs including the CRS record, point data records, and an Extra Bytes dimension. An arrow labelled writers.las with compression true, forward all, and extra_dims all points to a LAZ file on the right with the same four parts, where only the point data block is shown compressed. A note states the codec is lossless and the round trip back to LAS is byte-identical. source.las Public Header Block VLRs (CRS / WKT2) point data records uncompressed Extra Bytes dim writers.las compression: true forward: all extra_dims: all output.laz Public Header Block VLRs (CRS / WKT2) point data records LASzip compressed Extra Bytes dim lossless — round trip back to LAS is byte-identical only point records change size; metadata is copied

# Prerequisites and Assumptions

Requirement Detail
PDAL 2.4+ with LASzip compiled in (`pdal --drivers
Python pdal bindings 3.x (pip install pdal)
laspy (verification) 2.4+ for header and point comparison
Input any LAS 1.2–1.4 file; extra dimensions declared in an Extra Bytes VLR if present
Disk space for both source and output during conversion

If your source LAS has an empty or incorrect CRS, fix it before compressing — LASzip faithfully preserves whatever VLR is present, including a wrong one. The Coordinate Reference Systems guide covers repair. For header edge cases the conversion must carry across intact, the sibling How to Parse LAS Headers with Python guide is the reference.

# Step-by-Step Implementation

# Step 1 — Write the compression pipeline

A conversion pipeline is a reader and a writer, with the writer doing the compression. The .laz extension alone is not enough — set compression explicitly so intent is clear and version-independent.

json
{
  "pipeline": [
    "input.las",
    {
      "type": "writers.las",
      "filename": "output.laz",
      "compression": true,
      "forward": "all",
      "extra_dims": "all"
    }
  ]
}

Three settings do the real work. compression: true triggers LASzip. forward: "all" copies the source header — scale, offset, point format, creation date, and every VLR including the CRS — rather than letting PDAL recompute them. extra_dims: "all" carries any custom per-point dimensions declared in the Extra Bytes VLR into the output.

# Step 2 — Preserve the point format and header exactly

By default PDAL may pick a point format that fits the dimensions it sees, which can silently downgrade a format 6 file. forward: "all" pins the output format to match the source, so a format 7 file with RGB stays format 7. This matters because the point format ID governs which dimensions exist at all, as the LAS/LAZ File Structure reference details. If you need to force a specific version regardless of source, add minor_version:

json
{
  "type": "writers.las",
  "filename": "output.laz",
  "compression": true,
  "forward": "all",
  "extra_dims": "all",
  "minor_version": 4
}

# Step 3 — Batch a directory of tiles

Production surveys arrive as directories of tiles. Loop the same pipeline over every .las, writing a matching .laz beside it. Each file is an independent conversion, so failures are isolated per tile.

python
import json
from pathlib import Path

import pdal

def convert_las_to_laz(src: Path, dst: Path) -> int:
    """Compress one LAS to LAZ losslessly; return the point count written."""
    pipeline = {
        "pipeline": [
            str(src),
            {
                "type": "writers.las",
                "filename": str(dst),
                "compression": True,
                "forward": "all",
                "extra_dims": "all",
            },
        ]
    }
    return pdal.Pipeline(json.dumps(pipeline)).execute()

# Step 4 — Verify parity before deleting the source

Never delete the LAS until you have confirmed the LAZ holds the same points and header. The verification step below compares point count and key header fields with laspy.

# Complete Working Example

Save this as las_to_laz.py. It converts an entire directory of LAS tiles to LAZ, then verifies each output against its source for point-count and header parity, refusing to report success on any mismatch.

python
#!/usr/bin/env python3
"""
las_to_laz.py
Losslessly convert a directory of LAS tiles to LAZ with PDAL, then verify parity.

Usage:
    python las_to_laz.py /data/las_in /data/laz_out

Requirements:
    pip install pdal laspy
    PDAL 2.4+ with LASzip
"""

import json
import sys
from pathlib import Path

import laspy
import pdal


def convert_las_to_laz(src: Path, dst: Path) -> int:
    """Compress one LAS to LAZ; preserve header, VLRs, and extra dimensions."""
    pipeline = {
        "pipeline": [
            str(src),
            {
                "type": "writers.las",
                "filename": str(dst),
                "compression": True,
                "forward": "all",
                "extra_dims": "all",
            },
        ]
    }
    count = pdal.Pipeline(json.dumps(pipeline)).execute()
    if count == 0:
        raise RuntimeError(f"Converted 0 points from {src} — source may be empty.")
    return count


def verify_parity(src: Path, dst: Path) -> None:
    """Assert point count and core header fields match between LAS and LAZ."""
    with laspy.open(src) as a, laspy.open(dst) as b:
        ha, hb = a.header, b.header

        assert ha.point_count == hb.point_count, (
            f"Point count differs: {ha.point_count:,} vs {hb.point_count:,}"
        )
        assert ha.point_format.id == hb.point_format.id, (
            f"Point format changed: {ha.point_format.id} -> {hb.point_format.id}"
        )
        assert ha.scales.tolist() == hb.scales.tolist(), "Scale factors differ"
        assert ha.offsets.tolist() == hb.offsets.tolist(), "Offsets differ"

        # CRS VLR must survive the conversion
        src_has_crs = any(v.record_id in (2112, 34735) for v in ha.vlrs)
        dst_has_crs = any(v.record_id in (2112, 34735) for v in hb.vlrs)
        assert src_has_crs == dst_has_crs, "CRS VLR presence changed during conversion"


def main() -> None:
    if len(sys.argv) != 3:
        print("Usage: python las_to_laz.py <las_dir> <laz_dir>")
        sys.exit(1)

    src_dir, dst_dir = Path(sys.argv[1]), Path(sys.argv[2])
    dst_dir.mkdir(parents=True, exist_ok=True)

    tiles = sorted(src_dir.glob("*.las"))
    if not tiles:
        print(f"No .las files found in {src_dir}")
        sys.exit(1)

    total = 0
    for i, src in enumerate(tiles, start=1):
        dst = dst_dir / (src.stem + ".laz")
        count = convert_las_to_laz(src, dst)
        verify_parity(src, dst)

        ratio = src.stat().st_size / max(dst.stat().st_size, 1)
        total += count
        print(
            f"[{i}/{len(tiles)}] {src.name}: {count:,} pts, "
            f"{ratio:.1f}x smaller, parity OK"
        )

    print(f"\nConverted {len(tiles)} tiles, {total:,} points total. All parity checks passed.")


if __name__ == "__main__":
    main()

Typical output over a directory of survey tiles (LAS 1.4, point format 7, EPSG:2193 NZGD2000):

text
[1/3] tile_0001.las: 9,214,880 pts, 7.4x smaller, parity OK
[2/3] tile_0002.las: 8,903,551 pts, 7.1x smaller, parity OK
[3/3] tile_0003.las: 9,540,102 pts, 7.6x smaller, parity OK

Converted 3 tiles, 27,658,533 points total. All parity checks passed.

# Key Parameter Table

Parameter Stage Values Effect
compression writers.las true / false true writes LASzip (.laz); false writes plain LAS
forward writers.las "all", "header", dim list "all" copies header, scale, offset, and every VLR unchanged
extra_dims writers.las "all", name list "all" carries custom Extra Bytes dimensions into the output
minor_version writers.las 24 Forces LAS version; 4 required for point formats 6–10
a_srs writers.las EPSG / WKT2 Only needed to override or add a CRS; forward:"all" already copies an existing one

forward: "all" and an explicit a_srs can conflict — if you set a_srs, it overrides the forwarded CRS. Leave a_srs unset for a faithful copy and only supply it when you are deliberately correcting a missing or wrong CRS during the conversion.

# Verification

Parity has two independent parts: the points and the header. The example script checks both, but you can also confirm a full lossless round trip by decompressing the LAZ back to LAS and comparing raw point records.

python
import json
import numpy as np
import pdal

def assert_round_trip(las_path: str, laz_path: str) -> None:
    """Confirm LAS -> LAZ preserved every stored point value."""
    def load(path):
        p = pdal.Pipeline(json.dumps({"pipeline": [path]}))
        p.execute()
        return p.arrays[0]

    a, b = load(las_path), load(laz_path)
    assert a.shape[0] == b.shape[0], "Point count changed"
    for dim in a.dtype.names:
        assert np.array_equal(a[dim], b[dim]), f"Dimension {dim} changed during conversion"
    print(f"OK: {a.shape[0]:,} points identical across all {len(a.dtype.names)} dimensions.")

Because LASzip is lossless, np.array_equal holds for every dimension including custom Extra Bytes fields. If any dimension fails, suspect a missing extra_dims: "all" or a point-format downgrade, not the codec. Comparing on-disk sizes with os.stat gives the compression ratio, which typically lands between 5x and 8x for airborne LiDAR depending on point format width.

# Gotchas and Edge Cases

1. Extra dimensions vanish without extra_dims: "all". If a classifier or feature-extraction step wrote custom per-point dimensions into the Extra Bytes VLR, PDAL will not carry them into the LAZ unless you ask. The default writes only the standard dimensions for the point format, so the conversion looks fine — same point count, same coordinates — while silently dropping the data your downstream model needs. Always set extra_dims: "all" when converting analysed data.

2. forward: "all" copies a wrong CRS just as faithfully as a right one. Lossless preservation cuts both ways. If the source header carries an incorrect CRS VLR, the LAZ inherits it exactly. Validate and fix the CRS in the LAS before compressing, using the Coordinate Reference Systems workflow, rather than expecting the conversion to clean it up.

3. A point-format downgrade quietly truncates dimensions. Omitting forward: "all" lets PDAL choose a point format, and it may pick a narrower one than the source. A format 7 file (with RGB) written as format 1 loses colour with no error. Pinning the format via forward: "all" — or minor_version plus an explicit format — prevents this.

4. Compressing without keeping the source is risky until verified. It is tempting to compress in place and delete the LAS to reclaim disk. Do the verification first. A failed conversion that dropped extra dimensions is unrecoverable once the source is gone, whereas the parity check in the example catches it while you still have the original.

# Frequently Asked Questions

Does converting LAS to LAZ lose any precision?

No. LASzip is a lossless codec: it re-encodes the exact stored integers without altering scale, offset, or any attribute. Converting LAS to LAZ and decompressing back to LAS yields byte-identical point records, so coordinates, intensity, classification, and GPS time all survive unchanged.

Do extra dimensions survive LAS to LAZ conversion?

They do, but only if you tell PDAL to carry them. Set extra_dims: "all" on writers.las so custom per-point dimensions declared in the Extra Bytes VLR are copied into the LAZ output. Without it, PDAL writes only the standard dimensions for the point format and silently drops your custom fields.

How do I keep the original header and VLRs when compressing?

Use forward: "all" on writers.las. This propagates the source header’s scale, offset, point format, creation date, and every VLR — including the CRS record — into the output. Without forward: "all", PDAL recomputes some header fields and may drop non-CRS VLRs that downstream tools rely on. Inspecting those fields is covered in How to Parse LAS Headers with Python.

Can PDAL convert LAZ back to LAS?

Yes. The reverse is symmetric: read the .laz and write with writers.las using compression: false and a .las filename. Because compression is lossless, the decompressed LAS is identical in content to the original, which makes the round trip safe for archival verification. Whether to keep files as LAZ or LAS during active work is weighed in LAZ vs Uncompressed LAS for Iterative Processing.