Fixing a DuckDB Spatial Extension That Will Not Load

On this page

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.

Diagnosing a DuckDB spatial extension that will not load. work down this list1read which of the two errors it isnot found, or not loaded2compare the version in the pathto duckdb.__version__3reinstall after any upgradeforce install spatial4bake it into the imagea RUN line at build time5or install from a local fileinstall 'spatial' from '/opt/โ€ฆ'A directory named v1.4.1 beside a DuckDB reporting 1.5.5 is the whole diagnosis spatialworkflow.io
Extensions are compiled against DuckDBโ€™s internal API, which changes between versions.

Read the error, match the version, bake it in

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

print("duckdb    ", duckdb.__version__)
print("platform  ", duckdb.connect().execute("pragma platform").fetchone()[0])  # DuckDB's name for it
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")
duckdb     1.5.5
platform   linux_amd64
installed  v1.5.3/linux_amd64: ['spatial.duckdb_extension', 'spatial.duckdb_extension.info']
installed  v1.5.4/linux_amd64: ['spatial.duckdb_extension', 'spatial.duckdb_extension.info']
installed  v1.5.5/linux_amd64: ['excel.duckdb_extension', 'excel.duckdb_extension.info', 'h3.duckdb_extension', 'h3.duckdb_extension.info', 'httpfs.duckdb_extension', 'httpfs.duckdb_extension.info', 'spatial.duckdb_extension', 'spatial.duckdb_extension.info']

On the machine this page was run on, v1.5.3 and v1.5.4 directories sit beside the v1.5.5 one: each holds a spatial extension installed before an upgrade, and none of them can be loaded by 1.5.5. If the only directory is an older version, that is the whole diagnosis.

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.

The version- and platform-specific path where DuckDB caches extensions. why an upgrade breaks itsegmentexamplechanges whenroot~/.duckdb/extensionsDUCKDB_EXTENSION_DIRECTORY is setversionv1.5.5you upgrade duckdbplatformlinux_amd64you change architecturefilespatial.duckdb_extensionneverthe path in the error message names all fourpip install --upgrade duckdb invalidates the cache; reinstalling takes seconds spatialworkflow.io
An ARM container needs an ARM extension โ€” the platform segment is not cosmetic.

A loader, a smoke test and a skipping fixture

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


con = load_extensions(duckdb.connect())
print(con.execute("select extension_name, loaded from duckdb_extensions() "
                  "where extension_name = 'spatial'").fetchall())
[('spatial', True)]

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 lower(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")


smoke_test(con)
  st_point     ok
  st_area      ok
  st_read      present
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.

Example 4 โ€” reproducing both errors

empty = "empty_extensions"                     # an extension directory with nothing in it
os.makedirs(empty, exist_ok=True)
bare = duckdb.connect(config={"extension_directory": empty,
                              "autoinstall_known_extensions": False,
                              "autoload_known_extensions": False})
for sql in ("load spatial", "select * from st_read('x.gpkg')"):
    try:
        bare.execute(sql)
    except duckdb.Error as exc:
        print(f"{type(exc).__name__}: {str(exc).splitlines()[0]}")
IOException: IO Error: Extension "empty_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.

Why the extension lives outside DuckDB

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.

A test suite should skip when the environment cannot provide the extension. skip and fail mean different thingspytest.failโœ•says the code is brokenโœ•red build on an offline runnerโœ•hides real regressions in the noiseโœ•someone disables the testpytest.skipโœ“says the environment is limitedโœ“green build, visible skip countโœ“real failures stay visibleโœ“the test survivesConflating the two makes an offline CI run look like a regression spatialworkflow.io
A failing test says the code is broken; a skipped one says the environment is.

Forced installs, platforms and read-only homes

  • 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.