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.
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.
The number that matters in every row of that table is the decompressed size, and it is not derivable from the file listing. A GeoTIFF’s on-disk size is a function of its codec and its content: LZW on a 12-bit Sentinel-2 band typically lands between 2.5:1 and 3.5:1, DEFLATE with a horizontal predictor can reach 4:1 on smooth elevation data, and JPEG-compressed RGB overviews routinely exceed 10:1. Two files of identical byte size can therefore differ by a factor of four once GDAL has them open. The safe rule when sizing EphemeralStorage.Size is to compute from pixel geometry rather than from bytes on disk — width × height × bands × bytes_per_sample — and treat the compressed size only as a lower bound on what the download itself will occupy.
The second structural fact is that /tmp is per execution environment, not per function. Lambda mounts a fresh, isolated NVMe-backed volume into each sandbox, so ten concurrent executions of the same function hold ten independent copies of the allocation and cannot see one another’s files. That is good for isolation and bad for intuition: raising EphemeralStorage.Size from 512 MB to 5,120 MB does not cost ten times more when one invocation runs, but it does when a tiling fan-out drives a hundred of them at once, because the storage charge accrues per environment for as long as each is billed. It also means you cannot use /tmp as a cache shared across a fan-out — anything that must be visible to sibling invocations belongs in S3, ElastiCache, or an EFS mount. It is also the only writable path in the sandbox — /var/task, /opt, and /var/runtime are mounted read-only, so any library that writes beside its own installation fails with Errno 30 until it is redirected.
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.
Read those rows against the surrounding quotas, because scratch is never the only ceiling in play. Lambda gives 15 minutes of runtime, 10,240 MB of memory, 10,240 MB of /tmp above a 512 MB default, and 250 MB of unzipped deployment package; GCP Cloud Functions 2nd gen gives 60 minutes and 32,768 MB of memory, out of which roughly 8 GB of tmpfs is carved; Azure Functions on the Consumption plan gives 10 minutes, 1,536 MB of memory, and about 1.5 GB of shared local storage. A workload that fits Lambda’s disk but not its clock has the same outcome as one that fits the clock but not the disk — a failed invocation — so profile both axes against the same representative payload rather than tuning them in sequence.
The asymmetry worth internalising is that only AWS separates the two axes. On Lambda a 1,024 MB-memory function may write 10,240 MB to /tmp quite happily, because the volume is not carved out of RAM; on Cloud Functions the identical code needs a 12 GB memory tier to hold the same files. That single difference usually decides which provider a scratch-heavy raster pipeline belongs on.
Step-by-Step Implementation
1. Baseline your peak scratch requirement
Run this profiler locally against your largest expected input before touching IaC:
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):
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):
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
The volume outlives the invocation that wrote to it. AWS keeps a warm execution environment alive for several minutes after a request — sometimes far longer under steady traffic — and reuses it wholesale, /tmp included. Nothing between one invocation returning and the next one starting empties the directory; only a sandbox reclaim does, and that is entirely at the platform’s discretion.
The practical consequence is that the handler must treat /tmp as inherited state rather than as a clean slate. Purge at entry rather than at exit: an invocation that times out, is throttled mid-flight, or dies to an unhandled exception never reaches its cleanup block, and the files it left behind become the next invocation’s opening balance. A purge at entry runs unconditionally, which is exactly the property you want from the code that guarantees your headroom.
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:
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):
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.
Two details make or break this instrumentation. The first is dimension cardinality: EMF creates one metric stream per unique dimension combination, so adding the source S3 key or a job ID as a dimension turns one cheap metric into millions of them. Keep dimensions to FunctionName and at most a small enumerated JobType, and leave the high-cardinality identifiers as plain log properties, still queryable through Logs Insights but not billed as metrics. The second is sampling frequency: a reading taken when the handler returns records the state after GDAL has torn down its intermediates, which is systematically the low point of the invocation. Emit at entry, mid-transform, and exit, or run the background sampler from step 1 and emit only its maximum.
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:
# 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.
6. Concurrent invocations exhaust the account’s storage budget, not the function’s
Signature: Individual invocations stay well inside their allocation, yet the cost of Lambda-Storage-Duration climbs faster than invocation count during tiling fan-outs.
Root cause: Each concurrent execution environment holds its own EphemeralStorage.Size allocation for as long as it is billed. A function configured at 10,240 MB running at 200 concurrent executions is billing roughly 2 TB of storage-seconds while the burst lasts, even though no single invocation used more than 3 GB.
Fix: Size storage for the per-invocation peak, not for the worst input you can imagine, and reserve concurrency on the tile processor so a runaway fan-out cannot multiply the allocation without bound. If one rare oversized input drives the ceiling, route that input to a separate function with its own larger allocation instead of raising the setting for every tile.
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:
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.
That comparison is usually decisive. Memory GB-seconds bill at roughly $0.0000166667 in us-east-1 — about 450 times the storage rate — and memory is what buys you CPU, so the same 4.5 GB held as RAM for the same 60 seconds costs on the order of $0.0045 per invocation against $0.0000099 for storage. The engineering conclusion follows directly: when the constraint is scratch space rather than throughput, buy storage, and when a job is slow rather than cramped, buy memory. Setting both axes to 10,240 MB “to be safe” is the most common way to double a Lambda bill without moving either the failure rate or the p95 duration.
Duration is the multiplier that catches people out. Storage bills for the whole billed duration, not for the window in which files exist, so a function that writes intermediates for 8 seconds and then uploads for 50 pays for its full 5,120 MB across all 58. Streaming the upload while the transform is still running removes storage-seconds that were buying nothing.
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.
Do concurrent invocations share the same /tmp?
No. Every execution environment receives its own isolated volume, so a function running at 50 concurrent executions holds 50 separate copies of whatever you configured. Two invocations of the same function cannot see each other’s files, which rules out using /tmp as a shared cache across a tile fan-out — that state belongs in S3, DynamoDB, or an EFS mount. It also means the storage charge scales with concurrency, not with invocation count.
When should I use EFS instead of /tmp?
When the working set genuinely exceeds 10,240 MB, when intermediate data must outlive a single 15-minute invocation, or when several concurrent invocations need to read the same staged files. EFS removes the size ceiling entirely and is shared across executions, at the cost of roughly 10 ms of extra cold-start latency per mount point, VPC configuration, and per-GB storage charges that continue whether the function runs or not. For anything that fits in 10 GB and finishes inside one invocation, /tmp is faster and cheaper.
Guides in this topic
- Managing /tmp Storage Limits for GeoTIFF Extraction — How to keep Lambda /tmp usage under control when extracting GeoTIFFs: windowed reads via rasterio, in-memory…
Related
- Managing /tmp Storage Limits for GeoTIFF Extraction
- Cold Start Mapping for Python GDAL
- Memory and CPU Allocation for Raster Workloads
- IAM Security Boundaries for Cloud GIS
- Batch vs. Stream Geospatial Processing
Back to Serverless Geospatial Architecture & Platform Limits