Cost-per-Tile Math for Lambda Raster Jobs
One 512-pixel four-band raster tile on a 3,008 MB AWS Lambda function running for 5.4 seconds costs $0.00026723 in us-east-1 at the time of writing — of which $0.00026438, or 98.9%, is the compute charge and $0.00000285 is everything else combined. Multiply by 6,400 windows and a whole 10 GB scene costs $1.71 in function charges. That number is only trustworthy if you can show every term, so this guide works the arithmetic end to end, gives you a script that reproduces it, and then shows how much the answer moves when you change one input.
Context
The cost equation for AWS Lambda is short enough to fit on one line, which is exactly why people get it wrong. GB-seconds = (MemorySize ÷ 1024) × (BilledDuration ÷ 1000) uses the configured memory, not the memory the function used, and the billed duration, which includes cold-start initialisation. Both of those catch people out, and both matter more for a GDAL handler than for a typical API function: the GDAL stack initialises slowly, and raster functions are normally over-provisioned on memory to buy vCPU.
The wider structure — which non-compute charges dominate a real pipeline, and where the memory tier’s cost minimum sits — is covered in Cost Modelling for Serverless Raster Pipelines. This page is the arithmetic underneath it. Every figure below comes from one assumption set, stated once: a 512 × 512 window across four uint16 bands, read over /vsis3 from a same-region bucket, NDVI computed with NumPy, written back as a deflate-compressed tile, on x86 at 3,008 MB in us-east-1, at the time of writing.
Prerequisites
- A real REPORT line, not an estimate. Invoke the function against representative input with
--log-type Tailand readBilled Duration,Memory SizeandMax Memory Usedfrom the output. - A warm invocation and a cold one. Init duration is billed, so cost per tile differs between the first invocation on an execution environment and the rest. Model both.
- The GET count for one tile. Enable
CPL_CURL_VERBOSE=YESonce in a scratch deployment and count the range requests, or readAllRequestsfrom the bucket’s S3 request metrics over a controlled run. - The configured
/tmpsize. Only the portion above the 512 MB default is billed, so a function left at the default has a zero ephemeral term. The trade-offs of raising it are in Ephemeral Storage Limits in AWS Lambda. - Current rates for your region and architecture. Everything here is us-east-1 x86 on-demand list pricing at the time of writing; arm64 is roughly 20% lower per GB-second.
The arithmetic, term by term
Written out longhand:
memory_gb = 3008 / 1024 = 2.9375 GB
duration_s = 5400 / 1000 = 5.400 s
gb_seconds = 2.9375 * 5.400 = 15.8625 GB-s
compute = 15.8625 * 0.0000166667 = $0.00026438
request = 1 * (0.20 / 1_000_000) = $0.00000020
ephemeral_gb = (2048 - 512) / 1024 = 1.5 GB
ephemeral = 1.5 * 5.400 * 0.0000000309 = $0.00000025
s3_get = 6 * (0.0004 / 1000) = $0.00000240
-------------
cost_per_tile $0.00026723
Two things are worth reading off that column. The ephemeral-storage term is $0.00000025 for 1.5 GB of extra /tmp held for the whole invocation — about 1/1,000th of the compute charge, because the ephemeral rate of $0.0000000309 per GB-second is roughly 1/540th of the memory rate. Provisioning /tmp generously is free in practice. And the six S3 GETs cost nine times more than the invocation itself, which is the only reason the request-count tuning in tuning HTTP range requests for COG reads on S3 shows up in a cost model at all — and even then it is worth far more for the latency it removes.
Where the billed seconds go
The compute term is 98.9% of the cost, so the only lever that materially changes it is duration. Fencing each phase of the handler with time.perf_counter() shows what you are actually buying:
Fifty-seven per cent of the billed duration is network wait. No amount of NumPy tuning touches it, and neither does a larger memory tier, because more vCPU does not make S3 answer faster. It is reclaimable only by a runtime that lets another tile use the wait — which is the whole argument in comparing the cost of Lambda, Cloud Run and Azure Functions for tiling.
Implementation
Instrument the handler so it prices itself. The phase fence produces the segment breakdown above, and the cost record makes each invocation self-reporting rather than something you reconstruct later from Cost Explorer.
# handler.py — a tile worker that reports its own cost.
import json, os, time
from contextlib import contextmanager
import numpy as np
import rasterio
from rasterio.windows import Window
PRICE_GB_SECOND = 0.0000166667 # us-east-1, x86 on-demand, at the time of writing
PRICE_REQUEST = 0.20 / 1e6
PRICE_TMP_GB_SECOND = 0.0000000309 # only above the 512 MB default
PRICE_S3_GET = 0.0004 / 1000
PRICE_S3_PUT = 0.005 / 1000
TMP_FREE_MB = 512
PHASES: dict[str, float] = {}
@contextmanager
def phase(name: str):
"""Fence one phase of the tile so the billed seconds can be attributed."""
start = time.perf_counter()
try:
yield
finally:
PHASES[name] = PHASES.get(name, 0.0) + (time.perf_counter() - start)
def price_invocation(context, gets: int, puts: int, tmp_mb: int) -> dict:
"""Price this invocation from the runtime's own view of its allocation."""
memory_mb = int(context.memory_limit_in_mb)
# remaining time counts down from the configured timeout, so elapsed wall
# clock is the timeout minus what is left — close enough to billed duration
# for a per-invocation record, and reconciled against REPORT later.
duration_s = sum(PHASES.values())
gb_seconds = (memory_mb / 1024) * duration_s
billable_tmp_gb = max(0, tmp_mb - TMP_FREE_MB) / 1024
return {
"memory_mb": memory_mb,
"duration_s": round(duration_s, 4),
"gb_seconds": round(gb_seconds, 4),
"compute_usd": gb_seconds * PRICE_GB_SECOND,
"request_usd": PRICE_REQUEST,
"ephemeral_usd": billable_tmp_gb * duration_s * PRICE_TMP_GB_SECOND,
"s3_usd": gets * PRICE_S3_GET + puts * PRICE_S3_PUT,
"phases": {k: round(v, 3) for k, v in PHASES.items()},
}
def process_tile(event: dict, context) -> dict:
src_uri = event["s3_uri"].replace("s3://", "/vsis3/")
win = Window(event["col_off"], event["row_off"], event["width"], event["height"])
out_path = f"/tmp/{event['tile_id']}.tif"
PHASES.clear()
with phase("open_and_read"):
with rasterio.open(src_uri) as src:
data = src.read(window=win)
profile = src.profile.copy()
profile.update(count=1, dtype="float32", compress="deflate",
tiled=True, blockxsize=512, blockysize=512,
width=win.width, height=win.height,
transform=src.window_transform(win))
with phase("band_math"):
nir = data[3].astype("float32")
red = data[2].astype("float32")
ndvi = (nir - red) / (nir + red + 1e-6)
with phase("encode_and_write"):
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(ndvi, 1)
cost = price_invocation(context, gets=event.get("expected_gets", 6),
puts=1, tmp_mb=int(os.environ.get("TMP_MB", TMP_FREE_MB)))
cost["total_usd"] = (cost["compute_usd"] + cost["request_usd"]
+ cost["ephemeral_usd"] + cost["s3_usd"])
print(json.dumps({"event": "tile_cost", "tile_id": event["tile_id"], **cost}))
return {"tile_id": event["tile_id"], "cost_usd": cost["total_usd"]}
Verification
Invoke once and compare the handler’s own figure against the REPORT line, which is the authoritative source for billed duration:
aws lambda invoke --function-name raster-tiler \
--cli-binary-format raw-in-base64-out \
--payload '{"s3_uri":"s3://scenes/mosaic.tif","tile_id":"r12c07",
"col_off":3584,"row_off":6144,"width":512,"height":512}' \
--log-type Tail /dev/null --query LogResult --output text | base64 --decode
Expected output:
{"event": "tile_cost", "tile_id": "r12c07", "memory_mb": 3008, "duration_s": 5.3612,
"gb_seconds": 15.7492, "compute_usd": 2.6249e-04, "request_usd": 2.0e-07,
"ephemeral_usd": 2.484e-07, "s3_usd": 7.4e-06,
"phases": {"open_and_read": 4.102, "band_math": 0.501, "encode_and_write": 0.758},
"total_usd": 2.7014e-04}
REPORT RequestId: … Duration: 5401.22 ms Billed Duration: 5402 ms
Memory Size: 3008 MB Max Memory Used: 912 MB
Two checks matter. The handler’s duration_s should land within about 1% of Billed Duration ÷ 1000; a larger gap means work is happening outside the fenced phases, usually module import or a boto3 client constructed per invocation. And Max Memory Used at 912 MB against a Memory Size of 3,008 MB is not an over-provisioning signal here — you are buying 1.70 vCPU, not 3 GB of headroom, which is the sizing logic set out in Memory and CPU Allocation for Raster Workloads.
To reconcile a whole run against CloudWatch, sum Duration and Invocations for the period and recompute: (memory_mb / 1024) × (sum(Duration) / 1000) × 0.0000166667. A model that lands within 3% of that is good enough to plan a backfill against.
Choosing the window size
Window size changes the tile count and the per-tile duration in opposite directions, and the fixed per-invocation overhead does not scale down with the window at all.
The 1.8× spread between 256-pixel and 4,096-pixel windows is entirely per-invocation overhead: 25,600 request charges, 25,600 PUTs, 25,600 log records and 25,600 cold GDAL block caches against 100 of each. The curve is also strongly asymmetric — most of the saving arrives by 1,024 pixels, and the last three cents cost you a tile whose failed retry is 56× more expensive to redo. For pipelines that already chunk against the timeout ceiling, chunking raster jobs to fit the 15-minute Lambda ceiling usually sets the upper bound before cost does.
Gotchas
-
Max Memory Usedis not a billing input. Lambda billsMemorySizefor every millisecond, whatever the peak resident set turns out to be. UseMax Memory Usedto decide whether the tier is safe, and the cost curve to decide whether it is cheap. They frequently disagree. -
Init duration is billed on on-demand concurrency. A 2.1-second GDAL cold start on a 3,008 MB function adds $0.00010 — 38% of the tile’s cost — to every cold invocation. At a 1-in-40 cold-start rate that is under 1% of the run, but a wide burst fan-out can cold-start most of its environments at once and pay it on nearly every tile. Cold Start Mapping for Python GDAL has the breakdown of where those seconds go.
-
Window alignment silently multiplies the GET count. A window that straddles COG block boundaries makes GDAL fetch both blocks and discard half of each. Read block dimensions from
src.block_shapesrather than assuming 512, as in optimizing chunked I/O for multi-band Sentinel-2 processing. -
Cross-region reads add a charge that has no metric in your account. Reading a bucket in another region adds inter-region data transfer at roughly $0.02 per GB and typically doubles the range-read latency, which raises the compute term as well. Both effects are invisible in
AWS/Lambda.
Frequently Asked Questions
Does Lambda bill the memory I use or the memory I configure?
The memory you configure. MemorySize is the multiplier in the GB-second calculation regardless of what Max Memory Used reports, so a function set to 3,008 MB that peaks at 912 MB is billed for 3,008 MB every millisecond it runs. Max Memory Used is a sizing signal, not a billing one.
Is cold-start init duration billed on AWS Lambda?
Yes, for on-demand concurrency: Init Duration is included in Billed Duration, so a 2.1-second GDAL initialisation is charged at the full memory allocation. It is not billed separately when provisioned concurrency is in use, because the pre-initialised environment is already being paid for by the hour.
How many S3 GET requests does one windowed COG read issue?
A tuned read issues about six — a header range, an IFD range and one range per band block. An untuned read can issue twenty or more, because GDAL lists the prefix, probes for .aux.xml and .ovr sidecars and re-reads blocks the cache evicted. GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR, GDAL_PAM_ENABLED=NO and CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif remove most of the difference.
Why do smaller tiles cost more per scene?
Because every invocation carries fixed overhead that does not shrink with the window: a request charge, a PUT, its own log record, a cold GDAL block cache and the header reads. Tiling a 40,960-pixel-square scene at 256 pixels costs about $2.65 against $1.49 at 4,096 pixels — a 1.8× spread that is entirely per-invocation overhead.
Related
- Cost Modelling for Serverless Raster Pipelines — the full model, including the NAT, log and orchestration charges this page leaves out
- Comparing the Cost of Lambda, Cloud Run and Azure Functions for Tiling — what happens to the 57% network wait on a runtime with in-process concurrency
- Memory and CPU Allocation for Raster Workloads — why
Max Memory Usedfar belowMemorySizeis expected on a raster function - Tuning HTTP Range Requests for COG Reads on S3 — cutting the GET count and the 3.1 seconds of read wait behind it
- Partitioning a GeoTIFF into Step Functions Map Tiles — producing the window manifest whose size this page prices