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.

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.

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

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