Skip to content

PROJ and GDAL Runtime Configuration

A GDAL stack does not fail gracefully when its runtime environment is wrong — it fails at the first coordinate transformation with ERROR 4: Unable to open EPSG support file gcs.csv, or silently returns coordinates that are two metres out. Set five variables explicitly on every function — GDAL_DATA=/opt/share/gdal, PROJ_DATA=/opt/share/proj, PROJ_LIB=/opt/share/proj, LD_LIBRARY_PATH=/opt/lib, and PYTHONPATH=/opt/python/lib/python3.12/site-packages — and the whole category disappears. Everything else on this page is about the values those five take on each runtime, the transformation grids that proj.db deliberately does not contain, and the seven GDAL configuration options that change a Cloud Optimized GeoTIFF read from 2,410 ms to 520 ms.

This page sits inside the packaging and dependency management discipline, and it picks up exactly where native library compilation for serverless stops. Compilation gets a working libgdal.so into an artifact; configuration is what makes that artifact find its own data once the artifact is somewhere the compiler never anticipated.

What a GDAL Stack Actually Needs at Runtime

GDAL and PROJ are C libraries with a filesystem contract. Neither one carries its reference data inside the shared object: libgdal.so expects a directory of CSV and XSD files, and libproj.so expects a SQLite database plus, for some transformations, a set of raster grids. Both look those directories up by an environment variable first and a compile-time constant last, and the compile-time constant is always wrong in a serverless runtime because the build container’s /usr/share is not the function’s /usr/share.

Resolution order GDAL and PROJ use to locate gdal_data and proj.dbFive ordered steps: the GDAL_DATA or PROJ_DATA environment variable is read first, then a runtime SetConfigOption call, then the PROJ user-writable directory under HOME, then the prefix compiled into the binary at build time, and finally an unrecoverable error naming gcs.csv or proj.db when every step misses.The order GDAL and PROJ walk to find their data directories1Environment variableGDAL_DATA / PROJ_DATA read once, at first driver use/opt/share/proj2Runtime config optiongdal.SetConfigOption or pyproj.datadir.set_data_dir — must run before the first transformin-process3PROJ user directory$HOME/.local/share/proj — HOME is /home/sbx_user1051 and is read-onlymiss4Compile-time prefix/usr/share/gdal, /usr/local/share/proj — the build container's paths, not the layer'smiss5Hard failureERROR 4: Unable to open EPSG support file gcs.csvexit 1
Only the first two steps exist inside a Lambda. Steps three and four point at build-machine paths that were never copied into the layer, which is why an unset variable ends at step five rather than at a sensible default.

Four variables carry the whole contract, and each one breaks differently when it is missing:

  • GDAL_DATA — points at the directory holding gcs.csv, pcs.csv, gdalvrt.xsd, header.dxf and the driver metadata. Missing, GDAL still opens a GeoTIFF and still reads pixels, which is why this failure survives smoke tests. It breaks when the code asks for an EPSG definition, producing ERROR 4: Unable to open EPSG support file gcs.csv or a RuntimeError from osr.SpatialReference().ImportFromEPSG().
  • PROJ_DATA (and its older name PROJ_LIB) — points at the directory holding proj.db. Missing, every reprojection fails with PROJ: proj_create_from_database: Cannot find proj.db, and pyproj raises CRSError: Invalid projection. Unlike GDAL_DATA this one usually fails loudly and immediately.
  • LD_LIBRARY_PATH — the dynamic linker’s search path. Missing, the import chain aborts before your handler is reached with ImportError: libgdal.so.35: cannot open shared object file: No such file or directory. This is the only one of the four that fails at import rather than at use.
  • PYTHONPATH — where the interpreter finds rasterio, pyproj, fiona. On AWS Lambda the runtime already appends /opt/python and /opt/python/lib/python3.12/site-packages, so this is usually implicit — but a container-image function, a custom runtime, or a subprocess spawned with a scrubbed environment will not have it, and setting it explicitly costs nothing.

There is a fifth, quieter dependency: the CA bundle. GDAL’s /vsis3 and /vsicurl drivers use libcurl, and a stripped-down image without ca-certificates produces CURL error: SSL peer certificate or SSH remote key was not OK on the first range request. Set CURL_CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt on Amazon Linux 2023 rather than reaching for GDAL_HTTP_UNSAFESSL=YES, which disables verification instead of fixing it.

The ordering matters more than it looks. GDAL resolves and memoizes the data directory during driver registration, which happens on the first from osgeo import gdal or import rasterio in the process. A handler that mutates os.environ after a module-scope import has already lost — the value is cached in the execution environment and will stay wrong for every warm invocation that follows. That is why these belong in the function configuration, where they exist before the interpreter starts, and not in application code.

How /opt Lands, and Why the Paths Differ per Runtime

The single most common configuration bug is a path copied between two runtimes that mount their artifacts differently. AWS Lambda extracts every attached layer into /opt, merging their contents, so a layer zip whose root is share/proj/proj.db becomes /opt/share/proj/proj.db. That prefix is a property of layers, not of Lambda: the same function packaged as a container image has no /opt at all unless the Dockerfile put something there, and its GDAL data sits wherever the image’s package manager left it.

GDAL and PROJ runtime paths across AWS Lambda, GCP Cloud Run and Azure FunctionsComparison grid of where the GDAL data directory, the PROJ data directory, the shared-library search path and the Python module path resolve on AWS Lambda with a layer, GCP Cloud Run with a container image, and Azure Functions on the Consumption plan, plus each platform's payload ceiling and timeout.The same four variables, three different filesystemsAWS Lambda (layer)GCP Cloud Run (image)Azure Functions(Consumption)GDAL_DATA/opt/share/gdallayer extracts to /opt/usr/share/gdalimage filesystem/home/site/wwwroot/share/gdalapp payload mountPROJ_DATA (PROJ 9)/opt/share/projset PROJ_LIB to match/usr/share/projset PROJ_LIB to match/home/site/wwwroot/share/projset PROJ_LIB to matchLD_LIBRARY_PATH/opt/libprepend, never replace/usr/libusually already correct/home/site/wwwroot/libprepend, never replacePYTHONPATH/opt/python/lib/python3.12/site-packagesimplicit, but set it anywaysite-packages in theimageno override needed.python_packages/lib/site-packagesunder wwwrootPayload ceiling250 MB unzippedfunction + all layers, 5 layersmaxno zip ceilingimage layers only1,536 MB memory10 min timeoutWritable path/tmp10,240 MB max, 512 MB defaultin-memory filesystemcounts against 32 GiB/tmpshares the 1,536 MB poolAWS Lambda deployed as a container image behaves like the Cloud Run column, not the layer column — the /opt prefix only exists when a layer isattached.
A configuration copied from a Cloud Run image to a Lambda layer breaks on every row. The prefix is /opt only on Lambda, and only because layers are extracted there.

The platform quotas that constrain those choices are worth stating exactly, because the configuration strategy on each platform is downstream of them.

Constraint AWS Lambda GCP Cloud Functions 2nd gen / Cloud Run Azure Functions (Consumption)
Max timeout 15 min 60 min (Cloud Run: 60 min request timeout) 10 min
Max memory 10,240 MB 32,768 MB (Cloud Run: up to 32 GiB / 8 vCPU) 1,536 MB
Ephemeral /tmp 10,240 MB max, 512 MB default in-memory filesystem, counts against the memory allocation shares the 1,536 MB pool
Dependency payload 250 MB unzipped across the function and all layers container image, no zip ceiling 1 GB zip
Layer mechanism up to 5 layers, mounted at /opt none — use a container image extension bundle or container image
GDAL_DATA value /opt/share/gdal /usr/share/gdal /home/site/wwwroot/share/gdal
PROJ_DATA value /opt/share/proj /usr/share/proj /home/site/wwwroot/share/proj
LD_LIBRARY_PATH value /opt/lib /usr/lib (usually already correct) /home/site/wwwroot/lib
Writable grid cache /tmp/proj_cache /tmp/proj_cache (memory-backed) /tmp/proj_cache

Three consequences follow directly. On AWS the 250 MB unzipped ceiling across the function and all five layers is what makes the grid question a real engineering problem rather than a footnote — a full grid set does not fit, so a decision has to be made. On Cloud Run the ceiling disappears and the honest answer is usually to bake grids into the image, which is what multi-stage Dockerfiles for GDAL on Cloud Run is for. On Azure Functions Consumption the 1,536 MB memory ceiling means GDAL_CACHEMAX has to be set conservatively — a 512 MB block cache leaves under a gigabyte for the raster arrays themselves.

There is one asymmetry worth internalising: LD_LIBRARY_PATH must be prepended, never replaced. Setting it to bare /opt/lib on Lambda drops the runtime’s own paths and can break the AWS SDK’s own native components. Set it to /opt/lib:/var/task/lib:/var/lang/lib:/lib64:/usr/lib64, or set it in code before any import as os.environ["LD_LIBRARY_PATH"] = "/opt/lib:" + os.environ.get("LD_LIBRARY_PATH", "").

PROJ 9 Renamed PROJ_LIB to PROJ_DATA

PROJ 9.1 renamed the search-path environment variable from PROJ_LIB to PROJ_DATA. The old name still works — PROJ reads it, uses it, and emits a deprecation warning to stderr — but the new name takes precedence when both are set. This is a genuine trap in serverless packaging for one specific reason: a single function frequently contains two PROJ builds. The pyproj wheel vendors its own libproj under pyproj/proj_dir/share/proj, while rasterio vendors another under rasterio.libs, and those two can straddle the 9.1 boundary.

The rule that survives every version combination is trivial: set both variables, to the same value.

python
# In IaC, not in the handler. Shown here as the resulting environment.
PROJ_DATA = "/opt/share/proj"   # PROJ >= 9.1 reads this
PROJ_LIB  = "/opt/share/proj"   # PROJ  < 9.1 reads this; >= 9.1 warns and ignores

Setting only PROJ_LIB on a PROJ 9.4 build works but writes a deprecation line into CloudWatch on every cold start. Setting only PROJ_DATA on a PROJ 8.2 build fails outright with Cannot find proj.db. Setting both is correct on every version in the field, and it is the configuration that survives the day someone bumps a wheel. The same reasoning drives pinning GDAL and PROJ versions across build and runtime — configuration that tolerates version drift is good, but not drifting is better.

A related detail: pyproj does not read PROJ_DATA in all versions. It has its own resolution through pyproj.datadir.get_data_dir(), which checks PROJ_DATA, then PROJ_LIB, then the directory recorded at wheel build time. When those disagree — GDAL using /opt/share/proj and pyproj using its vendored copy — you get two different proj.db files answering the same question, and the two can hold different transformation pipelines. Assert on pyproj.datadir.get_data_dir() in the verification step below rather than assuming it agrees.

The Datum-Grid Problem

proj.db is about 9.4 MB and contains every CRS, datum, ellipsoid and transformation definition PROJ knows about. What it does not contain is grid data. A datum shift such as NAD27 to NAD83, or NZGD49 to NZGD2000, or the Australian GDA94 to GDA2020 transformation, is defined by a raster of per-cell offsets — and those rasters live in separate files that PROJ looks for in the same PROJ_DATA directory.

Composition of a 250 MB Lambda payload for a reprojecting GDAL functionA 250 megabyte unzipped budget divided into five parts: rasterio with its bundled GDAL, PROJ and GEOS shared objects at 118 megabytes, numpy and the remaining Python dependencies at 41 megabytes, the PROJ database at 9.4 megabytes, the GDAL data directory at 3.1 megabytes, and 78 megabytes of unused headroom.Where the 250 MB unzipped budget goes on a reprojecting functionrasterio + bundled libgdal, libproj, libgeosrasterio.libs after strip --strip-unneeded118 MBnumpy and remaining Python dependenciesone copy, deduplicated across layers41 MBproj.dbevery CRS, datum and transformation definition PROJ knows9.4 MBGDAL data directorygcs.csv, pcs.csv, gdalvrt.xsd, driver metadata3.1 MBHeadroom for transformation gridsbefore the 250 MB ceiling rejects the deployment78 MBMeasured on rasterio 1.4.3 with PROJ 9.4 on Amazon Linux 2023, x86_64, after stripping debug symbols and pruning test trees.
proj.db and the GDAL data directory together are 12.5 MB — five per cent of the budget and non-negotiable. The headroom is what transformation grids have to fit inside.

When a grid is missing, PROJ does not raise. It falls back to the next-best transformation in its pipeline list, which is usually a null or ballpark transformation, and returns coordinates that are correct to a few metres instead of a few centimetres. pyproj will tell you if you ask:

python
from pyproj.transformer import TransformerGroup

tg = TransformerGroup("EPSG:4267", "EPSG:4326")  # NAD27 -> WGS84
print(f"available  : {len(tg.transformers)}")
print(f"unavailable: {len(tg.unavailable_operations)}")
for op in tg.unavailable_operations:
    print("missing grid for:", op.name)

If unavailable_operations is non-empty, the accurate pipeline is not installed and the transform that runs is the fallback. In a pipeline that later writes a STAC item claiming centimetre georeferencing, that is a data-quality incident, not a warning.

Three strategies exist, and the correct one depends on the platform ceiling:

  1. Bundle the specific grids. Determine which grids your transformations name, and copy only those into the layer. One New Zealand grid is 4.6 MB; the CONUS GEOID18 grid is 27 MB; the full proj-data release is roughly 655 MB and cannot fit anywhere near the 250 MB Lambda ceiling. This is the right answer when the pipeline’s CRS pairs are fixed and known.
  2. Enable PROJ_NETWORK=ON. PROJ then fetches grids over HTTP from a CDN on demand, using range requests so it downloads only the cells the transformation touches. Point PROJ_NETWORK_ENDPOINT at https://cdn.proj.org or a mirror in your own bucket, and give it a writable cache: PROJ_USER_WRITABLE_DIRECTORY=/tmp/proj_cache. Without that last variable PROJ tries to write to $HOME/.local/share/proj, and HOME on Lambda is read-only.
  3. Stage grids on shared storage. An EFS access point mounted at /mnt/proj on Lambda, or a GCS FUSE mount on Cloud Run, holds the full grid set outside the package entirely. This costs a VPC configuration and adds cold-start latency, and it is the right answer only when the CRS pairs are genuinely open-ended.

The full decision, with sizes and the projinfo incantation that tells you which grids a given transformation needs, is in shipping PROJ transformation grids without blowing the package limit.

The GDAL Config Options That Move the Needle

GDAL exposes several hundred configuration options. Seven of them account for nearly all the difference between a fast and a slow serverless read, and every one of them is about the fact that the “filesystem” is actually HTTP.

Option Value to set What it changes
GDAL_DISABLE_READDIR_ON_OPEN EMPTY_DIR Stops GDAL listing the containing prefix on open. On a prefix with thousands of objects this alone removes 400–1,200 ms.
CPL_VSIL_CURL_ALLOWED_EXTENSIONS .tif,.tiff,.vrt,.ovr Suppresses speculative GETs for .aux.xml, .msk and other sidecars that do not exist — each is a round trip and a 404.
VSI_CACHE TRUE Turns on the per-file range cache so re-reading the same block does not re-fetch it. Off by default.
VSI_CACHE_SIZE 536870912 (512 MB) Size of that cache in bytes. The default 25 MB is far too small for multi-band reads.
GDAL_CACHEMAX 512 (MB) The decompressed block cache. Carved out of the function’s memory allocation — see the gauge below.
GDAL_HTTP_MULTIPLEX YES Allows HTTP/2 multiplexing so concurrent range requests share one connection.
GDAL_NUM_THREADS ALL_CPUS Parallel block decompression. Meaningful only above 1,769 MB on Lambda, where a second vCPU appears.

Two of these interact with the memory allocation in a way that catches people out. GDAL_CACHEMAX and VSI_CACHE_SIZE are both allocated out of the function’s memory, and they are not the same cache: VSI_CACHE_SIZE holds compressed bytes fetched over HTTP, GDAL_CACHEMAX holds decompressed blocks. Setting both to 512 MB on a 1,769 MB Lambda leaves roughly 700 MB for numpy arrays and the Python runtime, which is enough for a 512×512 tile pipeline and not enough for a whole-scene read. Size them against the model in memory and CPU allocation for raster workloads.

Runtime configuration usage against the AWS Lambda quotas it consumesFour usage meters for a tuned reprojecting Lambda: unzipped payload at 172 megabytes against the 250 megabyte ceiling, three layers attached against the maximum of five, 640 megabytes of ephemeral storage used against the 10,240 megabyte maximum, and a GDAL block cache of 512 megabytes against the 1,769 megabyte function memory allocation.A tuned function measured against the limits it can actually breachUnzipped payload, function + layershard ceiling at 250 MB172 MBLayers attachedhard ceiling at 53 of 5Ephemeral /tmp used for grid stagingprovisioned to 10,240 MB, 512 MB by default640 MBGDAL_CACHEMAX against functionmemory1,769 MB allocation — one full vCPU512 MBThe /tmp meter is drawn against the provisioned 10,240 MB; on a default function the same 640 MB would be at 125 per cent of the 512 MBallocation and the write would fail.
GDAL_CACHEMAX is the meter people forget: it is carved out of the same allocation the function's numpy arrays use, so 512 MB of block cache on a 1,769 MB function leaves under 1.2 GB for everything else.

Note also that GDAL_CACHEMAX changed units historically: values below 100,000 are interpreted as megabytes, larger values as bytes. GDAL_CACHEMAX=512 means 512 MB; GDAL_CACHEMAX=536870912 also means 512 MB. Both are correct; mixing the conventions in one codebase is how someone ends up with a 512-byte cache. The measured effect of each option, one at a time, is in GDAL config options that actually change COG read performance, and it pairs with the range-request tuning in tuning HTTP range requests for COG reads on S3.

Step-by-Step Implementation

Step 1: Lay the data directories out at a predictable path

Wheels put their data in awkward places — rasterio/gdal_data, pyproj/proj_dir/share/proj. Rather than naming those in the environment, normalise them into share/gdal and share/proj at the layer root during the build, so the runtime configuration is the same regardless of which wheel supplied the data.

bash
#!/usr/bin/env bash
# normalise_data_dirs.sh — run inside the build container, before zipping.
set -euo pipefail
LAYER=${1:?usage: normalise_data_dirs.sh ./layer}
SITE="${LAYER}/python/lib/python3.12/site-packages"

mkdir -p "${LAYER}/share/gdal" "${LAYER}/share/proj" "${LAYER}/lib"

cp -a "${SITE}/rasterio/gdal_data/." "${LAYER}/share/gdal/"
cp -a "${SITE}/pyproj/proj_dir/share/proj/." "${LAYER}/share/proj/"
cp -a "${SITE}/rasterio.libs/." "${LAYER}/lib/"

# Fail the build now rather than at the first invocation.
test -f "${LAYER}/share/proj/proj.db"
test -f "${LAYER}/share/gdal/gdalvrt.xsd"
echo "gdal_data: $(du -sh "${LAYER}/share/gdal" | cut -f1)"
echo "proj data: $(du -sh "${LAYER}/share/proj" | cut -f1)"

Step 2: Declare the environment in IaC

Every variable is set explicitly. Nothing is left to a compile-time default.

python
# cdk_stack.py
from aws_cdk import Duration, Stack
from aws_cdk import aws_lambda as lambda_

GDAL_ENV = {
    "GDAL_DATA": "/opt/share/gdal",
    "PROJ_DATA": "/opt/share/proj",
    "PROJ_LIB": "/opt/share/proj",
    "LD_LIBRARY_PATH": "/opt/lib:/var/task/lib:/var/lang/lib:/lib64:/usr/lib64",
    "PYTHONPATH": "/opt/python/lib/python3.12/site-packages",
    # Reads
    "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
    "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.tiff,.vrt,.ovr",
    "VSI_CACHE": "TRUE",
    "VSI_CACHE_SIZE": "536870912",
    "GDAL_CACHEMAX": "512",
    "GDAL_HTTP_MULTIPLEX": "YES",
    "GDAL_NUM_THREADS": "ALL_CPUS",
    # Writes: /var/task is read-only, so never let GDAL emit sidecars there
    "GDAL_PAM_ENABLED": "NO",
    "CPL_TMPDIR": "/tmp",
    # Grids over the network, cached in the only writable directory
    "PROJ_NETWORK": "ON",
    "PROJ_USER_WRITABLE_DIRECTORY": "/tmp/proj_cache",
}


class RasterStack(Stack):
    def __init__(self, scope, cid, gdal_layer_arn: str, **kw):
        super().__init__(scope, cid, **kw)
        layer = lambda_.LayerVersion.from_layer_version_arn(
            self, "GdalLayer", layer_version_arn=gdal_layer_arn
        )
        lambda_.Function(
            self, "Reprojector",
            runtime=lambda_.Runtime.PYTHON_3_12,
            handler="handler.main",
            code=lambda_.Code.from_asset("src"),
            layers=[layer],
            environment=GDAL_ENV,
            memory_size=1769,          # one full vCPU
            ephemeral_storage_size=None,
            timeout=Duration.minutes(5),
        )

The Terraform equivalent is the same map under environment { variables = {...} }; the important property is that the values are version-controlled next to the layer ARN, so a layer bump and a path change move together. That coupling is the whole point of the CI/CD pipeline sync for geo-dependencies workflow.

Step 3: Make the handler defensive about the one thing IaC cannot guarantee

PROJ_USER_WRITABLE_DIRECTORY names a directory that has to exist. Create it before the first transform, at module scope, so it is created once per execution environment rather than once per invocation.

python
# handler.py
import os
from pathlib import Path

Path(os.environ.get("PROJ_USER_WRITABLE_DIRECTORY", "/tmp/proj_cache")).mkdir(
    parents=True, exist_ok=True
)

import rasterio                     # noqa: E402 — must follow the mkdir above
from rasterio.warp import calculate_default_transform, reproject, Resampling

The import order is deliberate and worth a comment in the source: import rasterio triggers driver registration, which is when GDAL resolves and caches GDAL_DATA. Anything that must be true before that resolution has to happen above the import.

Measurement and Verification

Configuration that is not asserted on is configuration that will silently regress the next time someone rebuilds a layer. Return the resolved state from the handler and check it.

python
# verify.py — deploy as a separate function sharing the same layer and environment.
import json
import os
import time

import pyproj
import rasterio
from osgeo import gdal, osr
from pyproj.transformer import TransformerGroup


def main(event, context):
    t0 = time.perf_counter()
    gdal.UseExceptions()
    gdal.AllRegister()
    t_reg = (time.perf_counter() - t0) * 1000

    srs = osr.SpatialReference()
    srs.ImportFromEPSG(2193)          # NZGD2000 / New Zealand Transverse Mercator
    tg = TransformerGroup("EPSG:4326", "EPSG:2193")

    return {
        "gdal_version": gdal.VersionInfo("RELEASE_NAME"),
        "proj_version": pyproj.__proj_version__,
        "gdal_data": gdal.GetConfigOption("GDAL_DATA"),
        "proj_data_env": os.environ.get("PROJ_DATA"),
        "pyproj_data_dir": pyproj.datadir.get_data_dir(),
        "proj_db_exists": os.path.isfile(
            os.path.join(pyproj.datadir.get_data_dir(), "proj.db")
        ),
        "epsg_2193_name": srs.GetAttrValue("PROJCS"),
        "transformers_available": len(tg.transformers),
        "transformers_missing_grids": len(tg.unavailable_operations),
        "readdir_disabled": gdal.GetConfigOption("GDAL_DISABLE_READDIR_ON_OPEN"),
        "cachemax_bytes": gdal.GetCacheMax(),
        "driver_count": gdal.GetDriverCount(),
        "registration_ms": round(t_reg, 1),
        "rasterio": rasterio.__version__,
    }

Expected output on a correctly configured 1,769 MB Lambda:

json
{
  "gdal_version": "3.9.2",
  "proj_version": "9.4.1",
  "gdal_data": "/opt/share/gdal",
  "proj_data_env": "/opt/share/proj",
  "pyproj_data_dir": "/opt/share/proj",
  "proj_db_exists": true,
  "epsg_2193_name": "NZGD2000 / New Zealand Transverse Mercator 2000",
  "transformers_available": 3,
  "transformers_missing_grids": 0,
  "readdir_disabled": "EMPTY_DIR",
  "cachemax_bytes": 536870912,
  "driver_count": 247,
  "registration_ms": 384.2,
  "rasterio": "1.4.3"
}

Four of those fields are the assertions that matter. pyproj_data_dir must equal proj_data_env — if it does not, two proj.db files are in play. epsg_2193_name must be the full projected CRS name; a None there means proj.db was found but is truncated or from a much older PROJ. transformers_missing_grids must be 0 for the CRS pairs your pipeline actually uses. And driver_count in the low double digits instead of the low hundreds means GDAL_DATA resolved to a directory that exists but is not the real one.

Wire this into the deployment pipeline as a post-deploy smoke test rather than a page in a runbook:

bash
aws lambda invoke --function-name geo-verify --payload '{}' /dev/stdout \
  | python3 -c '
import json,sys
r = json.loads(sys.stdin.readline())
assert r["pyproj_data_dir"] == r["proj_data_env"], "two proj.db in play"
assert r["proj_db_exists"], "proj.db missing"
assert r["transformers_missing_grids"] == 0, "datum grid missing"
assert r["driver_count"] > 100, "GDAL_DATA is wrong"
print("runtime configuration OK")'

Failure Modes and Debugging

PROJ: proj_create_from_database: Cannot find proj.dbPROJ_DATA and PROJ_LIB are both unset, or set to a directory that does not contain proj.db. On Lambda the usual cause is a layer zipped from the wrong root, so the file landed at /opt/python/share/proj/proj.db rather than /opt/share/proj/proj.db. Confirm with a one-line handler: subprocess.run(["find", "/opt", "-name", "proj.db"], capture_output=True). Fix the path in the environment rather than the zip if a rebuild is expensive.

ERROR 4: Unable to open EPSG support file gcs.csvGDAL_DATA is unset or wrong. This is the GDAL-side twin of the previous error and it appears only when code touches an EPSG lookup, which is why it often survives into production after passing a pixel-reading smoke test. Note that GDAL 3.x resolves most EPSG queries through proj.db and only falls back to the CSVs for legacy paths, so seeing this error alongside a working reprojection is normal and still means GDAL_DATA is wrong.

ImportError: libgdal.so.35: cannot open shared object file: No such file or directoryLD_LIBRARY_PATH does not include the directory holding the shared objects, or the layer that holds them is not attached. Verify the attachment first with aws lambda get-function-configuration --function-name <fn> --query 'Layers', then check the path with ldd inside the matching container image as described in native library compilation for serverless.

CPLError: PROJ: pj_obj_create: Cannot find proj.db on warm invocations only — the environment was mutated inside the handler after driver registration cached the old value. Move the assignment into the function configuration. If the code genuinely must switch data directories at runtime, use gdal.SetConfigOption("PROJ_LIB", path) and pyproj.datadir.set_data_dir(path) together, before the first transform.

OSError: [Errno 30] Read-only file system: '/home/sbx_user1051/.local'PROJ_NETWORK=ON without PROJ_USER_WRITABLE_DIRECTORY. PROJ tried to create its grid cache under HOME. Set PROJ_USER_WRITABLE_DIRECTORY=/tmp/proj_cache and create the directory at module scope.

Transformations that succeed but are 1–3 m out — no error at all; the accurate pipeline was unavailable and PROJ fell back. Detect it with TransformerGroup(...).unavailable_operations as shown above, and treat a non-empty list as a deployment failure rather than a warning.

Cost and Scaling

Runtime configuration is one of the few optimisations that reduces both latency and bill without buying anything. On a 1,769 MB Lambda at $0.0000000295 per GB-second, a COG tile read that drops from 2,410 ms to 520 ms saves roughly $0.0000983 per invocation. At ten million tile reads a month that is about $983, and the change is seven environment variables.

The PROJ_NETWORK decision has a subtler cost profile. Fetching grids over HTTP adds 80–400 ms to the first transformation in each execution environment and nothing thereafter, because the grid is cached in /tmp for the life of that environment. On a bursty pipeline with poor warm-instance reuse that penalty is paid often; on a steady tiling fan-out it is amortised to nothing. If the CRS pairs are fixed, bundling the grids removes the variance entirely — see the sizing in the grid shipping guide.

Scaling changes which option matters. At low concurrency, cold-start cost dominates and the win is in a smaller payload — the ground covered by stripping unnecessary Python packages from AWS Lambda Layers. At high concurrency the S3 request count dominates, and GDAL_DISABLE_READDIR_ON_OPEN plus CPL_VSIL_CURL_ALLOWED_EXTENSIONS are worth more than any memory tuning: a thousand concurrent readers each issuing an unnecessary LIST against the same prefix is both a latency problem and a throttling problem. On Cloud Run, where a single container serves many concurrent requests inside one 60-minute-timeout process, a larger GDAL_CACHEMAX pays off far better than it does on Lambda because the cache is shared across requests rather than rebuilt per execution environment.

Frequently Asked Questions

Should I set PROJ_LIB or PROJ_DATA?

PROJ 9.1 renamed the variable to PROJ_DATA, and PROJ_LIB became a deprecated alias that still works but emits a warning. Because a serverless function usually contains a pyproj wheel and a GDAL build that may sit on either side of that boundary, set both to the same path. It costs nothing and removes an entire class of version-dependent failure.

Why does GDAL ignore GDAL_DATA when I set it inside the handler?

GDAL resolves and caches its data directory during the first driver registration, which happens at import rasterio or from osgeo import gdal. If those run at module scope before your code mutates os.environ, the cached value is already wrong for the life of that execution environment. Set the variables in the function configuration so they exist before the interpreter starts, or call gdal.SetConfigOption before the first driver use.

What does GDAL_DISABLE_READDIR_ON_OPEN actually save?

By default GDAL lists the containing prefix when it opens an object over /vsis3 or /vsicurl, so it can discover sidecar files. On a prefix holding thousands of scenes that LIST costs 400–1,200 ms and repeats on every cold read. GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR removes the LIST and the speculative sidecar GETs with it, typically halving time to first pixel.

How large is proj.db and do I need the transformation grids as well?

proj.db is about 9.4 MB in PROJ 9.4 and holds every CRS, datum and transformation definition — but no grid data. Grids are separate files, and the full proj-data release is roughly 655 MB, far beyond the 250 MB unzipped AWS Lambda ceiling. Ship only the grids your transformations name, or set PROJ_NETWORK=ON so PROJ fetches them from a CDN into a cache under /tmp.

Do these variables apply to Cloud Run and Azure Functions too?

The names are identical, because they are GDAL and PROJ features rather than platform features. Only the values change: /opt is an AWS Lambda layer artifact, a Cloud Run container resolves the same data under /usr/share, and Azure Functions on the Consumption plan resolves it under /home/site/wwwroot. Cloud Run’s 60-minute request timeout and 32 GiB ceiling also make a larger GDAL_CACHEMAX worthwhile, where Azure’s 1,536 MB memory limit makes it dangerous.


Guides in this topic

Back to Packaging & Dependency Management for Serverless GIS