Skip to content

Writing Async CLI Commands with click and Typer

Command-line tools are where many teams first use asyncio outside a server: a script that calls fifty APIs concurrently, a migration that streams rows, an admin command that talks to the same async clients the service uses. The two most popular CLI frameworks, click and Typer, are synchronous, and the obvious first attempt — decorating an async def as a command — fails in the worst possible way. Measured with click 8.5.0 and Typer 0.27.2 on Python 3.14: an async def command exited with status 0, printed nothing, and never ran its body; the only evidence was RuntimeWarning: coroutine 'fetch' was never awaited on stderr. A ten-line, ParamSpec-typed run_async wrapper made the same command work in both frameworks; asyncclick 8.4 runs async def commands natively. Startup cost matters for CLIs too: import asyncio took 26 ms, click 15 ms and Typer 29 ms (median cumulative import time), and a click CLI with the wrapper answered --help in 52 ms against 115 ms for the Typer version. This guide makes async commands work and keeps them fast.

Prerequisites

1. See the silent failure

The natural first attempt with either framework:

import asyncio
import click


@click.command()
@click.option("--n", default=3)
async def fetch(n: int) -> None:
    await asyncio.sleep(0.01)
    print("fetched", n)


if __name__ == "__main__":
    fetch()

Running python t_click.py --n 5 printed nothing and exited 0. click called the function, received a coroutine object, and discarded it; Python then warned <sys>:0: RuntimeWarning: coroutine 'fetch' was never awaited at shutdown. The Typer equivalent behaved identically. In a cron job or CI step, exit code 0 is all anyone looks at — the command "succeeded" without doing anything. This is exactly the bug class covered in catching missing awaits, moved into the framework where your own linters cannot see it.

Verify: for every command, a run against a test environment produces its output or side effect, not just exit status 0.

An async def command under each framework A grid of 4 rows by 4 columns. An async def command under each framework framework async def command exit code body ran click 8.5.0 coroutine discarded 0 no Typer 0.27.2 coroutine discarded 0 no click or Typer + run_async asyncio.run per call 0 yes asyncclick 8.4.2 awaited natively (anyio) 0 yes The failing cases differed from the working ones only in a warning on stderr.

2. Wrap async commands with a typed runner

The fix is to give the framework the synchronous function it expects, and run the coroutine inside it. Typed with ParamSpec, the wrapper preserves the signature, so both frameworks still read the parameters for option parsing:

import asyncio
import functools
from collections.abc import Callable, Coroutine
from typing import Any


def run_async[**P, R](fn: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return asyncio.run(fn(*args, **kwargs))
    return wrapper


@click.command()
@click.option("--n", default=3)
@run_async
async def fetch(n: int) -> None:
    await asyncio.sleep(0.01)
    print("fetched", n)

Measured: fetched 5 with both click and Typer. Order matters: @run_async goes closest to the function, under the framework decorators, so click sees a normal function. functools.wraps is not cosmetic here — Typer builds its options from the wrapped function's signature through __wrapped__; without it, --n 5 was rejected as an unknown option and the command exited 2. The typing pattern is the one from typing async decorators with ParamSpec, with the return type changed from a coroutine to its result.

Verify: --help lists the command's options, and running the command performs its work.

3. Use asyncclick when most commands are async

When a tool is async throughout, asyncclick — a fork of click built on anyio — accepts async def commands directly and runs one event loop for the whole invocation:

import anyio
import asyncclick as click


@click.command()
@click.option("--n", default=3)
async def fetch(n: int) -> None:
    await anyio.sleep(0.01)
    print("fetched", n)


if __name__ == "__main__":
    fetch()            # asyncclick starts the loop

Measured: it ran the body and exited 0, and --help took a median 69 ms. The advantage over the wrapper appears with command groups: callbacks for the group and the subcommand run on the same loop, so a client opened in the group callback can be used by the subcommand. Measured: a group callback that registered an httpx.AsyncClient with await ctx.with_async_resource(...) handed it to a status subcommand, which fetched a page with status 200 on the same loop; asyncclick closes the client when the context exits. With run_async, each decorated function gets its own asyncio.run and its own loop, and async resources cannot cross that boundary. The cost is a dependency that tracks click with a delay — the tested version was 8.4.2.1 against click's 8.5.0.

Verify: a group callback that opens an async client and a subcommand that uses it work in one invocation.

CLI startup, median of 7 runs of --help 4 horizontal bars comparing python -c pass with the others. CLI startup, median of 7 runs of --help python -c pass 9 ms click + run_async 52 ms asyncclick 69 ms Typer + run_async 115 ms Python 3.14 on Linux; cumulative import times: asyncio 26 ms, click 15 ms, typer 29 ms, rich.progress 28 ms. Every millisecond here is paid on every invocation, including tab completion.

4. Share one loop across a command group

A tool with several subcommands usually needs shared setup — configuration, an HTTP client, a database pool. Keep everything that touches the loop inside one coroutine per invocation:

@click.group()
@click.option("--base-url", default="https://api.example.com")
@click.pass_context
def cli(ctx: click.Context, base_url: str) -> None:
    ctx.obj = {"base_url": base_url}            # plain data only: no loop exists yet


async def _sync_users(base_url: str, since: str) -> int:
    async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
        resp = await client.get("/users", params={"since": since})
        resp.raise_for_status()
        return len(resp.json())


@cli.command()
@click.option("--since", required=True)
@click.pass_obj
def sync_users(obj: dict[str, str], since: str) -> None:
    count = asyncio.run(_sync_users(obj["base_url"], since))
    click.echo(f"synced {count} users")

The group callback holds only plain configuration; the subcommand creates and closes its async client inside the one asyncio.run. The trap is reuse across loops: an httpx.AsyncClient created outside any loop worked in its first asyncio.run (status 200), and the second asyncio.run using the same client failed with RuntimeError: Event loop is closed, because its pooled connection belonged to the first loop. If the tool genuinely needs several sequential async phases with shared state, use asyncio.Runner explicitly, as in reusing one loop across calls with asyncio.Runner.

Verify: no object that owns sockets, tasks or futures is created outside the coroutine that uses it.

5. Test commands without nested loops

click's CliRunner invokes the command synchronously, which is what the wrapper expects — so tests of wrapped commands must be plain def tests:

from click.testing import CliRunner


def test_fetch() -> None:                       # not async def
    result = CliRunner().invoke(fetch, ["--n", "5"])
    assert result.exit_code == 0
    assert "fetched 5" in result.output

Writing that test as async def under pytest-asyncio means the command's asyncio.run is called while a loop is already running, which raises RuntimeError: asyncio.run() cannot be called from a running event loop — and CliRunner catches it, so the test sees exit_code == 1 and an empty output. Asserting on the output, not just the exit code, catches both that and the silent-skip bug from step 1. For asyncclick, use its own CliRunner, whose invoke is awaitable.

Verify: each command's test asserts on output or side effects, and a deliberately unwrapped async def command fails its test.

How should this CLI run async code? A decision on What does the tool look like with 4 outcomes. How should this CLI run async code? What does the tool look like? a few async commands click/Typer + run_async ParamSpec-typed async throughout, shared setup asyncclick one loop per invocation several async phases, shared state asyncio.Runner explicit loop reuse startup-sensitive lazy imports in the command 26 ms for asyncio alone Never decorate a bare async def with click or Typer: it exits 0 without running.

Verification

Async CLI commands are correct when:

  • No bare async def is decorated as a click or Typer command.
  • The wrapper preserves signatures with ParamSpec and functools.wraps, and --help lists every option.
  • Async resources live inside one coroutine per invocation, or inside an explicit asyncio.Runner.
  • Tests are synchronous and assert on output, not only on exit codes.

Diagnostic Hook: in CI, run each command and fail the step if stderr contains was never awaited. Do not rely on python -W error::RuntimeWarning for this: measured, the bare async command still exited 0, because the warning is raised while the coroutine is finalized and Python only prints it as Exception ignored while finalizing coroutine.

Pitfalls & edge cases

  • Bare async def commands. Measured: exit 0, body never ran, in both click and Typer.
  • Wrapper above the framework decorators. Measured: invoking it failed with AttributeError: 'function' object has no attribute 'main'.
  • Clients reused across asyncio.run calls. Measured: the second run raised RuntimeError: Event loop is closed.
  • async def tests of wrapped commands. asyncio.run inside a running loop raises, and CliRunner reports exit code 1.

Frequently Asked Questions

Does click support async commands?

Not in click 8.5.0: an async def command is called, its coroutine is discarded, and the process exits 0 without running the body. Wrap the coroutine with asyncio.run in a synchronous function, or use asyncclick, which awaits async commands natively.

How do I use async functions with Typer?

Typer 0.27.2 behaved like click: the async command never ran. Decorate the async function with a ParamSpec-typed wrapper that calls asyncio.run, placed under @app.command(), and keep functools.wraps so Typer can read the parameters.

Why does my async CLI command exit with code 0 but do nothing?

The framework called an async def and threw the coroutine away; Python only warns that the coroutine was never awaited. Assert on each command's output in tests, and fail CI if stderr contains 'was never awaited'; -W error::RuntimeWarning did not change the exit code.

How much startup time does asyncio add to a CLI?

Importing asyncio took about 26 ms (cumulative import time) on Python 3.14. A click CLI with the wrapper answered --help in about 52 ms; Typer took about 115 ms.