In this guide
Every codebase has more messy code than anyone has time to clean up. The question is never "is this code bad?" but "is this the bad code that is costing us?" A 2,000-line file nobody has touched in three years is ugly, but it is not slowing anyone down. A 400-line file that changes in every second pull request and breaks in every third is where the time goes.
Hotspot analysis is a simple, evidence-based way to tell those apart. It combines how often code changes (churn, from version control) with how hard it is to change (complexity or centrality, from the code itself). This post walks through the git commands, a small scoring script, and a method for turning the result into a prioritized list of refactoring work.
Key takeaways
- A hotspot is code that is both frequently changed and complex (or central). Either property alone is usually not a problem.
- Churn comes straight from
git log. Count commits per file over a fixed window such as the last 12 months. - Complexity can be approximated by lines of code, or measured with tools like lizard, radon, gocyclo or scc. Centrality comes from the dependency graph.
- Multiply or rank-combine the two signals, then look at the top 10 to 20 files by hand. The ranking is a starting point, not a verdict.
- Change coupling (files that change together) finds hidden dependencies the import graph misses.
- Use hotspots to justify and target refactoring, to direct code review attention, and to decide where tests are most valuable.
Why churn matters
The idea comes largely from Adam Tornhill's work, popularized in Your Code as a Crime Scene: version control is a behavioral record of where developers actually spend their effort. Static analysis tells you what the code looks like; history tells you what it costs.
A few patterns show up in almost every repository:
- Change is highly concentrated. A small fraction of files accounts for a large share of commits.
- Many of the most-changed files are also the most complex, because complexity attracts changes (features keep landing in the same place) and changes add complexity.
- Defects cluster in the same files. Code that changes often has more opportunities to break.
You do not need to take any of this on faith. The commands below let you check it for your own repository in a few minutes.
Step 1: Measure churn with git
The simplest churn metric is the number of commits that touched each file over a window:
git log --since="12 months ago" --format=format: --name-only \
| grep -v '^$' \
| sort | uniq -c | sort -rn \
| head -30
--format=format: suppresses the commit header so only file names remain, grep -v '^$' drops the blank separator lines, and uniq -c counts occurrences.
Refinements worth adding:
# Exclude merge commits, lockfiles, and generated code
git log --since="12 months ago" --no-merges --format=format: --name-only -- . \
':(exclude)package-lock.json' ':(exclude)*.lock' ':(exclude)**/generated/**' \
| grep -v '^$' | sort | uniq -c | sort -rn | head -30
# Lines added plus deleted per file, a size-weighted churn measure
git log --since="12 months ago" --no-merges --numstat --format=format: \
| awk 'NF==3 && $1!="-" { churn[$3] += $1 + $2 } END { for (f in churn) print churn[f], f }' \
| sort -rn | head -30
# Number of distinct authors per file, a rough knowledge-spread signal
git log --since="12 months ago" --no-merges --format='--%aN' --name-only \
| awk '/^--/ { author = substr($0, 3); next } NF { seen[$0 SUBSEP author] = 1 }
END { for (k in seen) { split(k, p, SUBSEP); n[p[1]]++ } for (f in n) print n[f], f }' \
| sort -rn | head -30
Commit count is usually the better default. Line churn is dominated by large mechanical changes like reformatting or moving code, while commit count reflects how often someone had to open the file and think about it.
Choosing the window
Twelve months is a good default. Shorter windows (three months) reflect current work and are better for sprint-level decisions. Longer windows include parts of the system that have since stabilized. If the repository had a major rewrite, start the window after it.
Watch out for renames. git log --name-only reports the path at each commit, so a file moved last month will appear as two entries. Adding --follow only works for one file at a time; for repository-wide analysis, accept the split or map old paths to new ones with git log --name-status --diff-filter=R.
Step 2: Measure complexity
The cheapest complexity proxy is lines of code, and it correlates surprisingly well with more sophisticated metrics. For better signal, use cyclomatic complexity per function and sum or take the maximum per file.
# Any language: lines of code and a complexity estimate per file
scc --by-file --format csv . > complexity.csv
# Multi-language cyclomatic complexity per function (C, C++, Java, JS/TS, Python, Go, Rust, and more)
pip install lizard
lizard src/ --csv > lizard.csv
# Python
radon cc -s -a myapp/
# Go
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
gocyclo -top 30 ./...
Another useful signal is centrality: how many other modules depend on the file. A moderately complex file with 200 dependents is riskier to change than a very complex leaf. Fan-in from the dependency graph (see dependency graphs explained) is a fine proxy.
Step 3: Combine the signals
You now have two numbers per file. Combine them into a ranking. A small script does the job:
# hotspots.py: python hotspots.py
import csv
import subprocess
from collections import Counter
log = subprocess.run(
["git", "log", "--since=12 months ago", "--no-merges", "--format=format:", "--name-only"],
capture_output=True, text=True, check=True,
).stdout
churn = Counter(line for line in log.splitlines() if line.strip())
# scc --by-file --format csv . > complexity.csv
complexity = {}
with open("complexity.csv") as f:
for row in csv.DictReader(f):
# scc names the path column "Location" in recent versions, "Provider" in older ones
path = (row.get("Location") or row.get("Provider") or "").removeprefix("./")
complexity[path] = int(row["Complexity"]) or int(row["Code"])
rows = []
for path, commits in churn.items():
if path in complexity:
rows.append((commits * complexity[path], commits, complexity[path], path))
for score, commits, cx, path in sorted(rows, reverse=True)[:25]:
print(f"{score:>8} commits={commits:<4} complexity={cx:<5} {path}")
Check the header row of your scc version's CSV, and adjust the column names if you use a different tool. Multiplying is crude but effective. If one signal has a much wider range than the other, rank each separately and add the ranks instead, so neither dominates.
Step 4: Read the top of the list by hand
The script gives you candidates. Judgment turns them into a plan. For each of the top 15 or so files, open it and ask:
- Why does it change so often? Look at the last 20 commit messages with
git log --oneline -20 -- path/to/file. Are they features, bug fixes, or config tweaks? - Is the churn healthy? A route table or a feature-flag config changes often by design. That is not a hotspot problem.
- Where in the file is the change?
git log -L :functionName:path/to/fileshows the history of a single function. Often one function inside a large file is the real hotspot. - Who changes it? One author suggests a knowledge silo; many authors suggest a shared bottleneck.
- Is it tested? A complex, frequently changed file with weak tests is the highest-priority combination you will find.
Remove the healthy churn, and you usually end up with a short list of three to eight files that genuinely deserve investment.
Step 5: Look for change coupling
Some of the most valuable findings come from files that change together without importing each other. If api/orders.ts and web/checkout.tsx appear in the same commit 80% of the time, there is a hidden dependency, probably a shared shape or protocol, that the import graph does not show.
code-maat, Adam Tornhill's open-source tool, computes this from a specially formatted log:
git log --all --numstat --date=short --no-renames \
--pretty=format:'--%h--%ad--%aN' --after=2025-11-01 > git.log
java -jar code-maat-1.0.4-standalone.jar -l git.log -c git2 -a coupling > coupling.csv
java -jar code-maat-1.0.4-standalone.jar -l git.log -c git2 -a revisions > revisions.csv
The coupling analysis reports pairs of files with their degree of co-change. Pairs in different modules with high coupling are candidates for one of two actions: move the code closer together, or make the contract between them explicit with a shared type or schema. The module boundaries and coupling post covers how to measure and enforce that.
Turning hotspots into work
A hotspot list is only useful if it changes what the team does. A few ways to use it:
Target refactoring. Instead of a general "pay down tech debt" sprint, pick the top two hotspots and set specific goals: split this function, add characterization tests to that file, extract this responsibility. Measure success by whether the file's churn and bug rate drop over the next quarter. The map technical debt post covers how to present this to stakeholders.
Direct review attention. Changes to hotspot files deserve a second reviewer or a more careful review. A CODEOWNERS entry for the top hotspots is a cheap way to make that automatic.
Prioritize tests. Where you add tests matters more than how many. Tests on hotspots protect the code that changes most.
Plan refactors safely. Before restructuring a hotspot, check what depends on it; impact analysis before refactoring has the method.
Repeat quarterly. Rerun the analysis every quarter and compare. Hotspots that dropped off the list are evidence that refactoring worked. New entries show where the next problem is forming.
Common mistakes
- Treating the ranking as the answer. Generated files, config, and changelogs float to the top. Filter them, then apply judgment.
- Using all of history. A file that was rewritten two years ago carries churn from code that no longer exists. Use a window.
- Ignoring function-level detail. A large file with one hot function needs that function fixed, not the whole file rewritten.
- Using it to judge people. Churn by author measures where people were asked to work, not how well they worked. Keep the analysis about code.
Hotspots without the scripting
The scripts above are worth running once so you understand what the numbers mean. If you want the signal on demand, OpenVisio computes churn combined with centrality from the code graph and exposes it to coding agents through the get_hotspots MCP tool, so an agent can check whether the file it is about to edit is a high-risk one and be more careful accordingly. For humans, finding a hotspot file on the Explore architecture map and looking at what surrounds it makes it easier to tell whether a hotspot is an isolated messy file or the center of a much larger tangle.

