Find the synchronous call that stalls your event loop, and print the whole path to it: from the
async handler down through services/ and clients/ to the blocking requests.get, across as
many files and modules as it takes.
Existing linters catch a blocking call only when it sits directly in the async def body. In
real code it almost never does -- it is three to five calls deep, in another module, behind a
couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
A survey of 57 public aiogram/FastAPI repositories, with the protocol and thresholds registered in advance so the result could not be steered, asked one question: when a coroutine can reach a blocking call, how deep is it?
depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
depth 2 | ############### 28 (17.7%)
depth 3 | ################ 31 (19.6%)
depth 4 | ######### 17 (10.8%)
depth 5 | ## 3 ( 1.9%)
depth 6 | # 2 ( 1.3%)
| 158 reachable blocking call sites
51% of them sit at depth 2 or deeper, across module boundaries, where no per-file linter can
follow. As a cross-check, ruff --select ASYNC run on the same repositories flags 0 of those
81 deep sites. The histogram, and the fact that it rebuilds from scratch, is in
benchmark/depth_histogram.md (make bench-depth).
$ blockpath check .
BLK001 blocking call reachable from a coroutine
app/handlers/order.py:11 return resolve_address(address) <- entry: async def
app/services/geo.py:6 return geocode(query)
app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
hint: await asyncio.to_thread(resolve_address, ...)
1 finding(s): 1 BLK001The path through three files is the point: no existing linter prints it.
pip install blockpath
blockpath check .
blockpath check . --json # for CIExit code is 0 when clean, 1 when there are findings (fails a build), 2 if the tool itself
errored -- so a crash is never mistaken for a clean run.
Optional configuration, in your own pyproject.toml:
[tool.blockpath]
tiers = ["error"] # error (default) | warning | off
entry-decorators = ["router.message"] # mark framework handlers as entry points
strict = false # also report blocking calls handed to unresolved calleesSix layers, each a small tested unit, and one idea that carries the whole thing.
collect -> symbols -> resolve -> callgraph -> oracle -> analysis
(walk & (names & (name -> (CALL/REF (what (three BFS,
parse) scopes) function) edges) blocks) the witness)
The idea is that a finding is the intersection of two independent reachability questions, and that intersection is why the tool stays quiet:
- Backward from every blocking leaf: which functions can reach a blocking call at all?
- Forward from every coroutine: which functions can a coroutine reach?
- A third pass over the intersection recovers the witness -- the exact chain of call sites.
Report only their intersection and time.sleep in a synchronous CLI script in the same repo
stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
analysis is O(V+E) -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
(ADR 0002).
The call graph is built from the AST without importing or running your code, so a project that needs a database to import is analysed the same as any other.
Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a second pass auditing the riskiest calls (benchmark/adjudication.md):
- precision 110/112 = 98.2% by the tool's literal claim (a blocking call reachable from a coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or 99/112 = 88.4% counting product bugs only. Both numbers are in the table, and each of the two false positives is explained with a permalink.
- recall is a lower bound, never a point estimate. The runtime verifier (
blockpath verify -- pytest) records which functions actually ran on the loop and compares them to the static findings, but it only sees the paths the tests exercised. It can prove a miss and can never prove completeness, and it says nothing about precision -- only the hand adjudication does.
Precision is not a number the analyzer asserts about itself; it is what survived a human reading each finding against the source.
- Only calls by name are resolved. An attribute call on a value (
self.session.get()) needs type inference, which this does not do; the cost is measured (about half of all call sites on one benchmark repo) rather than waved away (ADR 0003). - A reference is not a call. A function passed to
run_in_executor(None, f)runs in a thread, so it is silent by construction -- no offload false positives -- whilerun_in_executor(None, f())with parentheses is a real bug (BLK002) (ADR 0007). - Unknown is quiet by default. A path with an unresolved edge is not reported unless you ask
with
--strict(BLK003) (ADR 0006). - The complete list of limitations is written as they were found, not at the end.
- ruff
ASYNCrules are written in Rust and run hundreds of times faster than this. They are the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work per file, by an explicit architectural choice (parallel analysis without global state), so they do not build a cross-module call graph and cannot print the path -- which is the entire reason this exists. The 0-of-81 cross-check above is that difference, measured. - flake8-async is likewise per-file and does not follow calls between modules.
blockpath is not a replacement for either; it is the cross-module analysis they deliberately do not do.
uv sync --all-extras
make check # ruff, mypy --strict, pytest
make bench-depth # rebuild the depth histogram from the pinned corpus
./scripts/reproduce_benchmark.sh # rebuild the precision findingsThis project is AI-assisted: written by a human, who made the product and architecture decisions and signed off at every checkpoint, with an AI pair implementing under direction. WORKLOG.md keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
Python 3.12+ (it uses sys.monitoring, ADR 0001). MIT licensed.