How to Parse LAS Headers with Python
TL;DR: Call laspy.open(filepath) and access f.header — this reads the header and VLRs into memory without touching the point records, so it is safe on files of any size.
# Context and Motivation
This guide is part of LAS/LAZ File Structure, which covers the full binary layout of the ASPRS point cloud format. Within the broader Point Cloud Data Standards & Fundamentals framework, header parsing is the entry point to every processing decision: the version number tells you which fields exist, the point format ID defines the record schema, the scale and offset govern coordinate reconstruction, and the VLRs carry the coordinate reference system metadata without which spatial queries are meaningless.
Skipping or misreading any of these fields causes silent corruption: coordinates reconstructed with the wrong scale drift by metres; an undetected CRS mismatch in a VLR will pass ingestion validation only to fail at the reprojection stage; a zero legacy point count on a LAS 1.4 file will make pipelines believe they processed an empty dataset.
The diagram below shows how the three major header regions map onto the binary stream before the point data begins, and how the scale and offset transform raw stored integers into real-world coordinates.
# Prerequisites and Assumptions
- Python 3.10 or later (the code uses PEP 604 union syntax
str | Path) laspy2.0+ installed:pip install laspy[lazrs](thelazrsextra adds native LAZ decompression)- A
.lasor.laztest file — USGS 3DEP tiles from the National Map work well - No assumption about LAS version; the examples handle 1.0 through 1.4
# Step-by-Step Implementation
# Step 1: Open the file without loading point records
laspy.open() memory-maps only the header and VLRs. It does not load point records into RAM until you explicitly request them, making it safe for files that are tens of gigabytes.
import laspy
from pathlib import Path
filepath = Path("survey.laz")
with laspy.open(filepath) as f:
h = f.header# Step 2: Extract version and point format
The version tuple determines which fields exist. LAS 1.4 introduced EVLRs, a 64-bit point count, and point formats 6–10. Point format ID defines the byte layout of every record in the file.
print(f"LAS version : {h.version.major}.{h.version.minor}")
print(f"Point format: {h.point_format.id}")
print(f"Point count : {h.point_count}") # normalized — correct for LAS 1.4 too# Step 3: Read the bounding box
The header bounding box is stored in real-world coordinates (scale and offset already applied by the writing software). Use it for spatial indexing before touching any point records.
print(f"X extent: {h.x_min:.4f} → {h.x_max:.4f}")
print(f"Y extent: {h.y_min:.4f} → {h.y_max:.4f}")
print(f"Z extent: {h.z_min:.4f} → {h.z_max:.4f}")# Step 4: Inspect scale and offset
Scale and offset are the two most critical header fields for numeric correctness. Every raw XYZ integer stored in the point records must be reconstructed as real = (raw * scale) + offset. Pipelines that ignore the offset introduce absolute positional errors equal to the magnitude of the offset value — often hundreds of thousands of metres for UTM-referenced datasets.
print(f"Scales : X={h.x_scale} Y={h.y_scale} Z={h.z_scale}")
print(f"Offsets: X={h.x_offset} Y={h.y_offset} Z={h.z_offset}")# Step 5: Scan VLRs for CRS metadata
VLR record ID 34735 is the GeoKey directory (legacy CRS, used in LAS 1.2/1.3). Record ID 2112 is the WKT2 string (modern, preferred in LAS 1.4). A file with neither is technically unconstrained in projection — always fall back to checking a .prj sidecar or the global_encoding bit flags. The coordinate reference system embedded here is what downstream reprojection stages read to align datasets.
if h.vlrs:
for vlr in h.vlrs:
print(f" VLR user={vlr.user_id!r:20s} id={vlr.record_id:5d} len={vlr.record_length_after_header}")
has_geokey = any(v.record_id == 34735 for v in h.vlrs)
has_wkt = any(v.record_id == 2112 for v in h.vlrs)
print(f"CRS: GeoKey={has_geokey} WKT2={has_wkt}")# Complete Working Example
This self-contained script runs against any LAS or LAZ file and prints a structured summary to stdout. Copy it into a file named parse_las_header.py and run it with python parse_las_header.py <file>.
import laspy
import sys
from pathlib import Path
def parse_las_header(filepath: str | Path) -> laspy.LasHeader | None:
"""
Extract critical metadata from a LAS/LAZ file header.
Compatible with laspy >= 2.0.0, Python 3.10+, LAS 1.0–1.4, and LAZ
(requires the lazrs extra: pip install laspy[lazrs]).
"""
filepath = Path(filepath)
if not filepath.exists():
print(f"ERROR: File not found: {filepath}", file=sys.stderr)
return None
try:
# laspy.open() uses memory-mapped I/O and reads only the header + VLRs.
# Point records are NOT loaded here — safe for multi-gigabyte files.
with laspy.open(filepath) as f:
h = f.header
sep = "-" * 52
print(sep)
print(f"File : {filepath.name}")
print(f"LAS version : {h.version.major}.{h.version.minor}")
print(f"Point format: {h.point_format.id}")
# laspy normalizes point_count across LAS 1.0–1.4.
# For LAS 1.4, it reads the 64-bit extended count when the
# legacy 32-bit field is zero.
print(f"Point count : {h.point_count:,}")
print(sep)
print("Bounding box (real-world coordinates):")
print(f" X: {h.x_min:.6f} → {h.x_max:.6f}")
print(f" Y: {h.y_min:.6f} → {h.y_max:.6f}")
print(f" Z: {h.z_min:.6f} → {h.z_max:.6f}")
print(sep)
print("Coordinate scale + offset (required for raw-integer reconstruction):")
print(f" Scale : X={h.x_scale} Y={h.y_scale} Z={h.z_scale}")
print(f" Offset: X={h.x_offset} Y={h.y_offset} Z={h.z_offset}")
print(sep)
print(f"VLR count: {len(h.vlrs)}")
if h.vlrs:
has_geokey = any(v.record_id == 34735 for v in h.vlrs)
has_wkt = any(v.record_id == 2112 for v in h.vlrs)
print(f" GeoKey CRS (id=34735): {has_geokey}")
print(f" WKT2 CRS (id=2112) : {has_wkt}")
for vlr in h.vlrs:
print(f" VLR user={vlr.user_id!r:20s} id={vlr.record_id:5d} "
f"len={vlr.record_length_after_header}")
print(sep)
return h
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return None
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python parse_las_header.py <path/to/file.las>")
sys.exit(1)
parse_las_header(sys.argv[1])# Zero-Dependency Fallback with struct
When third-party packages are prohibited (air-gapped servers, minimal containers), parse the public header manually. The ASPRS specification defines a fixed 227-byte layout for LAS 1.0–1.3.
Important axis ordering: the specification stores the bounding box as max before min for each axis pair. Reversing this silently corrupts spatial queries.
import struct
from pathlib import Path
def parse_las_header_struct(filepath: str | Path) -> dict:
"""
Parse the LAS 1.2 public header block using only Python built-ins.
Byte offsets are per the ASPRS LAS 1.2 specification.
Not suitable for LAZ point data or LAS 1.4 EVLRs.
"""
filepath = Path(filepath)
with open(filepath, "rb") as f:
data = f.read(227)
if data[:4] != b"LASF":
raise ValueError(f"Not a valid LAS file (bad signature): {filepath}")
version_major = struct.unpack_from("B", data, 24)[0]
version_minor = struct.unpack_from("B", data, 25)[0]
point_format = struct.unpack_from("B", data, 104)[0]
# 32-bit count — set to 0 in LAS 1.4 files with >2^32 points
point_count = struct.unpack_from("<I", data, 107)[0]
x_scale, y_scale, z_scale = struct.unpack_from("<ddd", data, 131)
x_offset, y_offset, z_offset = struct.unpack_from("<ddd", data, 155)
# ASPRS spec: max is stored BEFORE min for each axis
x_max, x_min = struct.unpack_from("<dd", data, 179)
y_max, y_min = struct.unpack_from("<dd", data, 195)
z_max, z_min = struct.unpack_from("<dd", data, 211)
return {
"version" : f"{version_major}.{version_minor}",
"point_format": point_format,
"point_count" : point_count,
"scales" : (x_scale, y_scale, z_scale),
"offsets" : (x_offset, y_offset, z_offset),
"bounds_x" : (x_min, x_max),
"bounds_y" : (y_min, y_max),
"bounds_z" : (z_min, z_max),
}# Key Parameter Reference
| Header field | Type | Byte offset (LAS 1.2) | Notes |
|---|---|---|---|
| File signature | char[4] |
0 | Must equal LASF |
| Version major | uint8 |
24 | 1 for all current versions |
| Version minor | uint8 |
25 | 0–4; LAS 1.4 adds EVLRs and 64-bit point count |
| Point Data Format ID | uint8 |
104 | 0–5 (LAS 1.2), 0–10 (LAS 1.4) |
| Legacy point count | uint32 |
107 | Zero when actual count exceeds 2³² − 1 in LAS 1.4 |
| X/Y/Z scale | float64 each |
131, 139, 147 | Applied as real = (raw * scale) + offset |
| X/Y/Z offset | float64 each |
155, 163, 171 | Subtracted before storage; re-added on read |
| X max / X min | float64 each |
179, 187 | Max is stored first — reversed from intuition |
| Y max / Y min | float64 each |
195, 203 | Same ordering |
| Z max / Z min | float64 each |
211, 219 | Same ordering |
# Verification
After parsing, validate the extracted metadata before handing it to downstream stages. Cross-check point count against file size: for an uncompressed LAS file, (file_size_bytes - offset_to_point_data) / point_data_record_length should equal point_count. Any discrepancy suggests a truncated or corrupted file.
def validate_header(h: laspy.LasHeader) -> list[str]:
"""Return a list of validation warnings. Empty list = clean."""
warnings = []
# Scale factors of zero cause division-by-zero in coordinate reconstruction
for axis, scale in [("X", h.x_scale), ("Y", h.y_scale), ("Z", h.z_scale)]:
if scale == 0.0:
warnings.append(f"Scale factor for {axis} is zero — coordinate reconstruction will fail.")
# Inverted bounding box indicates the header was not updated after writing
if h.x_min > h.x_max:
warnings.append("X bounding box is inverted (x_min > x_max).")
if h.y_min > h.y_max:
warnings.append("Y bounding box is inverted (y_min > y_max).")
# Empty file check
if h.point_count == 0:
warnings.append("Point count is zero — file may be empty or use LAS 1.4 extended count.")
# CRS presence
has_crs = any(v.record_id in (34735, 2112) for v in h.vlrs)
if not has_crs:
warnings.append("No CRS VLR found (GeoKey id=34735 or WKT2 id=2112). Check .prj sidecar.")
return warnings# Gotchas and Edge Cases
Axis ordering in struct parsing. The ASPRS spec stores the bounding box with max before min for each axis — x_max at offset 179, x_min at 187, y_max at 195, and so on. Reversing this produces a valid-looking dictionary that silently inverts every spatial query against that dataset.
LAS 1.4 point count. The legacy 32-bit point_count field at offset 107 is written as zero whenever the file contains more than 2³² − 1 points. The actual count lives in the extended_point_count field (8 bytes starting at offset 247 in the LAS 1.4 header). laspy handles this automatically, but any struct-based parser that reads only 227 bytes will report zero and silently skip the real count.
LAZ point data compression. LAZ files share the identical public header block with LAS, so header parsing with struct works correctly. However, the block that follows the VLRs is a LAZ chunk table, not raw point records. A manual parser that reads past the VLRs without handling the chunk table will misinterpret compressed chunk index data as point records. For anything beyond header extraction, use laspy[lazrs].
Missing CRS in legacy datasets. Many pre-2015 aerial survey LAS files were delivered without a GeoKey VLR or WKT string. Before assuming EPSG 4326 or a local projection, inspect the global_encoding bits (byte offset 6, uint16): bit 0 set means GPS time is GPS week time; bit 4 set (LAS 1.4) means OGC WKT is used for the CRS instead of GeoKeys. When both VLR approaches are absent, check for a .prj sidecar with the same base filename. Treating an unconstrained file as georeferenced causes silent drift that accumulates across coordinate reference system transformations — see Fixing CRS Mismatches in Point Clouds for a recovery workflow.
Scale precision and floating-point arithmetic. LAS coordinates are raw 32-bit integers (point formats 0–5) or 32-bit integers in a 64-bit-capable container (formats 6–10 in LAS 1.4). The scale and offset are 64-bit doubles. Always reconstruct real-world coordinates using float64 arithmetic. Reducing to float32 introduces rounding errors of roughly ±0.01 m for UTM datasets with kilometre-scale offsets.
# Frequently Asked Questions
What is the difference between laspy.open() and laspy.read()?
laspy.open() reads only the header and VLRs into memory without loading point records, making it safe for multi-gigabyte files. laspy.read() loads the entire file — including all point data — into RAM at once. Use laspy.open() whenever you only need metadata or plan to iterate through chunks.
Why is point_count zero for some LAS 1.4 files?
In LAS 1.4, the legacy 32-bit point_count field at byte offset 107 is set to zero when the actual count exceeds 2³² − 1 (about 4.3 billion points). The true count is stored in the extended_point_count field as a 64-bit integer. laspy normalises this transparently via header.point_count, so you always get the correct value regardless of version.
How do I parse a LAZ file header without laspy?
LAZ files store an identical public header block to LAS files, so reading the first 227 bytes with Python’s struct module works for LAS 1.2-formatted LAZ files. However, the point data block is compressed with a LAZ chunk table immediately following the VLRs. Any manual parser that reads past the header must skip this chunk table or it will misread point records.
# Related
- LAS/LAZ File Structure — binary architecture, VLR layout, and point record schemas
- Point Cloud Data Standards & Fundamentals — the parent reference covering format interoperability, ASPRS classification codes, and metadata standards
- Coordinate Reference Systems — how VLR CRS metadata maps to EPSG codes and projection workflows
- Fixing CRS Mismatches in Point Clouds — what happens when the VLR CRS does not match the actual data projection
- ASPRS Classification Codes — the classification dimension stored in every point record alongside XYZ