The Memory Allocator Domains

CPython does not have one heap; it has three, exposed through the C-API as the memory allocator domains: PYMEM_DOMAIN_RAW, PYMEM_DOMAIN_MEM, and PYMEM_DOMAIN_OBJ. Each domain is a triple of malloc/realloc/free (plus calloc) functions with a different contract about who may call it and which underlying allocator it routes through (per the C-API memory docs). The domains exist so that the runtime can (a) batch small Python-object allocations through the specialized pymalloc (or, in the free-threaded build, mimalloc) allocator while still letting truly low-level code reach the bare system malloc, and (b) swap the entire allocator out from under the interpreter through a single PyMemAllocatorEx vtable — which is how memory debuggers, sanitizers, and tools like tracemalloc hook in. Picking the wrong domain (freeing with PyObject_Free what you allocated with PyMem_Malloc) is a fatal, abort-the-process error under debug hooks.

This note covers the domain abstraction and the swappable-allocator API. The internals of the small-block allocator each domain routes into live in The pymalloc Allocator and Arenas Pools and Blocks; the big picture of CPython’s memory system is in CPython Memory Management Overview.

Mental Model: Three Doors Into the Heap

Think of the three domains as three doors into memory, ordered by how much machinery sits behind each. PYMEM_DOMAIN_RAW is the back door straight to the operating system’s C library malloc — no Python bookkeeping, no thread-state requirement, callable from anywhere. PYMEM_DOMAIN_MEM and PYMEM_DOMAIN_OBJ are front doors into the Python private heap: by default they funnel through pymalloc (GIL build) or mimalloc (free-threaded build), which keeps pools of small blocks to avoid hammering the system allocator. OBJ is reserved for actual PyObjects; MEM is for everything else Python-internal (buffers, scratch arrays) that still wants the fast small-block path.

flowchart TD
    subgraph callers[Caller code]
        A["PyMem_RawMalloc()"]
        B["PyMem_Malloc()"]
        C["PyObject_Malloc()<br/>(and tp_alloc / PyObject_GC_New)"]
    end
    A --> RAW["PYMEM_DOMAIN_RAW<br/>id 'r' — no thread state needed"]
    B --> MEM["PYMEM_DOMAIN_MEM<br/>id 'm' — thread state required"]
    C --> OBJ["PYMEM_DOMAIN_OBJ<br/>id 'o' — thread state required"]
    RAW --> SYS["system malloc / free<br/>(always)"]
    MEM --> PYM{"build?"}
    OBJ --> PYM
    PYM -->|GIL build| PYMA["pymalloc<br/>(arenas / pools / blocks)"]
    PYM -->|free-threaded build| MIMA["mimalloc"]
    PYMA --> SYS2["system malloc for arenas"]
    MIMA --> SYS2

Figure: the three domains and where each routes by default. The insight to take away: RAW always bypasses pymalloc/mimalloc and goes straight to the system allocator (so it is safe to call without an attached thread state), while MEM and OBJ share the small-block allocator and both require an attached thread state. The single-letter API identifier (‘r’/‘m’/‘o’) is stamped into each block’s header by the debug hooks so a domain mismatch can be caught.

The Three Domains in Detail

Every domain is the same shape — four function pointers (malloc, calloc, realloc, free) — but the contract differs. CPython’s pymem.h declares one enum, PyMemAllocatorDomain, with the three members PYMEM_DOMAIN_RAW, PYMEM_DOMAIN_MEM, PYMEM_DOMAIN_OBJ (verified in Include/cpython/pymem.h).

PYMEM_DOMAIN_RAW — the raw, GIL-free door

The raw domain is reached through PyMem_RawMalloc(), PyMem_RawCalloc(), PyMem_RawRealloc(), and PyMem_RawFree(). Its defining property: the memory request goes directly to the system allocator, and there need not be an attached thread state when it is called (per the docs). That is precisely why it exists. Most of the C-API assumes you hold an attached thread state (historically: “hold the GIL”), but some code runs outside that contract — allocations during interpreter pre-initialization (before any thread state exists), allocations inside a callback that has explicitly released the GIL, or allocations that must be safe to free from a thread the interpreter knows nothing about. For all of these you must use PYMEM_DOMAIN_RAW. Its default implementation is the system malloc/calloc/realloc/free in every build — confirmed in Objects/obmalloc.c, where MALLOC_ALLOC is {NULL, _PyMem_RawMalloc, _PyMem_RawCalloc, _PyMem_RawRealloc, _PyMem_RawFree} and PYRAW_ALLOC is set to MALLOC_ALLOC regardless of whether pymalloc or mimalloc is the object allocator.

PYMEM_DOMAIN_MEM — Python buffers via the fast allocator

The “mem” domain is PyMem_Malloc(), PyMem_Calloc(), PyMem_Realloc(), PyMem_Free(). The docs describe it as “allocating memory for Python buffers and general-purpose memory buffers where the allocation must be performed with an attached thread state.” In other words: use it for non-object scratch memory that is internal to the interpreter — a temporary char buffer for an I/O operation, a working array inside a C extension — where you do hold an attached thread state and want the small-block speed-up. There must be an attached thread state when these functions are called; the docs warn this explicitly. In a default GIL-enabled release build, PYMEM_ALLOC resolves to PYMALLOC_ALLOC (pymalloc); in a free-threaded build it resolves to MIMALLOC_ALLOC (mimalloc), per the obmalloc.c macro cascade.

PYMEM_DOMAIN_OBJ — Python objects only

The “obj” domain is PyObject_Malloc(), PyObject_Calloc(), PyObject_Realloc(), PyObject_Free(). This is where actual PyObjects come from. You rarely call it by hand — tp_alloc, PyObject_New, and PyObject_GC_New call it for you — and the docs are emphatic that to free an object you should call the type’s tp_free slot, never PyObject_Free directly. Like MEM, it requires an attached thread state and routes through pymalloc (GIL build) or mimalloc (free-threaded build). The free-threaded build adds a hard rule that did not exist before: “only Python objects are allocated using the ‘object’ domain and that all Python objects are allocated using that domain” (per the docs). Under the GIL this was merely best practice; without the GIL it is enforced because the cyclic garbage collector itself depends on it. The CPython internal GC design doc states the mechanism directly: “The default build implementation stores all tracked objects in a doubly linked list using PyGC_Head. The free-threaded build implementation instead relies on the embedded mimalloc memory allocator to scan the heap for tracked objects” (InternalDocs/garbage_collector.md). In the GIL build, every GC-tracked object is threaded onto a PyGC_Head linked list, so the collector enumerates candidates by walking that list regardless of which allocator domain produced the memory. The free-threaded build drops PyGC_Head entirely (it repurposes ob_tid for the unreachable list and stores flags in ob_gc_bits) and instead walks mimalloc’s object heap to enumerate tracked objects. That heap walk can only find — and only correctly interpret — blocks that mimalloc knows are Python objects, which is exactly the set allocated through the object domain. Put non-objects in the object heap, or objects outside it, and the collector either mis-reads a non-object as a PyObject or fails to ever visit a real object, so the split becomes a hard correctness requirement rather than a convention.

Resolved (2026-06-01)

Rationale pinned to InternalDocs/garbage_collector.md @ v3.14.5: the free-threaded GC has no PyGC_Head object list and instead scans the mimalloc heap to enumerate tracked objects, so the object/non-object domain split is what lets that heap walk find exactly the Python objects.

The Swappable Allocator: PyMemAllocatorEx

What makes the domains powerful is that each one is replaceable at runtime through a vtable struct, PyMemAllocatorEx (verified in pymem.h):

typedef struct {
    void *ctx;                                              /* opaque, passed as 1st arg */
    void* (*malloc)  (void *ctx, size_t size);              /* allocate a block */
    void* (*calloc)  (void *ctx, size_t nelem, size_t elsize); /* zero-initialised block */
    void* (*realloc) (void *ctx, void *ptr, size_t new_size);  /* resize a block */
    void  (*free)    (void *ctx, void *ptr);                /* free a block */
} PyMemAllocatorEx;

Walking the fields: ctx is an arbitrary pointer the runtime hands back to your callbacks as their first argument, so one set of C functions can serve several allocators by distinguishing on ctx (CPython itself uses this — the pymalloc state pointer is passed as ctx). The remaining four are the familiar allocator quartet, each taking that ctx up front. The Ex suffix and the calloc member arrived in Python 3.5; the older PyMemAllocator struct lacked calloc (per the docs).

Two functions read and write these vtables:

void PyMem_GetAllocator(PyMemAllocatorDomain domain, PyMemAllocatorEx *allocator);
void PyMem_SetAllocator(PyMemAllocatorDomain domain, PyMemAllocatorEx *allocator);

PyMem_GetAllocator copies the current vtable for domain into your struct; PyMem_SetAllocator installs yours. The contract on SetAllocator has sharp edges (all per the docs):

  • The new allocator must return a distinct non-NULL pointer when asked for zero bytes — Python relies on malloc(0) not returning NULL to distinguish “empty allocation” from “out of memory.”
  • For PYMEM_DOMAIN_RAW the allocator must be thread-safe, because it can be called with no attached thread state. For the other domains it must also be thread-safe, since it may be called from different interpreters that do not share a GIL.
  • Timing matters. You may call it between Py_PreInitialize() and Py_InitializeFromConfig() to install a fully custom allocator. But if you call it after initialization has completed, “the allocator must wrap the existing allocator. Substituting the current allocator for some other arbitrary one is not supported.” The reason is concrete: by the time the interpreter is up, it already holds live pointers handed out by the original allocator; a replacement that cannot free those pointers will crash on the first deallocation.

This wrapping pattern is exactly how the debug hooks (next section), tracemalloc, and external tools like the pymalloc-aware Valgrind suppressions and AddressSanitizer integrations work: they fetch the current vtable with GetAllocator, build a new vtable whose ctx points at the old one, and install it with SetAllocator, so every call passes through their hook and then down to the real allocator.

Debug Hooks: Catching Corruption and Domain Mismatches

CPython ships a set of debug hooks that wrap whatever allocator is installed and turn silent heap corruption into a loud, immediate abort. They are installed by PyMem_SetupDebugHooks(void) — called automatically when Python is built in debug mode, and switchable on in a release build via the PYTHONMALLOC=debug environment variable (per the docs).

The hooks work by padding every allocation with a header and a footer and filling the regions with sentinel “forbidden” / “clean” / “dead” bytes. With S = sizeof(size_t) and N = bytes requested, the layout the docs specify is:

p[-2*S : -S]   the original requested size N (size_t, big-endian)
p[-S]          one ASCII byte: the API identifier
                 'r' = PYMEM_DOMAIN_RAW
                 'm' = PYMEM_DOMAIN_MEM
                 'o' = PYMEM_DOMAIN_OBJ
p[-S+1 : 0]    PYMEM_FORBIDDENBYTE fence (under-write guard)
p[0 : N]       the memory you actually get back ("no man's land"):
                 filled with PYMEM_CLEANBYTE on alloc,
                 overwritten with PYMEM_DEADBYTE on free
p[N : N+S]     PYMEM_FORBIDDENBYTE fence (over-write guard)
p[N+S : N+2*S] serial number (only if PYMEM_DEBUG_SERIALNO), big-endian size_t

The sentinel byte values — verified in Objects/obmalloc.c, where _PyMem_DebugCheckAddress aborts via _Py_FatalErrorFunc if a pad byte does not equal 0xFD — are:

  • PYMEM_CLEANBYTE = 0xCD — stamped over freshly allocated, uninitialised memory (and over the extra bytes when a realloc grows a block), so reading uninitialised data shows up as a recognisable pattern.
  • PYMEM_DEADBYTE = 0xDD — stamped over memory the instant it is freed, so a use-after-free reads 0xDD and a double-free is detectable.
  • PYMEM_FORBIDDENBYTE = 0xFD — the fence bytes on either side of “no man’s land”; a buffer under-run or over-run corrupts the fence.

Resolved (2026-06-01)

The byte values are verified verbatim at the v3.14.5 tag in Include/internal/pycore_pymem.h: #define PYMEM_CLEANBYTE 0xCD, #define PYMEM_DEADBYTE 0xDD, #define PYMEM_FORBIDDENBYTE 0xFD. (The historical pre-3.8 values were 0xCB/0xDB/0xFB; the 3.8 change aligned them with the Windows CRT debug heap.) obmalloc.c at v3.14.5 references these same constants in its fill/check routines (fill_mem_debug(..., PYMEM_CLEANBYTE, ...), memset(tail, PYMEM_FORBIDDENBYTE, SST), PYMEM_DEADBYTE on free).

On every free and realloc, the hooks first verify the forbidden-byte fences at both ends are intact; if they have been altered, diagnostic output is written to stderr and the process is aborted via Py_FatalError(). They also check three other things: that PyObject_Free is not called on a block allocated by PyMem_Malloc (the single-byte API identifier p[-S] makes this a one-comparison check — this is the domain-mismatch detector), that there is an attached thread state when MEM- or OBJ-domain functions run, and — if tracemalloc is tracing — they print the Python traceback of where the block was allocated, turning a C-level corruption into something you can locate in Python source. The optional serial number, bumped by bumpserialno() on each malloc/realloc when PYMEM_DEBUG_SERIALNO is compiled in, lets you set a debugger breakpoint that fires at the exact allocation that later went bad.

Configuration: PYTHONMALLOC and the default table

The PYTHONMALLOC environment variable selects the allocator family without recompiling. The accepted values and their effect (per the docs):

PYTHONMALLOCRAWMEMOBJ
mallocsystem mallocsystem mallocsystem malloc
pymallocsystem mallocpymallocpymalloc
mimallocsystem mallocmimallocmimalloc
malloc_debugmalloc + debug hooksmalloc + debugmalloc + debug
pymalloc_debugmalloc + debugpymalloc + debugpymalloc + debug
mimalloc_debugmalloc + debugmimalloc + debugmimalloc + debug
debugadds debug hooks on top of whatever the default already is

The crucial reading of this table: RAW is malloc in every single row. It never becomes pymalloc or mimalloc — that is the whole point of the raw door. The _debug variants are the corresponding allocator with PyMem_SetupDebugHooks wrapped around it, and the bare debug value adds the hooks without changing which allocator sits underneath. The default when PYTHONMALLOC is unset is pymalloc for a normal release build and mimalloc for a free-threaded build; pymalloc is only available when CPython was compiled --with-pymalloc (the default), and selecting pymalloc on a build without it is rejected.

A typical debugging session looks like:

# Catch buffer overruns / use-after-free / domain mismatches, in a release build:
PYTHONMALLOC=debug python my_extension_test.py
 
# Combine with tracemalloc so the abort message shows the Python allocation site:
PYTHONMALLOC=debug PYTHONTRACEMALLOC=1 python my_extension_test.py
 
# Force the bare system allocator so Valgrind / ASan see real malloc calls
# instead of pymalloc's big arena allocations:
PYTHONMALLOC=malloc valgrind --leak-check=full python my_test.py

The last line is the standard recipe: pymalloc requests memory from the system in large arenas and then sub-allocates internally, so a leak detector watching malloc sees a handful of huge allocations and cannot attribute leaks to individual Python objects. PYTHONMALLOC=malloc (or building --without-pymalloc) makes every Python allocation a real malloc, restoring per-object visibility.

Failure Modes and Common Misunderstandings

  • Cross-domain free. Allocating with PyMem_Malloc (MEM, 'm') and freeing with PyObject_Free (OBJ, 'o'), or freeing a PyMem_Malloc block with the system free(). Under debug hooks this is a fatal abort with a precise message; without debug hooks it may appear to work in a GIL build and corrupt the heap subtly. The rule is mechanical: free with the same family you allocated with (PyMem_FreePyMem_Malloc, PyObject_Free/tp_freePyObject_Malloc, PyMem_RawFreePyMem_RawMalloc).
  • Calling MEM/OBJ without an attached thread state. Allocating in a callback after releasing the GIL, or before interpreter init, using PyMem_Malloc instead of PyMem_RawMalloc. Debug hooks catch the missing thread state; otherwise you risk corrupting pymalloc’s per-interpreter pools. The fix is always to use the RAW domain for thread-state-free contexts.
  • Replacing instead of wrapping post-init. Installing a brand-new allocator with PyMem_SetAllocator after Py_InitializeFromConfig returned: the interpreter already owns pointers from the old allocator, so the first free through the new one crashes. Always wrap after init.
  • Assuming pymalloc everywhere. On a free-threaded (Free-Threaded CPython) build, MEM and OBJ are mimalloc, not pymalloc; tooling that special-cases pymalloc’s arena/pool layout will not recognise the heap.

See Also