Skip to content

Ephemeral Storage Limits in AWS Lambda for Geospatial Processing

AWS Lambda mounts an NVMe-backed /tmp volume with a configurable ceiling of 10,240 MB (10 GB), up from the historical 512 MB default. For geospatial workloads, sizing this correctly is non-negotiable: a single 2 GB compressed GeoTIFF routinely expands to 6–8 GB of working files once GDAL decompresses blocks, writes VRT intermediates, and fills its block cache. Undershoot the allocation and the function terminates mid-transform with a DiskSpaceExhausted error; overshoot and you pay for storage you never touch.


Lambda /tmp lifecycle for geospatial workloads Diagram showing four stages: Lambda invocation mounts an isolated NVMe /tmp volume; GDAL, rasterio, and Python tempfile write intermediates; output is uploaded to S3 or GCS; on warm start /tmp contents persist until the sandbox is reclaimed. Invocation Lambda allocates isolated NVMe /tmp volume 512 MB – 10 GB GDAL / rasterio Decompressed blocks VRT intermediates CPL_TMPDIR files GDAL_CACHEMAX controls spill Output → S3 Staged output file uploaded via boto3 then deleted from /tmp os.unlink() or shutil.rmtree() Warm start /tmp contents persist check for stale files on entry Sandbox reclaimed /tmp wiped by AWS cold start on next invoke Each concurrent execution receives its own isolated /tmp — no cross-invocation sharing within a cold-started sandbox

Why This Constraint Matters for Geospatial Workloads

Geospatial formats are inherently I/O-heavy. A LZW-compressed GeoTIFF holding a single Sentinel-2 granule (110 × 110 km at 10 m resolution) weighs roughly 700 MB on disk but expands to 1.8–2.5 GB when GDAL decompresses it for processing. Add a VRT wrapper, an output draft file, and GDAL’s block cache and the total /tmp footprint easily crosses 5 GB for a single invocation. Common scenarios and their scratch footprints:

Workload Compressed input Peak /tmp usage
Single Sentinel-2 band reproject 200 MB 800 MB – 1.2 GB
Full 13-band S2 granule reproject 700 MB 3–5 GB
LiDAR LAZ → DEM (pdal) 400 MB 1.5–3 GB
GeoPackage 500k features to vector tiles 300 MB 1–2 GB
10 GB orthomosaic tiling 10 GB 20–30 GB (must chunk or offload)

For the orthomosaic case, even 10 GB /tmp is insufficient in a single Lambda invocation — that workload belongs in a batch vs. streaming split or a step-function workflow that shards the mosaic before dispatching tiles to individual functions.

Beyond raw capacity, the cold start sequence for a GDAL-heavy function already contends for /tmp during shared-library extraction. Insufficient space here aborts the function before a single pixel is processed.

Platform-by-Platform Limits Table

Constraint AWS Lambda GCP Cloud Functions (2nd gen) Azure Functions (Consumption)
Ephemeral storage default 512 MB 512 MB (in-memory tmpfs) ~500 MB (varies by SKU)
Ephemeral storage ceiling 10,240 MB Not configurable (capped at instance RAM) 10 GB (Premium/Dedicated plan only)
Billed separately from memory Yes — $0.0000000367/GB-s above 512 MB No — storage consumes instance RAM No — tied to plan tier
Config knob EphemeralStorage.Size (SAM/CDK/Terraform/console) Instance size (vCPU/RAM tier selection) Storage account binding on Premium plan
Timeout ceiling 15 minutes 60 minutes 10 minutes (Consumption), 60 min (Premium)
Memory ceiling 10,240 MB 32,768 MB 1,536 MB (Consumption)
GDAL temp dir env var CPL_TMPDIR=/tmp/gdal CPL_TMPDIR=/tmp/gdal CPL_TMPDIR=/tmp/gdal or D:\tmp\gdal

GCP Cloud Functions stores temp data in a tmpfs that draws from instance RAM — allocating 4 GB of RAM gives you at most 2 GB of usable /tmp before your function begins swapping. For large raster jobs on GCP, Cloud Run with a mounted Cloud Filestore volume is the more natural fit than Cloud Functions.

Azure Functions on the Consumption plan shares ephemeral storage across function instances on the same host; large geospatial jobs must use a Premium or Dedicated plan with managed disks.

Step-by-Step Implementation

1. Baseline your peak scratch requirement

Run this profiler locally against your largest expected input before touching IaC:

python
import shutil
import os
import rasterio
import tempfile

def profile_tmp_usage(input_path: str) -> dict:
    """
    Measure /tmp consumption during a typical rasterio read-transform cycle.
    Run locally with /tmp mounted; translate results to Lambda /tmp sizing.
    """
    os.environ["CPL_TMPDIR"] = "/tmp/gdal_profile"
    os.environ["GDAL_CACHEMAX"] = "512"       # MB — mirror your Lambda setting
    os.environ["GDAL_DATA"] = "/usr/share/gdal"
    os.environ["PROJ_LIB"] = "/usr/share/proj"
    os.makedirs("/tmp/gdal_profile", exist_ok=True)

    before = shutil.disk_usage("/tmp").used

    with rasterio.open(input_path) as src:
        with tempfile.NamedTemporaryFile(suffix=".tif", dir="/tmp", delete=False) as tmp:
            profile = src.profile.copy()
            profile.update(compress="lzw", tiled=True, blockxsize=512, blockysize=512)
            with rasterio.open(tmp.name, "w", **profile) as dst:
                for ji, window in src.block_windows(1):
                    dst.write(src.read(window=window), window=window)
            peak = shutil.disk_usage("/tmp").used
            os.unlink(tmp.name)

    return {
        "before_bytes": before,
        "peak_bytes": peak,
        "delta_mb": (peak - before) / 1_048_576,
    }

Add 20 % headroom to delta_mb and round up to the nearest 512 MB when setting EphemeralStorage.Size.

2. Set EphemeralStorage in your IaC

AWS SAM (template.yaml):

yaml
Resources:
  GeoTransformFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.12
      Handler: handler.process
      MemorySize: 3008          # memory controls CPU share and network bandwidth
      EphemeralStorage:
        Size: 5120              # MB — sized to 1.5x peak scratch from profiler
      Timeout: 600
      Environment:
        Variables:
          CPL_TMPDIR: /tmp/gdal
          GDAL_CACHEMAX: "512"
          GDAL_DISABLE_READDIR_ON_OPEN: EMPTY_DIR
          TMPDIR: /tmp
          GDAL_DATA: /opt/share/gdal
          PROJ_LIB: /opt/share/proj

Terraform (aws_lambda_function):

hcl
resource "aws_lambda_function" "spatial_processor" {
  function_name = "spatial-processor"
  runtime       = "python3.12"
  handler       = "handler.process"
  memory_size   = 3008
  timeout       = 600

  ephemeral_storage {
    size = 5120   # MB
  }

  environment {
    variables = {
      CPL_TMPDIR                    = "/tmp/gdal"
      GDAL_CACHEMAX                 = "512"
      GDAL_DISABLE_READDIR_ON_OPEN  = "EMPTY_DIR"
      TMPDIR                        = "/tmp"
      GDAL_DATA                     = "/opt/share/gdal"
      PROJ_LIB                      = "/opt/share/proj"
    }
  }
}

Note: MemorySize and EphemeralStorage.Size are billed independently. Setting both to 10,240 MB doubles your per-invocation cost — right-size each axis separately.

3. Initialise scratch directories at handler entry

python
import os
import shutil
import tempfile
import logging

logger = logging.getLogger(__name__)

# Module-level initialisation (runs once per cold start)
_TMP_ROOT = "/tmp"
_GDAL_CACHE = "/tmp/gdal"
os.makedirs(_GDAL_CACHE, exist_ok=True)
tempfile.tempdir = _TMP_ROOT

# Explicitly export every GDAL/PROJ path — never rely on defaults
os.environ.setdefault("CPL_TMPDIR", _GDAL_CACHE)
os.environ.setdefault("GDAL_CACHEMAX", "512")
os.environ.setdefault("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
os.environ.setdefault("TMPDIR", _TMP_ROOT)


def _check_warm_start_stale_files() -> None:
    """Remove any files left by a previous warm-start invocation."""
    for entry in os.scandir(_GDAL_CACHE):
        try:
            os.unlink(entry.path) if entry.is_file() else shutil.rmtree(entry.path)
        except OSError as exc:
            logger.warning("Could not remove stale /tmp file %s: %s", entry.path, exc)


def handler(event: dict, context) -> dict:
    _check_warm_start_stale_files()

    # Emit /tmp baseline usage at invocation start
    usage_start = shutil.disk_usage(_TMP_ROOT).used
    logger.info("tmp_usage_start_mb=%.1f", usage_start / 1_048_576)

    try:
        result = _process(event)
    finally:
        usage_end = shutil.disk_usage(_TMP_ROOT).used
        logger.info("tmp_usage_end_mb=%.1f", usage_end / 1_048_576)

    return result

4. Use block-windowed I/O to cap peak usage

Processing an entire GeoTIFF in memory at once is the single most common cause of /tmp exhaustion. The block_windows() pattern keeps the in-memory footprint bounded regardless of dataset size:

python
import rasterio
import numpy as np
import os

def reproject_raster(src_uri: str, dst_path: str, target_crs: str = "EPSG:3857") -> None:
    """
    Reproject a raster block-by-block, writing directly to /tmp.
    src_uri may be an s3:// path — rasterio streams via VSIFILE.
    """
    from rasterio.warp import calculate_default_transform, reproject, Resampling

    with rasterio.open(src_uri) as src:
        transform, width, height = calculate_default_transform(
            src.crs, target_crs, src.width, src.height, *src.bounds
        )
        profile = src.profile.copy()
        profile.update(
            crs=target_crs,
            transform=transform,
            width=width,
            height=height,
            compress="lzw",
            tiled=True,
            blockxsize=512,
            blockysize=512,
        )

        with rasterio.open(dst_path, "w", **profile) as dst:
            for band_idx in src.indexes:
                reproject(
                    source=rasterio.band(src, band_idx),
                    destination=rasterio.band(dst, band_idx),
                    src_transform=src.transform,
                    src_crs=src.crs,
                    dst_transform=transform,
                    dst_crs=target_crs,
                    resampling=Resampling.lanczos,
                )

For more targeted techniques around GeoTIFF extraction specifically, see Managing /tmp Storage Limits for GeoTIFF Extraction.

Measurement and Verification

Lambda does not emit /tmp utilisation metrics by default. Instrument your functions with CloudWatch custom metrics using the Embedded Metric Format (EMF):

python
import json
import shutil
import time

def emit_tmp_metric(value_mb: float, function_name: str) -> None:
    """
    Write an EMF metric to stdout. CloudWatch Logs ingests this automatically
    and creates a metric in the 'GeoSpatial/Lambda' namespace.
    """
    metric = {
        "_aws": {
            "Timestamp": int(time.time() * 1000),
            "CloudWatchMetrics": [
                {
                    "Namespace": "GeoSpatial/Lambda",
                    "Dimensions": [["FunctionName"]],
                    "Metrics": [{"Name": "TmpUsageMB", "Unit": "Megabytes"}],
                }
            ],
        },
        "FunctionName": function_name,
        "TmpUsageMB": value_mb,
    }
    print(json.dumps(metric))


def snapshot_tmp(label: str, function_name: str) -> float:
    used_mb = shutil.disk_usage("/tmp").used / 1_048_576
    emit_tmp_metric(used_mb, function_name)
    return used_mb

Expected output ranges after tuning:

Metric Healthy range Investigate if
TmpUsageMB at handler entry (warm) < 50 MB > 200 MB (stale files)
Peak TmpUsageMB during processing 40–80 % of EphemeralStorage.Size > 90 %
InitDuration (cold start) < 8 s for a 400 MB container image > 15 s

Use the CloudWatch metric math expression MAX(TmpUsageMB) / EphemeralStorageSizeMB * 100 over a 7-day window to right-size your allocation. Target the 95th-percentile peak, not the average.

The Cold Start Mapping for Python GDAL page explains how to correlate InitDuration spikes with /tmp write contention during shared-library extraction.

Failure Modes and Debugging

1. DiskSpaceExhausted mid-transform

Signature: OSError: [Errno 28] No space left on device or the Lambda runtime’s DiskSpaceExhausted error, usually mid-write inside rasterio.open(..., "w") or during GDAL’s internal block flush.

Root cause: EphemeralStorage.Size is smaller than the decompressed working set plus GDAL intermediates.

Fix: Increase EphemeralStorage.Size by 2× and re-run. Also set GDAL_CACHEMAX explicitly — an unset value defaults to 5 % of system RAM, which on a 3 GB Lambda is ~150 MB in memory but can generate large disk spills.

2. Silent cache overflow fills /tmp without error

Signature: TmpUsageMB grows monotonically across invocations (visible in your EMF metric), eventually triggering DiskSpaceExhausted on a warm invocation long after the offending file was written.

Root cause: CPL_TMPDIR is unset, so GDAL writes cache files to /tmp outside your managed directories. Without explicit cleanup, warm-start reuse accumulates them.

Fix:

python
# Always set before importing gdal or rasterio
import os
os.environ["CPL_TMPDIR"] = "/tmp/gdal"
os.makedirs("/tmp/gdal", exist_ok=True)

Then add a glob.glob("/tmp/gdal/*") purge at handler entry.

3. OSError: [Errno 30] Read-only file system

Signature: Immediate crash on the first file write, usually inside a library that defaults to /var/task or /var/runtime.

Root cause: Everything outside /tmp in Lambda is read-only. Some GDAL plugins or Python packages write to tempfile.gettempdir() which can resolve to /var if TMPDIR is not set.

Fix: Export TMPDIR=/tmp in your Lambda environment variables and confirm tempfile.gettempdir() returns /tmp from a test invocation.

4. Warm-start stale file collision

Signature: Transient FileExistsError or corrupted output on a fraction of invocations, correlating with high concurrency.

Root cause: A previous warm-start invocation left a partial output file at the same deterministic path (e.g., /tmp/output.tif). The next invocation tries to create it anew.

Fix: Use tempfile.NamedTemporaryFile(dir="/tmp", suffix=".tif", delete=False) to generate unique paths, or add a UUID prefix: f"/tmp/{uuid.uuid4().hex}_output.tif".

5. GeoPackage / SQLite journal exhausts /tmp

Signature: sqlite3.OperationalError: disk I/O error during fiona or GDAL GeoPackage writes.

Root cause: SQLite’s WAL journal can double the effective file size during large transactions. A 1 GB GeoPackage write briefly needs 2 GB of /tmp.

Fix: Set OGR_GPKG_JOURNAL_MODE=MEMORY as a GDAL config option, or reduce transaction batch size so the WAL journal stays small.

Cost and Scaling Considerations

Per-invocation cost math

Storage above 512 MB is billed at $0.0000000367 per GB-second (us-east-1, June 2026).

For a 5,120 MB allocation running a 60-second GeoTIFF reproject:

code
Billable storage = (5,120 − 512) MB = 4,608 MB = 4.5 GB
Cost = 4.5 GB × 60 s × $0.0000000367 = ~$0.0000099 per invocation

At 1 million invocations per month: ~$9.90/month in storage alone. Compare this with memory billing (which also covers CPU) before optimising the wrong axis.

When to prefer alternatives

Scenario Better than expanding /tmp
Input > 8 GB per job AWS Fargate with EFS or FSx for Lustre
Jobs run serially in a single region S3 Object Lambda with streaming transform
Output must land in a database AWS Step Functions with per-tile Lambda + RDS
Temp data must survive > 15 minutes EFS mounted to Lambda (no size limit)
High-concurrency tile generation Lambda + /tmp per tile (keep tiles small, < 50 MB each)

EFS mounts eliminate the /tmp ceiling entirely but add ~10 ms of cold-start latency per mount point. For workloads where 10 ms matters, profile against the memory and CPU allocation patterns before adding EFS.

At scale, the EphemeralStorage.Size setting becomes a cost-optimisation variable: use Lambda Power Tuning (AWS open-source tool) to sweep storage sizes alongside memory across a representative test payload and find the cheapest configuration that avoids DiskSpaceExhausted.

The IAM Security Boundaries for Cloud GIS page covers the complementary topic of scoping S3 write permissions to exactly the output prefix your Lambda targets — important when /tmp intermediates get uploaded during error recovery.

Frequently Asked Questions

What is the maximum /tmp storage in AWS Lambda? 512 MB to 10,240 MB (10 GB). The default is 512 MB if you do not set EphemeralStorage.Size. You can increase it in the console, SAM, CDK, or Terraform independently of memory allocation.

Does /tmp persist between Lambda invocations? Yes, during warm starts the execution environment is reused and /tmp contents survive. Data is only wiped when AWS reclaims the sandbox. Always clean up or check for stale files at the start of each handler.

How much /tmp does a 2 GB GeoTIFF need? Plan for 3–4 GB: the compressed source (~2 GB), an uncompressed working copy (~2–3× compressed size depending on band count), plus GDAL VRT and block-cache intermediates. A 4 GB /tmp allocation with GDAL_CACHEMAX capped at 512 MB is a safe starting point.

Is ephemeral storage billed separately from memory? Yes. Storage above 512 MB is billed at $0.0000000367 per GB-second. For a 4 GB allocation running 30 seconds, that is roughly (4 − 0.5) × 30 × $0.0000000367 ≈ $0.0000039 per invocation — negligible per call but material at millions of invocations per month.


Back to Serverless Geospatial Architecture & Platform Limits