Skip to content

Catching Missing Awaits with mypy, pyright and ruff

A missing await is the signature async bug: the code runs, nothing raises, and the work silently never happens. Static tools catch some forms of it by default, more with opt-in checks, and a few not at all. To find out which, eight common async mistakes were put in one file and checked with mypy 2.4.0 (--strict), pyright 1.1.414 and ruff 0.16.10. With default settings, mypy and pyright each caught 2 of 8 — the same two, both bare calls whose coroutine was discarded. Adding mypy's opt-in truthy-bool and unused-awaitable codes, pyright's strict mode and ruff's RUF006, ASYNC and F rules raised coverage to 6 of 8. The remaining two — a coroutine passed to print() and a list of coroutines never gathered — were caught by no static tool; pytest only failed on them when configured to turn both RuntimeWarning and pytest's unraisable-exception warning into errors, since either filter alone let the tests pass. This guide sets up all three layers.

Prerequisites

1. Know the eight shapes of the bug

The test file contained one function per mistake:

async def bug1_bare_call() -> None:
    send("hi")                                   # B1 coroutine never awaited

async def bug2_truthy(user: str) -> str:
    if is_allowed(user):                         # B2 coroutine object is always truthy
        return "ok"
    return "denied"

async def bug3_print() -> None:
    print(fetch(1))                              # B3 prints <coroutine object ...>

async def bug4_dangling_task() -> None:
    asyncio.create_task(job())                   # B4 task reference dropped

async def bug5_list() -> None:
    pending = [fetch(i) for i in range(3)]       # B5 coroutines created, never awaited
    print(len(pending))

async def bug6_blocking() -> None:
    time.sleep(1)                                # B6 blocks the event loop

async def bug7_unused_var() -> None:
    result = fetch(1)                            # B7 assigned, never awaited

async def bug8_sleep_no_await() -> None:
    asyncio.sleep(1)                             # B8 does nothing at all

B2 is the most dangerous: run with user "guest", it returned "ok", because a coroutine object is truthy. That is an authorization check that always passes. B4 is the subtle one — the task runs, but nothing holds a reference to it, as explained in preventing task garbage collection. B6 has no missing await at all but belongs in the same review: it is the blocking call that should have been an awaited one.

Verify: run your current lint and type-check commands over a copy of this file and count how many of the eight they report.

Which tool caught which bug A grid of 8 rows by 6 columns. Which tool caught which bug bug mypy default mypy opt-in pyright pyright strict ruff B1 send(x) discarded yes yes yes yes no B2 if coro(): no truthy-bool no yes no B3 print(coro()) no no no no no B4 create_task dropped no unused-awaitable no no RUF006 B5 [coro() for ...] no no no no no B6 time.sleep in async no no no no ASYNC251 B7 x = coro(), unused no no no unused var F841 B8 asyncio.sleep(1) yes yes yes yes no mypy 2.4.0 --strict, pyright 1.1.414, ruff 0.16.10 with RUF006, ASYNC and F selected.

2. Turn on mypy's opt-in async checks

mypy's default unused-coroutine code catches discarded coroutine calls (B1, B8). Two more codes are off by default and matter for async code:

# pyproject.toml
[tool.mypy]
strict = true
enable_error_code = ["truthy-bool", "unused-awaitable"]

On the command line each code needs its own flag — --enable-error-code truthy-bool --enable-error-code unused-awaitable; a comma-separated list was rejected as Invalid error code(s). With both enabled, mypy reported B2 as "g5.is_allowed" returns "Coroutine[Any, Any, bool]" which does not implement __bool__ or __len__ so it could always be true in boolean context and B4 as Value of type "Task[None]" must be used. unused-awaitable also flags intentionally discarded tasks and futures, so expect to add a few explicit _ = ... assignments or keep task references, which is the fix anyway.

Verify: mypy reports B2 and B4 on the sample file after the configuration change.

3. Use pyright strict where it adds coverage

Pyright's default mode catches B1 and B8 through reportUnusedCoroutine. Strict mode added two more:

// pyrightconfig.json
{
  "typeCheckingMode": "strict"
}

Measured: Conditional expression references coroutine which always evaluates to True (reportUnnecessaryComparison) for B2, and Variable "result" is not accessed for B7. Pyright strict did not flag the dropped task (B4). If the codebase is not ready for full strict mode, enable just the rules that matter: "reportUnnecessaryComparison": "error" and "reportUnusedVariable": "error" on top of the default configuration. Running both checkers is unusual but not unreasonable for async-heavy code: in this sample, mypy with its opt-in codes and pyright strict together caught 5 of the 8 bugs, one more than either alone.

Verify: pyright reports B2 on the sample file.

4. Add ruff for what type checkers do not model

Ruff's rules are syntactic, so they catch patterns that type checkers consider well-typed:

# pyproject.toml
[tool.ruff.lint]
extend-select = ["ASYNC", "RUF006", "F"]

Measured: RUF006 Store a reference to the return value of asyncio.create_task for B4, ASYNC251 Async functions should not call time.sleep for B6, and F841 Local variable result is assigned to but never used for B7. The ASYNC rules come from flake8-async and also cover blocking HTTP, subprocess and file calls inside async def — the same class of bug as B6, found in finding blocking calls with asyncio debug mode at runtime. Ruff caught none of the type-dependent bugs (B1, B2, B8), so it complements the checkers rather than replacing them.

Verify: ruff check reports B4, B6 and B7 on the sample file.

Bugs caught out of 8, by tool configuration 6 horizontal bars comparing mypy --strict (default codes) with the others. Bugs caught out of 8, by tool configuration mypy --strict (default codes) 2 pyright (default) 2 ruff (ASYNC, RUF006, F) 3 mypy + truthy-bool, unused-awaitable 4 pyright strict 4 all three combined 6 of 8 The two bugs no static tool caught: print(coro()) and a list of coroutines never awaited. Each layer catches a different slice; together they leave two for runtime checks.

5. Make tests fail on never-awaited coroutines

The remaining bugs (B3, B5) produce RuntimeWarning: coroutine '...' was never awaited when the coroutine is garbage-collected. pytest shows that as a warning, and the test passes. Turning it into a failure took two filters:

# pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
    error::RuntimeWarning
    error::pytest.PytestUnraisableExceptionWarning

Measured with pytest 9.1.1 on a test whose code called send(u) without await: with no filter, 2 passed, 2 warnings; with only error::RuntimeWarning, 2 passed; with only error::pytest.PytestUnraisableExceptionWarning, 2 passed; with both, 2 failed. The warning is raised inside a finalizer, where it cannot propagate normally, so pytest collects it as an "unraisable exception" — both the original warning and pytest's wrapper must be errors for the test to fail. The broad filterwarnings = error also works but turns every deprecation warning into a failure, which suits some projects and not others.

Verify: a deliberately unawaited coroutine in a test makes that test fail, not just warn.

Where should each missing-await bug be caught? A decision on What does the bug look like with 4 outcomes. Where should each missing-await bug be caught? What does the bug look like? coroutine call as a statement mypy / pyright defaults already on coroutine in if, dropped task truthy-bool, unused-awaitable, strict opt in blocking call, unreferenced task ruff ASYNC, RUF006 syntactic coroutine passed or stored pytest filterwarnings both filters Static tools first, a failing test as the backstop.

Verification

Missing awaits are caught when:

  • mypy runs with truthy-bool and unused-awaitable, or pyright runs in strict mode.
  • ruff selects ASYNC, RUF006 and F.
  • pytest turns both RuntimeWarning and PytestUnraisableExceptionWarning into errors.
  • The eight-bug sample file is caught 6 times statically and twice by tests.

Diagnostic Hook: keep the eight-bug file in the repository as a lint fixture — excluded from the normal run, but checked in CI with an expected count of findings. If a tool upgrade or configuration change drops a check, the count changes and the build says so.

Pitfalls & edge cases

  • Comma-separated mypy codes on the CLI. mypy 2.4.0 rejected them; repeat the flag.
  • One pytest filter. Measured: either filter alone let the tests pass.
  • if coro(): in authorization code. It always passes; enable truthy-bool or pyright strict.
  • Relying on ruff alone. It missed every bug that needs type information.

Frequently Asked Questions

Can mypy detect a missing await?

Partly. By default it reports a coroutine call used as a statement (unused-coroutine). Enabling truthy-bool catches if coro(): and unused-awaitable catches dropped tasks. In testing it caught 2 of 8 async bugs by default and 4 with the opt-in codes.

Does pyright warn about unawaited coroutines?

Yes, through reportUnusedCoroutine, which is on by default. Strict mode also flagged a coroutine used as a condition and an unused coroutine variable, for 4 of 8 bugs in testing.

How do I make pytest fail on 'coroutine was never awaited'?

Add both error::RuntimeWarning and error::pytest.PytestUnraisableExceptionWarning to filterwarnings. With pytest 9.1.1 either filter alone let the test pass; both together failed it.

Which ruff rules matter for asyncio code?

RUF006 for unreferenced create_task results, the ASYNC rules from flake8-async for blocking calls in async functions, and F841 for unused variables that often hide an unawaited coroutine.