Fixing a DuckDB Spatial Extension That Will Not Load

Problem statement

Two different errors, two different causes, and the fix for one does nothing for the other:

IOException: IO Error: Extension "/home/me/.duckdb/extensions/v1.5.5/
linux_amd64/spatial.duckdb_extension" not found.
CatalogException: Catalog Error: Table Function with name "st_read" is not in
the catalog, but it exists in the spatial extension.

The first means the binary is not on disk for this DuckDB version and platform. The second means it is on disk and not loaded into this connection.

Both are common in the same places: containers, CI runners, offline machines, and any environment where DuckDB was upgraded after the extension was installed.

Quick answer

import duckdb

con = duckdb.connect()
try:
    con.execute("load spatial")            # cheap, and usually enough
except duckdb.IOException:
    con.execute("install spatial")         # needs network, once per version
    con.execute("load spatial")

print(con.execute("""
    select extension_name, extension_version, installed, loaded, install_mode
    from duckdb_extensions() where extension_name = 'spatial'
""").fetchall())
[('spatial', 'eb1e57c', True, True, 'REPOSITORY')]

If install itself fails, the environment has no route to the extension repository and the fix is to supply the binary rather than to retry.

Five diagnostic steps for a DuckDB extension that will not load.
Extensions are compiled against DuckDBโ€™s internal API, which changes between versions.

Step-by-step solution

1. Read which of the two errors you have

Error Meaning Fix
IO Error: Extension "..." not found not on disk for this version install spatial
Catalog Error: ... exists in the spatial extension on disk, not loaded here load spatial
HTTP Error / connection failure during install no route to the repository supply the binary
Extension ... was built for DuckDB version ... version mismatch reinstall for this version

The path in the first message is the most useful diagnostic in the system: it names the DuckDB version and the platform DuckDB is looking for.

2. Check the version in the path against your DuckDB

import duckdb, os, platform

print("duckdb    ", duckdb.__version__)
print("platform  ", f"{platform.system().lower()}_{platform.machine()}")
home = os.path.expanduser("~/.duckdb/extensions")
if os.path.isdir(home):
    for version in sorted(os.listdir(home)):
        for target in sorted(os.listdir(os.path.join(home, version))):
            print(f"installed  {version}/{target}: "
                  f"{sorted(os.listdir(os.path.join(home, version, target)))}")
else:
    print("installed  nothing โ€” the extension directory does not exist")

A directory named v1.4.1 beside a DuckDB reporting 1.5.5 is the whole diagnosis: the extension was installed before an upgrade.

3. Reinstall after every DuckDB upgrade

Extensions are compiled against DuckDB's internal C++ API, which is not stable between versions. pip install --upgrade duckdb therefore invalidates the cache.

con.execute("force install spatial")     # re-download even if a copy exists
con.execute("load spatial")

Pinning DuckDB in requirements.txt prevents the surprise entirely, and is worth doing in any pipeline where the extension is baked into an image.

4. Fix it properly in a container

Installing at run time means a network dependency in production and a fresh download on every container start. Install at build time instead:

FROM python:3.12-slim
RUN pip install --no-cache-dir duckdb==1.5.5
RUN python -c "import duckdb; duckdb.connect().execute('install spatial; install httpfs;')"
ENV DUCKDB_EXTENSION_DIRECTORY=/root/.duckdb/extensions

Pin the DuckDB version in the same line that installs the extension. If they drift apart, the image builds fine and fails at the first query.

5. Cache it in CI

A CI job with a fresh home directory downloads the extension on every run โ€” slow, and a hard failure whenever the repository is unreachable.

- uses: actions/cache@v4
  with:
    path: ~/.duckdb
    key: duckdb-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}

Keying on the requirements file means a DuckDB upgrade invalidates the cache automatically, which is exactly the behaviour you want.

6. Install from a local file when there is no network at all

install 'spatial' from '/opt/duckdb-extensions';
load spatial;

or point at an internal mirror:

set custom_extension_repository = 'https://mirror.internal/duckdb';
install spatial;

Download the correct .duckdb_extension file for your DuckDB version and platform on a machine that has network, and ship it with the image or the deployment artefact.

Table of the four segments of the DuckDB extension cache path.
An ARM container needs an ARM extension โ€” the platform segment is not cosmetic.

Code examples

Example 1 โ€” a loader that explains itself

import duckdb


def load_extensions(con, names=("spatial",), allow_install=True):
    """Load extensions, and produce a message that says what to do when it fails."""
    for name in names:
        try:
            con.execute(f"load {name}")
            continue
        except duckdb.IOException as exc:
            if not allow_install:
                raise RuntimeError(
                    f"extension '{name}' is not installed for duckdb "
                    f"{duckdb.__version__} and installation is disabled here.\n"
                    f"  Bake it into the image:\n"
                    f"    RUN python -c \"import duckdb; "
                    f"duckdb.connect().execute('install {name}')\"\n"
                    f"  Original error: {str(exc).splitlines()[0]}") from None
        try:
            con.execute(f"install {name}")
            con.execute(f"load {name}")
        except Exception as exc:
            raise RuntimeError(
                f"could not install '{name}': {str(exc).splitlines()[0]}\n"
                f"  This environment probably has no route to the DuckDB "
                f"extension repository. Install from a local file:\n"
                f"    install '{name}' from '/path/to/extensions';") from None
    return con

Example 2 โ€” a smoke test that proves it works

def smoke_test(con):
    """Loading is not the same as working."""
    checks = [
        ("st_point", "select st_astext(st_point(1, 2))", "POINT (1 2)"),
        ("st_area", "select st_area(st_geomfromtext("
                    "'POLYGON((0 0,1 0,1 1,0 1,0 0))'))", 1.0),
        ("st_read", None, None),      # checked separately: needs a file
    ]
    for name, sql, expected in checks:
        if sql is None:
            available = con.execute(
                "select count(*) from duckdb_functions() where function_name = ?",
                [name]).fetchone()[0]
            print(f"  {name:12} {'present' if available else 'MISSING'}")
            continue
        got = con.execute(sql).fetchone()[0]
        ok = got == expected
        print(f"  {name:12} {'ok' if ok else f'FAILED: got {got!r}'}")
        if not ok:
            raise AssertionError(f"{name} returned {got!r}, expected {expected!r}")
    print("spatial extension is working")

Example 3 โ€” a pytest fixture that skips rather than fails

import pytest
import duckdb


@pytest.fixture(scope="session")
def spatial_con():
    """Skip, do not fail, when the environment cannot provide the extension."""
    con = duckdb.connect()
    con.execute("set enable_progress_bar = false")
    try:
        con.execute("load spatial")
    except duckdb.IOException:
        try:
            con.execute("install spatial; load spatial;")
        except Exception as exc:
            pytest.skip(f"duckdb spatial unavailable in this environment: "
                        f"{str(exc).splitlines()[0]}")
    yield con
    con.close()

A failing test says the code is broken. A skipped test says the environment is. Conflating the two makes an offline CI run look like a regression.

Explanation

Why the extension is not bundled

The spatial extension carries GDAL, GEOS and PROJ โ€” a large amount of native code that most DuckDB users never need. Bundling it would multiply the size of every DuckDB install for a minority of users.

Downloading on demand keeps the base package small and lets the extension ship on its own schedule. The cost is exactly the failure mode this article is about: a run-time dependency on a network resource, in environments that often have neither.

Why the version is in the path

An extension is a shared library compiled against DuckDB's internal API. That API changes between releases, so an extension built for 1.4 cannot be loaded by 1.5 โ€” it would crash rather than misbehave.

Namespacing the cache by version and platform makes the mismatch a clean "not found" instead. It also means the cache accumulates one directory per version you have used, which is harmless and occasionally confusing.

Why load is per connection

install is a filesystem operation; load registers the extension's functions in a connection's catalog. Connections are independent, so each one has to load what it needs.

This is why a notebook that worked can produce a script that fails: the notebook's connection loaded the extension in an earlier cell, and the script opens a new one.

Why baking it into the image is the durable fix

Installing at run time makes every container start depend on an external service. When that service is slow, the container is slow; when it is unreachable, the container fails โ€” usually in production, usually at an inconvenient time.

Installing at build time makes the image self-contained, the start-up deterministic and the dependency visible in the Dockerfile. It is one line, and it converts an operational risk into a build step.

Two panels contrasting failing and skipping a test when an extension is unavailable.
A failing test says the code is broken; a skipped one says the environment is.

Edge cases or notes

  • force install re-downloads over an existing copy โ€” useful after a partial install.
  • DUCKDB_EXTENSION_DIRECTORY relocates the cache, which matters when the home directory is read-only.
  • The platform string matters: linux_amd64, linux_arm64, osx_arm64. An ARM container needs an ARM extension.
  • httpfs is separate and needed for s3:// and https:// reads.
  • Some distributions ship statically linked builds where install_mode is STATICALLY_LINKED and no download is needed.
  • A read-only home directory breaks the default cache location; set the environment variable.
  • duckdb_extensions() is the source of truth โ€” check it before guessing.
  • Pin the DuckDB version anywhere the extension is cached or baked in.

FAQ

Why does load spatial say the extension was not found?

It is not on disk for this DuckDB version and platform. Run install spatial, which downloads it โ€” or supply the binary if the environment has no network.

Why does st_read not exist even though I installed the extension?

Because load is per connection. A new connection in the same process still needs load spatial.

It broke after I upgraded duckdb. Why?

Extensions are compiled against a specific DuckDB version and cached under a version-specific path. Re-run install spatial after any upgrade.

How do I use it in an offline container?

Install it at image build time with a RUN line, and pin the DuckDB version in the same Dockerfile so the two cannot drift apart.

Can I install the extension from a local file?

Yes: install 'spatial' from '/opt/duckdb-extensions', or set custom_extension_repository to an internal mirror.

How do I check what is installed?

select * from duckdb_extensions() lists every extension with its version, install status, loaded status and install mode.