Look at what an ingestion pipeline spends its time doing and very little of it is processing. A partner's manifest arrives at 1:58am and the files it lists trickle in over the next forty minutes. A Glue job starts and runs for twelve. A file fails validation and someone has to decide whether to load it anyway. Somewhere a scheduler polls a folder every five minutes, all night, to find out whether any of this has happened yet.
Until recently, AWS gave you three ways to write that waiting. A Lambda function, which stops at 15 minutes. A Step Functions workflow, which waits well but moves your logic into JSON. Or a scheduler and a status table you build yourself. Lambda durable functions, launched at re:Invent in December 2025, add a fourth: ordinary Python or TypeScript in a Lambda function that can run for up to a year, checkpoints its progress as it goes, and is billed no compute while it waits. Durable functions are a Lambda feature, not an S3 one, but S3 is where the files they wait for land, and that's where this article stays.
This is how we use them for file ingestion into S3: what they are, the shape of a pipeline built on them, the code, and the places where they behave differently from the Lambda functions you already know.
Ingestion is mostly waiting
A typical partner drop needs four kinds of waiting, and each has traditionally been solved in a different place:
- Waiting for files. A batch is complete only when every file in it has arrived, which is why we trigger on a completion file rather than on each upload. But partners send the manifest early, or send the files out of order, and someone still has to wait for the stragglers.
- Waiting for a job. A Glue job takes minutes. A Lambda function that starts it can't sit and watch it for long.
- Waiting for a person. When a file has the wrong header, the right answer is usually to hold it and ask the data owner, not to fail at 2am or load it silently.
- Waiting to retry. A throttled API, a job queue that's full: back off, try again, and don't lose your place.
Durable functions handle all four in one piece of code, and between them no compute runs.
What durable functions are
A durable function is a normal Lambda function with durable execution enabled and its handler wrapped by the AWS Durable Execution SDK, available for JavaScript and TypeScript, Python, Java, Go and .NET. The SDK hands your handler a DurableContext, and you wrap each piece of work in an operation on it:
| Operation | Python | What it does in an ingestion run |
|---|---|---|
| Step | context.step(...) | Runs a unit of work (read a manifest, start a job) and checkpoints its result. Retries with backoff if it fails. |
| Wait | context.wait(duration=...) | Suspends for a fixed time. Nothing runs and no compute is billed until it resumes. |
| Wait for condition | context.wait_for_condition(...) | Polls until something is true (all files present, Glue run finished), suspended between checks. |
| Callback | context.wait_for_callback(...) | Suspends until an outside system or a person calls back with a result, or a timeout passes. |
| Map / parallel | context.map(...), context.parallel(...) | Fans out over files or batches with a concurrency limit, each branch checkpointed on its own. |
| Invoke | context.invoke(...) | Calls another Lambda function, durable or not, and waits for its result. Same account only. |
Behind those operations is checkpoint and replay. Lambda records every operation's result as the function runs. When the function waits, or crashes, or its invocation times out, the execution stops. When it's time to continue, Lambda invokes the function again from the top, and the SDK replays the log: every operation that already finished returns its stored result instead of running again, and execution carries on from the first one that didn't. One logical run, called a durable execution, can therefore span many invocations and up to a year. Each individual invocation is still limited by the function timeout.
Two settings control the whole execution rather than one invocation: the execution timeout (24 hours by default, up to one year) and the retention period for the execution's history (14 days by default, 1 to 90 days).
One execution per batch
The shape we use is one durable execution per partner batch, started when the batch's manifest lands:
Everything a batch goes through lives in one place: the files it's still waiting for, which of them failed validation, which Glue run loaded it, and who released it if it was held. Lambda keeps each execution's history of operations and their results for the retention period, so “what happened to Tuesday's ACME batch?” has one answer.
Starting it from S3, exactly once
Turn on EventBridge delivery for the bucket (see our file-share article for the command), then match the manifest rather than every file:
EventBridge rule — a batch's manifest
{
"source": ["aws.s3"],
"detail-type": ["Object Created"],
"detail": {
"bucket": { "name": ["example-file-drops"] },
"object": { "key": [{ "wildcard": "incoming/*/manifest.json" }] }
}
}How the durable function is started matters more than it looks. S3 and EventBridge invoke Lambda asynchronously, and an asynchronous start lets a durable execution run for up to a year. But events can be delivered more than once, and an asynchronous trigger has no way to pass an execution name, the idempotency key that stops Lambda starting a duplicate. So we point the rule at a small, ordinary dispatcher function that names the execution after the S3 event and starts it:
Python — the dispatcher: one execution per S3 event
# dispatch-batch: a plain Lambda function (not durable), the target of the EventBridge rule.
# It starts one durable execution per manifest event, and only one, however often
# the event is delivered.
import hashlib, json, os
import boto3
lam = boto3.client("lambda")
INGEST_FN = os.environ["INGEST_FN"] # qualified: arn:aws:lambda:...:function:ingest-batch:prod
def handler(event, context):
obj = event["detail"]["object"]
job = {"bucket": event["detail"]["bucket"]["name"], "key": obj["key"],
"etag": obj["etag"], "sequencer": obj["sequencer"]}
# The same S3 event always produces the same name and the same payload, so a
# duplicate delivery returns the existing execution instead of starting another.
digest = hashlib.sha256(f"{job['bucket']}/{job['key']}/{job['sequencer']}".encode()).hexdigest()
try:
lam.invoke(FunctionName=INGEST_FN, InvocationType="Event",
DurableExecutionName=f"batch-{digest[:58]}", # 1-64 chars: [a-zA-Z0-9-_]
Payload=json.dumps(job, sort_keys=True))
except lam.exceptions.DurableExecutionAlreadyStartedException:
pass # this name already ran with a different payload; nothing new to startA repeated start with the same name and the same payload returns the existing execution instead of starting a second one, and names stay reserved until the execution's retention period expires. That's why the payload is built only from fields of the S3 event, with sorted keys: a redelivered event produces byte-for-byte the same request. Durable functions also require a qualified function ARN, a version or an alias. The dispatcher calls the prod alias, for reasons covered below.
Behind an SQS queue, Kinesis stream or DynamoDB stream, Lambda invokes a durable function synchronously through the event source mapping, so the whole durable execution, waits included, must finish within 15 minutes (90 minutes on Lambda Managed Instances). Creating the mapping fails if the execution timeout is set longer. For anything longer, put a plain function between the queue and the durable function and have it start the execution asynchronously, as the dispatcher above does.
The ingestion function
Here is the whole batch, from manifest to load. It's a working shape rather than a framework: the steps are short, each one checkpoints, and the waits are where the time goes.
Python — ingest-batch, the durable function
# ingest-batch: a Lambda durable function (Python 3.13+, durable execution enabled).
# One execution per partner batch: wait for its files, validate them, load them with
# Glue, and record the load. Every context.* call below is checkpointed.
import json, os
import boto3
from botocore.exceptions import ClientError
from aws_durable_execution_sdk_python import DurableContext, StepContext, durable_execution, durable_step
from aws_durable_execution_sdk_python.config import (
Duration, MapConfig, StepConfig, StepSemantics, WaitForCallbackConfig)
from aws_durable_execution_sdk_python.retries import RetryStrategyConfig, create_retry_strategy
from aws_durable_execution_sdk_python.waits import WaitForConditionConfig, WaitForConditionDecision
s3, glue, sns = boto3.client("s3"), boto3.client("glue"), boto3.client("sns")
GLUE_JOB = os.environ["GLUE_JOB"]
OWNERS = os.environ["OWNERS_TOPIC_ARN"]
HEADER = os.environ["EXPECTED_HEADER"] # e.g. "order_id,customer_id,amount,currency"
GLUE_DONE = {"SUCCEEDED", "FAILED", "STOPPED", "TIMEOUT", "ERROR", "EXPIRED"}
# ---- steps: ordinary code whose result is checkpointed ------------------------------
@durable_step
def read_manifest(step: StepContext, bucket: str, key: str) -> dict:
manifest = json.loads(s3.get_object(Bucket=bucket, Key=key)["Body"].read())
prefix = key.rsplit("/", 1)[0] + "/"
return {"bucket": bucket, "prefix": prefix, "expected": [prefix + f for f in manifest["files"]]}
@durable_step
def validate(step: StepContext, bucket: str, key: str) -> list[str]:
size = s3.head_object(Bucket=bucket, Key=key)["ContentLength"]
first = s3.get_object(Bucket=bucket, Key=key, Range="bytes=0-4095")["Body"].read()
header = first.split(b"\n", 1)[0].decode("utf-8-sig").strip()
problems = []
if size < 100:
problems.append(f"{key}: only {size} bytes")
if header != HEADER:
problems.append(f"{key}: unexpected header {header[:120]!r}")
return problems
@durable_step
def start_glue(step: StepContext, batch: dict) -> str:
run = glue.start_job_run(JobName=GLUE_JOB, Arguments={
"--input_prefix": f"s3://{batch['bucket']}/{batch['prefix']}"})
return run["JobRunId"]
@durable_step
def commit(step: StepContext, batch: dict, run_id: str) -> None:
# The marker downstream jobs wait for. IfNoneMatch makes it write-once, so a step
# that re-runs after a lost checkpoint can't overwrite it.
try:
s3.put_object(Bucket=batch["bucket"], Key=batch["prefix"] + "_LOADED.json",
Body=json.dumps({"glue_run_id": run_id, "files": batch["expected"]}),
IfNoneMatch="*")
except ClientError as e:
if e.response["Error"]["Code"] != "PreconditionFailed":
raise
def notify(subject: str, detail) -> None:
sns.publish(TopicArn=OWNERS, Subject=subject[:100], Message=json.dumps(detail, indent=2))
# ---- polling: each check runs as a step; between checks the function is suspended ---
def arrived(state: dict, check) -> dict:
listed = s3.list_objects_v2(Bucket=state["bucket"], Prefix=state["prefix"]).get("Contents", [])
have = {o["Key"] for o in listed}
return {**state, "missing": [k for k in state["expected"] if k not in have]}
def every_10_min_for_8_hours(state: dict, attempt: int) -> WaitForConditionDecision:
if not state["missing"] or attempt >= 48:
return WaitForConditionDecision.stop_polling()
return WaitForConditionDecision.continue_waiting(Duration.from_minutes(10))
def glue_status(state: dict, check) -> dict:
run = glue.get_job_run(JobName=GLUE_JOB, RunId=state["run_id"])["JobRun"]
return {**state, "status": run["JobRunState"]}
def until_glue_finishes(state: dict, attempt: int) -> WaitForConditionDecision:
if state["status"] in GLUE_DONE:
return WaitForConditionDecision.stop_polling()
return WaitForConditionDecision.continue_waiting(
Duration.from_seconds(min(30 * 2 ** (attempt - 1), 600))) # 30 s, 1 min, 2 min ... 10 min
# Starting a Glue run is a side effect with no idempotency token: run it at most once
# per attempt, and retry only when Glue says too many runs are already going.
START_ONCE = StepConfig(
step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY,
retry_strategy=create_retry_strategy(RetryStrategyConfig(
max_attempts=6, initial_delay=Duration.from_minutes(1), max_delay=Duration.from_minutes(10),
retryable_error_types=[glue.exceptions.ConcurrentRunsExceededException])))
def check_file(ctx: DurableContext, item: dict, index: int, items: list) -> list[str]:
return ctx.step(validate(item["bucket"], item["key"]), name=f"validate-{index}")
@durable_execution
def handler(job: dict, context: DurableContext) -> dict:
batch = context.step(read_manifest(job["bucket"], job["key"]))
# 1. Wait, without compute running, until every listed file has landed or 8 hours pass.
batch = context.wait_for_condition(
check=arrived, name="wait-for-files",
config=WaitForConditionConfig(wait_strategy=every_10_min_for_8_hours,
initial_state={**batch, "missing": batch["expected"]}))
# 2. Validate what arrived, ten files at a time.
present = [{"bucket": batch["bucket"], "key": k}
for k in batch["expected"] if k not in batch["missing"]]
checks = context.map(present, check_file, name="validate", config=MapConfig(max_concurrency=10))
checks.throw_if_error()
problems = ([p for found in checks.get_results() for p in found]
+ [f"missing: {k}" for k in batch["missing"]])
# 3. Anything wrong: hold the batch and ask its owner. Nothing runs while they decide.
if problems:
def ask_owner(callback_id: str, wait_ctx) -> None:
notify(f"Held: {batch['prefix']}", {"callback_id": callback_id, "problems": problems})
reply = context.wait_for_callback(
submitter=ask_owner, name="owner-decision",
config=WaitForCallbackConfig(timeout=Duration.from_days(3)))
decision = (json.loads(reply) if isinstance(reply, str) else reply)["decision"]
if decision != "load":
context.step(lambda _: notify(f"Rejected: {batch['prefix']}", problems), name="notify-rejected")
return {"status": "rejected", "problems": problems}
# 4. Load it, then wait for the Glue run the same way.
run_id = context.step(start_glue(batch), config=START_ONCE)
run = context.wait_for_condition(
check=glue_status, name="wait-for-glue",
config=WaitForConditionConfig(wait_strategy=until_glue_finishes,
initial_state={"run_id": run_id, "status": "STARTING"}))
if run["status"] != "SUCCEEDED":
context.step(lambda _: notify(f"Load failed: {batch['prefix']}", run), name="notify-failed")
raise RuntimeError(f"Glue run {run_id} ended {run['status']}")
# 5. Record the load.
context.step(commit(batch, run_id))
return {"status": "loaded", "glue_run_id": run_id, "files": len(present)}What each part is doing:
- Waiting for files uses
wait_for_condition. Each check lists the batch's prefix once, and the strategy decides whether to look again in ten minutes. After 48 checks, about eight hours, it stops, and whatever is still missing goes to the owner as a problem. Between checks the function isn't running. - Validation runs as a
mapover the files, ten at a time, each file its own checkpointed step. Our SFTP ingestion article covers checks worth adding: row counts against the manifest, encodings, duplicate file names. - Starting Glue is the one step with an external side effect and no idempotency token, which is why it runs at most once per attempt (see Replays and side effects).
- Waiting for Glue polls
GetJobRunwith backoff from 30 seconds to 10 minutes. A 40-minute job costs a handful of short checks, not 40 minutes of a function waiting on a status. The Glue job itself is whatever you'd run anyway; our Glue and PySpark article and the Iceberg CDC article cover what goes inside it. - Commit writes a
_LOADED.jsonmarker that downstream jobs can wait for, using an S3 conditional write so it can only ever be written once.
Holding a bad batch for a person
When something is wrong, the function publishes the problems and a callback ID to the owners' SNS topic, then suspends on wait_for_callback for up to three days. Nothing runs and nothing is lost while it waits. The owner fixes the file, or decides the batch is fine as it is, and releases it:
Release a held batch
# The data owner, or a small approval tool, releases a held batch:
aws lambda send-durable-execution-callback-success \
--callback-id "$CALLBACK_ID" \
--cli-binary-format raw-in-base64-out \
--result '{"decision": "load"}'Lambda invokes the function again, it replays to the callback, receives {"decision": "load"} and carries on to Glue. Any other decision ends the run as rejected. If nobody answers within three days, the callback raises an error and the execution fails, which is what the dead-letter queue is for. The result can be up to 256 KB, so an approval tool can send back more than a yes or no, such as a corrected file list.
Replays and side effects
Replay is what makes durable functions durable, and it's also where they surprise people. Three rules cover most of it:
- Code outside steps runs on every replay. It must be deterministic: no
datetime.now(), no random IDs, no reading a file that might have changed. Take dates from the manifest or the S3 event, and put anything that varies inside a step, whose result is then fixed. - Steps run at least once by default. A finished step never runs again, but a step that was running when an invocation died will run again. Make steps idempotent: listing a prefix, reading a manifest and validating a file already are. The commit uses
IfNoneMatch="*"so a second attempt finds the marker and treats “already exists” as success. - When a side effect can't be made idempotent, run it at most once.
StartJobRunhas no idempotency token, so starting it twice means two Glue runs.StepSemantics.AT_MOST_ONCE_PER_RETRYmakes the SDK skip a step that was interrupted before its result was saved and raise an error instead. A duplicate run becomes a failure you see. Pair it with a Glue job that overwrites or merges by batch, so even a manual rerun is safe, the same principle as processing a file twice and getting the same result.
Versions, aliases and failures
An execution that's suspended for three days resumes on the code it started with only if that code still exists. Executions started through a version or an alias are pinned to that version. Executions started on $LATEST resume on whatever code is there now, and replaying an old checkpoint log through new code is a reliable way to get non-determinism errors. So publish versions, point an alias at them, and start executions through the alias:
Deploy through an alias, and catch failures
# Publish an immutable version and move the alias to it (create-alias the first time).
# Paused executions resume on the version they started on; new ones use the alias.
VERSION=$(aws lambda publish-version --function-name ingest-batch --query Version --output text)
aws lambda update-alias --function-name ingest-batch --name prod --function-version "$VERSION"
# A durable execution that fails is not retried after an asynchronous start.
# Send the triggering event to a dead-letter queue so it can be replayed.
aws lambda update-function-configuration --function-name ingest-batch \
--dead-letter-config TargetArn=arn:aws:sqs:us-east-1:123456789012:ingest-batch-dlqThe second command matters because durable functions change Lambda's usual retry rules. For an ordinary function invoked asynchronously, Lambda retries a failure. For a durable execution started asynchronously, it doesn't: MaximumRetryAttempts doesn't apply, and Lambda destinations aren't supported. Retries belong inside the function, on steps. What's left, such as an execution that timed out, a rejected callback or a Glue run that failed, goes to the dead-letter queue with the triggering event, ready to replay through the dispatcher.
Backfills and the operation budget
Reloading ninety days of history is the same code run ninety times. A durable function can invoke another one and wait for it, and map runs those invocations with a concurrency limit and a failure tolerance:
Python — a backfill that reuses ingest-batch
# backfill: a durable function that reloads a list of past batches, four at a time.
# Each batch runs as its own child execution with its own operation budget.
import os
from aws_durable_execution_sdk_python import DurableContext, durable_execution
from aws_durable_execution_sdk_python.config import CompletionConfig, MapConfig
INGEST_ARN = os.environ["INGEST_ARN"] # qualified: ...:function:ingest-batch:prod
@durable_execution
def handler(event: dict, context: DurableContext) -> dict:
def load(ctx: DurableContext, manifest_key: str, index: int, keys: list) -> dict:
return ctx.invoke(INGEST_ARN, {"bucket": event["bucket"], "key": manifest_key},
name=f"batch-{index}")
result = context.map(
event["manifests"], load, name="backfill",
config=MapConfig(max_concurrency=4,
completion_config=CompletionConfig(tolerated_failure_count=3)))
return {"loaded": result.success_count, "failed": result.failure_count}Why not one big execution? Each durable execution can record up to 30,000 operations, a limit AWS raised from 3,000 in October 2026. A batch with a few files and a Glue run uses about thirty. Running each batch as its own child execution gives each one its own budget and its own history, and keeps the backfill's own log to one entry per batch. The same structure makes max_concurrency the only knob you need to avoid flooding Glue.
Keep checkpoints small
Everything a step returns is stored in the checkpoint log, and replayed. Return keys, counts and IDs, never file contents. A single checkpoint can hold up to 1 MB, raised from 256 KB in October 2026, which is plenty for a manifest of a few hundred files.
When state really is bigger, such as a very large file list or a validation report, the October update also added a file-system serializer to the JavaScript and Python SDKs. It writes the payload to an Amazon EFS or Amazon S3 Files mount and checkpoints only a pointer. With S3 Files, generally available since April 2026, that large state lives in the same bucket as the data it describes, rather than in a separate store you'd have to manage and clean up. The function needs to run in the VPC that can reach the mount.
What it costs
You pay normal Lambda compute and request charges for the time code actually runs, including replays, plus three durable charges: operations, checkpoint data written, and checkpoint data retained. On the default on-demand capacity, waits, callbacks and retry backoff bill no compute. For 100 batches a day, using our estimates for a batch like the one above:
| Charge (us-east-1) | Assumption per month | Estimate |
|---|---|---|
| Durable operations, $8.00 per million | 3,000 batches × about 30 operations | $0.72 |
| Compute, $0.0000133334 per GB-second (Arm) | about 8 short invocations per batch at 512 MB, ~1.5 s each | $0.24 |
| Requests, $0.20 per million | 24,000 invocations | under $0.01 |
| Checkpoint data written, $0.25 per GB | about 30 KB per batch | $0.02 |
| Checkpoint data retained, $0.15 per GB-month | 30-day retention | about $0.01 |
| Orchestration total | before the free tier | about $1 |
These are estimates from AWS's published us-east-1 prices, with our own assumptions about checks per batch and replay time. Two things change them most: how long files take to arrive, because each check is an operation and a short invocation, and how long you retain execution history. Either way, orchestration is a rounding error next to the Glue job it runs.
Durable functions, Step Functions or Airflow?
Durable functions don't replace the other two, and AWS's own guidance draws the line in a similar place to ours:
| Lambda durable functions | Step Functions | Airflow (MWAA) | |
|---|---|---|---|
| You write | Python, TypeScript, Java, Go or .NET in a Lambda function | Amazon States Language (JSON) or the visual designer | Python DAGs |
| Longest run | 1 year (asynchronous start) | 1 year (Standard workflows) | Per DAG run, on a schedule |
| While waiting | No compute billed; you pay per operation | Standard: billed per state transition | The environment runs all the time |
| Strongest at | Per-file and per-batch logic, callbacks, code you unit-test | Coordinating many AWS services through native integrations, such as running a Glue job and waiting for it | Schedules and dependencies across datasets, date-based backfills |
| Watch for | Deterministic replay, version pinning, the 15-minute limit behind queues | Business logic spread across JSON states | Not event-first; the cost of an always-on environment |
We reach for durable functions when the logic is per file or per batch, when it needs to wait on things that aren't AWS services (a partner, a person, an external API), and when the team would rather write and unit-test Python than JSON. We keep Step Functions where a workflow mostly connects AWS services and its value is the visual history. And for schedules and dependencies across many datasets, an orchestrator such as Airflow is still the right tool; our Airflow and Glue comparison covers that choice. Mixing them is normal: a durable function per batch, with Airflow scheduling the nightly jobs that depend on its _LOADED.json markers.
Questions we get asked
Are durable functions an S3 feature?
No. They're a Lambda feature, launched in December 2025. S3 is where they fit naturally in data platforms: files land in S3, S3 events start the executions, and since October 2026 the Python and JavaScript SDKs can keep state larger than 1 MB on an Amazon S3 Files or EFS mount.
Can an S3 event start a durable function directly?
Yes. S3 event notifications and EventBridge invoke Lambda asynchronously, so the execution can run for up to a year, as long as the target is a version or an alias. But they can't pass an execution name, so duplicate events start duplicate executions. A small dispatcher function that names the execution after the S3 event makes the start idempotent.
What happens if the function crashes halfway through?
Lambda invokes it again and replays the checkpoint log. Steps that finished return their saved results without running again, and execution continues from the first step that hadn't finished. That step may run a second time, so steps should be idempotent, or use at-most-once semantics when they can't be.
Do we pay while it waits for files or for a Glue job?
Not for compute. During waits, callbacks and retry backoff the function is suspended and bills no duration. You pay per durable operation, which includes each polling check, plus a small charge for checkpoint data written and retained.
Should we move our Step Functions pipelines to durable functions?
Not by default. Step Functions remains a strong choice for workflows that mainly connect AWS services, such as running a Glue job and waiting for it through a native integration. Durable functions suit new per-file and per-batch logic that waits on partners or people, written and tested as ordinary code.
Can a durable function read from an SQS queue?
Yes, but behind an event source mapping the whole execution must finish within 15 minutes, or 90 on Lambda Managed Instances. For longer workflows, let a plain function receive the messages and start the durable function asynchronously with an execution name.
Is your ingestion held together by schedules and status tables?
We'll map how files reach your platform today, find where it waits, retries and breaks, and redesign it around events and durable executions — with the Glue jobs behind it.
Book an ingestion review