skip to content

Why does calling inspect.stack() in a per-document logging helper blow a rebuilder's latency budget?

level: seniorimportance: should knowfreq 34%

answer

  1. cost depends on how deep you are
  2. it does not stop at your caller
  3. a source line is fetched per frame
  4. the context argument controls that lookup
  5. sys._getframe(1) is a single pointer hop

basics

~20 s

inspect.stack() walks every frame to the top of the stack, builds a record for each, and by default reads a source line per frame through the line cache. Cost scales with stack depth and touches the filesystem, so it is thousands of times slower than sys._getframe(1).

solid answer

~50 s

`inspect.stack()` is not a caller accessor — it materializes the **whole** stack. For every frame from the caller outward it builds a record holding the frame, filename, line number and function name, and with the default context of 1 it also fetches that frame's source line, which reads and caches source files. So one call is O(stack depth) plus filesystem work, while `sys._getframe(1)` is a pointer hop: on a stack around 35 frames deep that is roughly 0.3 ms versus 0.00003 ms. On a rebuild it lands in the tail, because deep code paths pay for more frames and cache misses arrive unpredictably. Replace it with `sys._getframe(1)` and read only `f_code.co_name`, `f_lineno` and the module name, or pass `stacklevel` to the logging call and let the library resolve the site. Reserve full stack capture for the rare failure path — and never store the frames it hands back.

code

python · 14 lines
python
import inspect
import sys
import timeit


def at_depth(n, fn):
    if n:
        return at_depth(n - 1, fn)
    return timeit.timeit(fn, number=1000)


print("inspect.stack()  ", at_depth(30, lambda: inspect.stack()))
print("inspect.stack(0) ", at_depth(30, lambda: inspect.stack(0)))
print("sys._getframe(1) ", at_depth(30, lambda: sys._getframe(1)))

go deeper

for a junior

Remember the shape of the answer: inspect.stack() gives the whole stack, not just your caller, and it is far more expensive than sys._getframe(1). Reach for the narrow call when you only want the caller.

for a middle

Explain the cost model: a record per frame from the caller upward, plus a source-line fetch per frame by default, so cost scales with depth and touches the filesystem. Name the cheap replacements.

for a senior

Diagnose it in production terms: why it lands in the tail rather than the median, why deep paths pay most, and why captured stacks retain locals and turn a rollback into a memory spike. Give the concrete fix.

for a principal

Own the standard: where introspection cost is allowed to sit in a pipeline, what the logging contract is so teams do not hand-roll call-site helpers, and how diagnostic depth is traded against a latency budget.

`inspect.stack()` looks like a cheap accessor and is not. It walks from the caller's frame all the way to the top of the stack and builds a record for *every* frame on the way, regardless of how many you intended to look at. Each record carries the frame itself, the filename, the line number, the function name, and — with the default `context` of 1 — the **source line**, which is fetched through the standard library's line cache. The first time a given file is touched that cache reads and stores the whole file, and subsequent lookups still stat the file to check the cache is valid. So one call costs: object construction proportional to stack depth, plus filesystem work per distinct source file, plus the garbage that all of it becomes a moment later. Measured on a stack roughly 35 frames deep, on CPython 3.14: `inspect.stack()` runs about 0.3 ms per call, `inspect.stack(0)` — the same walk with source lookup disabled — about 0.14 ms, and `sys._getframe(1)` about 0.00003 ms. Four orders of magnitude separate "tell me my caller" from "give me the entire stack". ### Why the rebuilder's tail latency is where it shows A per-document logging helper on a search-index rebuild runs once per document, and a 92nd-percentile budget is decided by the slow calls, not the median. Three things push `inspect.stack()` into the tail specifically. Stack depth is not constant: a document that goes through the deep path — nested enrichment, a retry wrapper, a few decorators — pays for more frames than the shallow path, so cost correlates with exactly the documents that were already slowest. Source-line lookups miss the cache whenever a new module appears in the walk, turning a CPU cost into a filesystem cost at unpredictable moments. And the churn — thousands of short-lived records and tuples per second — raises collection pressure, which lands as pauses rather than as uniformly slower calls. There is a second, worse failure mode on the rollback path. Every record holds a live reference to a frame, and each frame holds its locals and, through `f_back`, the frames outward from it. If the partial-failure handler captures the stack and stores it — on an error object, in a retry queue, in a structured log buffer waiting to be flushed — it pins the locals of every function on that stack, which during a rebuild means the partially built batch each of those functions was holding. Memory climbs precisely while you are unwinding and trying to free things. Capturing stacks for diagnostics and then holding them is a well-worn way to turn a recoverable failure into an out-of-memory one. ### What to do instead **Use the narrow tool.** If you want the caller, take `sys._getframe(1)` (or `inspect.currentframe().f_back`) and read the two or three things you need — `f_code.co_name`, `f_lineno`, `f_globals["__name__"]` — into plain strings. It is a pointer hop plus attribute reads, it is not depth-dependent, and it retains nothing once the strings are extracted. **Let the logging library do it.** Standard logging already resolves the call site, does it only for records that pass the level check, and accepts a `stacklevel` argument so a wrapper can say how many of its own frames to skip. That removes both the frame walk and the off-by-one bug from your code. If you only need module attribution, a module-level logger named `__name__` costs nothing at call time at all. **Drop the source lookup when you do need a walk.** Passing `0` as the context argument to `inspect.stack()` skips the per-frame source read and roughly halves the cost. It is still O(depth). **Move the walk to the cold path.** Full stack capture is a reasonable thing to do once per failure — a rollback that fires rarely can afford 0.3 ms and the diagnostic is worth it. It is not a reasonable thing to do once per document. The general principle: pay introspection cost per *event you care about*, never per unit of throughput. **Never store frames.** Extract strings at capture time. If a frame must be held briefly, `clear()` it when done to drop its local references. The interview point behind all of this is the habit, not the number: before putting an introspection call in a hot path, ask what it costs and what it *retains*. Frame walking answers both badly.

  • How does the logging module let a wrapper report the right call site without walking frames yourself?
    Logging resolves the call site internally, and only for records that survive the level check, so suppressed records cost almost nothing. A wrapper passes `stacklevel` on the logging call to say how many of its own frames to skip, which removes both the walk and the off-by-one from your code. If you only need module attribution, a module-level logger named `__name__` costs nothing at call time.
  • What memory problem appears if the rollback handler captures the stack and stores it on the error it reports?
    Each record holds a live frame, each frame holds its locals, and `f_back` chains outward — so one stored capture pins every local on that call chain, including partially built batches the rebuild was holding. Memory climbs exactly while you are trying to unwind and release. Extract the strings you want at capture time and drop the frames.
  • When is a full inspect.stack() call actually a reasonable thing to do?
    On cold paths where the diagnostic is worth a fraction of a millisecond and the event is rare — a failure handler that fires once per rollback, a startup-time integrity check, a debugging aid behind a flag. The rule is to pay introspection cost per event you care about, never per unit of throughput; a per-document helper is throughput.

Asking for the caller with inspect.stack() is like photographing every floor of the building on your way out when you only needed the number on the door you just came through.

saying these in an interview costs you the question

  • Assumes inspect.stack() only inspects the immediate caller
  • Overlooks the per-frame source-line lookup and its file I/O
  • Thinks the cost is constant regardless of stack depth
  • Stores the returned records in logs or retry buffers
  • Counts only CPU time and ignores the locals kept alive

context