Fixing GDAL PROJ Database Context Errors

You run a reprojection that worked yesterday and GDAL now aborts with PROJ: proj_create: Cannot find proj.db, or ERROR 1: PROJ: proj_create_from_database: ... no database context, or a CRS lookup that returns None where it used to return an EPSG code. The reconstruction never gets a chance to fail on the data — it dies during coordinate setup because the PROJ engine underneath GDAL and pyproj cannot locate its resource database proj.db, or because two different PROJ builds are fighting over the same process. This page explains exactly why that happens, gives a minimal fix you can drop into any ingestion script, and shows how to keep PROJ contexts thread-safe so the error never returns under concurrency. It is the environment-level companion to the broader troubleshooting ingestion and CRS failures methodology.

Why PROJ cannot find its database context

PROJ 6+ moved every datum shift, geoid grid, and CRS definition into a single SQLite file, proj.db, plus a directory of grid files. GDAL and pyproj do not embed that data — they locate it at runtime through a search path, and when the path is wrong or empty the library raises a database context error because it literally has no CRS catalogue to consult. Three distinct root causes produce the same family of messages:

  1. A missing or wrong resource path. PROJ resolves proj.db from the PROJ_DATA environment variable (called PROJ_LIB before PROJ 9.1). If that variable points at a stale conda environment, an uninstalled path, or nothing at all, the lookup fails. This is the dominant cause on servers, Docker images, and CI runners where the variable was inherited from a different environment.
  2. A GDAL-versus-PROJ build mismatch. GDAL is compiled against a specific PROJ ABI. If your Python process loads a GDAL wheel built against one PROJ and a pyproj wheel bundling another PROJ, two copies of the library initialize in one process and disagree about which proj.db is authoritative. The symptom is that pyproj works but osgeo.gdal fails, or vice versa, on the same CRS.
  3. A PROJ context reused across threads. A PROJ context object (PJ_CONTEXT) is not thread-safe. Sharing one Transformer — which owns a context — across a ThreadPoolExecutor corrupts internal state and surfaces as intermittent no database context errors that vanish when you run single-threaded. This is the cause that looks non-deterministic and wastes the most time.

The first two are configuration problems; the third is a concurrency bug. The fix below addresses all three: it pins the resource path, verifies both libraries resolve the same directory, and constructs one context per thread.

How PROJ decides where proj.db lives A search order drawn as four ordered candidates. First, the PROJ_DATA environment variable, or the older PROJ_LIB name still honoured by some builds. Second, a path compiled into the library at build time. Third, the package's bundled data directory, which is what a pip wheel ships. Fourth, a system-wide location installed by a distribution package. The first hit wins, and the failure mode is not an empty result but a wrong one: a stale database from a different installation answers first and returns transformations that do not match the library version. 1 · PROJ_DATA (or legacy PROJ_LIB) wins outright when set — including when set wrongly 2 · path compiled into the library correct on the build machine, often absent in the image 3 · the wheel's bundled data directory what pip install pyproj ships, and usually the right answer 4 · distribution-wide /usr/share/proj a different PROJ version's database, quite possibly older The first hit wins A stale proj.db from a conda install or a distribution package answers first and is silently used by a newer library. Symptom: "database context" errors, or transformations that quietly differ.

Figure 1 — The error message names a database, not a search order, which is why the fix so often looks like guesswork. Printing the resolved directory turns four candidates into one fact.

Minimal reproducible solution

The routine below is the drop-in guard. It sets PROJ_DATA from the location pyproj actually installed its data to (the one directory guaranteed to contain a matching proj.db), asserts the file exists before any CRS work, and hands out a thread-local transformer so no PROJ context is ever shared across workers. Keep it under the import block of every ingestion entry point.

import os
import threading
from pathlib import Path
import pyproj
from pyproj import Transformer

def pin_proj_data() -> Path:
    """Point PROJ_DATA at pyproj's bundled data dir and verify proj.db exists."""
    data_dir = Path(pyproj.datadir.get_data_dir())   # pyproj's own PROJ data
    proj_db = data_dir / "proj.db"
    if not proj_db.is_file():
        raise RuntimeError(f"proj.db missing under {data_dir}; reinstall pyproj/PROJ")
    # Set BOTH names: PROJ_DATA (PROJ >= 9.1) and PROJ_LIB (older builds).
    os.environ["PROJ_DATA"] = str(data_dir)
    os.environ["PROJ_LIB"] = str(data_dir)
    # Make GDAL resolve the SAME directory so the two builds cannot disagree.
    pyproj.datadir.set_data_dir(str(data_dir))
    return data_dir

# Thread-local storage: one PROJ context (via one Transformer) per worker thread.
_local = threading.local()

def get_transformer(src: str, dst: str) -> Transformer:
    """Return a per-thread Transformer; never share a PROJ context across threads."""
    key = (src, dst)
    cache = getattr(_local, "cache", None)
    if cache is None:
        cache = _local.cache = {}
    if key not in cache:
        # always_xy avoids the axis-order swap; built once per thread per CRS pair.
        cache[key] = Transformer.from_crs(src, dst, always_xy=True)
    return cache[key]

if __name__ == "__main__":
    pin_proj_data()                              # must run before any CRS lookup
    tf = get_transformer("EPSG:4326", "EPSG:32633")
    print(tf.transform(15.0, 47.0))              # (easting, northing) in metres

The load-bearing line is pyproj.datadir.get_data_dir(): it returns the directory pyproj shipped, which always contains a proj.db matching pyproj’s PROJ build, so pinning PROJ_DATA there resolves both the missing-path cause and — because we also call set_data_dir — forces GDAL to agree, resolving the mismatch cause. The thread-local cache resolves the concurrency cause by guaranteeing each worker owns its own context. For the correct axis-order and datum handling once the context is healthy, the managing coordinate reference systems in GDAL workflow is the reference.

Three environments, three ways the same code fails Three deployment environments compared. A pip-only virtual environment resolves to the wheel's bundled data and normally works, but breaks if PROJ_LIB is inherited from the shell. A conda environment carries its own share directory and works until a pip-installed pyproj is layered on top, giving two databases. A container built from a distribution package has a system database that may be older than the Python bindings expect. Each column lists the single check that identifies its case. pip venv uses the wheel's bundled data usually correct out of the box breaks when PROJ_LIB is inherited from the shell check: env | grep PROJ unset it and re-run conda env carries its own share/proj consistent while unmixed breaks when pip installs pyproj over the conda one check: pip list vs conda list pick one installer per env distro container system proj.db in /usr/share tied to the distribution release breaks when the bindings are newer than the database check: proj --version vs pyproj pin both in the image The code is identical in all three. What differs is which database answers. Which is why the first diagnostic step is to print the resolved directory, not to read the traceback.

Figure 2 — The same script, three environments, three different databases. Each column has one cheap check that settles which case you are in.

Edge-case matrix

These are the environment variants that produce the same error family and how the guard above handles each.

Input variant Downstream symptom if unchecked Expected handling
PROJ_DATA unset (fresh Docker image) Cannot find proj.db on first transform pin_proj_data() sets it from pyproj’s dir
PROJ_LIB points at a removed conda env no database context despite pyproj installed Overwrite both PROJ_DATA and PROJ_LIB explicitly
GDAL wheel + pyproj bundle different PROJ pyproj works, osgeo fails on same CRS set_data_dir forces one shared directory
One Transformer shared across threads intermittent no database context under load Per-thread Transformer via thread-local cache
proj.db present but grid .tif missing transform succeeds but geoid height is wrong Assert grid presence separately; not this error class
Network-mounted PROJ_DATA times out slow or hanging CRS lookups Copy proj.db to local disk, repoint PROJ_DATA
Pinning the data directory so the fix survives deployment A before-and-after comparison of how a project pins its PROJ data. Before: the directory is left to the search order, an environment variable is set ad hoc in one shell, and the container inherits whatever the base image provides — so the fix works on the machine where it was applied and nowhere else. After: the directory is set explicitly at process start from the installed package, the value is written into the run manifest, and a startup assertion refuses to proceed if the resolved database cannot answer a known EPSG lookup. before — fixed in one shell export PROJ_LIB=... typed once nothing records what was used container inherits the base image's works here, fails in CI the bug returns on the next deploy after — pinned in the process set from the installed package at start recorded in the run manifest startup assertion on a known EPSG refuses to run against a stale database the failure becomes loud and early The assertion costs one lookup at startup and converts a silent wrong-transformation into a refusal to start.

Figure 3 — Setting an environment variable fixes the symptom for one shell. Pinning the directory in the process, recording it, and asserting on it is what stops the same afternoon repeating after the next image rebuild.

Verification snippet

Confirm the context is healthy before trusting any reprojection: assert both libraries resolve the same directory, that proj.db is there, and that a known transform actually returns finite metres rather than raising.

import math
from pathlib import Path
import pyproj
from osgeo import osr

def verify_proj_context() -> None:
    """Assert GDAL and pyproj share a working PROJ database context."""
    data_dir = pin_proj_data()
    assert (Path(data_dir) / "proj.db").is_file(), "proj.db not resolvable"

    # GDAL/OSR must build a CRS from the same catalogue without error.
    srs = osr.SpatialReference()
    assert srs.ImportFromEPSG(32633) == 0, "GDAL cannot read EPSG from proj.db"

    # A concrete transform returns finite metres, proving the context works.
    tf = get_transformer("EPSG:4326", "EPSG:32633")
    e, n = tf.transform(15.0, 47.0)
    assert math.isfinite(e) and math.isfinite(n), "transform returned non-finite"
    print(f"PROJ context OK at {data_dir}: {e:.1f}, {n:.1f}")

verify_proj_context()

A clean run means every CRS lookup in the process now points at the same proj.db, and the no database context error cannot recur for either library. Wrap this assertion into your pipeline’s startup so a broken environment fails in seconds at launch rather than mid-reconstruction.

When to escalate

This page fixes the PROJ environment. Escalate to the parent guide when the context is healthy but coordinates are still wrong:

  • The context resolves but heights are off by tens of metres. That is a vertical datum problem, not a database problem — proj.db was found, but no geoid grid was applied. Return to troubleshooting ingestion and CRS failures and follow the vertical-drift branch.
  • The transform runs but the output lands in the wrong zone or hemisphere. The context is fine; this is an axis-order or EPSG-selection error. Resolve it with the axis and reprojection rules in managing coordinate reference systems in GDAL.
  • Results still differ between two machines after pinning. The builds themselves diverge. Pin pyproj and GDAL to exact versions across every environment, then re-run the ingestion diagnostic methodology to confirm parity.

Troubleshooting Ingestion and CRS Failures