← Back to list

Async Python with AWS Lambda Durable Functions: Official SDK vs Async-First SDK

AWS Lambda Durable Functions are a strong fit for long-running workflows: approvals, polling jobs, retries, multi-step business processes…

James Ashford in Towards AWS · 2026-06-25 02:11 · 0 claps · 4.2 min read
#aws-lambda #durable-functions #developpement-durable #python-libraries #asyncio
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🏃 · Running & Endurance

Async Python with AWS Lambda Durable Functions: Official SDK vs Async-First SDK

AWS Lambda Durable Functions are a strong fit for long-running workflows: approvals, polling jobs, retries, multi-step business processes, and AI agent loops that may need to pause and resume over time.

The model is powerful. Your function can checkpoint progress, wait without paying for compute, and replay safely after a pause or failure. But for Python developers, there is an important practical detail in the official Python SDK today: the durable programming model is synchronous at the user-code boundary.

That is fine if the rest of your application is synchronous. It becomes awkward when your application is already built around asyncio.

Current Official Python SDK Behavior

The AWS quickstart shows the current Python shape with DurableContext, StepContext, @durable_step, and @durable_execution:

from durable_execution import DurableContext
from durable_execution import Duration
from durable_execution import StepContext
from durable_execution import durable_execution
from durable_execution import durable_step

@durable_step
def my_step(step_context: StepContext) -> str:
  step_context.logger.info("Hello from my_step")
  return "Hello from Durable Lambda!"

@durable_execution
def lambda_handler(event, context: DurableContext) -> dict:
  message: str = context.step(my_step())
  context.wait(Duration.from_seconds(10))
  context.logger.info("Resumed after wait")
  return {"statusCode": 200, "body": message}

The important part is the programming model. The handler is synchronous. The step is synchronous. Durable operations are invoked through DurableContext.

This is clean for traditional Python code. But a lot of production Python is no longer written this way. Modern applications often use async HTTP clients, async database drivers, async LLM clients, websockets, async queues, and async web frameworks such as FastAPI.

So the practical question becomes: what do you do when the durable SDK wants synchronous functions, but your actual business logic is async?

The Official Async Workaround: asyncio.run

The typical bridge is to keep the durable step synchronous and run the async function inside it:

import asyncio

import httpx
from durable_execution import DurableContext
from durable_execution import StepContext
from durable_execution import durable_execution
from durable_execution import durable_step

async def fetch_user_async(user_id: str) -> dict:
  async with httpx.AsyncClient() as client:
    response = await client.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

@durable_step
def fetch_user(step_context: StepContext, user_id: str) -> dict:
  return asyncio.run(fetch_user_async(user_id))

@durable_execution
def lambda_handler(event, context: DurableContext) -> dict:
  user = context.step(fetch_user(event["user_id"]))
  return {"user": user}

This works in simple cases. It also preserves the official SDK’s current model: durable workflow code stays synchronous, while async code is hidden behind synchronous step wrappers.

But it is a compromise, and the direction of the compromise matters.

Why asyncio.run Becomes a Problem

The issue is not that asyncio.run() is wrong. It is the correct API when you own the top-level process and need to start an async program from synchronous code.

The problem is using asyncio.run() repeatedly as an adapter inside a larger application.

Python’s async ecosystem is built around a running event loop. Once your application is async-first, forcing async code back behind synchronous APIs creates friction. asyncio.run() only works when no event loop is already running in the same thread. In environments that already own the loop, it can fail with:

RuntimeError: asyncio.run() cannot be called from a running event loop

That is not rare. Many Python environments already have an event loop:

  • FastAPI
  • Jupyter
  • async Lambda handlers
  • GUI apps
  • Temporal workers
  • Discord bots
  • websocket services

There are also workflow-level problems:

  • Every wrapper creates and closes an event loop.
  • Async resource lifecycle becomes awkward, especially for shared HTTP clients, database pools, or SDK clients.
  • Async composition is hidden inside step wrappers instead of being visible in the workflow.
  • Concurrency is harder to express naturally.
  • Cleanup and cancellation semantics can become harder to reason about.

The deeper issue is that async code tends to move upward through the call stack.

Once a lower layer becomes async:

async def db_query() -> dict:
  …

its callers usually need to become async too:

async def service() -> dict:
  return await db_query()

Eventually, the whole stack wants to be async.

The reverse direction is easier. If you have an async application and need to call blocking synchronous code, Python gives you a clean escape hatch:

result = await asyncio.to_thread(sync_func, arg1, arg2)

or:

result = await loop.run_in_executor(None, sync_func, arg1, arg2)

That direction composes better because the async runtime remains in control. Blocking work is isolated. The main event loop continues to exist. You do not create nested event loops.

In short: wrapping sync code into async is usually safer than wrapping async code into sync.

For durable execution, that means this style can become painful:

@durable_step
def durable_step(step_context: StepContext) -> dict:
  return asyncio.run(actual_async_logic())

It works until your workflow grows, your clients need proper async lifetimes, or your runtime already owns an event loop.

The Alternative: async-durable-execution

async-durable-execution is a community-maintained fork of the official AWS Python durable execution SDK. It keeps the same core durable-function concepts: checkpointed steps, waits, callbacks, retries, child contexts, maps, parallel branches, and replay-safe execution.

The difference is the user programming model.

Instead of forcing user-provided durable code to be synchronous, handlers, steps, child contexts, callback submitters, and condition checks are async:

import httpx

from async_durable_execution import durable_callable
from async_durable_execution import durable_execution
from async_durable_execution import step

@durable_callable
async def fetch_user(user_id: str) -> dict:
  async with httpx.AsyncClient() as client:
    response = await client.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

@durable_execution
async def handler(event: dict) -> dict:
  user = await step(fetch_user(event["user_id"]), name="fetch-user")
  return {"user": user}

Now the workflow itself can use await. Durable operations remain explicit, but async code no longer needs to be wrapped in asyncio.run() at every step.

The replay rules do not change. Non-deterministic work still belongs inside durable steps. Side effects still need to be checkpointed carefully. Code outside durable operations still has to be deterministic.

The difference is that the workflow can participate naturally in Python’s async ecosystem.

Recommendation

Use the official Python SDK if you want the AWS-supported path and your workflow is mostly synchronous. If you only have one or two async calls, wrapping them with asyncio.run() inside durable steps is practical.

Use async-durable-execution if your workflow is naturally async: async HTTP, async databases, async AI calls, async tool execution, websockets, or agent loops. In that case, an async-first durable model keeps the workflow readable and avoids scattering sync wrappers throughout the codebase.

The decision is mostly about where your application lives.

If your app is sync-first, sync orchestration is a reasonable default.

If your app is already async-first, choose an async-native durable framework from the beginning. It is much easier to isolate occasional blocking code inside an async runtime than to hide an async application behind repeated synchronous wrappers.

References


메타데이터
post_id
e2d12e718f30
slug
async-python-with-aws-lambda-durable-functions-official-sdk-vs-async-first-sdk-e2d12e718f30
url
https://towardsaws.com/async-python-with-aws-lambda-durable-functions-official-sdk-vs-async-first-sdk-e2d12e718f30
canonical_url
https://towardsaws.com/async-python-with-aws-lambda-durable-functions-official-sdk-vs-async-first-sdk-e2d12e718f30
author_url
https://medium.com/@pqzjhzm
status
ok
fetched_at
2026-06-26 21:52:29