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.
# 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.
{
"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:
{
"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.
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.
#!/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):
[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 |
2–4 |
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.
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.
# Related
- LAS/LAZ File Structure — parent guide on the binary layout that compression preserves
- How to Parse LAS Headers with Python — inspect the header and VLR fields the conversion must carry across
- LAZ vs Uncompressed LAS for Iterative Processing — when to keep files compressed versus uncompressed during development
- Coordinate Reference Systems — validate and fix the CRS VLR before compressing
- Point Cloud Data Standards & Fundamentals — parent section on LAS/LAZ, CRS, metadata, and classification standards