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 split into five initialization phasesA 6.4 second cold start divided into five sequential phases: runtime bootstrap at 1.2 seconds, package import at 0.2 seconds, shared-library linking of libgdal, libproj and libgeos at 1.5 seconds, driver registration at 2.6 seconds, and PROJ coordinate-system initialization at 0.9 seconds.A typical unoptimized cold start, phase by phaseRuntime bootstrapLibrary linkingDriver registrationPROJ / SRS init1.2 s1.5 s2.6 s0.9 sPackage import — 0.2 sOne representative run. Across deployments the phases range 0.5–2.0 s bootstrap, 0.1–0.3 s import, 0.5–2.5 s linking, 1.0–4.0 s driverregistration and 0.3–1.5 s PROJ init — 4–12 s in total.
Driver registration and shared-library linking together take two thirds of the budget, and neither is application code — which is why stripping the build beats raising memory.

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.

Cold-start-relevant limits on AWS Lambda, GCP Cloud Functions and Azure FunctionsComparison grid of maximum timeout, memory ceiling, default memory, ephemeral storage, the recommended minimum memory for GDAL, and the warm-instance mechanism, for AWS Lambda, GCP Cloud Functions 2nd generation, and Azure Functions on the Consumption plan.What each platform gives a GDAL function before it startsAWS LambdaGCP Functions (2nd gen)Azure (Consumption)Max timeout15 min60 min10 minMemory ceiling10,240 MB32,768 MB1,536 MBDefault memory128 MBOOM-kills driver registration256 MBtoo small for a full build1,536 MBfixed by the planEphemeral /tmp10,240 MB512 MB default~8 GBtmpfs, shares RAM~1.5 GBshared poolRecommended for GDAL512 MBprofile upward from here512 MBprofile upward from here1,536 MBno headroom left for rastersKeeping containers warmProvisionedconcurrencyper version or aliasMin instancesper revisionPremium plan onlynot on ConsumptionDeployment packages are capped at 250 MB unzipped on Lambda, which a full rasterio, shapely and pyproj stack already exceeds beforeapplication code is added.
Lambda's 128 MB default is the trap: it is below the working set gdal.AllRegister() needs, so the container is OOM-killed before any handler code runs.

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%.

Cold start initialization time by GDAL build configurationHorizontal bar chart of four build configurations measured by the synthetic cold-start probe: PROJ network lookups with proj.db absent at 3,110 milliseconds, a full 200-driver build at 2,310 milliseconds, a build stripped to GeoTIFF, GeoJSON and GeoPackage at 1,150 milliseconds, and that stripped build with GDAL_DATA and PROJ_LIB pinned at 850 milliseconds.Measured osgeo import + AllRegister() + PROJ initPROJ_NETWORK=ON, proj.db absent3,110 msFull build, 200+ drivers2,310 msStripped to GTiff, GeoJSON, GPKG1,150 msStripped + GDAL_DATA/PROJ_LIB pinned850 ms03,200 msTimings from the synthetic probe on a 1,024 MB Lambda; runtime bootstrap sits outside this measurement, so these numbers are smaller than theend-to-end cold start.
The two fixes that cost nothing — stripping drivers and pinning the data paths — remove 1.5 seconds between them, landing inside the 400–1,400 ms target band without touching memory or provisioned concurrency.
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.

Choosing between provisioned concurrency, build stripping and lazy importsA decision with three outcomes based on the latency requirement and traffic shape: a sub-one-second SLA with steady baseline traffic leads to provisioned concurrency, bursty or infrequent traffic leads to stripping the GDAL build and pinning environment paths, and functions where GDAL is used on fewer than thirty percent of invocations lead to lazy imports.Which mitigation the traffic pattern actually justifiesWhat is the SLA, and how steady is thetraffic?under 1 s, steady baselineProvisioned concurrency≈$0.0323 per hour for 5 × 1 GBinstancesbursty or infrequentStrip drivers, pin pathsZero ongoing cost; saves 1–3 s per coldstartGDAL on under 30% of callsLazy import osgeoNon-spatial requests never payinitialization
Provisioned concurrency is the only option here with an ongoing bill — roughly $23.30 per month per GB of provisioned capacity — so it is the last lever, not the first.

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.


Guides in this topic

Back to Serverless Geospatial Architecture & Platform Limits