Running Geospatial Python in AWS Lambda Containers

Build on the Lambda Python base image; rasterio’s wheels bring their own GDAL:

FROM public.ecr.aws/lambda/python:3.12
RUN pip install --no-cache-dir "rasterio>=1.3.9" "numpy>=1.26" "boto3>=1.34"
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif \
    GDAL_CACHEMAX=256
COPY handler.py ${LAMBDA_TASK_ROOT}/
CMD ["handler.main"]

Lambda runs short tasks with no servers to manage and bills per millisecond, which suits event-driven raster jobs: a new scene arrives, an index is computed, a thumbnail is written. This page belongs to containerizing geospatial Python environments in Cloud Execution & Orchestration.


What Fits in a Lambda

Limits that shape the design A Lambda function runs for at most fifteen minutes, with up to 10 GB of memory and six vCPU proportional to memory, up to 10 GB of ephemeral storage in tmp, and a container image up to 10 GB. Tasks that read a window or a tile of a COG, compute an index or thumbnail and write a small output fit comfortably. Full-scene mosaics, long time-series reductions and model training do not. Lambda limits and raster tasks 15 min max duration 10 GB memory, ≤ 6 vCPU 10 GB /tmp storage 10 GB image size fits: per-tile index, thumbnail, COG convert does not: big mosaics, long cubes, training Design each invocation as one small, independent unit of work.

The fifteen-minute ceiling is the defining constraint. A job that processes one tile or one window in under a minute can scale to thousands of concurrent invocations and costs nothing when idle; a job that might take twenty minutes on a large scene will fail unpredictably. Splitting work so every invocation is small is the whole design discipline. When a job cannot be split, AWS Batch is the better home, as described in processing COGs on AWS Batch with Docker.


Environment & Setup

Package Version pin Used for
rasterio >=1.3.9 Reads and writes; wheels include GDAL and PROJ
numpy >=1.26 Array arithmetic
boto3 >=1.34 Included in the base image; pin for reproducibility
Docker 24+ Building the image locally
docker build --platform linux/arm64 -t raster-lambda .
aws ecr create-repository --repository-name raster-lambda
docker tag raster-lambda:latest <account>.dkr.ecr.<region>.amazonaws.com/raster-lambda:2026.09
docker push <account>.dkr.ecr.<region>.amazonaws.com/raster-lambda:2026.09

Complete Working Example

# handler.py
import json
import os
import time

import numpy as np
import rasterio
from rasterio.windows import from_bounds

OUT_BUCKET = os.environ.get("OUT_BUCKET", "derived-products")

# module scope runs once per cold start and is reused by warm invocations
_ENV = rasterio.Env(GDAL_CACHEMAX=256, GDAL_HTTP_MULTIRANGE="YES", GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES")
_ENV.__enter__()


def ndvi_window(red_uri: str, nir_uri: str, bounds: list[float]) -> np.ndarray:
    with rasterio.open(red_uri) as r, rasterio.open(nir_uri) as n:
        win = from_bounds(*bounds, transform=r.transform)
        red = r.read(1, window=win, out_dtype="float32")
        nir = n.read(1, window=win, out_dtype="float32")
        profile = r.profile | {"driver": "GTiff", "dtype": "float32", "count": 1,
                                "height": red.shape[0], "width": red.shape[1],
                                "transform": r.window_transform(win), "nodata": np.nan,
                                "tiled": True, "blockxsize": 256, "blockysize": 256, "compress": "deflate"}
    total = nir + red
    out = np.where(total != 0, (nir - red) / total, np.nan).astype("float32")
    return out, profile


def main(event, context):
    t0 = time.perf_counter()
    job = event if "red" in event else json.loads(event["Records"][0]["body"])   # direct or SQS
    arr, profile = ndvi_window(job["red"], job["nir"], job["bounds"])
    key = f"ndvi/{job['tile']}.tif"
    with rasterio.open(f"s3://{OUT_BUCKET}/{key}", "w", **profile) as dst:
        dst.write(arr, 1)
    result = {"tile": job["tile"], "key": key, "seconds": round(time.perf_counter() - t0, 2),
               "remaining_ms": context.get_remaining_time_in_millis()}
    print(json.dumps(result))
    return result

Everything expensive to set up — imports, the GDAL environment — lives at module scope, so warm invocations reuse it and only cold starts pay for it. The handler reads only the window it needs over HTTP range requests, keeps arrays in float32, and writes directly to S3 through GDAL’s virtual file system without touching /tmp. Logging one JSON line per invocation feeds straight into CloudWatch Logs Insights, following logging structured events from raster tasks.


Cold Starts and Image Size

Where cold-start time goes A cold start for a geospatial image includes fetching and initialising the image, which Lambda caches and streams lazily, then importing Python modules — numpy and rasterio with GDAL take most of this — and finally module-level set-up. Warm invocations skip all three. Slimmer images and fewer imports shorten cold starts; provisioned concurrency removes them for latency-sensitive paths. Cold start, roughly image init imports (numpy, GDAL) setup work warm: Batch-style workloads amortise cold starts; interactive ones may need provisioned concurrency.

Container images for Lambda can be up to 10 GB, but size still matters for cold starts, and geospatial stacks are heavy. Installing rasterio’s wheels rather than a full system GDAL keeps the image to a few hundred megabytes; avoid pulling in GeoPandas, Dask or Jupyter unless the handler uses them. For batch-style fan-out — thousands of tiles processed after a scene lands — cold starts are amortised over many warm invocations and matter little. For a tile endpoint answering users, provisioned concurrency keeps instances warm at a fixed hourly cost.


Memory Buys CPU

Lambda allocates CPU in proportion to memory: at 1,769 MB a function gets one full vCPU, and at 10,240 MB up to six. A raster handler that runs slowly at 1 GB may be CPU-starved rather than memory-bound, and doubling memory can halve duration for the same cost. Benchmark a representative invocation at a few memory settings and pick the cheapest per invocation — the relationship is often surprising. ARM (Graviton) functions are about 20% cheaper per GB-second and run the rasterio wheels without changes. Build the image for the same architecture as the function — an x86 image on an ARM function fails at start-up with an exec format error that says nothing about architecture, so pass the --platform flag explicitly on every single build.


Fanning Out Work

Lambda’s strength is concurrency. A queue of tile jobs consumed by a function with a reserved concurrency of a few hundred processes a whole region in minutes, and a Step Functions distributed map can drive tens of thousands of invocations from a tile manifest in S3 with retries and failure thresholds built in. Reserved concurrency doubles as protection for downstream services — a STAC API or a database — that would otherwise be overwhelmed by thousands of simultaneous functions. Credentials come from the function’s execution role, exactly as for other workers in passing credentials to remote raster workers.


Verification

Three checks before fan-out Run the image locally with the runtime interface emulator. Invoke once in the cloud and confirm the remaining time is a large fraction of the timeout, leaving margin for larger tiles. Open the output and confirm it is a valid, georeferenced raster. Before invoking it ten thousand times local emulator image runs time margin > 50% of timeout left valid output CRS, transform, values Test with the largest tile you expect, not a typical one.
docker run --rm -p 9000:8080 -e AWS_REGION -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN raster-lambda
curl -s localhost:9000/2015-03-31/functions/function/invocations \
  -d '{"tile":"T33UVP_0412","red":"s3://bucket/B04.tif","nir":"s3://bucket/B08.tif","bounds":[500000,5800000,502560,5802560]}'

Common Errors

Read-only file system when writing

Only /tmp is writable. Write to /tmp or directly to S3 through GDAL.

Timeouts on some tiles only

Those tiles are larger or slower to read. Split them further or send them to Batch.

Cold starts take many seconds

The image contains heavy unused packages. Install only what the handler imports.

PROJ database not found

A system GDAL was mixed with rasterio’s bundled one. Use rasterio’s wheels alone, or set PROJ_DATA to the right path.


Frequently Asked Questions

Q: Can rasterio run in AWS Lambda? Yes. Container images built on the Lambda Python base with rasterio’s wheels, which bundle GDAL and PROJ, work without system packages.

Q: Which raster jobs suit Lambda? Small independent units — a tile’s index, a thumbnail, a COG conversion, a window extraction — that finish well within fifteen minutes.

Q: How much memory should a raster Lambda get? Enough for the peak of one task plus overhead, then benchmark a few settings: more memory also means more CPU, which often reduces cost per invocation.

Q: Should I use Lambda or Batch for tiles? Lambda for short, event-driven or highly parallel tasks; Batch for long tasks, large memory needs, or anything that might exceed fifteen minutes.