Managing /tmp Storage Limits for GeoTIFF Extraction
Avoid writing GeoTIFF data to /tmp entirely: stream windowed reads from cloud storage via GDAL VSI paths, buffer extracted tiles in rasterio.io.MemoryFile, and guard every invocation with a pre-flight /tmp usage check. AWS Lambda provisions 512 MB of /tmp by default (expandable to 10,240 MB), but even with the maximum allocation a naive full-file download of a multi-band Sentinel-2 scene will exhaust it. The only reliable strategy is zero-disk materialization combined with explicit GDAL cache limits.
Context: What Drives /tmp Exhaustion During GeoTIFF Work
Ephemeral storage limits in AWS Lambda are the primary constraint for raster extraction jobs. Three failure modes produce OSError: [Errno 28] No space left on device in the middle of a rasterio or GDAL operation:
- Full-file downloads.
boto3.download_file()or a rawrequests.get()writes every byte of the source GeoTIFF to/tmpbefore the first pixel is decoded. A single 1 GB Cloud-Optimized GeoTIFF (COG) wipes out the default 512 MB quota immediately. - GDAL block-cache spills. GDAL maintains an in-memory block cache controlled by
GDAL_CACHEMAX. When that limit is hit, GDAL silently evicts blocks to a temp directory — which resolves to/tmpinside Lambda. Without an explicit cap this can grow unbounded. - Intermediate file artefacts. Reprojection, format conversion, and in-place compression all produce
.aux.xmlmetadata files,.tmpswap files, and partially-written COG staging files. These accumulate across warm container reuse.
A secondary concern is that cold start timing for Python GDAL is sensitive to /tmp I/O load: if GDAL driver registration coincides with an active /tmp spill from a prior warm-container extraction, latency spikes compound.
The diagram below shows the two execution paths — the disk-heavy path that exhausts /tmp and the zero-disk path that this page implements.
Prerequisites
Before implementing the pattern below, confirm each of the following:
- Python 3.11+ and rasterio >= 1.3.9 (earlier versions have a
MemoryFileseek regression that corrupts multi-band writes) - GDAL >= 3.6 (bundled inside the rasterio wheel; verify with
python -c "from osgeo import gdal; print(gdal.__version__)") - boto3 >= 1.28 for
put_objectwith streaming body support - Lambda ephemeral storage set to at least 512 MB (the default) — increase to 2,048 MB if you anticipate GDAL cache spills from concurrent warm invocations; see Ephemeral Storage Limits in AWS Lambda for the SAM/CDK config syntax
- IAM permissions on the Lambda execution role:
s3:GetObjecton the source bucket (read)s3:PutObjecton the destination bucket (write)- No
s3:GetBucketLocationneeded — GDAL VSI handles region resolution automatically whenAWS_DEFAULT_REGIONis set
- Environment variables set in the function configuration (not in code):
GDAL_CACHEMAX=256M GDAL_DISABLE_READDIR_ON_OPEN=TRUE VSI_CURL_CACHE_SIZE=100M GDAL_DATA=/opt/share/gdal PROJ_LIB=/opt/share/proj AWS_DEFAULT_REGION=eu-west-1
Implementation
The function below extracts a geographic bounding box from a cloud-hosted COG without writing anything to /tmp. All intermediate data lives in process RAM via rasterio.io.MemoryFile. A pre-flight /tmp check guards against accumulated state from warm container reuse.
import os
import shutil
from urllib.parse import urlparse
import boto3
import rasterio
from rasterio.enums import Resampling
from rasterio.io import MemoryFile
from rasterio.windows import from_bounds, transform as window_transform
def _tmp_used_mb() -> float:
"""Return current /tmp disk usage in MB using shutil.disk_usage."""
usage = shutil.disk_usage("/tmp")
return (usage.total - usage.free) / (1024 ** 2)
def _to_vsi(uri: str) -> str:
"""
Convert an S3 or HTTPS URI to a GDAL Virtual File System path.
/vsis3/ enables range-request streaming with no intermediate download.
/vsicurl/ handles HTTPS COGs hosted on any server (e.g. Planetary Computer).
"""
parsed = urlparse(uri)
if parsed.scheme == "s3":
return f"/vsis3/{parsed.netloc}{parsed.path}"
if parsed.scheme in ("http", "https"):
return f"/vsicurl/{uri}"
# Assume already a VSI path or local path — pass through unchanged
return uri
def extract_geotiff_window(
src_uri: str,
dst_uri: str,
bounds: tuple[float, float, float, float],
*,
max_tmp_mb: int = 50,
resampling: Resampling = Resampling.bilinear,
compress: str = "deflate",
tile_size: int = 256,
profile_overrides: dict | None = None,
) -> str:
"""
Extract a spatial window from a cloud-hosted GeoTIFF and upload to S3.
No data is written to /tmp at any point. The output tile is buffered
entirely in RAM via rasterio.io.MemoryFile.
Args:
src_uri: S3 (s3://bucket/key) or HTTPS URI to a COG.
dst_uri: S3 URI (s3://bucket/key) for the output tile.
bounds: (left, bottom, right, top) in the source dataset CRS.
max_tmp_mb: Abort before opening the raster if /tmp already
exceeds this threshold (guards warm container drift).
resampling: Resampling algorithm applied during windowed read.
compress: GDAL compression codec for the output tile.
tile_size: Internal tile block size for the output COG.
profile_overrides: Any additional rasterio profile keys to apply last.
Returns:
The dst_uri string, confirming successful upload.
Raises:
RuntimeError: If /tmp usage exceeds max_tmp_mb before processing.
"""
# --- 1. Pre-flight /tmp guard -------------------------------------------
# Warm containers can carry leftover /tmp state from previous invocations.
# Fail fast here rather than letting a partial write exhaust the partition
# midway through raster I/O.
current_tmp = _tmp_used_mb()
if current_tmp > max_tmp_mb:
raise RuntimeError(
f"/tmp already at {current_tmp:.1f} MB (threshold: {max_tmp_mb} MB). "
"Cannot safely proceed — prior invocation may have left residual files."
)
# --- 2. Resolve VSI path -------------------------------------------------
# rasterio.open() on a /vsis3/ path issues HTTP range requests directly
# against S3 — no intermediate download, no /tmp write.
vsi_path = _to_vsi(src_uri)
# --- 3. Windowed read ----------------------------------------------------
# from_bounds() converts geographic coordinates to the pixel window that
# covers the requested bounding box. Only those pixels are fetched.
with rasterio.open(vsi_path) as src:
window = from_bounds(*bounds, transform=src.transform)
# Read returns a NumPy array (bands × rows × cols) for this window only.
data = src.read(window=window, resampling=resampling)
# Build the output profile: preserve CRS, dtype, and band count, then
# override spatial metadata and encoding settings for the extracted tile.
profile = src.meta.copy()
profile.update(
width=round(window.width),
height=round(window.height),
transform=window_transform(window, src.transform),
compress=compress,
tiled=True,
blockxsize=tile_size,
blockysize=tile_size,
**(profile_overrides or {}),
)
# --- 4. Write to MemoryFile and upload -----------------------------------
# MemoryFile is a RAM-backed file object that satisfies rasterio's file
# protocol. Writing here never touches /tmp.
with MemoryFile() as buf:
with buf.open(**profile) as tile:
tile.write(data)
# Rewind the MemoryFile buffer before reading its bytes.
buf.seek(0)
payload = buf.read()
parsed_dst = urlparse(dst_uri)
boto3.client("s3").put_object(
Bucket=parsed_dst.netloc,
Key=parsed_dst.path.lstrip("/"),
Body=payload,
ContentType="image/tiff",
)
return dst_uri
Why each design decision matters
/vsis3/VSI path: GDAL’s virtual filesystem layer interceptsopen()and translates it into HTTP range requests. Combined with a Cloud-Optimized GeoTIFF’s internal tile index, only the pixel blocks that overlapboundsare ever transferred.rasterio.windows.from_bounds(): This is the canonical API for converting geographic coordinates to a pixelWindow. Computing the window manually via affine arithmetic is error-prone with rotated or south-up rasters.MemoryFileinstead of a tempfile:MemoryFileallocates its buffer from the process heap. For tile sizes under ~200 MB it is faster than NVMe I/O because it avoids a kernel round-trip; it also works correctly when Lambda memory is configured for raster workloads at 3–6 GB.- Pre-flight threshold check: A Lambda container that processed a large job in its previous invocation may have lingering
/tmpfiles from GDAL’s.aux.xmlwriter or a partial cache spill. Checking before opening the raster surface this before any data transfer begins.
Verification
Run the following against a known COG to confirm no /tmp growth occurs during extraction:
import shutil
import json
def _tmp_snapshot() -> dict:
u = shutil.disk_usage("/tmp")
return {"total_mb": u.total / 1e6, "used_mb": (u.total - u.free) / 1e6, "free_mb": u.free / 1e6}
before = _tmp_snapshot()
extract_geotiff_window(
src_uri="s3://your-bucket/sentinel2/B04.tif",
dst_uri="s3://your-output-bucket/tiles/B04_crop.tif",
bounds=(13.0, 52.4, 13.2, 52.6), # lon/lat bbox over Berlin
)
after = _tmp_snapshot()
growth_mb = after["used_mb"] - before["used_mb"]
print(json.dumps({"before": before, "after": after, "growth_mb": growth_mb}))
assert growth_mb < 1.0, f"/tmp grew by {growth_mb:.2f} MB — investigate GDAL cache config"
Expected output (successful run):
{"before": {"total_mb": 536.9, "used_mb": 0.0, "free_mb": 536.9},
"after": {"total_mb": 536.9, "used_mb": 0.1, "free_mb": 536.8},
"growth_mb": 0.1}
The 0.1 MB growth is from GDAL’s internal driver state, not a data write. The assertion will pass. If growth_mb exceeds 1 MB, GDAL has spilled a cache block — recheck that GDAL_CACHEMAX=256M is present in the Lambda environment variables.
Gotchas and Edge Cases
-
Warm container
/tmpaccumulation. Lambda may reuse a container across dozens of invocations. GDAL writes.aux.xmlsidecar metadata files to the same directory as the source path. When the source is a VSI path, GDAL resolves this relative to/tmp. Over many invocations these files accumulate. Add anos.scandir("/tmp")cleanup step at the end of each handler, or setGDAL_DISABLE_READDIR_ON_OPEN=TRUEto suppress.aux.xmlcreation entirely. -
MemoryFile+ very large windows.MemoryFileallocates from heap. Extracting a window that covers most of a large mosaic (e.g. a 5 000 × 5 000 pixel crop of a 10-band dataset) will require several hundred MB of RAM. If your tile size approaches the function’s memory allocation, split the bounding box into a grid of smaller sub-windows using a quadtree or fixed-stride approach, and queue each sub-request separately via SQS queue routing strategies. -
rasterio< 1.3.9MemoryFileseek bug. In versions before 1.3.9 a missingseek(0)inMemoryFile.__exit__causes the buffer to be uploaded at the wrong offset, producing a corrupt TIFF that passesput_objectwithout error but fails to open on the receiving end. Pinrasterio>=1.3.9in your Lambda layer or Docker image. -
from_bounds()CRS mismatch.boundsmust be in the same coordinate reference system assrc.crs. If your caller provides WGS-84 (EPSG:4326) coordinates but the source COG is in a projected CRS (e.g. UTM), the computed window will be wrong or empty. Reproject the bounds first usingpyproj.Transformer.from_crs()before callingfrom_bounds(), or exposebounds_crsas a parameter and handle the reprojection inside the function.
Frequently Asked Questions
What is the default /tmp size on AWS Lambda and can it be increased?
AWS Lambda provisions 512 MB of /tmp by default. You can increase this independently of memory, up to 10,240 MB, via the EphemeralStorage configuration in SAM, CDK, or the AWS Console. The additional storage is billed at roughly $0.0000000309 per GB-second, independently of the memory billing dimension.
Does rasterio.io.MemoryFile write anything to /tmp?
No. MemoryFile holds its buffer entirely in process RAM. As long as your extracted tile fits in the available memory allocation, /tmp is never touched during the write phase. The only /tmp activity you may observe is GDAL’s .aux.xml metadata writer, which you can suppress with GDAL_DISABLE_READDIR_ON_OPEN=TRUE.
Why does GDAL_CACHEMAX matter for /tmp usage?
When GDAL’s in-memory block cache is exhausted, GDAL evicts overflow blocks to a temporary directory — which defaults to /tmp inside Lambda. Setting GDAL_CACHEMAX to a value well below your available RAM (256 MB is safe for most workloads) prevents this silent spill. For more on GDAL cache configuration see the GDAL Virtual File Systems documentation.
Can /tmp data persist between Lambda invocations?
Yes. On warm container reuse, /tmp content survives between invocations in the same execution environment. Accumulated partial writes from a prior invocation can exhaust the partition for a new one. Always run the pre-invocation threshold check (_tmp_used_mb()) rather than assuming /tmp is empty.
Related
- Ephemeral Storage Limits in AWS Lambda — quota details, SAM/CDK provisioning syntax, and billing model for
/tmp - Reducing Python GDAL Cold Starts with Provisioned Concurrency — pre-warming containers so GDAL driver registration does not compete with
/tmpI/O - Memory and CPU Allocation for Raster Workloads — choosing the right memory tier so
MemoryFilehas headroom for large extracted tiles - Building Minimal Docker Images with Alpine and GDAL — packaging rasterio and GDAL without bloat that inflates container startup time
- SQS and Pub/Sub Queue Routing Strategies — routing oversized bounding-box requests to batch queues when tile size exceeds
MemoryFileheadroom