Skip to content

Process a 10 GB GeoTIFF with AWS Step Functions and Lambda

A 10 GB GeoTIFF cannot be opened whole inside AWS Lambda — the runtime caps memory at 10,240 MB, /tmp at 10 GB, and wall-clock at 15 minutes per invocation. The working pattern is to tile the scene and let the platform parallelise: an S3 upload triggers a Step Functions state machine, a metadata Lambda writes a manifest of ~400 tile windows, a distributed Map state fans out one Lambda per window (each reading its window through /vsis3 range requests against the Cloud Optimized GeoTIFF), and a merge Lambda reassembles the outputs into a COG registered in a STAC catalog. Peak memory per worker stays near 512 MB regardless of scene size, and the whole 10 GB job completes in two to four minutes of wall-clock at a few hundred-way concurrency. This recipe is the flagship worked example of the serverless geospatial pipeline skeleton.

Why This Recipe Matters for Geospatial Workloads

Large single-file rasters are the default deliverable of aerial survey, drone mosaicking, and hydrological modelling. They arrive as one object and, naively, invite one big processing job — exactly the shape serverless refuses to run. Loading a 40000×40000 pixel, four-band Float32 scene into memory would need roughly 24 GB, more than double the Lambda ceiling, and reading it sequentially would blow the 15-minute timeout ceiling long before the transform finished.

Tiling converts one impossible invocation into hundreds of trivial ones. Because a Cloud Optimized GeoTIFF stores its pixels in independent internal blocks with an offset index, a worker can fetch just the byte ranges covering its window using chunked I/O — no full download, no full decode. Step Functions then supplies the orchestration that raw Lambda lacks: durable state, automatic per-tile retry, bounded concurrency, and a clean fan-in point. The result respects every constraint documented in Serverless Geospatial Architecture & Platform Limits while still turning a 10 GB scene around in minutes.

10 GB GeoTIFF Step Functions pipeline An S3 upload event starts the state machine. A metadata Lambda writes a tile manifest to S3. A distributed Map state reads the manifest and runs one tile-worker Lambda per window against the source COG. Outputs feed a merge Lambda that writes a COG, then a catalog Lambda registers a STAC item. S3 upload ObjectCreated Metadata Lambda manifest to S3 Distributed Map ItemReader: manifest tile worker · /vsis3 tile worker · /vsis3 tile worker · /vsis3 Merge Lambda VRT to COG + overviews STAC catalog item Each worker range-reads only its window from the source COG in S3 — peak memory ≈ one tile, never the 10 GB scene.

Platform-by-Platform Limits

Although this recipe is written for AWS, the same tiling maths must be checked against whichever provider you deploy on, because the tile size that fits one runtime overruns another. The figures below are the ceilings each stage is sized against.

Constraint AWS Lambda GCP Cloud Functions (2nd gen) Azure Functions (Consumption)
Max timeout 15 min 60 min 10 min
Memory ceiling 10,240 MB 32,768 MB 1,536 MB
Ephemeral /tmp 10,240 MB (512 MB default) ~8 GB (RAM-backed tmpfs) ~1.5 GB shared
Orchestrator Step Functions Cloud Workflows Durable Functions
Parallel fan-out Distributed Map, up to 10,000 concurrent children parallel branches, quota-bound fan-out/fan-in activities
State payload 256 KB per transition 512 KB per step variable durable storage, no fixed cap
Manifest streaming ItemReader from S3 (JSON/CSV) manual GCS read per branch activity input from Blob

The Azure Consumption row is the binding constraint: at 1.5 GB memory a single 2048×2048 four-band Float32 tile (~256 MB decoded, plus GDAL working buffers) is close to the ceiling, so on Azure the manifest must use smaller 1024×1024 tiles. On AWS, the 10 GB memory and disk-backed 10 GB /tmp give comfortable headroom — which is why the flagship recipe targets Lambda. The per-tile packaging of GDAL that each worker depends on follows Packaging & Dependency Management for Serverless GIS.

Step-by-Step Implementation

Step 1: Trigger the State Machine on S3 Upload

Configure an S3 ObjectCreated notification on the ingest prefix (for example s3://geo-ingest/incoming/) to start the state machine. Use EventBridge rather than a direct Lambda notification so the payload starts Step Functions and the input carries the object key.

json
{
  "detail-type": ["Object Created"],
  "source": ["aws.s3"],
  "detail": {
    "bucket": {"name": ["geo-ingest"]},
    "object": {"key": [{"prefix": "incoming/"}]}
  }
}

The EventBridge rule targets the state machine with an input transformer that passes {"bucket": "geo-ingest", "key": "<object key>"}. Deriving a deterministic run ID from that key keeps the pipeline idempotent, matching the idempotency guidance for S3 events.

Step 2: Extract Metadata and Emit a Tile Manifest

The metadata Lambda opens only the COG header — never pixel data — to read dimensions, block size, CRS, and band count, then writes a manifest of tile windows to S3. The detailed windowing algorithm lives in partitioning a GeoTIFF into Step Functions Map-state tiles; the handler below is the recipe’s entry point.

python
import json
import os
import boto3
import rasterio
from rasterio.windows import Window

os.environ.setdefault("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
os.environ.setdefault("CPL_VSIL_CURL_ALLOWED_EXTENSIONS", ".tif,.tiff")

s3 = boto3.client("s3")
TILE = int(os.environ.get("TILE_PIXELS", "2048"))
OUTPUT_BUCKET = os.environ["OUTPUT_BUCKET"]


def handler(event, context):
    bucket, key = event["bucket"], event["key"]
    run_id = key.rsplit("/", 1)[-1].rsplit(".", 1)[0]
    src_uri = f"/vsis3/{bucket}/{key}"

    with rasterio.open(src_uri) as src:
        width, height, count = src.width, src.height, src.count
        profile = {"crs": str(src.crs), "dtype": src.dtypes[0],
                   "transform": list(src.transform)[:6]}

    # Build the manifest of bounded work items — one per tile window.
    tiles = []
    for row_off in range(0, height, TILE):
        for col_off in range(0, width, TILE):
            w = min(TILE, width - col_off)
            h = min(TILE, height - row_off)
            index = f"{row_off:06d}_{col_off:06d}"
            tiles.append({
                "index": index,
                "src_uri": src_uri,
                "col_off": col_off, "row_off": row_off,
                "width": w, "height": h,
                "out_key": f"runs/{run_id}/tiles/{index}.tif",
            })

    manifest_key = f"runs/{run_id}/manifest.json"
    # Newline-delimited JSON so the distributed Map ItemReader can stream it.
    body = "\n".join(json.dumps(t) for t in tiles)
    s3.put_object(Bucket=OUTPUT_BUCKET, Key=manifest_key, Body=body.encode())

    return {
        "run_id": run_id,
        "manifest_bucket": OUTPUT_BUCKET,
        "manifest_key": manifest_key,
        "tile_count": len(tiles),
        "profile": profile,
        "out_prefix": f"runs/{run_id}/tiles/",
        "output_bucket": OUTPUT_BUCKET,
    }

The Lambda returns only a manifest pointer and small summary — never the tile list itself — so the state payload stays far below the 256 KB transition limit.

Step 3: Fan Out Per-Tile Lambdas with a Distributed Map State

The Amazon States Language definition below reads the manifest with ItemReader and runs one tile-worker execution per line, capped at 500-way concurrency so the fan-out cannot exhaust regional quota. A reserved concurrency allocation on the worker function protects it from starvation.

json
{
  "Comment": "Process a 10 GB GeoTIFF by tiling across Lambda",
  "StartAt": "ExtractMetadata",
  "States": {
    "ExtractMetadata": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "${MetadataFunctionArn}",
        "Payload.$": "$"
      },
      "ResultSelector": {"meta.$": "$.Payload"},
      "ResultPath": "$.plan",
      "Next": "ProcessTiles"
    },
    "ProcessTiles": {
      "Type": "Map",
      "ItemProcessor": {
        "ProcessorConfig": {"Mode": "DISTRIBUTED", "ExecutionType": "STANDARD"},
        "StartAt": "ProcessTile",
        "States": {
          "ProcessTile": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
              "FunctionName": "${TileWorkerFunctionArn}",
              "Payload.$": "$"
            },
            "Retry": [
              {"ErrorEquals": ["States.TaskFailed", "Lambda.TooManyRequestsException"],
               "IntervalSeconds": 2, "MaxAttempts": 4, "BackoffRate": 2.0}
            ],
            "Catch": [
              {"ErrorEquals": ["States.ALL"], "ResultPath": "$.error",
               "Next": "TileToDlq"}
            ],
            "End": true
          },
          "TileToDlq": {
            "Type": "Task",
            "Resource": "arn:aws:states:::sqs:sendMessage",
            "Parameters": {
              "QueueUrl": "${TileDlqUrl}",
              "MessageBody.$": "$"
            },
            "End": true
          }
        }
      },
      "ItemReader": {
        "Resource": "arn:aws:states:::s3:getObject",
        "ReaderConfig": {"InputType": "JSONL"},
        "Parameters": {
          "Bucket.$": "$.plan.meta.manifest_bucket",
          "Key.$": "$.plan.meta.manifest_key"
        }
      },
      "MaxConcurrency": 500,
      "ResultPath": "$.mapResult",
      "Next": "MergeTiles"
    },
    "MergeTiles": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "${MergeFunctionArn}",
        "Payload.$": "$.plan.meta"
      },
      "ResultPath": "$.merge",
      "Next": "Catalog"
    },
    "Catalog": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "${CatalogFunctionArn}",
        "Payload.$": "$.merge.Payload"
      },
      "End": true
    }
  }
}

Each tile-worker Lambda reads only its window via /vsis3 range requests, applies the transform (here a reprojection and rescale), and writes an independent GeoTIFF to a deterministic key:

python
import os
import boto3
import numpy as np
import rasterio
from rasterio.windows import Window

# COG-friendly GDAL config, applied before the first rasterio open.
os.environ.setdefault("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
os.environ.setdefault("GDAL_HTTP_MULTIRANGE", "YES")
os.environ.setdefault("VSI_CACHE", "TRUE")
os.environ.setdefault("GDAL_DATA", "/opt/share/gdal")
os.environ.setdefault("PROJ_LIB", "/opt/share/proj")

s3 = boto3.client("s3")
LOCAL = "/tmp/tile.tif"


def handler(tile, context):
    """Process one manifest window; peak memory is proportional to a single tile."""
    with rasterio.open(tile["src_uri"]) as src:
        window = Window(tile["col_off"], tile["row_off"],
                        tile["width"], tile["height"])
        data = src.read(window=window)                 # only this window's bytes
        transform = src.window_transform(window)
        profile = src.profile.copy()

    # Example per-tile transform: clamp to valid range and cast to float32.
    data = np.clip(data, 0, 10000).astype("float32")

    profile.update(width=tile["width"], height=tile["height"],
                   transform=transform, dtype="float32",
                   driver="GTiff", tiled=True, blockxsize=512, blockysize=512,
                   compress="deflate")

    with rasterio.open(LOCAL, "w", **profile) as dst:
        dst.write(data)

    out_bucket = tile["out_key"].split("/")[0] if False else os.environ["OUTPUT_BUCKET"]
    s3.upload_file(LOCAL, out_bucket, tile["out_key"])
    os.remove(LOCAL)  # release /tmp immediately for the next warm invocation

    return {"index": tile["index"], "out_key": tile["out_key"],
            "bytes": os.path.getsize(LOCAL) if os.path.exists(LOCAL) else None}

Because workers write to deterministic keys under runs/<run_id>/tiles/, a retried tile overwrites rather than duplicates, and the merge stage can enumerate outputs by prefix.

Step 4: Merge Tiled Outputs into a COG

The merge Lambda builds a VRT over every per-tile output and translates it to a single Cloud Optimized GeoTIFF with overviews, watching /tmp as it goes. The full merge implementation, including the /tmp guard and rio-cogeo validation, is documented in merging tiled Lambda outputs into a COG. In outline:

python
import os
import boto3
from osgeo import gdal

s3 = boto3.client("s3")
gdal.UseExceptions()


def handler(meta, context):
    bucket, prefix = meta["output_bucket"], meta["out_prefix"]
    # Enumerate tile outputs and reference them in-place via /vsis3 (no download).
    keys = [o["Key"] for page in s3.get_paginator("list_objects_v2")
            .paginate(Bucket=bucket, Prefix=prefix)
            for o in page.get("Contents", [])]
    vsis3 = [f"/vsis3/{bucket}/{k}" for k in keys]

    vrt_path = "/tmp/mosaic.vrt"
    gdal.BuildVRT(vrt_path, vsis3)

    cog_local = "/tmp/product.tif"
    gdal.Translate(
        cog_local, vrt_path,
        format="COG",
        creationOptions=["COMPRESS=DEFLATE", "OVERVIEWS=AUTO", "BLOCKSIZE=512"],
    )

    out_key = f"runs/{meta['run_id']}/product.tif"
    s3.upload_file(cog_local, bucket, out_key)
    return {"run_id": meta["run_id"], "product_bucket": bucket,
            "product_key": out_key, "crs": meta["profile"]["crs"]}

Step 5: Register a STAC Item

The catalog Lambda writes a STAC item describing the product’s bounds, CRS, and asset href to the catalog prefix, making the result queryable without scanning the bucket.

python
import json
import boto3

s3 = boto3.client("s3")


def handler(product, context):
    item = {
        "type": "Feature",
        "stac_version": "1.0.0",
        "id": product["run_id"],
        "properties": {"proj:epsg": product["crs"]},
        "assets": {
            "data": {
                "href": f"s3://{product['product_bucket']}/{product['product_key']}",
                "type": "image/tiff; application=geotiff; profile=cloud-optimized",
                "roles": ["data"],
            }
        },
    }
    s3.put_object(
        Bucket=product["product_bucket"],
        Key=f"catalog/{product['run_id']}.json",
        Body=json.dumps(item).encode(),
        ContentType="application/json",
    )
    return {"stac_id": product["run_id"], "status": "cataloged"}

Measurement and Verification

Confirm the pipeline stayed within limits by inspecting the Step Functions execution and the per-tile metrics. A healthy run shows every tile worker peaking well below its memory allocation and finishing in seconds:

bash
# Inspect the most recent execution and its Map run distribution
aws stepfunctions describe-execution \
  --execution-arn "$EXECUTION_ARN" \
  --query '{status:status, started:startDate, stopped:stopDate}'

# Tile-worker memory headroom from CloudWatch Lambda Insights
aws cloudwatch get-metric-statistics \
  --namespace "LambdaInsights" --metric-name "memory_utilization" \
  --dimensions Name=function_name,Value=tile-worker \
  --start-time "$(date -u -d '-1 hour' +%FT%TZ)" \
  --end-time "$(date -u +%FT%TZ)" \
  --period 300 --statistics Maximum

Expected shape of a successful 10 GB run (40000×40000, 4-band, 2048 px tiles):

code
status        : SUCCEEDED
tile_count    : 400
map_max_conc  : 500
worker p95 mem: 1180 MB / 2048 MB allocated
worker p95 dur: 24 s
end_to_end    : ~3 min
product       : s3://geo-ingest/runs/<id>/product.tif  (valid COG)

Validate the merged product is a genuine COG with rio cogeo validate s3://.../product.tif — a non-zero exit means the overviews or internal tiling were not written and downstream range reads will be slow.

Failure Modes and Debugging

States.DataLimitExceeded on the Map transition: The manifest was inlined into the state payload instead of read via ItemReader, breaching the 256 KB limit. Confirm the metadata Lambda returns only a manifest pointer and that ItemReader points at the S3 object — the fix is detailed in partitioning a GeoTIFF into Map-state tiles.

Tile worker Task timed out after 900.00 seconds: A window is too large or the source is not a real COG, so each read pulls far more than its bytes. Verify the source with rio cogeo validate; if it is a striped (non-tiled) GeoTIFF, add a one-off translation step to a COG before tiling, or shrink TILE_PIXELS.

Runtime.OutOfMemory in the worker: The decoded tile plus GDAL buffers exceeded the allocation. Either raise the worker memory tier (which also adds vCPU per the memory and CPU allocation model) or reduce tile pixels; a four-band Float32 2048×2048 tile decodes to ~256 MB before working copies.

/tmp fills during merge — [Errno 28] No space left on device: The merge Lambda downloaded tiles instead of referencing them via /vsis3, or the output COG plus VRT exceeded 10 GB /tmp. Reference tiles in place and stream the translate; the ephemeral storage limits page covers provisioning and guarding /tmp.

Silent throttling storm: The Map concurrency exceeded the worker’s reserved concurrency and tiles retried in a loop. Cap MaxConcurrency below the reserved allocation and watch map_concurrency against the regional quota.

Cost and Scaling Considerations

The bill for this recipe is dominated by GB-seconds across the fan-out, and it scales almost linearly with pixel count rather than with the number of tiles — smaller tiles simply mean more, shorter invocations for roughly the same total compute. A 10 GB scene at 400 tiles × 24 s × 2 GB is about 19,000 GB-seconds, a few cents at current Lambda pricing, plus a small Step Functions state-transition charge. The larger cost lever is data transfer: keep source, tiles, and product in one region behind a VPC endpoint so range reads and writes never traverse NAT.

Scaling up follows the 15-minute ceiling chunking rule: for a 100 GB scene, hold tile pixels constant and let the tile count grow, raising MaxConcurrency toward the distributed Map’s 10,000-child limit while keeping worker reserved concurrency comfortably above it. If a single tile ever cannot be subdivided below the memory ceiling — a pathological block layout — route just that tile to a Fargate task via the circuit-breaker pattern, preserving the serverless profile for the rest of the run. Provisioning provisioned concurrency on the worker is worthwhile only for latency-critical pipelines, since the fan-out already hides cold starts behind parallelism.

Frequently Asked Questions

How can Lambda process a 10 GB GeoTIFF within a 10 GB memory limit?

It never loads the whole file. Each tile-worker Lambda reads only its window through /vsis3 range requests against the Cloud Optimized GeoTIFF, so peak memory is proportional to one tile — typically 256 to 512 MB decoded — not the 10 GB scene. The distributed Map state runs hundreds of these workers in parallel.

How many tiles should a 10 GB GeoTIFF be split into?

Size tiles so one worker finishes well under the 15-minute timeout and peaks under its memory allocation. For a 40000×40000 pixel scene, 2048×2048 tiles yield about 400 windows, each processed in 10–40 seconds at a 2 GB tier — a safe margin against both ceilings.

Why use a distributed Map state instead of a standard Map?

A standard inline Map holds all iterations in the 256 KB state payload and caps at 40 concurrent iterations. A distributed Map reads items from an S3 manifest with ItemReader, supports up to 10,000 concurrent child executions, and keeps the payload tiny — essential when a scene tiles into hundreds or thousands of windows.

What happens when one tile fails?

The Map state retries that tile with exponential backoff, then routes it to a dead-letter queue while sibling tiles complete. Because each tile writes to a deterministic S3 key, re-running reprocesses only the failed window.


Back to Serverless Geospatial Pipeline Recipes