Skip to main content

memblame: git blame for memory

PyPI Python versions CI License: MIT

Find the commit and the function that made your Python code use more memory.

memblame runs your own workload (a pytest test, a script or a function) at several git commits, measures memory with tracemalloc, and maps any growth to the function and the diff hunk that caused it.

Quick start

pip install memblame              # or: pipx install memblame
cd your-project                   # any git repository with Python code
memblame range HEAD~20..HEAD -w "pytest:tests/test_something.py"

That runs the test at several of the last 20 commits and reports where memory changed, and which function did it. memblame finds your project's virtualenv (.venv, or backend/.venv next to the tests you name); if yours lives elsewhere, add --python path/to/python.

Want to see it work before pointing it at your own code? This builds a small repository with two memory regressions planted in its history and finds both:

git clone https://github.com/rmnvg/memblame && cd memblame
python tests/fixture_repo.py /tmp/demo-repo planted
cd /tmp/demo-repo
memblame range HEAD~9..HEAD -w call:shop.app:run

The numbers differ a little per machine; the report looks like this.

$ memblame range main~9..main -w call:shop.app:run
workload  (median of runs; ▲ = significant change; measured 7 of 10 commits)
  commit         peak      Δpeak    retained       Δret  author / subject
  da9eedf     29.5 MB                11.4 KB             Asha Rao       initial pipeline
  ...
  326798b     57.7 MB  +28.2 MB▲     12.3 KB   +0.1 KB   Rahul Mehta    include raw payload in rows
  7427306     57.7 MB   +0.4 KB      57.7 MB  +57.7 MB▲  Chen Wu        cache summarize results

Findings:
  7427306  retained +57.7 MB  "cache summarize results"
      direct: shop/report.py:7 summarize()  +57.7 MB
        changed in shop/report.py @@ -6,1 +8,4 @@
        memory allocated at shop/parse.py:8  57.7 MB
  326798b  peak +28.2 MB  "include raw payload in rows"
      direct: shop/parse.py:4 load_rows()  +28.6 MB
        changed in shop/parse.py @@ -7,1 +7,2 @@
        hot line shop/parse.py:8  57.3 MB

There is also an editor extension (source in vscode-ext/) that shows a timeline, CodeLens above the blamed function, and a one-click "Memory vs HEAD" on every pytest test. It is on Open VSX: search for MemBlame in the Extensions view of Cursor, VSCodium or Windsurf. For VS Code, download the .vsix from the latest release and run code --install-extension memblame-*.vsix; it is not on the VS Code Marketplace yet.

report

Install

pip install memblame        # or: pipx install memblame

No dependencies: the core is standard-library only. Python 3.9+.

Usage

memblame diff                     # working tree (uncommitted changes) vs HEAD
memblame diff main feature        # two revisions
memblame range main~50..main      # timeline over a range (adaptive; --all for every commit)
memblame bisect --good v1.2 --bad HEAD --threshold +20MB
memblame run HEAD                 # one revision, with top allocating functions
memblame diff main HEAD --report md -o memblame.md
memblame range main~50..main --report html -o memblame.html

Pick what to run with -w (or workload in [tool.memblame] in pyproject.toml):

workload measures
pytest:tests/test_big.py::test_load one test (only the test itself: setup, call, teardown)
pytest:tests/test_big.py each test in the file, separately
script:bench/run.py --n 10 a script; the path may be absolute (outside the repo), which keeps the workload identical at every commit
call:mypkg.pipeline:main a function

Absolute script paths inside the repo follow the selected revision, just like relative paths. External scripts stay fixed. Quote paths or pytest node IDs containing spaces inside the workload, e.g. -w "script:'bench scripts/run.py'".

Options

option default meaning
-w, --workload from config what to run (table above)
-C, --repo . repository to analyse
--python active venv / conda env, else .venv/venv in the repo, else one next to the workload (backend/.venv for pytest:backend/tests/...), else the current Python interpreter with your project's dependencies (3.9+)
--pythonpath DIR src + . if src/ exists, else . where to import your project from; repeatable
--runs N 3 maximum runs per commit (stops early once two runs agree)
--nframe N 16 traceback depth for attribution; raise it if a verdict notes truncated stacks
--timeout S 900 seconds per run; a commit that takes longer is skipped
--no-cache ignore and don't write .memblame/cache/
--cache-env NAME invalidate cached measurements when this environment variable changes; repeatable
--cache-input PATH invalidate cached measurements when this file or directory changes; repeatable
--json machine-readable output ("schema": 1), used by the editor extension
--report md|html portable Markdown or self-contained interactive HTML report
-o, --output PATH stdout write terminal, JSON or report output to a file; parent directories are created
range --all measure every commit instead of subdividing adaptively
bisect --good REV / --bad REV --bad HEAD the range to search
bisect --threshold noise band 200MB (absolute), +20MB or +10% (relative to good)
bisect --unit NAME / --metric peak|retained the one that grew most what to track, e.g. a pytest node id
bisect --verify measure every candidate and return the earliest observed threshold crossing

Exit codes: 0 a completed check with no significant increase, 3 a significant memory increase was found (handy in CI), 1 error or incomplete measurement, 2 not a git repository. Failed or skipped workloads, invalid environments, and inconsistent repeated runs return 1 for run, diff, and range, even if a partial report has findings. Bisect can still return 3 after skipping broken intermediate commits; unmeasurable or non-passing endpoints return 1.

CI reports

Markdown is suitable for a CI job summary, while HTML contains the measurements, attribution, warnings, raw schema-1 result and an interactive range timeline in one dependency-free file:

- name: Check memory regression
  run: |
    memblame diff origin/main HEAD -w pytest:tests/test_pipeline.py \
      --report html --output memblame.html
- if: always()
  uses: actions/upload-artifact@v4
  with:
    name: memblame-report
    path: memblame.html

Exit code 3 still fails the analysis step after writing the report, so the artifact is available for diagnosis without turning a regression green. For a GitHub job summary, write the Markdown to stdout and append it, so an earlier step's summary survives (-o replaces the file it writes to):

- run: memblame diff origin/main HEAD --report md >> "$GITHUB_STEP_SUMMARY"

Configuration

These keys can live in pyproject.toml (or in a memblame.toml at the repo root, which wins). Command-line flags override them; a relative python path is relative to the repo root.

[tool.memblame]
workload = "pytest:tests/test_pipeline.py"
runs = 3
nframe = 16
pythonpath = ["src"]
python = ".venv/bin/python"
timeout = 600
threshold = "+10%"   # default for bisect
cache_env = ["DATASET_VERSION"]
cache_inputs = ["../bench-data/input.json"]

Committed files, the interpreter, installed packages and memblame's measurement engine are included in cache keys automatically. If a workload depends on inherited environment variables or files outside the selected commit, declare them with cache_env / cache_inputs (or the matching command-line flags); undeclared external inputs cannot invalidate an existing cache.

Reading config needs Python 3.11+ (on 3.9/3.10 install memblame[toml]); otherwise memblame says so and uses the command line only.

How it works

  1. Each committed revision is checked out into a temporary git worktree, so your current checkout is never switched. WORKTREE workloads run in the current checkout and can still create or modify files; memblame disables Python bytecode writes while running workloads. Every workload runs in a fresh subprocess of your project's interpreter.
  2. Fast runs measure peak and retained (still allocated after the run) traced memory. Returned values and script globals are released before retained memory is sampled, so the metric represents caches, module state and other objects that outlive workload output. They repeat until two runs agree, and the median is reported. tracemalloc counts are nearly deterministic: on real projects run-to-run noise was a few KB.
  3. A change counts only if it exceeds the noise band: max(2 × spread, 2 % of peak, 64 KiB).
  4. Only for commits around a significant change, an attribution run records tracebacks and snapshots memory as it approaches the known peak. It uses a cheap polling thread first, and an exact profile hook only if the peak was too short-lived to catch. Each allocation is credited to project functions: own bytes (allocated in the function) and incl. callees bytes.
  5. The function deltas are matched against git diff -U0 hunks, on the new side for growth and the old side for memory that went away.
    • direct: the function whose code changed accounts for the growth.
    • indirect: memory grew in code that did not change (the cause is a caller, data or config); the changed functions are listed.
  6. range is adaptive: it measures both ends and only subdivides segments whose ends differ, so the work is roughly log₂(N) per change. Results are cached per commit in .memblame/cache/, keyed by the commit plus the interpreter, installed packages, settings and memblame version.

Honest limits

  • tracemalloc sees memory allocated through Python's allocators. numpy reports its buffers to tracemalloc, so arrays are counted. Native libraries that call malloc directly are not.
  • Tracing is slow: the fast runs are several times slower than normal, and the attribution run can be 10–40× slower on allocation-heavy or deeply recursive code. Use small, deterministic workloads.
  • The environment is fixed: all commits run with the dependencies currently installed. If your dependencies changed across the range, results may not be comparable.
  • If your project is installed so that imports resolve outside the checked-out commit (for example pip install -e . with a src/ layout and no --pythonpath), memblame detects it and reports invalid environment instead of wrong numbers.
  • pytest workloads always run in-process, in file order and without coverage: memblame adds -n 0 (pytest-xdist), -p no:randomly and --no-cov (pytest-cov) when those plugins are installed.
  • A commit where the workload fails, skips, or cannot be measured (crash, timeout) makes the check incomplete (exit code 1), never a successful memory check. A range still lists the findings between commits that were measured and passed, under an "incomplete" banner: more may hide in the gaps, so it is not an all-clear. Two failing runs are never compared. bisect can skip broken intermediate commits the way git bisect skip does, but its endpoints must pass.
  • Each run gets its own process group and a closed stdin. On a timeout, or when you cancel from the editor or with Ctrl-C, the whole process tree the workload started is terminated, and background processes a finished run left behind are removed (POSIX). A process that deliberately detaches into a new session (setsid) is not tracked, and Windows cannot identify the children of an already-finished process.
  • Fast bisect assumes the metric crosses its threshold once; use bisect --verify when the earliest crossing must be established on a potentially nonmonotonic history.
  • Adaptive range can miss a change that is exactly undone later within one unsplit segment. Use --all to measure every commit.
  • Attribution names where memory was allocated. For "kept alive too long" problems it still points at the changed function through the incl. callees numbers, and it shows where the memory was allocated.

Tested on real projects

Adaptive range runs with a fixed benchmark script, each finding checked against the diff:

project, range measured finding cause (verified in the diff)
tomlkit 0.11.0..HEAD (233 commits) 21 231370c peak −65 % (60.6 → 21.1 MB), direct in Source.__init__ source is indexed instead of materialized
ae1b679 peak +3.9 %, direct in Container.__init__ a new dict and set on every Container
a766d3a retained +1.0 MB at module level in items.py new import dataclasses (pulls in inspect)
pyparsing 3.1.0..HEAD (510 commits) 11 cd081ef retained +1.56 MB (+22 %), hot line pyparsing/testing.py:6 import unittest added; since 3.3.0 every import pyparsing loads unittest
markdown-it-py v2.0.0..HEAD (136 commits) 19 f52249e peak −15 %, direct in StateBase.src setter removed a per-character tuple(ord(c) ...)
6649229, 145a484 peak −4 % / −3 % Token became a dataclass, then got __slots__

No chore, docs or CI commit was flagged. On the markdown-it-py range, commit-to-commit noise was under 0.05 % of the peak. See PROJECT.md for the full log.

Development

uv venv && uv pip install -e . pytest ruff pytest-xdist pytest-cov pytest-randomly
pytest            # unit + end-to-end tests against generated git repos (~90 s)
ruff check src tests
python tests/fixture_repo.py /tmp/demo   # a repo with two planted regressions

License

MIT

Release files for memblame 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for memblame 0.1.2
File Size Uploaded
memblame-0.1.2.tar.gz 93.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for memblame 0.1.2
File Interpreter ABI Platform
memblame-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 155.9 kB

Release files / memblame-0.1.2.tar.gz

Download URL memblame-0.1.2.tar.gz
Size 93.1 kB
Tags Source
SHA-256 checksum
How to use checksums
313e30665addcb643260fc7de57dcc6dae3e04bf0e094a5c27629121b0751669
BLAKE2b-256 checksum
How to use checksums
5135f2cce075830a7f645b380bf15934c91c227bb21c30ef8cea55f7c921983b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release files / memblame-0.1.2-py3-none-any.whl

Download URL memblame-0.1.2-py3-none-any.whl
Size 62.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
df1c4db92584c6dc9eb901aba1f297f44e1b9964e503c12ab8cf0e2afe8b2a19
BLAKE2b-256 checksum
How to use checksums
3e76aa9589151d1e669ee8de8d6d14230cf6775b64b78b2b7587e2d3fb6ee7c4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.2 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page