Pinning PDAL Versions with conda-lock
TL;DR: Put loose version constraints (pdal >=2.8,<2.9, python-pdal, gdal) in environment.yml, run conda-lock -f environment.yml -p linux-64 -p osx-arm64 to resolve every package to an exact build with hashes, commit conda-lock.yml, and install from the lock everywhere. The same PDAL, GDAL and PROJ builds then run on laptops, in CI and in production containers, and an upgrade is a reviewed change to one file.
# Context and Motivation
This guide is part of PDAL Docker Containers. LiDAR results depend on library versions more than people expect. A PDAL release can change a filter’s defaults, a GDAL release can change how a raster writer handles nodata, and a PROJ release with new transformation grids can shift reprojected coordinates by centimetres. An environment.yml that says pdal resolves to whatever is newest on the day the image is built, so two builds a month apart may produce different DTMs from the same tiles.
A lock file records the exact solution — every package, version, build string and checksum, for each platform — so installing it is deterministic. It is the foundation for the slim image in building a slim PDAL Docker image.
# Prerequisites and Assumptions
conda-lockinstalled (pipx install conda-lockor from conda-forge) andmicromambafor installs.- conda-forge as the only channel; mixing
defaultsand conda-forge causes unsolvable or inconsistent environments for the geospatial stack. - A repository where the spec and lock are committed together.
# Step-by-Step Implementation
# Step 1 — Write the spec
List direct dependencies only, with ranges on the libraries that affect results: PDAL, GDAL, PROJ and python-pdal.
# Step 2 — Generate the lock
conda-lock lock -f environment.yml -p linux-64 -p linux-aarch64 -p osx-arm64 writes a unified conda-lock.yml.
# Step 3 — Render per-platform explicit files for Docker
conda-lock render -p linux-64 writes conda-linux-64.lock, a plain explicit list that micromamba installs without resolving.
# Step 4 — Install from the lock everywhere
conda-lock install -n lidar conda-lock.yml locally, and micromamba create -p /opt/env -f conda-linux-64.lock in Docker and CI.
# Step 5 — Upgrade deliberately
Change a range in the spec, regenerate the lock, and run the regression tile set before merging; the lock diff shows exactly which packages moved.
# Complete Working Example
environment.yml:
name: lidar
channels:
- conda-forge
dependencies:
- python =3.12
- pdal >=2.8,<2.9
- python-pdal >=3.4
- gdal >=3.9,<3.10
- proj >=9.4,<9.6
- laspy >=2.5
- lazrs-python
- numpy >=1.26,<3
- boto3
- pytest
platforms:
- linux-64
- linux-aarch64
- osx-arm64Lock, render and install:
conda-lock lock -f environment.yml # uses platforms from the file
conda-lock render -p linux-64 -p linux-aarch64 conda-lock.yml
git add environment.yml conda-lock.yml conda-linux-64.lock conda-linux-aarch64.lock
# local development
conda-lock install -n lidar conda-lock.yml
# CI or Dockerfile
micromamba create -y -p /opt/env -f conda-linux-64.lock
/opt/env/bin/pdal --versionA regression check to run after every lock change:
"""test_regression.py: fail if the environment changes results on reference tiles."""
import json
import subprocess
import rasterio
REF = json.load(open("tests/reference.json")) # {"tile": {"ground": n, "dtm_mean": z}}
def test_reference_tiles(tmp_path):
for tile, expected in REF.items():
out = tmp_path / f"{tile}.tif"
subprocess.run(["pdal", "pipeline", "pipelines/dtm.json",
f"--readers.las.filename=tests/data/{tile}.laz",
f"--writers.gdal.filename={out}"], check=True)
with rasterio.open(out) as src:
z = src.read(1, masked=True)
assert abs(float(z.mean()) - expected["dtm_mean"]) < 0.005, tile# Reading a Lock Diff
A lock regeneration often moves far more than the package you asked about. Raising the PDAL range can pull a new GDAL build, a new PROJ, a new libtiff and a different compiler runtime. The diff of conda-lock.yml lists each one, which is the point: you can see that PROJ moved from 9.4 to 9.5 and check whether its release notes mention grid or datum changes relevant to your projects. Keep the reference tiles representative — one tile per CRS and vertical datum you process, one dense urban tile, one steep forested tile — so the regression test exercises the parts that library changes are most likely to affect.
It is also worth locking without changing ranges on a schedule, for example monthly, to pick up bug-fix builds. Treat that as a normal upgrade: review the diff, run the regression tiles, then merge.
# Why Transitive Packages Matter Most
The packages you list are rarely the ones that surprise you. PDAL’s behaviour depends on the GDAL, PROJ, GEOS, laz-perf and libtiff builds it links against, none of which appear in a short environment.yml. Without a lock, a rebuild can hold PDAL at the same version while swapping PROJ underneath it, and reprojected outputs shift even though the “PDAL version” in your notes is unchanged. The lock pins the whole tree, so it is the full set of libraries, not just the headline one, that stays fixed between runs.
# Key Parameter Table
| Item | Recommended | Why |
|---|---|---|
| Channel | conda-forge only |
Consistent geospatial builds |
| Ranges | minor-version for PDAL/GDAL/PROJ | Controlled upgrades |
| Platforms | fleet + developer machines | One lock for all |
| Lock format | unified conda-lock.yml |
Reviewable diff |
| Docker input | rendered explicit .lock |
No solve at build time |
| Regression set | 5–10 reference tiles | Catches result changes |
# Verification
- Determinism. Two fresh installs from the same lock produce identical
micromamba list --explicit --md5output. - Versions in logs. Print
pdal --versionandgdal-config --versionat the start of each job, so logs record what produced each output. - Regression pass. The reference test passes before and after any lock change you merge.
# Gotchas and Edge Cases
pip packages. conda-lock can include pip: dependencies, but they resolve separately and can overwrite conda packages. Prefer conda-forge builds of everything, especially NumPy.
Platform-specific solves. A package missing for one platform makes the whole lock fail. Drop that platform from platforms, or lock it separately.
Lock drift in images. An image built from the lock is only reproducible if the Dockerfile copies the rendered lock and nothing installs extra packages afterwards.
# Frequently Asked Questions
Why pin PDAL versions for production LiDAR processing?
Filter defaults, raster writer behaviour and PROJ transformation grids change between releases, so unpinned environments can produce different outputs from the same data. A lock file makes every install identical.
What is the difference between environment.yml and a conda-lock file?
environment.yml lists direct dependencies with version ranges. The lock file records the resolved solution: every package, including transitive ones, at an exact version and build with checksums, for each platform.
How do I use a conda-lock file in Docker?
Render an explicit per-platform lock file with conda-lock render and install it with micromamba create in the builder stage. No dependency solving happens during the image build.
How should I upgrade PDAL once it is pinned?
Change the version range in environment.yml, regenerate the lock, review which packages changed, and run regression tests on reference tiles before merging and rebuilding images.
Can I lock environments with pip instead?
pip lock tools pin Python packages, but PDAL, GDAL and PROJ are compiled libraries that pip does not manage well. conda-forge ships them as consistent binary builds, which is why conda-lock is the usual choice for the geospatial stack.
# Related
- PDAL Docker Containers — containerising PDAL
- Building a Slim PDAL Docker Image — installs the rendered lock
- Testing PDAL Pipelines with pytest — the regression test pattern
- Inspecting PROJ Transformations Before Reprojecting — why PROJ versions matter