A LAZ File Will Not Open in Python

Problem statement

laspy.read("tile.laz") fails, and the message rarely names the cause directly. There are five distinct failures, and they need five different fixes:

  • no compression backend installed
  • a LAS version or point format the installed laspy predates
  • a file that is not actually LAZ
  • a truncated or corrupt file
  • a path issue mistaken for a format issue

The first is by far the most common, because laspy is pure Python and ships without a LAZ decompressor.

Quick answer

pip install "laspy[lazrs]"

Then verify:

import laspy

print(laspy.__version__)
print([b for b in laspy.LazBackend if b.is_available()])
2.7.0
[LazBackend.LazrsParallel, LazBackend.Lazrs]

An empty list means no backend, and every .laz read will fail with LaspyException: No LazBackend selected, cannot decompress data.

Five causes of a LAZ open failure β€” missing backend, unsupported version, wrong format, truncated file, and path error β€” each with its diagnostic.
The error text often names none of these. The first four minutes of debugging are identifying which one you have.

Step-by-step solution

1. Cause one: no LAZ backend

laspy.errors.LaspyException: No LazBackend selected, cannot decompress data

Two backends exist:

  • lazrs β€” a Rust implementation, installs as a wheel with no system dependency, supports parallel decompression. The default choice.
  • laszip β€” bindings to the reference C++ library. Needs the shared library available; marginally faster on some files.
pip install "laspy[lazrs]"          # recommended
pip install "laspy[laszip]"         # alternative
pip install "laspy[lazrs,laszip]"   # both, with fallback

Check availability with the snippet above rather than by trying a read; the error from a missing backend and the error from a corrupt file look similar at a glance.

2. Cause two: an unsupported version or point format

laspy.errors.LaspyException: Point format 8 is not supported

LAS 1.4 introduced point formats 6–10, which extend the return numbering to 15 per pulse and the classification range to 256. Older laspy versions and other tools predate them.

with laspy.open(path) as reader:
    h = reader.header
    print(h.version, h.point_format.id)

Reading the header works even when reading the points does not, because the header format has been stable. If the point format is 6 or above, upgrade laspy, or convert the file with a tool that supports both.

3. Cause three: the file is not what its extension says

with open(path, "rb") as f:
    signature = f.read(4)
print(signature)          # b'LASF' for both LAS and LAZ

Every LAS and LAZ file starts with LASF. Anything else β€” PK\x03\x04 for a zip, \x1f\x8b for gzip, <?xm for XML β€” means the extension is wrong, usually because a download saved an error page or a zip archive under the expected name.

The distinction between LAS and LAZ is in the point format ID's high bit, not in the signature, which is why a .las file can be compressed and a .laz file can be uncompressed.

4. Cause four: truncation

import os


def check_length(path):
    with laspy.open(path) as reader:
        h = reader.header
        expected = h.offset_to_point_data + h.point_count * h.point_format.size
    actual = os.path.getsize(path)
    print(f"  header expects {expected:,} bytes (uncompressed point records)")
    print(f"  file is        {actual:,} bytes")
    if actual < h.offset_to_point_data:
        print("  ! truncated before the point data begins")

For an uncompressed LAS, the arithmetic is exact and a short file is provably truncated. For LAZ the compressed size is unpredictable, so the useful test is whether the file is shorter than the header offset β€” and whether reading the last chunk raises.

A truncated LAZ typically reads correctly up to the damaged chunk and then fails, which is why streaming with chunk_iterator can recover most of a damaged file.

5. Cause five: it is a path problem

import os
print(os.path.exists(path), os.path.getsize(path) if os.path.exists(path) else None)

A zero-byte file from a failed download, a path with a typo, or a remote URL passed where a local path is expected all produce errors that mention the format. Check the file exists and has a plausible size first.

Checking which LAZ backends are available before attempting a read, with lazrs and laszip as the two options.
An empty backend list is the single most common cause and takes one line to rule out.

Code examples

Example 1 β€” a diagnostic that names the cause

import os
import laspy


def diagnose_laz(path):
    """Identify which of the five failures you have."""
    if not os.path.exists(path):
        return f"path does not exist: {path}"
    size = os.path.getsize(path)
    if size == 0:
        return "file is zero bytes β€” the download or write failed"
    print(f"  {size / 1e6:.2f} MB")

    with open(path, "rb") as f:
        signature = f.read(4)
    if signature != b"LASF":
        return (f"not a LAS/LAZ file: starts with {signature!r}, "
                "expected b'LASF'")

    backends = [b for b in laspy.LazBackend if b.is_available()]
    print(f"  laspy {laspy.__version__}, backends: "
          f"{[b.name for b in backends] or 'NONE'}")

    try:
        with laspy.open(path) as reader:
            h = reader.header
            print(f"  LAS {h.version}, point format {h.point_format.id}, "
                  f"{h.point_count:,} points")
            compressed = path.lower().endswith(".laz")
    except Exception as exc:
        return f"header unreadable: {type(exc).__name__}: {exc}"

    if compressed and not backends:
        return ('no LAZ backend installed β€” run pip install "laspy[lazrs]"')

    try:
        with laspy.open(path) as reader:
            next(reader.chunk_iterator(1000))
    except Exception as exc:
        return (f"header reads but points do not: {type(exc).__name__}: {exc}. "
                "Likely a truncated file or an unsupported point format.")

    return "opens correctly"

Reading the header first and the points second is what separates "unsupported format" from "corrupt data". The header is readable in almost every broken file.

Example 2 β€” recovering what is readable from a damaged file

import laspy
import numpy as np


def salvage(path, chunk_size=100_000):
    """Read chunks until one fails, and keep what came before."""
    xs, ys, zs, cls = [], [], [], []
    chunks = 0
    try:
        with laspy.open(path) as reader:
            expected = reader.header.point_count
            for points in reader.chunk_iterator(chunk_size):
                xs.append(np.asarray(points.x))
                ys.append(np.asarray(points.y))
                zs.append(np.asarray(points.z))
                cls.append(np.asarray(points.classification))
                chunks += 1
    except Exception as exc:
        print(f"  failed after {chunks} chunk(s): {type(exc).__name__}: {exc}")

    if not xs:
        raise ValueError("nothing readable")

    recovered = sum(len(a) for a in xs)
    print(f"  recovered {recovered:,} of {expected:,} points "
          f"({recovered / expected:.1%})")
    return (np.concatenate(xs), np.concatenate(ys),
            np.concatenate(zs), np.concatenate(cls))

LAZ compresses in independent chunks, so damage late in a file leaves everything before it readable. That is a real advantage over formats with a single compressed stream.

Example 3 β€” a directory sweep before a batch job

import glob
import os
import laspy


def survey_directory(pattern):
    """Check every tile opens, before starting a job that assumes they do."""
    files = sorted(glob.glob(pattern))
    ok, bad = [], []
    versions, formats = set(), set()

    for path in files:
        try:
            with laspy.open(path) as reader:
                h = reader.header
                versions.add(str(h.version))
                formats.add(h.point_format.id)
            ok.append(path)
        except Exception as exc:
            bad.append((os.path.basename(path), f"{type(exc).__name__}: {exc}"))

    print(f"  {len(ok)} of {len(files)} tiles open")
    print(f"  versions {sorted(versions)}, point formats {sorted(formats)}")
    if len(versions) > 1 or len(formats) > 1:
        print("  ! mixed versions or formats β€” check attribute availability")
    for name, error in bad[:10]:
        print(f"    {name}: {error}")
    return ok, bad

Header-only checks are microseconds each, so surveying 287 tiles costs nothing and turns a batch failure at tile 200 into a message before the job starts.

Explanation

Why laspy has no built-in decompressor

laspy is pure Python, which makes it installable everywhere without a compiler. LAZ decompression is CPU-bound bit manipulation that pure Python cannot do at a usable speed.

So the decompressor is an optional native dependency, and the extras syntax (laspy[lazrs]) is how you ask for it. The design is deliberate: users who only handle uncompressed LAS pay nothing.

Why lazrs is usually the better backend

laszip is the reference implementation and needs its shared library present, which means a system package, a conda install or a wheel that bundles it β€” all of which can go wrong in a container.

lazrs is a Rust reimplementation distributed as a self-contained wheel. It installs anywhere pip works, and it offers parallel decompression across chunks, which matters on multi-core machines reading large tiles.

Installing both gives a fallback and costs a few megabytes.

Why LAS 1.4 point formats break older tools

LAS 1.4 added point formats 6–10 to lift two limits that mattered: returns per pulse from 5 to 15, and classification codes from 32 to 256.

Those formats have a different record layout, so a reader that predates them cannot parse the points β€” though it can still parse the header, since the header format was extended compatibly.

The practical consequence is that "the header reads and the points do not" points strongly at a version mismatch, and the fix is to upgrade rather than to convert.

Why chunked compression makes damage survivable

LAZ compresses in independent chunks of typically 50,000 points. Each chunk decompresses without reference to any other.

So a corrupt byte destroys one chunk and everything after it in the read order, but everything before it is intact. Streaming with chunk_iterator and catching the exception recovers whatever precedes the damage β€” often nearly all of the file.

A format with one continuous compressed stream would lose everything after the first bad byte.

LAZ chunks decompressing independently, with a corrupt chunk ending the read but leaving earlier chunks intact.
A single compressed stream would lose everything. Chunking means you keep what came first.

Edge cases or notes

  • pip install "laspy[lazrs]" fixes the most common failure.
  • Check laspy.LazBackend availability rather than inferring it from an error.
  • Every LAS and LAZ file starts with LASF. Anything else is a mislabelled download.
  • A .las extension can hold compressed data, and vice versa β€” the point format ID's high bit decides.
  • Header reads but points do not usually means an unsupported point format.
  • Salvage damaged LAZ with chunk_iterator; chunks are independent.
  • Survey a directory with header-only opens before a batch job.
  • Zero-byte files are failed downloads, not format problems.

FAQ

Why does laspy say "No LazBackend selected"?

No LAZ decompressor is installed. Run pip install "laspy[lazrs]".

Which LAZ backend should I use?

lazrs. It is a self-contained wheel with no system dependency and supports parallel decompression. laszip is the reference implementation and needs its shared library present.

Why can laspy read my header but not my points?

Usually an unsupported point format β€” LAS 1.4 formats 6–10 have a different record layout. The header format is stable, so it parses regardless. Upgrade laspy.

How do I tell if a file is really LAZ?

Read the first four bytes; every LAS and LAZ file starts with LASF. The extension is not authoritative β€” a .las file can be compressed.

Can I recover data from a corrupt LAZ file?

Often most of it. LAZ compresses in independent chunks, so streaming with chunk_iterator and catching the exception keeps everything before the damage.

Why does one tile in my directory fail?

Check versions and point formats across the directory. Mixed deliveries are common, and one tile written by a different tool can use a format the others do not.

Does the file extension have to be .laz?

No. Compression is recorded in the point format ID, not the name. Tools generally follow the extension as a hint and the header as the truth.