Run

Turn a normal function into a durable function. Any function passed to step.run will be executed in a durable way, including retries and memoization.

Arguments

  • Name
    step_id
    Type
    str
    Required
    required
    Description

    Step ID. Should be unique within the function.

  • Name
    handler
    Type
    Callable
    Required
    required
    Description

    A callable that has no arguments and returns a JSON serializable value.

  • Name
    *handler_args
    Type
    Required
    optional
    Description

    Positional arguments for the handler. This is type-safe since we infer the types from the handler using generics.

Examples

@inngest_client.create_function(
    fn_id="my_function",
    trigger=inngest.TriggerEvent(event="app/my_function"),
)
async def fn(ctx: inngest.Context) -> None:
    # Pass a function to step.run
    await ctx.step.run("my_fn", my_fn)

    # Args are passed after the function
    await ctx.step.run("my_fn_with_args", my_fn_with_args, 1, "a")

    # Kwargs require functools.partial
    await ctx.step.run(
        "my_fn_with_args_and_kwargs",
        functools.partial(my_fn_with_args_and_kwargs, 1, b="a"),
    )

    # Defining functions like this gives you easy access to scoped variables
    def use_scoped_variable() -> None:
        print(ctx.event.data["user_id"])

    await ctx.step.run("use_scoped_variable", use_scoped_variable)

async def my_fn() -> None:
    pass

async def my_fn_with_args(a: int, b: str) -> None:
    pass

async def my_fn_with_args_and_kwargs(a: int, *, b: str) -> None:
    pass

Step decorator

Decorate a Python function with step from inngest.experimental to turn it into a reusable step. You don't pass ctx to it or wrap each call in ctx.step.run().

Requires Python SDK inngest>=0.5.14. Import it with from inngest.experimental import step (or import inngest.experimental); import inngest alone doesn't load the experimental module.

This API is experimental and may have breaking changes outside of semantic versioning.

The decorator takes one required argument, step_id, with the same meaning as the ID passed to step.run. Choose a stable ID for each distinct step in your workflow. Positional and keyword arguments are passed through to the decorated function.

Usage

The decorator works with both async and sync functions. Match the decorated function to the Inngest function that calls it:

  • In an async def Inngest function, decorate an async def function and await the call, as shown below.
  • In a def (sync) Inngest function, decorate a def function and call it without await.
import inngest
from inngest.experimental import step

inngest_client = inngest.Inngest(app_id="my-app")

@step("prepare-greeting")
async def prepare_greeting(name: str, *, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

@inngest_client.create_function(
    fn_id="greet-user",
    trigger=inngest.TriggerEvent(event="app/greet-user"),
)
async def greet_user(ctx: inngest.Context) -> str:
    name = ctx.event.data["name"]
    assert isinstance(name, str)
    return await prepare_greeting(name, greeting="Welcome")

Execution behavior

  • Inside an Inngest function: the decorator calls step.run using the current execution context. The step gets the same retries and memoization as an explicit ctx.step.run() call. Return a JSON-serializable value.
  • Outside an Inngest function: the original function runs directly, without durable execution, memoization, or Inngest retries. You can reuse the function in other application code or call it in unit tests. Async functions still need to be awaited.
  • Inside another step: do not call a decorated function from a step.run handler or another decorated step. This would create nested steps, which are not supported. Call decorated steps from the Inngest function's body instead.
  • Called more than once: calling the same decorated function several times in one run (for example, in a loop) works like reusing a step.run ID. The SDK adds a counter to each repeat, so every call is its own memoized step.

Don't call a sync (def) decorated function from an async Inngest function. Inside an async function the call returns an un-awaited coroutine instead of the result, so the step never runs, and type checkers won't flag it because the decorator keeps the original return type.

For example, a direct call outside an Inngest function runs normally:

from inngest.experimental import step

@step("prepare-greeting")
def prepare_greeting(name: str, *, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

assert prepare_greeting("Ada", greeting="Welcome") == "Welcome, Ada!"

Choosing step.run or the decorator

Both create the same kind of step: the same retries, memoization, and trace in the dashboard. They differ in where the step boundary is written down.

ctx.step.run()@step decorator
Step boundaryAt the call site, in the Inngest functionOn the function definition
Step IDChosen per callFixed by the decorator
Passing ctxHandler can close over ctx and local variablesNo ctx needed; pass data as arguments
Keyword argumentsNeed functools.partialPassed through directly
Same call outside InngestNo, the call needs ctxYes, runs as a normal function
StabilityStable APIExperimental

Use ctx.step.run() when:

  • The step is a few lines specific to one function, or needs local variables from the function body.
  • The same logic runs as different steps, with different IDs, in different places.
  • You want a stable API.

Use the decorator when:

  • A unit of work (charge a card, call an LLM, sync a record) is shared by several Inngest functions and should always be a step with the same ID.
  • The same function is also called from regular application code or unit tests, where it should run directly.
  • You want Inngest function bodies to read as plain Python calls.

You can mix both styles in one function.

Retries

Each step.run() call has its own independent retry counter. When a step raises an exception, it will be retried according to your function's retry configuration. The retry configuration applies to each individual step, not as a shared pool across all steps in your function.

For example, if your function is configured with retries=4, each step.run() will be retried up to 4 times independently (5 total attempts including the initial attempt). If you have multiple steps in your function, each step gets its own full set of retries.

Learn more about configuring retries.