Skip to content

INW013 async-blocking

What it does

Opt-in, and an error when on. Reports a synchronous database, cache or cloud call inside an async def, on the call. Two kinds of call count:

  • A method on a blocking receiver. The receiver is a name whose methods do synchronous I/O:
    • a parameter whose annotation resolves to a blocking type: plain, Annotated[T, ...], Optional[T], T | None, or an alias defined in the same file (DbSession = Annotated[Session, Depends(get_db)]);
    • a name the module or the function binds from a call to a blocking type or factory (cache = redis.Redis(), s3 = boto3.client("s3"), with Session(engine) as session), or annotates with one (client: Redis = make_client());
    • a call to one, inline: Session(engine).scalar(query).
  • A call that blocks by itself: a DB-API driver's connect.
  • A call to a sync helper that does one of the above (#294): the async def calls a first-party plain def, in the same module or another one, whose body makes a blocking call. FastAPI calls such a helper directly, not in its threadpool, so its body runs on the event loop. The finding sits on the call in the async def and names the helper, its file and line, and its first blocking call.

The built-in list:

Family Receivers Methods that count
SQLAlchemy and SQLModel Session sqlalchemy.orm.Session, scoped_session, sqlmodel.Session The ones AsyncSession makes awaitable, since they may emit SQL: execute, scalar, scalars, get, get_one, flush, commit, rollback, delete, merge, refresh, connection, close, invalidate, reset; plus the sync-only query and bulk_*, and SQLModel's exec. add, add_all, expunge and the other bookkeeping methods don't count.
SQLAlchemy Engine and Connection create_engine(...) (SQLAlchemy's and SQLModel's), Engine, Connection Every method but execution_options and get_execution_options.
redis-py redis.Redis, StrictRedis, RedisCluster, redis.from_url Every command. pipeline, pubsub and lock don't count: they build an object and send nothing yet.
boto3 boto3.client(...), boto3.resource(...) Every method but the ones that sign or build locally: generate_presigned_url, generate_presigned_post, get_paginator, get_waiter, can_paginate, and a capitalised sub-resource such as s3.Bucket("b").
DB-API drivers sqlite3.connect, psycopg2.connect, psycopg.connect, pymysql.connect, MySQLdb.connect, mysql.connector.connect, and their connection classes The connect call itself, and every method of the connection it returns.

extend-blocking-calls and extend-blocking-types add your own. Files are checked whatever layer they are in, apart from test modules (check-tests = true brings them back, options tables); modules narrows the rule to some of them, such as the routers.

Why is this bad

An async def runs on the event loop. While a synchronous call waits for the database, Redis or AWS, the loop waits too, and so does every other request the worker is serving. FastAPI says to use async def "unless your path operation functions use code that performs blocking I/O", and runs a plain def route or dependency in a threadpool so it doesn't block the server (FastAPI: Concurrency and async / await). Agents copy db: Session = Depends(get_db) from the SQL tutorial and put async def on the route. The tests pass, because they send one request at a time; the slowdown shows up under load.

Ruff's ASYNC rules already catch blocking HTTP calls, subprocesses, open, time.sleep, input and pathlib in async functions (ASYNC210 to ASYNC251). They don't know SQLAlchemy's Session, redis-py, boto3 or the database drivers, and the receiver's type usually comes from an annotation or from a module-level client, not from the call's own name. INW013 covers that gap and leaves the rest to Ruff.

Example

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain", modules = ["store.domain"] },
  { name = "api", modules = ["store.api"] },
]

[tool.inwards.rules]
extend-select = ["INW013"]
store/api/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

engine = create_engine("sqlite:///store.db")


def get_db():
    with Session(engine) as session:
        yield session
store/api/orders.py
from typing import Annotated

import redis
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session

from store.api.db import get_db

router = APIRouter()
cache = redis.Redis()


@router.get("/orders/{order_id}")
async def get_order(order_id: int, db: Annotated[Session, Depends(get_db)]) -> dict:
    cached = cache.get(f"order:{order_id}")
    if cached is not None:
        return {"id": order_id, "total": int(cached)}
    total = db.scalar(text("select total from orders where id = :id"), {"id": order_id})
    return {"id": order_id, "total": total}
inwards check store
store/api/orders.py:16:14: INW013 `cache.get` sends a command through a synchronous Redis client inside `async def get_order`. That blocks the event loop, and every other request on this worker, until Redis answers.
  fix: Use `redis.asyncio.Redis` and await the call, or make `get_order` a plain `def`.
    1. Replace `redis.Redis` with `redis.asyncio.Redis`, and `await` the call to `cache.get`.
    2. If `get_order` is a FastAPI or Starlette route or dependency, declare it with `def` instead: FastAPI runs a plain `def` in its threadpool, off the event loop.
    3. If the call has to stay synchronous inside `async def`, run it in a worker thread: `await anyio.to_thread.run_sync(...)`, `await asyncio.to_thread(...)` or Starlette's `run_in_threadpool`.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW013/

store/api/orders.py:19:13: INW013 `db.scalar` talks to the database through a synchronous SQLAlchemy `Session` inside `async def get_order`. That blocks the event loop, and every other request on this worker, until the database answers.
  fix: Use `AsyncSession` from `sqlalchemy.ext.asyncio` and await the call, or make `get_order` a plain `def`.

Fixed, the quick way: the route is a plain def, so FastAPI runs it in its threadpool. The same calls now pass:

store/api/orders.py
from typing import Annotated

import redis
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session

from store.api.db import get_db

router = APIRouter()
cache = redis.Redis()


@router.get("/orders/{order_id}")
def get_order(order_id: int, db: Annotated[Session, Depends(get_db)]) -> dict:
    cached = cache.get(f"order:{order_id}")
    if cached is not None:
        return {"id": order_id, "total": int(cached)}
    total = db.scalar(text("select total from orders where id = :id"), {"id": order_id})
    return {"id": order_id, "total": total}
inwards check store

The other fix keeps async def and switches to async clients: redis.asyncio.Redis, and AsyncSession from create_async_engine, with every call awaited.

One hop into a sync helper

The route is async def, and the database work sits in a service function:

pyproject.toml
[tool.inwards.rules]
extend-select = ["INW013"]
shop/application/users.py
from sqlalchemy import insert


def create_user(db, name):
    db.add(User(name=name))
    db.execute(insert(Audit))
    db.commit()
    return name
shop/api/routes.py
from typing import Annotated

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from shop.application import users

router = APIRouter()


@router.post("/users")
async def create(name: str, db: Annotated[Session, Depends(get_db)]):
    return users.create_user(db, name)
shop/api/routes.py:13:12: INW013 `users.create_user` runs the plain `def create_user` (shop/application/users.py, line 4) on the event loop inside `async def create`, and `db.execute` on line 6 of it, the first of 2 blocking calls, talks to the database through a synchronous SQLAlchemy `Session`. That blocks the event loop, and every other request on this worker, until the database answers.
  fix: Run `create_user` in a worker thread, make it `async def` with `AsyncSession` from `sqlalchemy.ext.asyncio`, or make `create` a plain `def`.

create_user doesn't annotate db; the route passes its own db, a Session, so the helper's db is one too. Handing the function to a worker thread passes: await run_in_threadpool(users.create_user, db, name) calls nothing on the loop.

How it works

A text pre-filter runs first: a file with no async def, or one that names none of sqlalchemy, sqlmodel, redis, boto3, sqlite3, psycopg, pymysql, MySQLdb and mysql and not the first part of a configured pattern, is never parsed. A file that passes gets one parse of its own.

  • Functions: every async def, methods and nested functions included. Its body counts up to a nested def, lambda or class: their bodies don't run when the function runs, which is why await anyio.to_thread.run_sync(lambda: db.execute(q)) passes.
  • Names are qualified through the file's imports, so from redis import Redis as Cache and cache: Cache still read as redis.Redis.
  • Receivers come from the module's top-level bindings, then the function's parameters (a parameter shadows a module name of the same spelling), then the names its own body binds. A blocking call that is awaited is still reported: a sync client doesn't become async by awaiting it.
  • One hop (#294). For each call in an async def that isn't a blocking call itself, Inwards looks for the function it names: a top-level function of the same module called by bare name, or, through the project's module index, a top-level function of another first-party module, following re-exports in a package's __init__.py. It follows a plain, undecorated def only: an async def helper gets its own check, and a decorator may move the call off the loop. An awaited call and a call in a nested def or lambda aren't followed, and a function passed to run_in_threadpool, anyio.to_thread.run_sync, asyncio.to_thread or loop.run_in_executor isn't called at all. The helper's receivers are its module's, its own annotated parameters', the names its body binds, and for an unannotated parameter the receiver the caller passes by bare name, by position or keyword. The helper's own calls are not followed further.
  • Cost. Each module a hop reads is parsed and read once per run, however many checked files call into it (#359), and only when an async def calls a function in it; a call to a method of a module-level object (service.create(...)) reads nothing, and a checked file a hop already parsed isn't parsed again; follow-modules limits which modules are read. While following is on, a checked file with an async def may reach a blocking helper in a module that names the library when the file itself doesn't. In a run with many such files (at least 200, and one in 20 of the run), a second pre-filter reads every listed module once, parses only those whose imports can spell a blocking type, and skips a file that names neither a blocking type nor one of their plain defs that block, under its own name or an alias a re-export gives it (#369). A smaller run, such as a hook's, parses each such file. With follow-modules = [] the text pre-filter above applies again.
  • One finding per call. sqlite3.connect(p).execute(q) is reported once, on sqlite3.connect; db.query(Order).filter(...).all() once, on db.query.

How to fix

  1. Switch to the library's async client and await the call: AsyncSession and create_async_engine from sqlalchemy.ext.asyncio, redis.asyncio.Redis, asyncpg or psycopg.AsyncConnection, aiosqlite, aiomysql, aioboto3.
  2. Or, for a FastAPI or Starlette route or dependency, declare it with def: the framework runs it in a threadpool.
  3. Or keep the call synchronous and run it in a worker thread: await anyio.to_thread.run_sync(...), await asyncio.to_thread(...) or run_in_threadpool.

Fix safety

No automatic fix (autofix: false). Switching to an async client changes the dependency, the session factory and every call site; switching to def changes how the route runs.

Configuration

Severity error. Opt-in: turn it on with extend-select. The fastapi preset of inwards init turns INW013 on as a warning in every module. The options live in [tool.inwards.rules.async-blocking] (options tables):

pyproject.toml
[tool.inwards.rules]
extend-select = ["INW013"]

[tool.inwards.rules.async-blocking]
modules = ["shop.*.router", "shop.*.service"]   # optional: only these modules
extend-blocking-calls = ["shop.legacy.fetch_*"] # calls that block by themselves
extend-blocking-types = ["pymongo.MongoClient"] # every method of these blocks
follow-modules = ["shop.*.service"]             # optional: the modules a hop reads
  • extend-blocking-calls: qualified names, with * and ? as wildcards, of functions whose call blocks. They add to the built-in drivers.
  • extend-blocking-types: qualified names, with * and ? as wildcards, of types or factories whose every method blocks. A parameter annotated with one, or a name bound from a call to one, is a receiver.
  • follow-modules: module prefixes or selectors, as in a layer's modules, of the first-party modules a hop may follow into. Packages above a module an entry matches are read too, since a re-export may pass through their __init__.py. Unset, every first-party module may be followed; [] keeps the check to helpers in the checked module. Helpers in the checked module are always followed.

The built-in list can't be shortened; use modules, or an inline suppression for a single call: # inwards: ignore[INW013] reason="..." on the line of the call.

A template can turn INW013 on for one of its roles: router = { async-blocking = true } sets modules to the role's modules.

Known limitations

  • One hop only. A sync helper that calls another helper which blocks isn't reported, and neither is a method (service.create(db) on an instance, self.repo.save()), a class's static method, or a function stored in a variable. Wrap the inner call, or suppress it with a reason.
  • What reaches the helper. Only a receiver passed by bare name reaches the helper's parameter; helper(db=get_session()), helper(*args) or a receiver in a dict says nothing. A helper that annotates its parameter needs nothing from the caller.
  • Indirection. self.db, a client passed through **kwargs or a dict, a client a factory builds in another module (SessionLocal() from a sessionmaker in db.py), and a type alias imported from another module or written as a string all hide the receiver. Annotate the parameter instead.
  • Rebinding. A name stays a receiver once a binding makes it one: after db = Session(engine) and then db = AsyncSession(engine), db still counts as blocking.
  • Untyped parameters aren't receivers: async def f(db) says nothing about db.
  • Not every method blocks. The Engine, redis-py and driver families count every method but a few; a rare local-only method is a false positive.
  • Startup code. An async def lifespan or startup hook that opens a connection once blocks the loop once. Narrow modules, or suppress the line with a reason.
  • Measured on the corpus (#182, chapter 6): 1 finding in the five repos, a sync Session.query in an async def query handler of python-ddd, and it is a true positive. Polar's async code uses AsyncSession and gets none. On Polar a cold full check with INW013 on takes 0.73 s against 0.55 s without it (#369); follow-modules narrows it further.

References