Skip to content

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 Tail and read Billed Duration, Memory Size and Max Memory Used from 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=YES once in a scratch deployment and count the range requests, or read AllRequests from the bucket’s S3 request metrics over a controlled run.
  • The configured /tmp size. 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

The per-tile cost calculation for a 3,008 MB Lambda raster jobSix numbered steps converting a Lambda configuration into a cost per tile: memory of 3,008 MB divided by 1,024 gives 2.9375 GB; billed duration of 5,400 milliseconds gives 5.400 seconds; the product is 15.8625 GB-seconds; multiplied by the GB-second price gives 26.438 hundred-thousandths of a dollar; one request plus six S3 GETs plus 8.1 ephemeral storage GB-seconds add 0.285 hundred-thousandths; the total per tile is 26.723 hundred-thousandths of a dollar, of which compute is 98.9 percent.One tile, 3,008 MB, 5.4 s — every term1Memory in GB3,008 MB / 1,024 — Lambda bills the allocation, not the peak RSS2.9375 GB2Billed duration5,400 ms of wall clock, rounded up to the nearest 1 ms5.400 s3GB-seconds2.9375 x 5.40015.8625 GB-s4Compute charge15.8625 x $0.0000166667 per GB-second$0.000264385Everything else1 request + 6 S3 GET + 8.1 /tmp GB-s above 512 MB$0.000002856Cost per tilecompute is 98.9% of it; the request charge is 0.07%$0.00026723
Every term is visible, and five of the six barely matter. For a warm, tuned tile the compute charge is 98.9% of the cost — which is exactly why the expensive mistakes all live outside this calculation.

Written out longhand:

code
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:

Where a warm 512-pixel tile invocation spends its billed durationA 5.4-second billed duration divided into five segments: 3.1 seconds waiting on S3 range reads, 1.0 second decoding LZW blocks, 0.5 seconds of NumPy NDVI band math, 0.7 seconds of deflate encode and the S3 PUT, and 0.1 seconds of handler bookkeeping and logging.What the 5.4 billed seconds are actually spent onS3 range readsLZW block decodeNDVIband mathDeflateencode + PUT3.1 s1.0 s0.5 s0.7 sBookkeeping — 0.1 sAssumption set A: one 512 x 512 x 4-band uint16 window read over /vsis3 from a same-region bucket, warm container, us-east-1, at the timeof writing. Segment boundaries come from a time.perf_counter fence around each phase.
Fifty-seven per cent of the GB-seconds you pay for is the function holding 3,008 MB of idle RAM while the network answers. Nothing in the handler can reclaim it — only a runtime that lets another tile use the wait.

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.

python
# 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:

bash
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:

code
{"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.

Total cost of one 10 GB scene at five tile sizesFive horizontal bars showing the total cost of tiling one 40,960 by 40,960 pixel scene at window sizes of 256, 512, 1,024, 2,048 and 4,096 pixels. Cost falls from 2 dollars 65 at 256 pixels to 1 dollar 49 at 4,096 pixels as per-invocation overhead is amortised over fewer, longer invocations.Same scene, five window sizes, five prices256 px · 25,600 tiles · 1.9 s each$2.65512 px · 6,400 tiles · 5.4 s each$1.761,024 px · 1,600 tiles · 19.6 s each$1.552,048 px · 400 tiles · 76.5 s each$1.504,096 px · 100 tiles · 305 s each$1.490$2.75 per 40,960 x 40,960 px sceneAssumption set B: 3,008 MB Lambda, 4 bands uint16, tuned range reads at 6 GET per tile, one PUT per tile, 6 KB of logs per invocation,us-east-1 list prices at the time of writing.
Small windows are expensive because every invocation pays its own request, PUT, log line and cold-cache penalty. The last three cents of the saving buy a tile whose failed retry costs 56x more than a 512-pixel one.

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 Used is not a billing input. Lambda bills MemorySize for every millisecond, whatever the peak resident set turns out to be. Use Max Memory Used to 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_shapes rather 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.


Back to Cost Modelling for Serverless Raster Pipelines