Syncing Metadata Between LAS and Shapefiles: A Python Workflow
TL;DR: Extract the bounding box and CRS from a LAS header with laspy, build a GeoDataFrame bounding polygon with geopandas, enforce DBF field-name (10-char) and string-value (254-char) limits, then call to_file() — geopandas writes the synchronized .prj, .dbf, .shp, and .shx in one step.
# Context and Motivation
This guide is part of Metadata & Header Sync, which covers the full lifecycle of validating and reconciling LAS/LAZ header fields with point data and external spatial files.
Point clouds and vector boundaries rarely share identical metadata pipelines. LAS files embed spatial context inside a structured binary header: generation date, software ID, bounding box extents, and a coordinate reference system stored in variable-length records (VLRs). Shapefiles split equivalent metadata across three sidecars — .prj (CRS as WKT), .dbf (tabular attributes), and optional .xml (extended metadata) — each with its own format constraints and legacy size limits.
When engineering teams generate delivery boundaries by deriving a bounding polygon from a LiDAR survey tile, metadata typically degrades at the format handoff. CRS definitions get dropped, software ID strings exceed the 254-character DBF limit and are silently truncated, and generation timestamps disappear entirely. The result is an audit gap: the shapefile boundary can no longer be traced back unambiguously to its source scan. For infrastructure and urban-planning deliverables subject to survey-grade QA, that gap is a liability.
Understanding how a LAS header is structured is covered in LAS/LAZ File Structure. Resolving CRS discrepancies before synchronization is covered in Coordinate Reference Systems.
# Prerequisites and Assumptions
- Python 3.10+
laspy >= 2.4.0— for the modernlaspy.open()API and VLR accessgeopandas >= 0.14— forGeoDataFrame.to_file()with the ESRI Shapefile driverpyproj >= 3.3— for CRS parsing and optional reprojectionshapely >= 2.0— forbox()geometry construction- Input LAS/LAZ file with valid
x_min,x_max,y_min,y_maxheader fields - Target CRS known in advance (an EPSG code or WKT string) if the source file may lack an embedded CRS
Install with:
pip install "laspy[lazrs]>=2.4.0" geopandas pyproj shapely# Step-by-Step Implementation
# Step 1 — Open the LAS File and Read the Header
Use laspy.open() in read mode. This loads only the header and VLRs; the full point array stays on disk until explicitly requested, which is efficient for files larger than a few hundred megabytes.
import laspy
with laspy.open("survey_tile.las") as las:
header = las.header
print(header.version.major, header.version.minor) # e.g., 1 4
print(header.point_count) # e.g., 4_832_011
print(header.x_min, header.x_max) # UTM eastingsThe header’s VLRs are accessible as header.vlrs — a list of laspy.VLR objects. Each VLR carries a user_id, record_id, and raw record_data bytes. The CRS is stored in VLR record_id 2112 (WKT2, preferred) or record_id 34735 (legacy GeoKey, LAS 1.2).
# Step 2 — Extract the CRS from the VLR
from pyproj import CRS
import warnings
def extract_las_crs(header: laspy.LasHeader) -> CRS | None:
"""
Return a pyproj.CRS from the WKT2 VLR (record_id 2112).
Returns None if no recognized CRS VLR is found.
"""
for vlr in header.vlrs:
if vlr.record_id == 2112:
try:
wkt_str = vlr.record_data.decode("utf-8").rstrip("\x00")
return CRS.from_wkt(wkt_str)
except Exception as exc:
warnings.warn(f"Malformed WKT2 VLR: {exc}")
return NoneLAS 1.2 files store CRS only in GeoKey VLRs (record IDs 34735–34737). For those, parsing requires a GeoTIFF key decoder — or simply accepting the fallback EPSG code described in step 3. See how to parse LAS headers with Python for a full GeoKey walkthrough.
# Step 3 — Resolve the CRS with a Safe Fallback
import pyproj
crs = extract_las_crs(header)
if crs is None:
warnings.warn(
"No embedded CRS detected in LAS VLRs. "
"Defaulting to EPSG:4326 (WGS84). "
"Verify this matches your project datum before delivery."
)
crs = pyproj.CRS.from_epsg(4326)Always emit a warning on fallback rather than silently assuming WGS84. A silent default is indistinguishable from a correctly embedded CRS in logs.
# Step 4 — Build the Bounding Polygon
import geopandas as gpd
from shapely.geometry import box
with laspy.open("survey_tile.las") as las:
header = las.header
crs = extract_las_crs(header) or pyproj.CRS.from_epsg(4326)
min_x, min_y = header.x_min, header.y_min
max_x, max_y = header.x_max, header.y_max
point_count = header.point_count
version_str = f"{header.version.major}.{header.version.minor}"
system_id = str(header.system_identifier).strip()
gen_soft = str(header.generating_software).strip()
bbox = box(min_x, min_y, max_x, max_y)
gdf = gpd.GeoDataFrame(geometry=[bbox], crs=crs)Passing crs to the constructor is what causes geopandas to generate the .prj sidecar on export. Omitting it produces a shapefile with no spatial reference — a common silent mistake.
# Step 5 — Map Header Fields to DBF-Safe Attributes
The dBase III+ format limits field names to 10 ASCII characters and string values to 254 characters. geopandas does not validate these constraints before writing; violations silently corrupt the .dbf or raise a GDAL error at export time.
from datetime import datetime
gdf["LAS_VER"] = version_str # e.g., "1.4"
gdf["PT_COUNT"] = int(point_count) # numeric — no char limit
gdf["GEN_DATE"] = datetime.now().strftime("%Y-%m-%d")
gdf["SYSTEM_ID"] = system_id[:254] # clamp to 254 chars
gdf["GEN_SOFT"] = gen_soft[:254]
gdf["X_MIN"] = float(min_x)
gdf["Y_MIN"] = float(min_y)
gdf["X_MAX"] = float(max_x)
gdf["Y_MAX"] = float(max_y)All column names above are 10 characters or fewer. Use explicit float() casting for coordinate values to avoid numpy dtype coercion warnings from GDAL.
# Step 6 — Export the Shapefile
gdf.to_file("survey_tile_boundary.shp", driver="ESRI Shapefile")geopandas generates four sidecars: .shp (geometry), .shx (index), .dbf (attributes), and .prj (CRS as WKT). The .prj content is derived directly from the crs you passed in step 4.
# Complete Working Example
import laspy
import geopandas as gpd
import pyproj
from pyproj import CRS
from shapely.geometry import box
from datetime import datetime
import warnings
def extract_las_crs(header: laspy.LasHeader) -> CRS | None:
"""
Return a pyproj.CRS from the WKT2 VLR (record_id 2112).
Returns None if no recognized CRS VLR is present.
"""
for vlr in header.vlrs:
if vlr.record_id == 2112:
try:
wkt_str = vlr.record_data.decode("utf-8").rstrip("\x00")
return CRS.from_wkt(wkt_str)
except Exception as exc:
warnings.warn(f"Malformed WKT2 VLR — skipping: {exc}")
return None
def sync_las_to_shapefile(las_path: str, out_shp: str) -> str:
"""
Extract LAS header metadata, build a bounding polygon,
and export a synchronized shapefile.
Args:
las_path: Path to the source LAS or LAZ file.
out_shp: Output .shp path (sidecars written alongside).
Returns:
Absolute path to the written .shp file.
"""
# 1. Read header only — no point array loaded
with laspy.open(las_path) as las:
header = las.header
crs = extract_las_crs(header)
min_x, min_y = header.x_min, header.y_min
max_x, max_y = header.x_max, header.y_max
point_count = header.point_count
version_str = f"{header.version.major}.{header.version.minor}"
system_id = str(header.system_identifier).strip()
gen_soft = str(header.generating_software).strip()
# 2. Resolve CRS with auditable fallback
if crs is None:
warnings.warn(
f"{las_path}: No embedded CRS. Defaulting to EPSG:4326 (WGS84)."
)
crs = pyproj.CRS.from_epsg(4326)
# 3. Build bounding polygon and GeoDataFrame
bbox = box(min_x, min_y, max_x, max_y)
gdf = gpd.GeoDataFrame(geometry=[bbox], crs=crs)
# 4. Map to DBF-safe attributes
gdf["LAS_VER"] = version_str
gdf["PT_COUNT"] = int(point_count)
gdf["GEN_DATE"] = datetime.now().strftime("%Y-%m-%d")
gdf["SYSTEM_ID"] = system_id[:254]
gdf["GEN_SOFT"] = gen_soft[:254]
gdf["X_MIN"] = float(min_x)
gdf["Y_MIN"] = float(min_y)
gdf["X_MAX"] = float(max_x)
gdf["Y_MAX"] = float(max_y)
# 5. Export — generates .shp, .shx, .dbf, .prj
gdf.to_file(out_shp, driver="ESRI Shapefile")
return out_shp# Key Parameter Table
| Parameter / Field | Type | Default / Example | Notes |
|---|---|---|---|
record_id (VLR) |
int |
2112 |
WKT2 CRS record; LAS 1.2 uses 34735 (GeoKey) |
fallback_epsg |
int |
4326 |
Override with project datum (e.g., 26918 for NAD83 UTM 18N) |
| DBF field name length | int |
max 10 |
GDAL truncates silently; always verify manually |
| DBF string value length | int |
max 254 |
Apply [:254] slice before assignment |
driver (to_file) |
str |
"ESRI Shapefile" |
Use "GPKG" (GeoPackage) to lift DBF constraints for modern workflows |
float() cast |
type | numpy.float64 |
Prevents GDAL dtype coercion warnings for coordinate columns |
# Verification
After export, confirm three properties:
1. CRS fidelity — re-read the .prj and compare against the source:
import pyproj
written_crs = pyproj.CRS.from_file("survey_tile_boundary.prj")
assert written_crs.equals(crs), f"CRS mismatch: {written_crs} vs {crs}"2. Bounding box accuracy — load the shapefile and check geometry bounds:
result = gpd.read_file("survey_tile_boundary.shp")
bounds = result.geometry.iloc[0].bounds # (minx, miny, maxx, maxy)
assert abs(bounds[0] - min_x) < 1e-6, "X_MIN drift detected"
assert abs(bounds[2] - max_x) < 1e-6, "X_MAX drift detected"3. DBF field lengths — inspect with ogrinfo:
ogrinfo -al -so survey_tile_boundary.shpLook for any field name that has been auto-truncated (shorter than the original column name) and any String(n) where n < len(original_value).
# Gotchas and Edge Cases
GeoPackage over Shapefile for modern deliverables. The 10-character field-name limit and 254-character string limit are dBase III+ artefacts. If your downstream GIS accepts GeoPackage (.gpkg), pass driver="GPKG" to to_file() and all constraints disappear. Shapefiles remain necessary only for workflows locked to legacy ESRI interchange formats.
LAS 1.2 files without a WKT2 VLR. extract_las_crs() above targets record_id 2112 only. Files generated by older scanners or flight management software may embed CRS solely in GeoKey VLRs (record_id 34735). If the function returns None and you know the datum, pass the project EPSG code explicitly rather than defaulting to WGS84. Incorrect CRS assumptions compound across every downstream spatial join and can shift geometry by tens of metres when UTM zones are confused.
Bounding box header vs. actual point extents. The LAS header x_min / x_max values are written by the generating software and may be stale after in-place edits or partial point deletions. For audit-critical deliverables, verify the header extents against actual point-array statistics — the full procedure is in Metadata & Header Sync.
Empty system_identifier or generating_software fields. Some encoders leave these fields as null bytes. str(header.system_identifier).strip() collapses a null-filled 32-byte field to an empty string, which is a valid DBF string value. Log the empty value rather than assigning a placeholder to avoid fabricating metadata.
Reprojection before export. If the LAS CRS does not match the project delivery CRS, reproject before writing:
target_crs = pyproj.CRS.from_epsg(26918) # NAD83 / UTM zone 18N
if not gdf.crs.equals(target_crs):
gdf = gdf.to_crs(target_crs)Always reproject the GeoDataFrame, not just the CRS attribute. Changing only gdf.crs reassigns the label without transforming coordinates and is a silent correctness error.
# Related
- Metadata & Header Sync — parent guide covering the full LAS header validation and reconciliation lifecycle
- LAS/LAZ File Structure — VLR layout, point record formats, and scale/offset conventions
- How to Parse LAS Headers with Python — step-by-step extraction of every header field with
laspy - Coordinate Reference Systems — diagnosing and resolving CRS ambiguity in LAS/LAZ files
- Point Cloud Data Standards & Fundamentals — pillar overview of ASPRS specifications, file formats, and delivery standards