diffox
Blog

Blast radius: what a pull request touches outside its diff

Blast radius is the code a change reaches beyond its diff. Using a real pull request, we show how to find it from the code alone, without drawing false arrows.

ShareXLinkedIn

Summary. Blast radius is the code a change reaches beyond itself: the files that call or import what changed. This post shows how to find a pull request’s blast radius from the code alone, with no model. It also explains why matching by name draws false arrows, and how to turn the result into a diagram on GitHub. We checked each tool’s behaviour against its own documentation on 14 October 2026. The example is a merged pull request in the requests library.

  • A diff does not show who calls the code. It shows the lines that changed, not the files that use them. In our example two files change, and 9 files outside the diff use them.
  • Matching by name draws false arrows. We found four kinds in real output: a second definition with the same name, a comment that merely mentions the name, a second copy of the source tree, and an import resolved to the wrong file.
  • Draw an arrow only when the import resolves to the changed file. A few arrows you can trust beat many that the reader has to check one by one.
  • If the scan cannot read everything, draw nothing. A half-finished diagram reads as “nobody calls this code”.

What is blast radius?

4kinds of false arrow we found in real output
9outside callers in the example
8boxes per diagram, at most

The term comes from infrastructure engineering, where it describes how far a failure spreads. AWS’s guide to cell-based architecture says that splitting a system into isolated cells reduces the blast radius. It adds that this helps not only when serving customer traffic but also when you deploy a new feature or a bug fix (source).

In code review the question is narrower: outside this pull request, what calls or imports the code it changes? If a function’s return type changes, every caller is affected, so a reviewer has to keep those files in mind too. People also search for this as “change impact analysis”.

This post sticks to the nearest ring: the files that call or import the changed file directly. Drawing that diagram with nothing but real arrows comes down to four rules.

4 rules for real arrows only. A blast radius diagram, from the code alone. 1. Resolve imports to files. The import line decides, not the name. 2. Read only the code. A name in a comment is not an arrow. 3. Use names defined once. A name defined twice hides its owner. 4. Incomplete? Draw nothing. Half a diagram reads “nobody calls it”.
Four rules for drawing a pull request’s blast radius without a model, from the code alone.

What a diff does not show

When you open a pull request, GitHub shows you the changed files and lines. Files that use the changed code but did not change themselves are not on that screen; the reviewer has to remember them.

Take a real example: pull request 7441 in the requests library changes two files, _types.py and models.py, and the diff shows only those two. Scanning the tree at the pull request’s head commit, we found 9 other files that use them. sessions.py and adapters.py, for instance, use the PreparedRequest class from models.py directly, so a change to that class can change how those two files behave.

The rest of this post explains how we got that number, and why we leave some arrows out on purpose.

Three common ways to find callers

  • Text search. git grep -w name finds every line where the name appears as a whole word (documentation). It is fast and needs no setup. But it also matches comments, strings and other functions that share the name, and it is up to you to tell which hits are real calls.
  • Language server. “Find All References” in your editor sends the Language Server Protocol’s textDocument/references request, and the language server returns every reference to the symbol under the cursor across the project (specification). The result is exact, but it covers one symbol at a time and needs the project set up in your editor. GitHub’s code navigation in the browser works differently: it uses tree-sitter and, according to its documentation, searches all definitions and references in a repository for entities with a given name (documentation). In other words, it matches by name.
  • Call graph tools. Tools such as pyan for Python and madge for JavaScript graph the whole repository. pyan calls its own analysis “rather superficial” (source), and madge maps module dependencies rather than function calls (source). Both look at the entire repository rather than a single pull request, and their output will not fit in a review comment.
Three ways, three blind spots. Text search: git grep -w. Where it fails: Same name. Comments and namesakes match too. Language server: Find All References. Where it fails: One symbol. Exact, but one name at a time. Call graph: pyan, madge. Where it fails: Whole repo. Sees the repo, not the pull request. Resolving imports: changed files. What it draws: Real arrows. Few arrows, none to double-check. The fourth card is the approach in this post.
Three common ways to find callers, and the fourth that this post proposes. Source: each tool’s own documentation, 14 October 2026.

A pull request needs something in between: a diagram as exact as a language server, as fast as text search, and limited to the neighbourhood of the changed files. The way to get there is to follow the import line, not the name.

Four kinds of false arrow we found in real output

When we built the diffox step that draws a pull request’s blast radius, we went through the early versions’ output by hand and found four kinds of false arrow. Each one led to a rule:

  1. A name defined in more than one place. Three separate tools each defined a helper function with the same name, and callers of one were drawn as callers of another. Rule: draw no arrow for a name that is not defined exactly once in the whole tree.
  2. A second copy of the tree. A git worktree inside the repository held a full copy of the tree, and two thirds of what the first run found came from it. Rule: skip any folder whose name starts with a dot.
  3. A line that only mentions the name. A comment used the function’s name as an example. Rule: read only the code itself; once comments and strings are stripped out, a call or an import must remain.
  4. An import resolved to the wrong file. The line from werkzeug.exceptions import NotFound was drawn as a caller of the repository’s own exceptions.py, just because the two files share a name. Rule: draw an arrow only if the caller’s import line resolves to the changed file itself. A relative import is resolved by working out the path; any other import is matched to a single file by looking at the files in the tree.

The fourth rule has a cost: a caller whose import cannot be followed stays out of the diagram. In Go and Ruby an import binds no name, so files in those languages get no arrows, and a method called through an object (obj.x()) cannot be tied to a file either. A missing arrow costs the reader nothing. A false one costs them their trust in the rest of the diagram.

Keeping the diagram readable

In a real repository the number of callers grows fast. Two things help.

  • Leave the tests out. In our own repository, 16 of the 19 arrows the first version drew came from test files. The author of a pull request does not need to be told “your tests call this function”. Tell a test by its path, not its contents: a tests/ folder, test_*.py, *.test.ts and so on. How much this matters varies by repository: in the requests example, including the tests would have added just one file.
  • Cap the number of boxes, and say what was left out. Nobody can read a diagram with forty boxes, and a diagram that does not say it was capped looks complete. We draw at most 8 boxes and at most 6 callers per changed file, and count the rest. In the requests example 6 of the 9 files were drawn; one sentence under the diagram mentions the other 3.

Speeding up the search on large changes

The simplest way to find callers is to take every name the changed files define and search every line of the tree for \bNAME\b. A large change defines hundreds of names, and in a tree of hundreds of files that adds up to millions of regex searches. On one large pull request in our own repository, this step ran 19.1 million searches and the diagram missed its 20-second budget on the server. Two changes cut the time:

  1. Filter the candidates first. A file that never mentions the changed file’s name as a word cannot import it, because every import line, such as from service.app import x or require("./app"), spells out the last part of the path as a word. In our old tool, indexing the whole tree took 51 seconds for 299 files; this filter cut it to 38 files and 3.2 seconds without losing a single arrow.
  2. Search per line, not per name. Python’s documentation defines \b as the boundary between a word character and a non-word character (documentation). So for a name made only of word characters, \bNAME\b matches exactly when one of the line’s words is that name. One findall per line therefore answers for every name at once, and all that is left is a set intersection. Names with other characters, such as $foo or valid?, still get a pattern of their own.
import re

WORD = re.compile(r"\w+")

def hits(lines, names):
    """The names on each line: one findall per line instead of one regex per name."""
    wanted = set(names)
    for number, line in enumerate(lines, 1):
        found = wanted.intersection(WORD.findall(line))
        if found:
            yield number, found

How much this saves depends on how many names you search for. We timed both approaches on the same trees and the same machine, and they found exactly the same results every time:

total time of scan and index · median of three runs · Apple M4 laptop Django, 1 file (127 names · per name): 0.94 s; Django, 1 file (per line): 0.79 s; Django, 5 files (577 names · per name): 5.30 s; Django, 5 files (per line): 1.37 s; Our own pull request (570 names · per name): 12.70 s; Our own pull request (per line): 1.54 s.
Three changes, each searched both ways. Time is the scan plus the index, median of three runs, measured on a laptop on 14 October 2026; the server is slower.

In Django, when one large file changes (127 names), the gap between the two is small. When the change spans the five largest files (577 names), searching per line is 3.9 times faster, and on our own pull request (570 names) it is 8.2 times faster. So this does not speed up every review; it brings the diagram back within budget when the number of names grows. We found the hot spot with a profiler rather than by guessing: most of the time went into a single loop.

When not to draw the diagram at all

If a scan hits a time or size limit and stops halfway through the tree, what you get is not a sample of the callers. A file that was never read shows up with no callers, which looks like “nobody calls it”. So in these cases we draw nothing at all:

  • Time ran out. The scan and the index must finish within a time budget; ours is 20 seconds.
  • A changed file could not be read. It was too large, a symlink, or not a regular file.
  • A limit was hit. The number of files read, their total size or the number of names searched for went over its threshold.
  • There are no callers. A lone box with no arrows would claim an isolation the data does not support.

Showing the reader nothing beats showing them something wrong. But the reason a diagram was skipped belongs in the log: only the log tells you which skips were errors and which were expected.

Rendering the diagram on GitHub with Mermaid

The easiest way to show the diagram is Mermaid. GitHub’s documentation says diagrams render in issues, discussions, pull requests, wikis and Markdown files; you put the Mermaid syntax in a fenced code block tagged mermaid (documentation).

Here is the diagram for the requests example, as written in a pull request comment:

```mermaid
flowchart LR
  n0["_types.py"]:::changed
  n1("utils.py")
  n2("adapters.py")
  n3("cookies.py")
  n4("sessions.py")
  n5["models.py"]:::changed
  n6("__init__.py")
  n7("exceptions.py")
  n1 -.->|"SupportsItems"| n0
  n2 -.->|"is_prepared"| n0
  n3 -.->|"is_prepared"| n0
  n4 -.->|"is_prepared"| n0
  n6 -.->|"Request, PreparedRequest +1"| n5
  n7 -.->|"Request, PreparedRequest +1"| n5
  n4 -->|"Request, PreparedRequest +1"| n5
  n1 -.->|"Request, PreparedRequest +1"| n5
  n2 -->|"PreparedRequest, Response"| n5
  classDef changed stroke:#3a6b8b,stroke-width:2px
```
EXAMPLE. Four parts, one diagram. pull request comment: flowchart LR /   n0["_types.py"]:::changed /   n5["models.py"]:::changed /   n4("sessions.py") /   n4 -->|"PreparedRequest"| n5 /   n4 -.->|"is_prepared"| n0 /   classDef changed stroke-width:2px. :::changed: Changed file in its own frame. -->: Solid arrow: at least one call. -.->: Dashed arrow: import only. |"name"|: The names used, on the arrow.
The same diagram in four parts: the changed file’s frame, the solid arrow, the dashed arrow and the names on the arrow. Example: requests 7441.

Three small choices make the diagram readable. Changed files get a frame of their own. Each arrow is labelled with the names it uses; beyond two, the rest collapse into a count such as “+1”. A file that only imports the changed file, with no direct call found, gets a dashed arrow. And one sentence under the diagram says what was left out.

Blast radius in diffox

Every time diffox reviews a pull request, it reruns this step on the head commit. If it finds callers outside the diff, it adds a Mermaid diagram to its summary comment under the heading “What this touches”. The rules are the ones in this post: no model, only arrows whose import resolves to the changed file, no tests, at most 8 boxes, a 20-second budget, and no diagram unless the scan is complete. It supports Python, JavaScript/TypeScript and Java. If you do not want the diagram, turn off “Change overview” on the console’s Settings tab.

The diagram shows the blast radius; if something actually breaks, the findings say so. The Blast Radius Agent, one of 17 specialist agents, looks for broken callers, changed API surfaces, and signature or return-type changes that break other files (a separate post explains how it works).

Frequently asked questions

What is blast radius in software engineering?

It is how far the effect of a failure or a change spreads. In infrastructure it describes how many users a failure reaches; in code review, which files outside the pull request call or import the changed code.

Are blast radius and change impact analysis the same thing?

They are closely related. Change impact analysis is the general name for working out in advance what else a change will affect. A pull request’s blast radius is the nearest ring of that analysis: the files that use the changed file directly.

How do I find where a function is called?

Use “Find All References” in your editor; the language server resolves every reference to the symbol. For a quick look, git grep -w function_name is enough, but you will have to weed out matches in comments and in other definitions with the same name by hand. To cover every file a pull request changes, you need a scan that resolves imports to the changed file.

Do Mermaid diagrams work in a GitHub pull request comment?

Yes. GitHub renders Mermaid diagrams in issues, discussions, pull requests, wikis and Markdown files. Just put the syntax in a fenced code block tagged mermaid.

Why are there no tests in the blast radius diagram?

Tests are among the files that call the changed code most often, and they clutter the diagram; in our own repository, most of the first version’s arrows came from tests. For the author of a pull request, the real question is what is affected in production code.

What do the dashed arrows in the diagram mean?

A dashed arrow shows that the calling file imports something from the changed file but no direct call was found. A solid arrow means at least one call was found.

Which languages does diffox draw the blast radius for?

Python, JavaScript/TypeScript and Java. In these languages an import binds a name, so you can tell which file it points to. In Go and Ruby an import binds no name, so diffox draws no arrows there rather than guess.