Skip to content

Cold Start Mapping for Python GDAL

Python GDAL cold starts in serverless environments take 4–12 seconds in unoptimized deployments — almost entirely due to shared-library resolution, format driver registration, and PROJ coordinate system initialization, not to application logic. Mapping these phases into discrete, measurable units lets you apply targeted optimizations that typically cut initialization below 1.5 seconds without removing any geospatial capability.

When a serverless function scales from zero, the provider provisions a fresh container. Python must start, import osgeo, dynamically link against libgdal, libproj, and libgeos, then register every compiled format driver before a single gdal.Open() call can succeed. Without systematic measurement, teams misattribute this latency to network I/O or algorithmic complexity and reach for expensive mitigations — more memory, larger timeouts — rather than the surgical fixes that actually work.

Why Initialization Overhead Hits Geospatial Workloads Hardest

Python GDAL is a SWIG-generated wrapper around a large C++ codebase that was built for persistent, long-running GIS server processes. Every import forces a sequence of operations that compound in stateless environments:

Dynamic library resolution — the runtime walks LD_LIBRARY_PATH looking for libgdal.so, libproj.so, libgeos_c.so, and driver-specific shared objects. Path misalignment triggers fallback searches across filesystem layers, adding hundreds of milliseconds per missing entry.

Driver registrationgdal.AllRegister() (called implicitly on import unless you call gdal.UseExceptions() first) scans all compiled plugins and allocates format handler tables in memory. A full-stack GDAL build registers over 200 raster and vector drivers. Each format driver executes its own Open() probe function at registration time.

PROJ initialization — importing osgeo.osr loads the PROJ coordinate transformation subsystem, which parses proj.db (an SQLite database shipped with the PROJ data package). On cold containers, this database may not be in the page cache, forcing disk reads on every lookup until the OS warms the cache.

Memory allocation — the C++ runtime allocates thread pools, internal caches, and spatial reference system registries. In containers provisioned with less than 512 MB, malloc pressure during this phase can trigger the OOM killer before your handler code even runs.

These dynamics interact directly with the Serverless Geospatial Architecture & Platform Limits you are working within: container startup speed, filesystem extraction latency, and memory ceilings all influence how long each phase takes.

Python GDAL Cold Start Phase Timeline A horizontal bar chart showing six sequential initialization phases and their typical duration ranges when loading Python GDAL in a cold AWS Lambda container. Runtime bootstrap Package import Library linking Driver registration PROJ / SRS init 0.5–2.0 s 0.1–0.3 s 0.5–2.5 s 1.0–4.0 s 0.3–1.5 s 0 s 2 s 4 s 6 s 8 s Typical Maximum

Platform-by-Platform Limits

Cold start behaviour differs significantly across providers because each platform’s container startup model, filesystem layout, and default memory allocation interact differently with GDAL’s initialization sequence.

Constraint AWS Lambda GCP Cloud Functions 2nd gen Azure Functions (Consumption)
Max execution timeout 15 minutes 60 minutes 10 minutes
Max memory 10 GB 32 GB 1.5 GB
Default memory 128 MB (inadequate for GDAL) 256 MB 1.5 GB
Ephemeral storage 512 MB – 10 GB (/tmp) In-container filesystem ~500 MB (/tmp)
Cold start frequency Per invocation when scaled to zero Per instance scale-out Per instance scale-out
Provisioned warmth Provisioned Concurrency (reserved cost) Min instances (reserved cost) Premium/Dedicated plan
Container image support Yes (ECR, up to 10 GB) Yes (Artifact Registry) Yes (ACR)
Layer/extension support Lambda Layers (50 MB unzipped per layer, 250 MB total) Extension bundles
Recommended min memory for GDAL 512 MB 512 MB 1.5 GB (plan minimum)
Billing granularity 1 ms 100 ms 1 ms

AWS Lambda at 128 MB will OOM-kill during driver registration. Start at 512 MB for GDAL workloads and profile upward. On Azure Consumption, the 1.5 GB fixed allocation is enough for most GDAL use cases but leaves almost no headroom for large raster reads alongside the library itself.

Step-by-Step Implementation

Step 1: Set Environment Variables at Build Time

Never rely on runtime discovery for GDAL_DATA, PROJ_LIB, or LD_LIBRARY_PATH. Hardcode them in your Dockerfile or deployment manifest so they are resolved before any Python code runs.

python
# In your Dockerfile or Lambda environment variable configuration:
# ENV GDAL_DATA=/opt/share/gdal
# ENV PROJ_LIB=/opt/share/proj
# ENV LD_LIBRARY_PATH=/opt/lib:$LD_LIBRARY_PATH
# ENV PROJ_NETWORK=OFF  # Disables remote grid lookups that add 200–800 ms

# Verify at function startup (add to top-level module, not inside handler):
import os

REQUIRED_ENV = {
    "GDAL_DATA": "/opt/share/gdal",
    "PROJ_LIB": "/opt/share/proj",
    "LD_LIBRARY_PATH": "/opt/lib",
}

for var, expected_prefix in REQUIRED_ENV.items():
    val = os.environ.get(var, "")
    if not val.startswith(expected_prefix):
        raise RuntimeError(
            f"Environment variable {var!r} is misconfigured: got {val!r}. "
            f"Expected path starting with {expected_prefix!r}."
        )

Step 2: Instrument Each Initialization Phase

Deploy phase-level timing before committing to any optimization. You cannot tune what you have not measured.

python
import time
import json
import logging
import os

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

_PHASE_LOG: list[dict] = []

def _phase(name: str):
    """Context manager that records wall-clock duration for a named phase."""
    import contextlib

    @contextlib.contextmanager
    def _ctx():
        t0 = time.perf_counter()
        yield
        elapsed_ms = (time.perf_counter() - t0) * 1000
        entry = {"phase": name, "ms": round(elapsed_ms, 2)}
        _PHASE_LOG.append(entry)
        logger.info(json.dumps(entry))

    return _ctx()


# Module-level initialization — runs once per container lifetime
with _phase("env_check"):
    _gdal_data = os.environ["GDAL_DATA"]   # raises KeyError if missing
    _proj_lib  = os.environ["PROJ_LIB"]

with _phase("osgeo_import"):
    from osgeo import gdal, ogr, osr
    gdal.UseExceptions()  # Raise Python exceptions instead of silently returning None

with _phase("driver_registration"):
    gdal.AllRegister()
    _driver_count = gdal.GetDriverCount()

with _phase("proj_srs_init"):
    # Force proj.db to be loaded and cached before the first request
    _srs = osr.SpatialReference()
    _srs.ImportFromEPSG(4326)
    del _srs


def handler(event: dict, context) -> dict:
    # At this point GDAL is warm; log the cold-start summary on first invocation
    if len(_PHASE_LOG) > 0:
        total_ms = sum(e["ms"] for e in _PHASE_LOG)
        logger.info(json.dumps({
            "event": "cold_start_summary",
            "total_ms": round(total_ms, 2),
            "driver_count": _driver_count,
            "phases": _PHASE_LOG,
        }))
        _PHASE_LOG.clear()

    # Application logic here
    src_path = event.get("s3_uri", "")
    ds = gdal.Open(f"/vsicurl/{src_path}")
    return {"bands": ds.RasterCount, "crs": ds.GetProjection()[:50]}

Step 3: Strip Unused Drivers from Your Build

A full GDAL build compiles over 200 format drivers. Most geospatial Lambda functions use two or three. Rebuild with --without-<format> flags to reduce registration time by 40–60%.

bash
# Example: build GDAL for a pipeline that only processes GeoTIFF, GeoJSON, and GeoPackage
./configure \
  --prefix=/opt \
  --with-python \
  --with-proj=/opt \
  --with-geos=/opt/lib \
  --without-netcdf \
  --without-hdf4 \
  --without-hdf5 \
  --without-openjpeg \
  --without-ecw \
  --without-kakadu \
  --without-mrsid \
  --without-pg \
  --without-mysql \
  --without-sqlite3 \
  --with-spatialite=no

# Verify the driver list post-build:
python3 -c "
from osgeo import gdal
gdal.UseExceptions()
gdal.AllRegister()
drivers = [gdal.GetDriver(i).ShortName for i in range(gdal.GetDriverCount())]
print(f'Driver count: {len(drivers)}')
print(drivers)
"

Step 4: Apply Lazy Imports for Mixed Workloads

When geospatial processing is an occasional code path, defer import osgeo until the handler actually needs it. This prevents GDAL initialization from blocking non-spatial requests entirely.

python
def handler(event: dict, context) -> dict:
    if not event.get("requires_gis"):
        # Non-spatial request: GDAL is never imported; no cold-start cost
        return process_tabular(event)

    # Import deferred to this branch only
    from osgeo import gdal, ogr
    gdal.UseExceptions()
    gdal.AllRegister()
    return process_raster(event)


def process_tabular(event: dict) -> dict:
    # Handles JSON, CSV, Parquet — no geospatial deps
    return {"status": "ok", "rows": len(event.get("records", []))}


def process_raster(event: dict) -> dict:
    from osgeo import gdal  # Already imported above; no re-initialization cost
    ds = gdal.Open(event["s3_uri"])
    return {"bands": ds.RasterCount}

Step 5: Configure Provisioned Concurrency for SLA-Bound Paths

When latency SLAs are under 1 second, Reducing Python GDAL Cold Starts with Provisioned Concurrency is the correct mitigation. Provisioned concurrency keeps containers pre-initialized, shifting cost from unpredictable latency to a predictable hourly rate.

bash
# AWS CLI: configure 5 pre-warmed instances for the production alias
aws lambda put-provisioned-concurrency-config \
  --function-name gdal-raster-processor \
  --qualifier production \
  --provisioned-concurrent-executions 5

# Verify the configuration is in READY state (not PENDING)
aws lambda get-provisioned-concurrency-config \
  --function-name gdal-raster-processor \
  --qualifier production \
  --query 'Status'

Measurement and Verification

After applying optimizations, confirm the improvement with deterministic synthetic tests rather than relying on production traffic samples.

python
# synthetic_cold_start_test.py — deploy as a separate function that destroys
# its own container on every invocation by raising an unhandled exception
# after logging timings. Run it on a schedule or via CI.

import time
import json
import logging
import os

logger = logging.getLogger(__name__)

def force_cold_start_handler(event, context):
    """
    Forces a fresh container on every invocation by never reaching a warm state.
    Expected output in CloudWatch Logs:
      {"phase": "osgeo_import", "ms": 820.14}
      {"phase": "driver_registration", "ms": 1240.33}
      {"event": "cold_start_complete", "total_ms": 2310.55}
    """
    results = {}

    t0 = time.perf_counter()
    from osgeo import gdal, osr
    gdal.UseExceptions()
    results["osgeo_import_ms"] = round((time.perf_counter() - t0) * 1000, 2)

    t1 = time.perf_counter()
    gdal.AllRegister()
    results["driver_registration_ms"] = round((time.perf_counter() - t1) * 1000, 2)

    t2 = time.perf_counter()
    srs = osr.SpatialReference()
    srs.ImportFromEPSG(4326)
    results["proj_init_ms"] = round((time.perf_counter() - t2) * 1000, 2)

    results["total_ms"] = sum(results.values())
    results["driver_count"] = gdal.GetDriverCount()
    results["gdal_data"] = os.environ.get("GDAL_DATA", "MISSING")
    results["proj_lib"] = os.environ.get("PROJ_LIB", "MISSING")

    logger.info(json.dumps({"event": "cold_start_complete", **results}))

    # Thresholds: fail the test if optimization targets are not met
    assert results["total_ms"] < 3000, (
        f"Cold start {results['total_ms']:.0f} ms exceeds 3000 ms threshold — "
        f"check driver count ({results['driver_count']}) and GDAL_DATA path."
    )
    return results

CloudWatch metric query — use this Logs Insights query to track cold start trends over time:

code
fields @timestamp, total_ms, driver_count
| filter event = "cold_start_complete"
| stats avg(total_ms) as avg_ms, max(total_ms) as max_ms, count() as invocations by bin(1h)
| sort @timestamp desc

Expected output ranges after optimization:

  • osgeo_import_ms: 50–150 ms (bytecode already compiled, .pyc cached)
  • driver_registration_ms: 200–800 ms (stripped build)
  • proj_init_ms: 100–400 ms (proj.db warm in page cache on subsequent calls)
  • total_ms: 400–1400 ms

Failure Modes and Debugging

1. ImportError: libgdal.so.XX: cannot open shared object file

LD_LIBRARY_PATH does not include the directory containing libgdal.so, or the compiled .so file’s major version number does not match the version that the Python wheel was built against. Fix: verify the LD_LIBRARY_PATH environment variable at the container level (printenv LD_LIBRARY_PATH in a test invocation) and rebuild the wheel against the exact .so you ship.

2. PROJ: proj_create_from_database: Cannot find proj.db

PROJ_LIB is either unset or points to a directory that does not contain proj.db. The PROJ data package ships proj.db separately from the shared library. Fix: confirm ls $PROJ_LIB/proj.db resolves inside the container. If using a Lambda layer, the data directory must be explicitly included — it is not bundled with the PROJ .so file.

3. Driver silently not registered after dependency upgrade

gdal.GetDriverByName("GTiff") returns None after upgrading GDAL from 3.7 to 3.8. This is caused by a compiled plugin that links against a shared library version that no longer exists in the new build. Fix: run gdal-config --formats in the new build environment and compare the output against the previous version. Missing formats indicate a linking regression.

4. Cold start time increases by 2–4 seconds after PROJ 9.x upgrade

PROJ 9.x switched coordinate system lookups to use proj.db instead of legacy .dat files. If proj.db is absent and PROJ_NETWORK=ON, PROJ attempts remote lookups over HTTPS for every coordinate system lookup, adding 200–800 ms per lookup. Fix: set PROJ_NETWORK=OFF as an environment variable and ensure proj.db is present at $PROJ_LIB/proj.db.

5. OOM kill during driver registration at 128 MB memory

The CloudWatch metric aws/lambda/Errors spikes with no corresponding error log. The function container is killed by the Linux OOM killer during gdal.AllRegister() before Python’s exception handler can run. Fix: increase memory to at least 512 MB. GDAL’s internal driver registry requires approximately 150–250 MB of working set for a full-stack build. The Ephemeral Storage Limits in AWS Lambda guide covers how memory and /tmp quotas interact under concurrent spatial workloads.

Cost and Scaling Considerations

Cold start optimization is a cost-benefit trade-off between compute spend and engineering complexity. The right approach depends on your traffic pattern.

Minimal driver compilation is zero ongoing cost and zero infrastructure change. It is the correct first step for every deployment. A build that strips 180 unused drivers saves 1–3 seconds of initialization with no billing impact.

Lazy imports are also free but introduce conditional complexity and make cold start latency non-deterministic from the caller’s perspective. Use them when geospatial processing handles less than 30% of function invocations.

Provisioned concurrency eliminates cold starts entirely but bills at a fixed hourly rate regardless of invocations. At the time of writing, AWS charges approximately $0.0000646 per GB-second for provisioned concurrency. A 1 GB function with 5 provisioned instances running continuously costs roughly $0.0323 per hour — $23.30 per month per GB of provisioned capacity. This is worthwhile when your baseline traffic keeps the provisioned instances busy, but wasteful for bursty or infrequent workloads.

Container image packaging trades layer extraction overhead for a predictable filesystem layout. Images up to 10 GB eliminate the 250 MB layer limit and avoid the extraction step that occurs when Lambda unpacks a layer zip on cold start. Use container images for GDAL deployments that include PROJ grids, PROJ data, or multiple shared libraries that together exceed 200 MB unzipped.

Scale-out behaviour — when traffic spikes rapidly, AWS Lambda may provision dozens of new containers simultaneously. Without optimization, a burst of 50 concurrent cold starts means 50 concurrent 4–12 second initialization delays, all absorbing compute time and generating costs with no useful work. Reducing cold start from 8 seconds to 1.2 seconds across a 50-container burst saves approximately 340 GB-seconds of compute per burst event — at $0.0000166667 per GB-second, that is roughly $0.006 per burst event, which compounds significantly at scale.

Frequently Asked Questions

How long does a Python GDAL cold start take on AWS Lambda?

Unoptimized deployments take 4–12 seconds. Shared-library linking (libgdal, libproj, libgeos) accounts for 0.5–2.5 seconds; driver registration adds 1–4 seconds. Stripping unused drivers and hardening environment variables reduces total initialization to under 1.5 seconds in most workloads.

Does increasing Lambda memory reduce cold start time?

Yes, but only up to the point where I/O is the bottleneck. Higher memory unlocks proportional CPU, which speeds up dlopen() calls and Python bytecode execution. Beyond approximately 2048 MB, gains plateau because driver registration is primarily single-threaded C++ work that does not parallelize across additional vCPUs.

When should I use provisioned concurrency vs lazy imports?

Use provisioned concurrency when your SLA is under 1 second and the function receives consistent baseline traffic that keeps pre-warmed instances busy. Use lazy imports when geospatial processing is an occasional code path inside a mixed-purpose function — deferred initialization limits the cold-start cost to requests that genuinely need GDAL.


Back to Serverless Geospatial Architecture & Platform Limits