Tracking native allocations with Valgrind and ASan
This page answers one question: your process grows and Python’s own profilers show nothing, so how do you find which native allocation in GDAL, PROJ or your own extension is never released — using Valgrind, AddressSanitizer and a suppression file that makes CPython’s ordinary behaviour stop drowning the signal? It sits inside the Memory Management in Geospatial Extensions section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the invocation, the suppressions and the way to read the output.
Context & Root Cause
Native leaks in geospatial code are invisible to Python tooling because they are invisible to Python: the allocation was made by CPLMalloc, by GEOS, or by a driver, and the interpreter never knew about it. Resident set size shows that something grows; it cannot say what, and a leak of a few kilobytes per call takes hundreds of thousands of iterations to become visible that way at all.
Valgrind and AddressSanitizer both instrument the allocator itself, so they can name the exact call site of every block that was never freed. The obstacle is noise. CPython deliberately does not free a great deal at shutdown — interned strings, type objects, the small-object arena — and GDAL, PROJ and GEOS all keep caches alive for the life of the process. Run either tool naively on an interpreter and you get thousands of “still reachable” blocks, none of which are bugs. Making the output usable is mostly a matter of configuring the tool to ignore what is normal, which is what this page is about.
Solution / Fix
This targets Valgrind 3.19+, GCC 13 or Clang 16 with AddressSanitizer, and CPython 3.9–3.13.
1. Build CPython’s allocator out of the way
Python’s pooled allocator hides individual allocations from both tools. Setting one environment variable routes everything through malloc:
export PYTHONMALLOC=malloc # required — otherwise pools mask the call sites
export PYTHONDEVMODE=1 # extra checks, and disables some caching
2. Run Valgrind with the right filters
valgrind \
--leak-check=full \
--show-leak-kinds=definite,indirect \
--errors-for-leak-kinds=definite \
--num-callers=25 \
--suppressions=ci/valgrind-python.supp \
--log-file=valgrind.log \
python ci/leak_workload.py
--show-leak-kinds=definite,indirect is the setting that makes the report readable: it excludes “still reachable”, which is where every cache in the process lands and where essentially no bugs are.
3. Write a suppression file for the known-normal noise
# ci/valgrind-python.supp
{
cpython-interned-strings
Memcheck:Leak
match-leak-kinds: reachable
...
fun:PyUnicode_InternInPlace
}
{
gdal-driver-registry
Memcheck:Leak
match-leak-kinds: reachable
...
fun:GDALAllRegister
}
{
proj-context-cache
Memcheck:Leak
match-leak-kinds: reachable
...
fun:proj_context_create
}
4. Or build with AddressSanitizer for a faster loop
export CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1"
export LDFLAGS="-fsanitize=address"
pip install --no-binary :all: -e .
ASAN_OPTIONS=detect_leaks=1:fast_unwind_on_malloc=0 \
LSAN_OPTIONS=suppressions=ci/lsan.supp \
python ci/leak_workload.py
The workload script matters as much as the tool: it should do the suspect operation a few hundred times and then exit cleanly, so that anything still held at exit is either a cache or a leak.
Verification
# 1. No definite losses attributable to your own code
grep -A6 'definitely lost' valgrind.log | grep -c 'geo_core'
# expected: 0
# 2. The total definitely-lost figure is stable across workload sizes
for n in 50 500; do
ITERATIONS=$n valgrind --leak-check=summary --log-file=/tmp/v$n.log \
python ci/leak_workload.py >/dev/null 2>&1
grep 'definitely lost' /tmp/v$n.log
done
# expected: the same byte count for both — a leak would scale with n
# 3. Under ASan, a clean exit with no leak report
ASAN_OPTIONS=detect_leaks=1 python ci/leak_workload.py && echo "no leaks detected"
The second check is the decisive one and the one people skip. A fixed number of bytes lost regardless of iteration count is a one-time allocation — a cache, a registry, a singleton — and not a leak. Bytes that scale with the iteration count are a leak, and the multiplier tells you how much is lost per call.
Reading a Leak Report Without Drowning
Valgrind’s four leak categories mean different things, and knowing which to act on removes most of the volume from the report.
A practical reading order: filter to definitely lost, sort by total bytes, and start at the top. The largest single entry is usually one allocation site called many times, and fixing it removes most of the report. Entries measured in tens of bytes are rarely worth the effort unless they scale — a 40-byte leak per raster block adds up in a pipeline processing millions of blocks, and the iteration-scaling test is what distinguishes it from a one-time cost.
The stack trace is the other half of the value. With --num-callers=25 the trace reaches from the allocation site up through the binding into the Python call, which usually identifies not just what leaked but which of your API functions failed to release it.
Making It Affordable in CI
Neither tool is fast enough for every commit, and both are cheap enough for a schedule. The arrangement that works is a small, deliberately-designed workload run nightly.
The suppression file deserves the review discipline the diagram mentions. It is tempting, when a report is noisy, to add a broad entry matching anything in libgdal; that silences the noise and every future bug in the same region. Keep entries narrow — matched on a specific function — and add a comment saying why each one is legitimate, so the next person can tell a genuine cache from a suppressed defect.
Pitfalls & Alternatives
Forgetting PYTHONMALLOC=malloc. Without it, Python’s pooled allocator satisfies most small requests from arenas the tools see as single large blocks, so individual call sites disappear and the report becomes useless.
Treating “still reachable” as a leak. Every driver registry, coordinate-operation cache and interned string lands there. Suppressing the category wholesale is correct for this domain, and doing so is what makes the remaining output short enough to act on.
Running against a release build. Without -g there are no symbols, and the stack traces name addresses. Build the extension with debug information for the leak-hunting run, even if the shipped wheel is stripped.
Concluding from one workload size. A fixed number of lost bytes is a one-time allocation; only bytes that scale with the iteration count are a leak. Running two sizes takes twice as long and answers the question definitively.
Frequently Asked Questions
Can I run these tools against a released wheel?
Valgrind yes, AddressSanitizer no. Valgrind instruments at run time and needs no rebuild, so it works on any installed package — though without debug information the stack traces name addresses. AddressSanitizer requires the extension to be compiled with it, so it applies to your own builds only.
Why does the report change between runs?
Allocation order and cache state vary, so the set of still-reachable blocks moves even when nothing leaks. That is another reason to filter to definite losses, which are stable, and to compare totals across workload sizes rather than reading one run in isolation.
Do these tools work on macOS and Windows?
AddressSanitizer works on both with the appropriate compiler; Valgrind’s support on recent macOS is limited and on Windows absent. For those platforms the practical substitutes are the sanitizer and the resident-set-size approach, which is portable.
Is a leak in a cache still a leak?
Not usually. A cache that grows to a bound and stops is doing its job; one that grows without limit is a leak whatever it is called. The iteration-scaling test distinguishes them without any argument about terminology.
How long should the leak workload run?
Long enough for a per-call leak to be distinguishable from a fixed cost, which usually means a few hundred iterations rather than a few. Running two sizes and comparing totals is more informative than running one size for longer, because the comparison is what identifies scaling.
Can these tools find leaks in GDAL itself?
They can, and the result usually is not actionable by you. Before reporting one upstream, confirm it scales with iterations and that your own teardown is correct — most apparent library leaks turn out to be a handle the caller never closed.
Does running under a sanitizer change program behaviour?
It changes timing and memory layout, which occasionally hides a race or exposes one that ordinarily does not occur. For leak hunting that is unimportant; for concurrency work it is worth remembering that a workload passing under a sanitizer has not been proved correct at full speed, only at instrumented speed. The practical consequence is to use the sanitizer for leaks and correctness, and to confirm timing-sensitive behaviour with an uninstrumented run at full speed afterwards.
Related
- Memory management in geospatial extensions — the ownership rules whose violations these tools find.
- Fixing memory leaks in GDAL Python bindings — the resident-set-size approach these tools complement.
- Thread safety and concurrency in GDAL bindings — where ThreadSanitizer fits alongside these two.