Fixing “DLL load failed” for GDAL on Windows

This page answers one question: your GDAL extension installs on Windows but import raises ImportError: DLL load failed while importing _gdal: The specified module could not be found, so how do you make the bundled gdal.dll and its dependencies discoverable given that Windows has no RPATH and does not search the directory next to your .pyd? It sits inside the Platform-Specific ABI Quirks section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the delvewheel repair, the os.add_dll_directory shim, and the dumpbin diagnosis.

Windows DLL search order and the add_dll_directory fix A pyd import triggers the Windows loader, which searches the system directories and PATH but not the package folder holding the bundled gdal DLL, so the load fails. After delvewheel copies mangled DLLs into the package and the package init calls os.add_dll_directory on that folder, the loader finds the DLL and the import succeeds. import _gdal Windows loader searches system + PATH not the package folder ✗ add_dll_directory registers package folder ✓ bundled gdal.dll gdal-<hash>.dll found

Context & Root Cause

DLL load failed is Windows’ equivalent of cannot open shared object file: the loader could not find a DLL the .pyd depends on. Two things about Windows make this bite geospatial packages specifically. First, there is no RPATH — a Windows binary carries no embedded search path, so the $ORIGIN trick that makes Linux wheels relocatable has no counterpart. Second, since Python 3.8, the loader deliberately does not search PATH or the current directory for extension dependencies; it searches the system directories, the directories added via os.add_dll_directory, and nothing else. A gdal.dll sitting right next to your _gdal.pyd is invisible to the loader by default.

The consequence is that bundling the DLLs is necessary but not sufficient — the package must also tell the loader where they are at import time. delvewheel handles the bundling (copying and name-mangling the DLLs into the package) and injects a small loader shim, but understanding the shim is essential when it fails or when you vendor DLLs by hand. This is the Windows arm of the platform matrix in Platform-Specific ABI Quirks.

Solution / Fix

This targets Windows Server 2022 / Windows 11, MSVC 2022, GDAL 3.8.x, and delvewheel 1.x.

1. Diagnose which DLL is missing

:: In a Visual Studio Developer Command Prompt
dumpbin /dependents build\_gdal.cp312-win_amd64.pyd
:: look for gdal.dll, proj.dll, geos_c.dll among the listed imports

A dependency that is not a system DLL (KERNEL32.dll, VCRUNTIME140.dll) and not present in the package is the culprit.

2. Repair with delvewheel

delvewheel copies the dependency DLLs into the package, mangles their names to avoid clashing with any other package’s gdal.dll, and adds a _delvewheel_init call to the package:

delvewheel repair -w repaired --add-path C:\gdal\bin dist\*.whl

--add-path tells delvewheel where to find the build-time DLLs to copy. Inspect the result:

delvewheel show repaired\*.whl
:: lists the vendored, mangled DLLs now inside the wheel

3. Confirm (or add) the loader shim

delvewheel injects the shim automatically. If you vendor by hand, the package __init__.py must register the DLL folder before importing the extension:

# mypkg/__init__.py — must run BEFORE `from . import _gdal`
import os
from pathlib import Path

_dll_dir = Path(__file__).with_name("mypkg.libs")   # where the DLLs live
if _dll_dir.is_dir():
    os.add_dll_directory(str(_dll_dir))

from . import _gdal   # now the loader can resolve gdal-<hash>.dll

Verification

:: 1. The pyd's GDAL dependency must resolve to a bundled, mangled DLL
dumpbin /dependents repaired\_gdal*.pyd | findstr /i gdal
:: expected: gdal-<hash>.dll  (mangled name)
:: 2. Clean-machine import — a VM or runner with NO GDAL on PATH
pip install repaired\*.whl
python -c "from osgeo import gdal; print(gdal.__version__)"
:: expected: 3.8.x  (no 'DLL load failed')

A mangled DLL name in the dependents list and a version printed on a machine with no system GDAL confirm the fix. If it still fails, run python -X dev and read which DLL is reported missing — it is usually a second-order dependency (libtiff, libcurl) that was not on --add-path.

The Search Order, and Why 3.8 Changed It

Understanding this error properly means knowing what Python 3.8 took away. Before it, extension modules were loaded with the process’s ordinary DLL search order, which included the current working directory and every directory on PATH. That made geospatial packages appear to work — GIS workstations have GDAL on PATH from QGIS or OSGeo4W — while making them impossible to isolate, because whichever GDAL appeared first on PATH won for every package in the process.

Python 3.8 switched to LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, which deliberately excludes PATH and the working directory. The upside is that packages became isolatable: a wheel can bundle its own DLLs and register only its own directory. The downside is the error this page is about, because bundling alone changed nothing — the loader still had no reason to look inside the package.

Windows DLL search for an extension module, before and after Python 3.8 Two ordered lists. Before Python 3.8 the loader searched the application directory, the system directories, the current working directory and every directory on PATH, so a GDAL installed by QGIS or OSGeo4W satisfied the import by accident. From Python 3.8 the loader searches the application directory, the system directories and only those directories registered with os.add_dll_directory; PATH and the working directory are excluded, so a bundled DLL is invisible until the package registers its own folder. Python ≤ 3.7 — PATH decided 1 · application directory 2 · System32, Windows 3 · current working directory 4 · every directory on PATH QGIS or OSGeo4W on PATH satisfied the import — for every package in the process, whether or not that GDAL matched what it was built against Python ≥ 3.8 — registration decides 1 · application directory 2 · System32, Windows 3 · os.add_dll_directory() entries ✕ PATH and cwd — no longer searched the bundled DLL exists but is invisible until the package registers its own folder — which is what delvewheel's injected shim does at import time the same change is why a wheel that worked on Python 3.7 can fail on 3.8+ with no rebuild and no code change and why "it works on my machine" is nearly always PATH — the one input the new order deliberately ignores

One consequence worth internalising: os.add_dll_directory returns a handle whose lifetime matters. If you call it inside a function and let the return value be garbage-collected, the directory is removed from the search set, and a DLL loaded lazily afterwards will fail. Keeping the handle alive at module scope — which is what delvewheel’s generated shim does — avoids a failure that appears only when some code path defers loading until after import.

A second consequence concerns dependency order. Windows resolves a DLL’s own dependencies when it is loaded, so registering the directory is not enough if a bundled DLL depends on another bundled DLL that lives elsewhere. delvewheel handles this by putting everything in one directory and mangling names, which is why mixing hand-vendored DLLs with repaired ones tends to fail in confusing ways.

What delvewheel Leaves in the Package

Knowing the shape of a repaired Windows wheel makes it obvious whether a repair ran and whether a hand-vendored DLL has been added in the wrong place.

The layout of a delvewheel-repaired Windows wheel The package directory contains an __init__.py holding the injected loader shim, the extension module _gdal.pyd whose import table now names hashed DLLs, and a sibling libs directory holding the mangled copies of gdal, proj, geos_c and their dependencies. A note records that the shim must run before the extension is imported and that the directory handle it returns must stay alive. mypkg/ __init__.py _delvewheel_init_patch(...) ← runs first _gdal.cp312-win_amd64.pyd imports gdal-4f2a1c.dll, proj-9b31e7.dll mypkg.libs/ gdal-4f2a1c.dll proj-9b31e7.dll geos_c-2c80f4.dll sqlite3-…dll tiff-…dll zlib-…dll curl-…dll what to check 1 · the shim is present and imported before _gdal 2 · every name in the import table is hashed 3 · every hashed DLL exists in mypkg.libs/ 4 · the add_dll_directory handle is kept alive 5 · nothing was hand-copied beside the .pyd — that directory is not searched an unhashed gdal.dll anywhere means an unrepaired build the shim's whole job is step 4: registering mypkg.libs so the loader can resolve the hashed names in step 2

Point five is the one that most often surprises people migrating from an older layout: copying DLLs next to the .pyd, which worked before Python 3.8, now achieves nothing at all.

Diagnosing Which DLL Is Actually Missing

The error message names the extension module, never the missing dependency, which is the single most frustrating property of this failure. Three techniques get to the real name.

The most direct is dumpbin /dependents on the .pyd, followed by checking each listed DLL against the package contents and the system directories. It is reliable but shallow: it lists only direct dependencies, so a missing second-level DLL will not appear. For that, repeat the command on each bundled DLL, or use a tool that walks the graph.

The second is to let the loader tell you. Setting the PYTHONVERBOSE environment variable does not help here, but Windows’ own loader snaps diagnostics do, and the lighter-weight version is to attempt the load explicitly with ctypes, which surfaces the Win32 error for a specific file rather than a generic import failure.

# Point ctypes at the exact file and read the real Win32 error
import ctypes, os
from pathlib import Path

libs = Path(__file__).with_name("mypkg.libs")
with os.add_dll_directory(str(libs)):
    for dll in sorted(libs.glob("*.dll")):
        try:
            ctypes.WinDLL(str(dll))
            print("ok  ", dll.name)
        except OSError as exc:
            print("FAIL", dll.name, exc)   # names the file that could not load

The third is a clean-room test that removes the ambiguity entirely: import the package in a process whose PATH has been emptied. If the import succeeds with a full PATH and fails with an empty one, the package is relying on a DLL from the machine rather than from the wheel — which is the actual defect, regardless of which file the loader happened to name.

:: A wheel that passes this is genuinely self-contained
set PATH=C:\Windows\System32
python -c "from osgeo import gdal; print(gdal.__version__)"

Frequently Asked Questions

Why does delvewheel rename the DLLs?

Because Windows resolves DLLs by name across the whole process, not per package. If two installed wheels each bundled a gdal.dll, whichever loaded first would serve both, and the second package would be running against a GDAL it was never built for — the Windows form of the collision problem that version scripts solve on Linux. Hashed names make each package’s copy unambiguous.

Do I need the Visual C++ redistributable?

Usually not, if the extension and its dependencies are built with a toolset whose runtime ships with supported Windows versions, and if the runtime DLLs are bundled where they are not. The failure mode when it is missing is the same opaque DLL load failed, with VCRUNTIME140_1.dll as the actual culprit — worth checking early, because it is invisible on any machine with Visual Studio installed.

Can I set PATH in __init__.py instead of using add_dll_directory?

No. Since Python 3.8 the loader does not consult PATH for extension dependencies at all, so modifying it has no effect on the import that is failing — while still polluting the environment for subprocesses. add_dll_directory is the supported mechanism and the only one that works.

How do I test this without a Windows machine?

A Windows CI runner is the practical answer, and the test is cheap: install the wheel and import it with a minimal PATH. Emulation is not a realistic option for Windows, and a Linux developer’s usual instinct — check it in a container — does not transfer, because the DLL search order is a property of the operating system rather than of the packaging.

Pitfalls & Alternatives

Adding GDAL to PATH as a fix. On Python 3.8+ the loader ignores PATH for extension dependencies, so this appears to work only when a copy also happens to be in a system directory. Use os.add_dll_directory or delvewheel; do not rely on PATH.

Hard-coding LoadLibrary("gdal.dll"). delvewheel renames the DLL to gdal-<hash>.dll, so any code that loads it by its original name fails. Import through the package so the mangled name resolves via the registered directory.

Missing second-order dependencies. GDAL pulls proj.dll, geos_c.dll, libtiff.dll, libcurl.dll, and more. If --add-path covers only GDAL’s own folder, the transitive DLLs are left out and the import fails on the next missing name. Point --add-path at every directory holding a needed DLL, or use a pixi environment that gathers them in one prefix.