Call Graphs Explained: Static vs Dynamic, Limits and Uses

A call graph answers who calls what. Static and dynamic call graphs answer it differently, and both have blind spots. Here is how they work and when to trust them.

By The OpenVisio Team · · 11 min read

"Who calls this function?" is one of the most common questions in software engineering. You ask it before changing a signature, while debugging a value that arrives wrong, and when deciding whether code is dead. A call graph is the data structure that answers it: a node per function, an edge per call. It sounds simple, and for straight-line code it is. For real code with interfaces, callbacks, dependency injection and reflection, building an accurate call graph is one of the classic hard problems of program analysis.

This post explains how static and dynamic call graphs are built, why they disagree, which tools produce them in common languages, and how to use them in practice without trusting them more than they deserve.

Key takeaways

  • A call graph has functions as nodes and calls as edges. It is finer-grained than a file-level dependency graph.
  • Static call graphs are built from source without running it. They can cover all code but must guess at dynamic dispatch.
  • Dynamic call graphs are recorded while the program runs. Every edge is real, but they only cover the paths you exercised.
  • Static analysis trades off soundness (no missed edges) against precision (no spurious edges). Practical tools are neither fully sound nor fully precise.
  • Interfaces, callbacks, DI containers, reflection, and dynamic languages are where call graphs get unreliable.
  • Use call graphs for impact analysis, debugging, onboarding and finding candidates for dead code. Always confirm critical conclusions with tests or a search.

What a call graph is

Given this code:

function handleCheckout(req: Request) {
  const cart = loadCart(req.userId)
  const total = priceCart(cart)
  return chargeCard(req.userId, total)
}

function priceCart(cart: Cart) {
  return cart.items.reduce((sum, item) => sum + priceItem(item), 0)
}

The call graph contains the edges handleCheckout -> loadCart, handleCheckout -> priceCart, handleCheckout -> chargeCard, and priceCart -> priceItem. The anonymous reducer is technically a function too; tools differ on whether they show it or attribute its calls to the enclosing function.

Two directions are useful:

  • Callees (forward): what does this function call? Useful for understanding what a function does and what it can fail on.
  • Callers (reverse): what calls this function? Useful for impact analysis and for finding how a code path is reached.

Walking callers transitively from a function gives you every entry point that can reach it. Walking callees transitively gives you everything a request might touch.

Static call graphs

A static call graph is computed from the source or compiled code without running it. The easy part is direct calls: priceCart(cart) resolves to one function by name and scope. The hard part is everything else.

The dynamic dispatch problem

Consider an interface:

type Notifier interface {
    Send(ctx context.Context, msg Message) error
}

func (s *OrderService) Complete(ctx context.Context, o Order) error {
    // ...
    return s.notifier.Send(ctx, confirmation(o))
}

Which Send runs? It depends on what was injected into s.notifier at startup: maybe EmailNotifier, maybe SlackNotifier, maybe a test fake. A static analyzer has to choose a strategy, and the standard ones form a spectrum:

AlgorithmHow it resolves s.notifier.SendTrade-off
Name-basedEvery method called Send anywhereFast, very imprecise
Class Hierarchy Analysis (CHA)Every type that implements NotifierSound for typed code, many spurious edges
Rapid Type Analysis (RTA)Implementations of Notifier that are actually instantiated somewhere reachableMore precise, needs whole-program view
Points-to / pointer analysisImplementations that can actually flow into s.notifierMost precise, expensive

Go's tooling exposes this choice directly:

go install golang.org/x/tools/cmd/callgraph@latest
callgraph -algo=cha ./...      # fast, more edges
callgraph -algo=rta ./cmd/api  # needs a main package, fewer edges
callgraph -algo=vta ./...      # variable type analysis, a middle ground

The output is one edge per line, which is easy to filter with grep or feed into Graphviz. For an interactive view, go-callvis renders the graph for a main package and lets you focus on one package at a time.

Soundness and precision

Two words come up constantly in call graph literature:

  • Sound: the graph includes every call that could happen at runtime. Nothing is missed.
  • Precise: the graph includes only calls that can happen. Nothing is spurious.

You cannot have both in general, and real tools have neither fully. CHA tends toward soundness for statically typed code with many false edges. Name-based approaches in dynamic languages are neither sound nor precise. When you read a static call graph, ask which way the tool leans: an empty caller list from a tool that misses edges does not mean the function is unused.

Static tools by language

  • Go: callgraph (CHA, RTA, VTA, static), go-callvis, and the gopls "call hierarchy" feature in editors.
  • Java and JVM: IntelliJ's "Call Hierarchy" (Ctrl+Alt+H), and research-grade frameworks like WALA, Soot and its successor SootUp, which implement CHA, RTA and points-to analysis.
  • C and C++: cflow for C, Doxygen with CALL_GRAPH = YES and CALLER_GRAPH = YES (requires Graphviz), clangd call hierarchy in editors.
  • Python: pyan3 produces static call graphs from source, with the caveats of a dynamic language. Pyright and Pylance provide call hierarchy in editors using type inference.
  • TypeScript and JavaScript: the TypeScript language server supports call hierarchy (VS Code "Show Call Hierarchy"), which handles many cases thanks to types. Whole-repo graphs typically come from code-graph tools built on tree-sitter or the TypeScript compiler API.
  • Rust: rust-analyzer's call hierarchy in editors.

Editor call hierarchy is the most underused of these. It is already installed, it is type-aware, and for "who calls this?" on a single function it is usually the fastest answer.

Dynamic call graphs

A dynamic call graph is recorded while the program runs. Profilers and tracers do this as a side effect: every sample or trace event includes a stack, and stacks imply caller-callee edges.

# Python: cProfile, then render the call graph
python -m cProfile -o out.prof app.py
gprof2dot -f pstats out.prof | dot -Tsvg -o callgraph.svg

# Python: sampling profiler with flame graph output, attach to a running process
py-spy record -o profile.svg --pid 12345

# Go: CPU profile with pprof, interactive graph in the browser
go test -cpuprofile cpu.out ./orders
go tool pprof -http=:8080 cpu.out

# Linux native code: sample with call stacks
perf record -g ./server
perf report --call-graph

Flame graphs are the most common visualization: each bar is a function, its width is time spent, and the bars stacked above it are its callees. They are call graphs aggregated by time.

Distributed tracing (OpenTelemetry and compatible backends) extends the idea across services: spans have parents, so a trace is a call graph over network boundaries. That is the only practical way to see calls that cross HTTP or queue boundaries.

Strengths and limits of dynamic graphs

Every edge in a dynamic call graph is real: it resolved the interface, followed the callback, survived the reflection. That makes dynamic graphs excellent for questions like "which Send actually runs in production?"

The limit is coverage. A dynamic graph only contains paths you exercised. Error handlers, rarely used features, admin endpoints and month-end batch jobs will be missing unless your workload hit them. Sampling profilers also miss short functions that never happen to be on the stack when a sample is taken.

Static versus dynamic, summarized

StaticDynamic
Needs to run the codeNoYes
CoverageAll code, including unexecuted pathsOnly exercised paths
Dynamic dispatchApproximatedExact for what ran
Spurious edgesCommonNone
Missing edgesPossible (reflection, dynamic calls)Common (unexercised code)
CostAnalysis time, sometimes highRuntime overhead, needs a realistic workload
Best forImpact analysis, navigation, refactoringPerformance, debugging, verifying real behavior

The practical answer is to use both. Static graphs tell you what could happen; dynamic graphs tell you what does.

Where call graphs get unreliable

Expect gaps or noise in these situations:

  • Dependency injection containers (Spring, NestJS, Angular, .NET DI). Wiring is configured by annotations or registration code, so the edge from a controller to the concrete service implementation runs through the container.
  • Callbacks and event emitters. emitter.on('order.placed', handler) separates the call site from the registration. Static tools often lose the edge entirely.
  • Reflection and metaprogramming. Java reflection, Python getattr, Ruby send and method_missing, decorators that wrap functions.
  • Framework entry points. Route handlers, test functions, and lifecycle hooks are called by the framework, so static tools often report them as having no callers.
  • String-based dispatch. Task queues and RPC systems that look up handlers by name.
  • Higher-order functions. A function passed through two layers of map and compose is hard to track without full data-flow analysis.

When one of these is involved, supplement the call graph with a text search for the function name, the event name, or the route path.

Practical uses

Impact analysis before a change. Before changing a function's signature or behavior, list its transitive callers and the entry points they come from. This is the function-level version of the file-level analysis in impact analysis before refactoring.

Tracing a request. Starting from a route handler, follow callees down to the database and external calls. Combined with a profile or trace from a real request, this is the fastest way to learn an unfamiliar flow.

Debugging bad values. A wrong value arriving in a function must come from one of its callers. Listing callers narrows down where to put the breakpoint.

Finding dead code candidates. Functions with no static callers, outside known framework entry points, are candidates for removal. Cross-check with coverage data from production or a long test run before deleting.

Scoping work for AI coding agents. An agent asked to change a function will edit better if it is given that function's callers and callees up front instead of guessing from a text search. This is one of the main reasons code-graph context helps agents, as discussed in how AI coding agents understand a codebase.

Getting call graphs without per-language setup

Each tool above is excellent within its language and awkward across them. Code-graph tools take a different approach: parse many languages with a common parser, resolve calls as well as static analysis allows, and expose the result as a queryable graph. OpenVisio works this way. For agents connected over MCP, trace_calls returns the callers and callees of a function, and in the Ask space a question like "what happens when a user checks out?" is answered with the relevant code path and a flowchart. The same caveat applies as to every static call graph: treat a missing edge as a reason to search, not as proof that nothing calls the function.

call graphstatic analysisprofilingcode navigation

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading