8/23/2026

The X-macro, from #define to runtime reflection

C · Preprocessor

C forgets the name of every field the moment it finishes compiling. The X-macro is how a C programmer gets those names back — by writing the list once and replaying it as an enum, a string table, a descriptor table, and a set of registration calls that run before main.

Almost everyone meets #define as a way to name the number 100 and then never thinks about it again. That is a shame, because the preprocessor is the only part of C that can write C for you, and there is one pattern in particular that turns it from a curiosity into something load-bearing.

The problem it solves is dull enough that it hides in plain sight: one piece of knowledge, written down in two places. An enum and the array of names that goes with it. A struct and the code that serialises it. A config table and the parser that reads it. Each pair is fine on the day it is written and wrong three months later, when someone edits one half.

This is written as a path. Part 1 is the mechanics — what a macro actually does, and how a list grows a hole in it. Part 2 builds the useful thing: a runtime table describing a struct, with offsets the compiler computes. Part 3 makes that table reachable, first from elsewhere in the program and then from Python. Part 4 is the part people learn the hard way.

Everything here was compiled and run before publishing — gcc 11.4.0 on x86-64, CPython 3.10 — and every output block is copied from a real run, including the error message.

Contents

  1. The preprocessor — what is really happening before the compiler starts
  2. Describing a struct — the payoff: a table the compiler fills in
  3. Making it reachable — from a table nobody can find to one everybody can
  4. Practice — traps, tooling, and knowing when to stop
  5. Appendix — the three programs in full, and how to build them

Part 1 · The preprocessor

A macro is text, not a value

Most people meet #define as a way to name a constant, and stop there.

the half everyone knows
#define MAX_USERS 100

The other half is that a macro can take parameters, and that what it does is substitute text before the compiler ever sees the file.

the other half
#define SQUARE(x) ((x) * (x))

int n = SQUARE(3 + 1);      /* becomes ((3 + 1) * (3 + 1)), so 16 */

Nothing is evaluated. No types are checked. There is no function call, no scope, no x anywhere at runtime. The preprocessor pastes characters and moves on, and the compiler then reads the result as if you had typed it yourself.

Hold on to that sentence, because every clever thing below is just careful pasting. The parentheses around (x) in that snippet are the classic example of why: without them, SQUARE(3 + 1) pastes into 3 + 1 * 3 + 1, which is 7.

Mental model

A macro is not a function that runs early. It is a find-and-replace rule that runs on your source text. When an expansion surprises you, the answer is always visible in the pasted text — see debugging expansions.

Two lists that must agree

Say you have three colours. You need an enum for the code, and names for logging.

the obvious way
enum color { COLOR_RED, COLOR_GREEN, COLOR_BLUE, COLOR_COUNT };

static const char *color_names[] = { "RED", "GREEN", "BLUE" };

This is fine. It stays fine right up until someone adds COLOR_YELLOW to the enum and does not touch the array. Now color_names[COLOR_YELLOW] reads past the end of the array, and nothing warns you — as far as the compiler is concerned these are two unrelated declarations that happen to sit next to each other. Keeping them in sync is a rule that lives only in someone's memory.

The same shape shows up everywhere once you start looking for it: an enum and the switch over it, a struct and its serialiser, a set of command-line flags and the help text. Two lists, one fact, and a silent failure when they disagree.

The X-macro: give the list a parameter

The idea is to write the list exactly once, but leave a hole where the action goes.

the list
#define COLOR_LIST(X) \
    X(RED)            \
    X(GREEN)          \
    X(BLUE)

COLOR_LIST is not a value and cannot be used on its own. It is a list that does not yet know what to do with its items — X is the hole. By convention that parameter is named X, which is the whole reason the pattern is called an X-macro; any name works.

Now define what a single item should become, and hand it in.

two actions, one list
#define AS_ENUM(name)   COLOR_##name,
#define AS_STRING(name) #name,

enum color { COLOR_LIST(AS_ENUM) COLOR_COUNT };

static const char *color_names[] = { COLOR_LIST(AS_STRING) };

COLOR_LIST(AS_ENUM) pastes into COLOR_RED, COLOR_GREEN, COLOR_BLUE, and COLOR_LIST(AS_STRING) pastes into "RED", "GREEN", "BLUE",. Two completely different pieces of code out of one list, and the trailing comma is harmless in both because COLOR_COUNT follows the first and C allows a trailing comma in an initialiser.

Add X(YELLOW) to the list and both the enum and the name array grow. The synchronisation bug is not fixed by discipline here; it has been made impossible to write.

./demo
colors: 3
  0 = RED
  1 = GREEN
  2 = BLUE
Note

Notice where the comma lives: inside AS_ENUM and AS_STRING, not in COLOR_LIST. The list carries no punctuation at all, because different actions want different separators — an enum wants commas, a sequence of statements wants none.

The two operators: # and ##

Those action macros used two operators that exist nowhere else in C.

OperatorNameGiven name = RED
#namestringifyproduces the string literal "RED"
A##Btoken pasteCOLOR_##name produces the single token COLOR_RED

The property that makes # worth the trouble is that the string comes from the actual token you wrote. You cannot end up with a name string that disagrees with the identifier beside it, because they are literally the same characters. Every hand-written "id" next to a field called id is a copy waiting to go stale; #M is not a copy.

## is what lets one list generate distinct identifiers — COLOR_RED, sensor_reading_fields, FIELD_IDX_timestamp. It has one sharp edge, which we will hit in the section on unique names: ## suppresses macro expansion of its own operands.

Part 2 · Describing a struct

What C throws away

Colours are a warm-up. Here is the use that actually earns the pattern its place.

C discards field names at compile time. After the compiler is done, a struct is a size and a set of byte offsets; the word timestamp exists in the debug info, if you asked for it, and nowhere else. So the moment something outside the compiler needs to know that "byte 16 of this struct is a double called timestamp" — a serialiser, a logger, a test harness, another language — somebody has to write that down.

Writing it down by hand is the two-lists problem again, with worse consequences, because now the second list contains numbers.

demo.c — the struct
struct sensor_reading {
    int    id;
    int    sample_count;
    float  temperature;
    double timestamp;
    char  *label;
};
demo.c — what we want to produce
typedef enum { FIELD_INT, FIELD_FLOAT, FIELD_DOUBLE, FIELD_PTR } field_type;

typedef struct {
    const char *name;
    field_type  type;
    unsigned    offset;
    unsigned    size;
} field_desc;

Building the field table

Same shape as before: write the list once, with a hole. Each item now carries three pieces of information instead of one — the struct it belongs to, the member, and a type tag.

demo.c — the list
#define SENSOR_FIELDS(X)                               \
    X(struct sensor_reading, id, FIELD_INT)            \
    X(struct sensor_reading, sample_count, FIELD_INT)  \
    X(struct sensor_reading, temperature, FIELD_FLOAT) \
    X(struct sensor_reading, timestamp, FIELD_DOUBLE)  \
    X(struct sensor_reading, label, FIELD_PTR)
demo.c — one action, one table
#define AS_FIELD_DESC(S, M, T) \
    { #M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M) },

static const field_desc sensor_layout[] = { SENSOR_FIELDS(AS_FIELD_DESC) };

Two things in that action macro are worth stopping on.

offsetof(S, M) is a standard macro from <stddef.h>, and the crucial word is standard: the compiler computes the byte offset. You never type a number, so the table cannot drift from the real layout when a field is inserted, when the order changes, or when padding rules differ between targets.

sizeof(((S *)0)->M) is the idiom for "the size of member M of type S" when you have no instance to point at. It looks like a null dereference and is not one: sizeof does not evaluate its operand, it only inspects the operand's type, so nothing is ever read from address zero. C11 also offers sizeof(((S){0}).M) if the cast makes you uncomfortable.

Here is what the preprocessor genuinely emits for the first entry, lifted out of gcc -E -P demo.c:

gcc -E -P demo.c — after pasting
{"id", (FIELD_INT), (unsigned)__builtin_offsetof (struct sensor_reading, id),
 (unsigned)sizeof(((struct sensor_reading *)0)->id)},

…and what the compiler folds that into, before anything runs:

constant-folded
{"id", 0, 0, 4}

There is no runtime cost to any of this. The table is a static array of constants in .rodata, exactly as if it had been typed out by hand — the difference is only in who typed it.

The padding you would have got wrong

The full table, printed at runtime:

./demo
sizeof(struct sensor_reading) = 32
field count = 5
name           type  offset  size
id             0     0       4
sample_count   0     4       4
temperature    1     8       4
timestamp      2     16      8
label          3     24      8

Look at temperature: it starts at byte 8 and is 4 bytes long, so it ends at 12. But timestamp starts at 16, not 12. The compiler inserted four bytes of padding so the double lands on an 8-byte boundary. The five field sizes add up to 28; the struct is 32.

A hand-written table would have said 12, and every read through it would have been off by four bytes from that field onward — the kind of bug that produces plausible-looking garbage rather than a crash. offsetof did not get it wrong, and cannot.

ABI

These numbers are for the x86-64 System V ABI. On a different target the offsets and the total size may differ. That is an argument for offsetof rather than a caveat about it: the same source produces the right table on each target.

The same list, a different output

The list is now a reusable asset, and this is where the pattern starts paying compound interest. A different action over the same list gives you named indices into the table:

demo.c
#define AS_INDEX(S, M, T) FIELD_IDX_##M,

enum { SENSOR_FIELDS(AS_INDEX) SENSOR_FIELD_COUNT };
./demo
FIELD_IDX_timestamp = 3

Note that AS_INDEX ignores two of its three parameters. That is normal and fine — the list is a fixed shape, and each action takes what it needs. Adding one field to the struct and one line to the list now updates the descriptor table, the index enum, and the count, in one edit.

Other actions people write over the same list, without changing it: a switch that converts a field name string back to an index, a printf-style dumper with the right format specifier per type tag, a JSON writer, a comparison function, a fuzzer that fills each field with the right kind of noise.

Renaming a field breaks the build — on purpose

This is the property that makes the syntax worth tolerating, and it is easy to miss because nothing visibly happens on a good day.

Rename id to reading_id in the struct and forget the list. The build stops:

gcc -std=c11 -Wall -Wextra -o demo demo.c
error: ‘struct sensor_reading’ has no member named ‘id’
   50 |     {#M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M)},
      |                         ^~~~~~~~
   43 |     X(struct sensor_reading, id, FIELD_INT)
      |     ^ in expansion of macro ‘AS_FIELD_DESC’

Compare that with the hand-written table, where the string "id" would go on cheerfully naming a field that no longer exists, and the offset next to it would now describe a different field entirely. A stale hand-written mirror is silent; a stale X-macro list is a compile error. The entire pattern can be justified on that one sentence.

Reading the error

The compiler points at line 50 — inside AS_FIELD_DESC, which is not where your mistake is — and then adds an "in expansion of macro" note pointing at line 43, which is. Always read down to the note.

Part 3 · Making it reachable

Registering yourself before main

A table is only useful if something can find it. The usual arrangement is a small registry that types add themselves to:

reflect.c — the registry API
void layout_register(const char *type_name, const field_desc *fields,
                     size_t count, size_t struct_size);

const field_desc *layout_lookup(const char *type_name, size_t *count_out);

You could call layout_register from main. But then every new type means editing main, and a central list of every type in the program is exactly the drift problem from part 1, moved up one level. Better to have the call happen by itself:

reflect.c — one line adds a type
#define REGISTER_LAYOUT(NAME, STRUCT, FIELDS)                               \
    static const field_desc NAME##_fields[] = { FIELDS(AS_FIELD_DESC) };    \
    __attribute__((constructor)) static void NAME##_register_layout(void) { \
        layout_register(#NAME, NAME##_fields,                               \
                        sizeof(NAME##_fields) / sizeof(NAME##_fields[0]),   \
                        sizeof(STRUCT));                                    \
    }

REGISTER_LAYOUT(sensor_reading, struct sensor_reading, SENSOR_FIELDS)

That one invocation generates three things at once: a static table built from the field list, a function that hands it to the registry, and the annotation that makes the function run on its own. #NAME supplies the lookup key, and sizeof(arr)/sizeof(arr[0]) supplies the count without anyone counting.

__attribute__((constructor)) marks a function to run before main — or, in a shared library, at load time. It is a GCC and Clang extension rather than ISO C; MSVC has its own mechanism via .CRT$XCU, and the portable fallback is an explicit init() that you do have to remember to call. In C++ the same effect falls out of a global object's constructor, which is why you see the trick spelled as a dummy static Registrar r{} there.

Why this shape keeps showing up

  • The knowledge sits next to the thing it describes. The field list lives beside the struct, not in a registry file three directories away.
  • Adding a type is a local edit. No shared file to touch, so no merge conflict when two people add a type in the same week.
  • Nothing can be half-added. The table, the registration and the name all come from one macro invocation, so there is no state where one exists without the others.

Unique names when the macro is used twice

NAME##_fields works because the caller supplies a distinct NAME. Sometimes a macro has to be usable twice in one file with nothing to distinguish the uses — two registrations of the same struct under different keys, say. The standard-ish trick is __COUNTER__, which expands to 0, 1, 2, … each time it is read.

the two-step dance
#define UNIQUE_IMPL2(ctr) static int tmp_var_##ctr;
#define UNIQUE_IMPL(ctr)  UNIQUE_IMPL2(ctr)
#define MAKE_UNIQUE()     UNIQUE_IMPL(__COUNTER__)
Trap

The middle layer is not decoration. ## suppresses expansion of its operands, so without UNIQUE_IMPL you get a variable literally named tmp_var___COUNTER__ — and a redefinition error on the second use. The extra indirection forces __COUNTER__ to expand to a number before it reaches the paste.

__COUNTER__ is supported by GCC, Clang and MSVC but is not in the standard. __LINE__ is the portable substitute and is fine whenever one use per line is an acceptable restriction.

Reading the table from another language

Once the table lives in a shared library behind a plain C function, anything with an FFI can read it. Python's ctypes is in the standard library, so this needs no dependencies:

read_layout.py
class FieldDesc(ctypes.Structure):
    _fields_ = [("name", ctypes.c_char_p), ("type", ctypes.c_int),
                ("offset", ctypes.c_uint), ("size", ctypes.c_uint)]

lib = ctypes.CDLL("./libreflect.so")
lib.layout_lookup.restype = ctypes.POINTER(FieldDesc)

count = ctypes.c_size_t(0)
table = lib.layout_lookup(b"sensor_reading", ctypes.byref(count))

The FieldDesc declaration on the Python side does have to match the C one by hand — that is the one mirror you cannot escape. But it is a single four-field descriptor type that changes approximately never, and it buys you the description of every other struct in the program.

Building the mirror at runtime

Because the table carries names, type tags and offsets, the caller can construct a matching structure definition while the program runs, instead of hand-writing one per struct:

read_layout.py
TAGS = {0: ctypes.c_int, 1: ctypes.c_float, 2: ctypes.c_double, 3: ctypes.c_void_p}

fields = [(table[i].name.decode(), TAGS[table[i].type]) for i in range(count.value)]
Mirror = type("SensorReading", (ctypes.Structure,), {"_fields_": fields})
python3 read_layout.py
id             tag=0 offset=0   size=4
sample_count   tag=0 offset=4   size=4
temperature    tag=1 offset=8   size=4
timestamp      tag=2 offset=16  size=8
label          tag=3 offset=24  size=8

built mirror : SensorReading
python sizeof: 32
c sizeof     : 32
mirror.timestamp offset: 16

The two sizeof values agreeing is the check that matters, and it is worth asserting rather than eyeballing. If they differ, the description is wrong somewhere and every subsequent read through the mirror lands on the wrong bytes — silently, with plausible values.

This is the whole payoff, and it is worth stating plainly: the struct is defined once, in C, and everybody else asks it what it looks like. No generated bindings to regenerate, no Python file to update when a field moves, no version skew between a library and the script reading it — because the description ships inside the library.

Part 4 · Practice

Pitfalls

The trailing backslash must be the last character on the line

One trailing space after it and the continuation silently ends, taking the rest of your list with it. Some compilers warn; do not count on it. This is the single most common way to lose an hour to this pattern, and it is invisible in a diff.

Put the punctuation in the action, not the list

Different actions need different separators. Keep the list free of commas and semicolons so it can serve an enum, an initialiser and a sequence of statements without special cases.

Order matters when position matters

If consumers index the table by position, the list must follow the struct's declaration order. Nothing enforces this — the build still succeeds, and only the offset column looks odd. If you can, index by name instead.

Macros do not recurse

A list cannot include itself, directly or through another macro. Nested lists work only in a strict hierarchy. If you find yourself wanting recursion, you have outgrown the pattern.

Keep the list next to the thing it describes

A field list in a different file from the struct can drift in the one way the pattern exists to prevent: somebody adds a field, never sees the list, and the table is quietly incomplete. Note that this failure is not caught by the compiler — the rename check only fires for fields the list mentions.

The one gap

An X-macro list catches renamed and deleted fields. It does not catch added ones. If completeness matters, add a static assertion that the field sizes plus known padding equal sizeof the struct, or simply assert the struct size and let the build break when it changes.

Debugging with the preprocessor, not by staring

Nearly every X-macro bug becomes obvious the moment you look at the pasted text. Do not reason about it; print it.

see what the compiler sees
gcc -E -P demo.c                      # preprocess only, strip line markers
gcc -E -P demo.c | clang-format       # ...and make it readable
gcc -E -P demo.c | sed -n '/sensor_layout/,/;/p'

-E stops after preprocessing and -P drops the #line markers that otherwise dominate the output. Everything the compiler will see is in there, including the expansion you were sure was correct.

When not to reach for it

When the list has exactly one consumer

If you only need the enum, write the enum. The pattern costs readability up front and repays it per consumer; it breaks even at two and clearly pays at three.

When the list is large, or comes from somewhere else

Past a few dozen entries, or when the real source of truth is a schema file, a protobuf definition or a database, generate a .c file from a script. Generated code can be read, diffed and stepped through in a debugger; a wall of backslash continuations cannot.

When you want type dispatch, not list replay

C11's _Generic selects an expression based on a type. It solves a different problem and reads far better for that one.

When the language has real reflection

This entire pattern is a workaround for C discarding names. In a language that keeps them, use what the language gives you.

Cheat sheet

Preprocessor operators and predefined macros

#xStringify the argument as written
a##bPaste two tokens into one; suppresses expansion of both operands
__COUNTER__0, 1, 2, … per read — needs a two-step macro to paste with
__LINE__, __FILE__Portable alternatives; one use per line
__VA_ARGS__Variadic macro arguments, for actions of varying arity

Layout introspection

offsetof(S, M)Byte offset of a member, from <stddef.h>
sizeof(((S *)0)->M)Size of a member with no instance; operand is not evaluated
sizeof(a) / sizeof(a[0])Element count of a real array — not of a pointer
_Static_assert(c, "msg")Compile-time check; useful for pinning struct size

Running code before main

__attribute__((constructor))GCC and Clang; also runs at dlopen time
Global object constructorC++; the same effect, portably
.CRT$XCU sectionMSVC equivalent
Explicit init()Portable fallback, at the cost of a central call site

Commands

gcc -E -P f.cShow the pasted text the compiler will read
gcc -Wall -WextraCatches trailing-comma and unused-macro-argument mistakes
pahole ./a.outPrint struct layout and padding, independently of your table

Wrapping up

What actually matters here

The X-macro has a reputation for being a clever trick, which undersells it. It is a way of making a class of bug unrepresentable, and the syntax is the price.

The short list

  • The list with a hole is the whole idea. LIST(X) plus an action macro; everything else in this document is a choice of action.
  • Let the compiler compute the numbers. offsetof and sizeof mean the table cannot disagree with the struct, on any target, ever.
  • A stale hand-written mirror is silent; a stale list is a compile error. That trade — a little syntax now for a loud failure later — is the reason to use it.

The registry and the FFI reader in part 3 are a step further than most code needs, but they are the natural end of the road: once the description exists as data, the interesting question stops being "how do I keep these in sync" and becomes "who else would like to read this". Usually the answer is more people than you expected.

Further reading

  • The C Preprocessor — GCC's manual; the chapters on stringizing, concatenation and argument prescan are the relevant ones
  • offsetof — including exactly when it is undefined behaviour
  • ctypes — the FFI used in part 3, standard library, no build step
  • The Linux kernelinclude/linux/kvm_host.h and the tracepoint headers are X-macros at industrial scale, for better and worse

Appendix · The complete programs

Building and running

Everything above came from three short files, reproduced in full below so this page stands on its own — copy the three snippets into a directory and the commands work as written.

build and run everything
gcc -std=c11 -Wall -Wextra -o demo demo.c && ./demo

gcc -std=c11 -Wall -Wextra -shared -fPIC -o libreflect.so reflect.c
python3 read_layout.py

gcc -E -P demo.c | head -40        # see what the preprocessor produced

Verified on gcc 11.4.0 (Ubuntu 22.04, x86-64) and CPython 3.10.12. Every output block on this page is copied from a real run, including the compiler error in the rename section, which was produced by renaming id to reading_id in a copy of demo.c.

demo.c 74 lines

The warm-up and the field table, in one standalone program. Nothing outside the C standard library.

demo.c
#include <stddef.h>
#include <stdio.h>

/* ---------- Warm-up: one list, two outputs ---------- */

#define COLOR_LIST(X) \
    X(RED)            \
    X(GREEN)          \
    X(BLUE)

#define AS_ENUM(name)   COLOR_##name,
#define AS_STRING(name) #name,

enum color { COLOR_LIST(AS_ENUM) COLOR_COUNT };

static const char *color_names[] = {COLOR_LIST(AS_STRING)};

/* ---------- Main: a runtime field table ---------- */

struct sensor_reading {
    int    id;
    int    sample_count;
    float  temperature;
    double timestamp;
    char  *label;
};

typedef enum {
    FIELD_INT = 0,
    FIELD_FLOAT = 1,
    FIELD_DOUBLE = 2,
    FIELD_PTR = 3
} field_type;

typedef struct {
    const char *name;
    field_type  type;
    unsigned    offset;
    unsigned    size;
} field_desc;

#define SENSOR_FIELDS(X)                              \
    X(struct sensor_reading, id, FIELD_INT)           \
    X(struct sensor_reading, sample_count, FIELD_INT) \
    X(struct sensor_reading, temperature, FIELD_FLOAT)\
    X(struct sensor_reading, timestamp, FIELD_DOUBLE) \
    X(struct sensor_reading, label, FIELD_PTR)

#define AS_FIELD_DESC(S, M, T) \
    {#M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M)},

static const field_desc sensor_layout[] = {SENSOR_FIELDS(AS_FIELD_DESC)};

#define AS_INDEX(S, M, T) FIELD_IDX_##M,

enum { SENSOR_FIELDS(AS_INDEX) SENSOR_FIELD_COUNT };

int main(void) {
    printf("colors: %d\n", (int)COLOR_COUNT);
    for (int i = 0; i < COLOR_COUNT; i++)
        printf("  %d = %s\n", i, color_names[i]);

    printf("\nsizeof(struct sensor_reading) = %zu\n",
           sizeof(struct sensor_reading));
    printf("field count = %d\n", (int)SENSOR_FIELD_COUNT);
    printf("%-14s %-5s %-7s %s\n", "name", "type", "offset", "size");
    for (size_t i = 0; i < sizeof(sensor_layout) / sizeof(sensor_layout[0]); i++)
        printf("%-14s %-5d %-7u %u\n", sensor_layout[i].name,
               (int)sensor_layout[i].type, sensor_layout[i].offset,
               sensor_layout[i].size);

    printf("\nFIELD_IDX_timestamp = %d\n", (int)FIELD_IDX_timestamp);
    return 0;
}

reflect.c 83 lines

The registry plus a type that puts itself in it. Built as a shared library; it has no main, and the only thing that runs is the constructor.

reflect.c
#include <stddef.h>
#include <string.h>

typedef struct {
    const char *name;
    int         type;
    unsigned    offset;
    unsigned    size;
} field_desc;

typedef struct {
    const char       *type_name;
    const field_desc *fields;
    size_t            count;
    size_t            struct_size;
} type_layout;

/* A tiny fixed-capacity registry. */
static type_layout g_registry[16];
static size_t      g_registry_count;

void layout_register(const char *type_name, const field_desc *fields, size_t count,
                     size_t struct_size) {
    if (g_registry_count >= 16) return;
    g_registry[g_registry_count].type_name = type_name;
    g_registry[g_registry_count].fields = fields;
    g_registry[g_registry_count].count = count;
    g_registry[g_registry_count].struct_size = struct_size;
    g_registry_count++;
}

const field_desc *layout_lookup(const char *type_name, size_t *out_count) {
    for (size_t i = 0; i < g_registry_count; i++) {
        if (strcmp(type_name, g_registry[i].type_name) == 0) {
            if (out_count) *out_count = g_registry[i].count;
            return g_registry[i].fields;
        }
    }
    if (out_count) *out_count = 0;
    return NULL;
}

size_t layout_sizeof(const char *type_name) {
    for (size_t i = 0; i < g_registry_count; i++)
        if (strcmp(type_name, g_registry[i].type_name) == 0)
            return g_registry[i].struct_size;
    return 0;
}

/* ---- a type that registers itself ---- */

struct sensor_reading {
    int    id;
    int    sample_count;
    float  temperature;
    double timestamp;
    char  *label;
};

#define FIELD_INT 0
#define FIELD_FLOAT 1
#define FIELD_DOUBLE 2
#define FIELD_PTR 3

#define SENSOR_FIELDS(X)                               \
    X(struct sensor_reading, id, FIELD_INT)            \
    X(struct sensor_reading, sample_count, FIELD_INT)  \
    X(struct sensor_reading, temperature, FIELD_FLOAT) \
    X(struct sensor_reading, timestamp, FIELD_DOUBLE)  \
    X(struct sensor_reading, label, FIELD_PTR)

#define AS_FIELD_DESC(S, M, T) \
    {#M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M)},

#define REGISTER_LAYOUT(NAME, STRUCT, FIELDS)                                 \
    static const field_desc NAME##_fields[] = {FIELDS(AS_FIELD_DESC)};        \
    __attribute__((constructor)) static void NAME##_register_layout(void) {   \
        layout_register(#NAME, NAME##_fields,                                 \
                        sizeof(NAME##_fields) / sizeof(NAME##_fields[0]),     \
                        sizeof(STRUCT));                                      \
    }

REGISTER_LAYOUT(sensor_reading, struct sensor_reading, SENSOR_FIELDS)
Note

field_desc is declared identically in both C files rather than shared through a header, so that each one can be read on its own. In a real program it would live in a header, and layout_register would be declared there too.

read_layout.py 37 lines

The other side of the wall: no C compiler involved, no generated bindings, just the library and the standard library.

read_layout.py
import ctypes


class FieldDesc(ctypes.Structure):
    _fields_ = [
        ("name", ctypes.c_char_p),
        ("type", ctypes.c_int),
        ("offset", ctypes.c_uint),
        ("size", ctypes.c_uint),
    ]


lib = ctypes.CDLL("./libreflect.so")

lib.layout_lookup.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_size_t)]
lib.layout_lookup.restype = ctypes.POINTER(FieldDesc)
lib.layout_sizeof.argtypes = [ctypes.c_char_p]
lib.layout_sizeof.restype = ctypes.c_size_t

TAGS = {0: ctypes.c_int, 1: ctypes.c_float, 2: ctypes.c_double, 3: ctypes.c_void_p}

count = ctypes.c_size_t(0)
table = lib.layout_lookup(b"sensor_reading", ctypes.byref(count))

fields = []
for i in range(count.value):
    f = table[i]
    print(f"{f.name.decode():14} tag={f.type} offset={f.offset:<3} size={f.size}")
    fields.append((f.name.decode(), TAGS[f.type]))

Mirror = type("SensorReading", (ctypes.Structure,), {"_fields_": fields})

print()
print("built mirror :", Mirror.__name__)
print("python sizeof:", ctypes.sizeof(Mirror))
print("c sizeof     :", lib.layout_sizeof(b"sensor_reading"))
print("mirror.timestamp offset:", Mirror.timestamp.offset)

Setting argtypes and restype is not optional decoration. Without them ctypes assumes every argument and the return value are int, which on x86-64 truncates pointers to 32 bits and produces a segfault that looks like it came from the C side.

7/29/2026

The list that remembers: mutable default arguments in Python

Python · Language

def append_to(item, target=[]) looks like it starts from an empty list every time. It does not. One list is created when the def statement runs, and every call for the rest of the program's life shares it.

This is the most famous gotcha in Python, and it is famous for a reason: the code is short, the intent is obvious, and it is wrong. There is no error, no warning at runtime, and the symptom depends on what earlier callers happened to do — which makes it genuinely hard to track down when you meet it in a real codebase rather than in a blog post.

It is also a two-minute fix once you know the rule. Every output below is copied from a real run on CPython 3.10.

Contents

  1. The bug — three calls, one list
  2. Why it happens — the default lives on the function object
  3. The shape you actually hit — two carts, one basket
  4. The fix — the None sentinel
  5. Variations and near-misses or [], sentinel objects, dataclasses
  6. What is safe, what is not
  7. Letting a linter catch it — B006 vs B008
  8. Cheat sheet

Part 1 · The bug

Three calls, one list

append.py
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))
print(append_to(2))
print(append_to(3))
$ python append.py
[1]
[1, 2]        ← expected [2]
[1, 2, 3]     ← expected [3]

The first call behaves. The second reveals that target was not empty when the call started. The list is accumulating across calls, because it is the same list each time.

Part 2 · Why it happens

The default lives on the function object

A default argument expression is evaluated once, when the def statement executes — not on each call. The resulting object is stored on the function and handed to every call that omits the argument. So [] is not an instruction to make a fresh list; it is one list, made once.

def append_to(item, target=[]):
                           ~~
                           evaluated once, at def time —
                           the resulting list is stored on the function

   call 1 ──┐
   call 2 ──┼──▶ that one list ──▶ [1] ─▶ [1, 2] ─▶ [1, 2, 3]
   call 3 ──┘    (never replaced, never reset)

You do not have to take this on faith. The list is reachable through __defaults__:

show_default.py
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to.__defaults__)
append_to(1)
print(append_to.__defaults__)
append_to(2)
print(append_to.__defaults__)
$ python show_default.py
([],)
([1],)        ← the list attached to the function has changed
([1, 2],)

That is the whole story. The default is a piece of state hanging off the function, and target.append(item) mutates it. Nothing resets it between calls, because nothing was ever going to.

Note what is not the problem

Rebinding is fine. def f(n=0): n += 1 cannot leak, because n += 1 on an integer creates a new object and points the local name at it — the stored default is untouched. The bug needs mutation of the default object, which is why only mutable types are affected.

Part 3 · The shape you actually hit

Two carts, one basket

Nobody writes append_to in production. What people do write is a constructor with an empty collection as its default — and then the shared object is shared between instances:

cart.py
class Cart:
    def __init__(self, items=[]):
        self.items = items

a = Cart()
b = Cart()
a.items.append("apple")

print(b.items)
print(a.items is b.items)
print(Cart.__init__.__defaults__)
$ python cart.py
['apple']              ← a different cart, with someone else's apple in it
True                   ← it was the same list all along
(['apple'],)           ← and it is living on Cart.__init__

Two independent objects, one shared basket. Worse, the default is attached to the class, so the contamination outlives both instances and reaches every Cart() created later in the process.

Why it is hard to debug

The reproduction condition is "what did some earlier caller put in there", which is not visible from the failing line, is not in the traceback, and often depends on test ordering. A unit test that constructs one Cart passes. The suite fails only when another test ran first — and then passes again when you run it alone to investigate.

Passing the argument explicitly sidesteps it, which is part of why the bug hides so well: the code path that everyone tests is usually the one that supplies a value.

explicit argument, no sharing
c = Cart(items=[])       # a genuinely new list
c.items.append("pear")
print(c.items, a.items)  # ['pear'] ['apple']

Part 4 · The fix

The None sentinel

Make the default an immutable marker, and build the real object inside the body. The construction then happens per call, which is what you wanted in the first place.

broken
def append_to(item, target=[]):
    target.append(item)
    return target


class Cart:
    def __init__(self, items=[]):
        self.items = items
correct
def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

class Cart:
    def __init__(self, items=None):
        self.items = [] if items is None else items
the fixed version
append_to(1) → [1]
append_to(2) → [2]
append_to(3) → [3]
append_to.__defaults__ → (None,)      ← nothing to accumulate into

None is a singleton and immutable, so storing it on the function is harmless. The important part is the is None check: it is what turns "one object, made once" into "a new object per call".

Type it honestly

If the codebase is annotated, the parameter now genuinely accepts None, and the annotation should say so:

annotated
def append_to(item: int, target: list[int] | None = None) -> list[int]:
    if target is None:
        target = []
    target.append(item)
    return target

On Python 3.9 and earlier, write Optional[List[int]] from typing, or add from __future__ import annotations to use the | syntax anyway.

Part 5 · Variations and near-misses

Three things worth knowing

or [] is shorter and subtly different

target = target or [] is a common abbreviation. It fixes the sharing, but it also replaces any falsy argument the caller passed — including an empty list they expected you to fill:

or_trap.py
def with_or(item, target=None):
    target = target or []          # empty list from the caller gets discarded
    target.append(item)
    return target

def with_is_none(item, target=None):
    target = [] if target is None else target
    target.append(item)
    return target

mine = []
with_or("x", mine)
print("or      →", mine)

mine = []
with_is_none("x", mine)
print("is None →", mine)
$ python or_trap.py
or      → []            ← the caller's list was never touched
is None → ['x']

The or version quietly appended to a throwaway list and the caller's stayed empty. Same class of confusion for 0, "" and False. Prefer is None: it tests for exactly the thing you meant.

When None is itself a valid value

Sometimes None is meaningful data and you still need to distinguish "not supplied". Use a private sentinel object:

sentinel object
_MISSING = object()

def fetch(key, default=_MISSING):
    if default is _MISSING:
        raise KeyError(key)     # no default supplied
    return default              # default supplied — possibly None

print(fetch("k", None))         # None
fetch("k")                      # KeyError: 'k'

This is how dict.pop and friends behave, and why they can tell those two cases apart.

Dataclasses refuse to let you do it

@dataclass knows about this bug and raises at class-creation time rather than letting you ship it:

bad_dc.py
from dataclasses import dataclass

@dataclass
class Bad:
    items: list = []
$ python bad_dc.py
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Note that this fires at import, before any instance exists — the decorator inspects the defaults while building the class. The sanctioned form is a factory, called once per instance:

good_dc.py
from dataclasses import dataclass, field

@dataclass
class Good:
    items: list = field(default_factory=list)

g1, g2 = Good(), Good()
g1.items.append("apple")
print(g1.items, g2.items, g1.items is g2.items)
$ python good_dc.py
['apple'] [] False

default_factory is the "call it per invocation" idea made explicit — the same thing the None check does by hand.

When the sharing is deliberate

A mutable default occasionally is the point — a memo dict that must persist across calls. It works, but it hides a piece of global state in a signature where no reader expects it. Use a module-level variable or functools.cache instead; both announce themselves.

Part 6 · What is safe, what is not

A quick classification

The rule follows directly from the mechanism: the stored object is only a hazard if something can mutate it.

DefaultSafe?Because
None, 0, "", TrueyesImmutable; cannot be changed in place
(), frozenset()yesImmutable containers
A module-level constantusuallySafe if genuinely immutable — a shared CONFIG dict is not
[], {}, set()noOne object, mutated in place by every caller
A custom class instancenoOne instance shared across calls
datetime.now()noNot mutation — it freezes at import time

That last row is a different bug wearing the same clothes, and it is worth seeing on its own:

frozen at import, not "now"
import time
from datetime import datetime

def stamped(when=datetime.now()):
    return when

t1 = stamped()
time.sleep(0.05)
t2 = stamped()
print(t1 == t2)          # True — both are the moment the module was imported

A long-running service will hand out the same timestamp for days. The test suite will not notice, because it imports and calls within the same instant.

Part 7 · Letting a linter catch it

B006 and B008

You should never be finding this by reading. flake8-bugbear — bundled into Ruff as the B rules — catches both variants:

RuleCatchesExample
B006
mutable-argument-default
A mutable literal or constructor as a default — the bug in this post def f(x=[])
B008
function-call-in-default-argument
Any function call in a default, evaluated once at import def f(t=datetime.now())
pyproject.toml
[tool.ruff.lint]
extend-select = ["B"]      # all of flake8-bugbear, B006 and B008 included
$ ruff check --select B006,B008 --output-format concise .
app.py:4:28: B006 Do not use mutable data structures for argument defaults
app.py:9:21: B006 Do not use mutable data structures for argument defaults
app.py:13:18: B008 Do not perform function call `datetime.now` in argument defaults;
              instead, perform the call within the function, or read the default from
              a module-level singleton variable
app.py:18:30: B006 Do not use mutable data structures for argument defaults
Found 4 errors.
The two rules get conflated

target=[] is B006, not B008. The advice "perform the call within the function" belongs to B008's message — apt for datetime.now(), but a list literal is not a call. The remedy happens to be the same shape in both cases: move the work inside the body.

B008 has real exceptions

Some frameworks give a call in the default an actual meaning — FastAPI's Depends() is the standard example. Ruff's extend-immutable-calls exists for these; list the specific callable rather than disabling the rule.

Wrapping up

Cheat sheet

Instead ofWrite
def f(x=[])def f(x=None) then if x is None: x = []
def f(x={})def f(x=None) then x = {} if x is None else x
def f(t=datetime.now())def f(t=None) then t = t or datetime.now()
x = x or []x = [] if x is None else x — unless discarding falsy input is intended
items: list = [] in a dataclassitems: list = field(default_factory=list)
A memo dict in the signatureA module-level variable, or functools.cache

The short list

  • Defaults are evaluated once, at def time. The object is stored on the function and reused forever; [] means one list, not a fresh one per call.
  • You can watch it happen in f.__defaults__ — the list grows in place.
  • The damage scales in constructors. A default items=[] in __init__ is shared by every instance the process ever creates.
  • Default to None and build inside the body. Test with is None, not or, so a caller's empty list is still theirs.
  • Enable B in Ruff. B006 and B008 find every instance in seconds, and neither bug ever announces itself at runtime.

Further reading

Every runnable snippet verified against CPython 3.10.

7/28/2026

The loop variable that changed: late binding and default arguments in Python

Python · Language

You define a function inside a loop, collect the functions, call them later — and every single one returns the last value. The usual fix is a strange-looking line where the same name appears twice. Here is what is actually going on, and why the fix works.

This bug has a particular texture. The code reads correctly, each function looks like it captured its own value, and nothing raises. You only find out when the results are all identical — and if the loop happens to have one iteration during development, you never find out at all.

The explanation is one sentence long: a closure captures the variable, not the value. Everything else in this post is unpacking what that means and what to do about it.

Every snippet was run on CPython 3.10 before publishing, and the output shown is copied from those runs.

Contents

  1. The bug — three functions, one value
  2. Why it happens — variables, cells, and Python's missing block scope
  3. Anatomy of the fix — reading def f(x: T = x) as three parts
  4. Defaults are evaluated once — the rule that makes the fix work
  5. The same rule, as a trap — mutable default arguments
  6. Other ways to capture partial, factories, and which to prefer
  7. Where it bites — threads, callbacks, handlers, tasks
  8. What the type hint does — and when it is evaluated
  9. Scoping rules, briefly — LEGB, nonlocal, comprehensions
  10. Letting a linter catch it — B023 and B008
  11. Cheat sheet

Part 1 · The bug

Three functions, one value

Build a list of functions in a loop. Each one returns the loop variable.

late.py
fns = []
for i in range(3):
    def show():
        return i
    fns.append(show)

print([f() for f in fns])
$ python late.py
[2, 2, 2]

Not [0, 1, 2]. All three functions return 2, the value i had when the loop finished. The functions were created at three different times, but they were never looking at three different values — they were all looking at the same i.

Why it survives review

Nothing about the code is unusual, and the failure is silent. It also depends on when you call: if you call show() inside the loop it returns the right thing, because i still holds that value at that moment. Deferring the call is what exposes it.

Part 2 · Why it happens

Variables, cells, and the missing block scope

A closure is a function plus a reference to the enclosing scope's variables. Not copies of their values — references to the variables themselves. Python stores each captured variable in a cell, and every closure over that variable shares the one cell.

You can look at the cell directly:

the cell is shared and mutable
def make():
    n = 0
    def inc():
        nonlocal n
        n += 1
        return n
    return inc

c = make()
print(c(), c(), c())              # 1 2 3
print(c.__closure__[0].cell_contents)   # 3

The three calls returned different numbers because they all read and wrote the same cell. That is the whole point of a closure — and it is exactly what goes wrong in the loop, because the loop variable is also just a variable in the enclosing scope.

There is a second ingredient. In most C-like languages, for (int i = ...) creates a new i scoped to the loop body. Python has no block scope: if, for and while do not create scopes. Only modules, functions and classes do. So there is exactly one i, it belongs to the enclosing function, and it outlives the loop:

the loop variable leaks
for i in range(3):
    pass
print(i)          # 2 — still in scope, still holding the last value

Put the two facts together and the behaviour is inevitable:

  • The loop rebinds one variable three times.
  • All three closures reference that one variable.
  • Calling them afterwards reads it once the loop has finished, so all three see the final value.
"Late binding" names the timing

The name lookup happens when the function runs, not when it is defined. That is the general rule for every free variable in Python, and it is usually what you want — it is why you can define a function that calls a helper declared later in the file.

Part 3 · Anatomy of the fix

Reading def f(x: T = x) as three parts

The fix is to bind the value into the function's own signature. In a typed codebase it ends up looking like this, which is where the confusion starts:

the idiom
def invoke(kernel: Kernel = kernel) -> None:
    ...

Three independent things are packed into kernel: Kernel = kernel:

def invoke(kernel: Kernel = kernel) -> None:
           ~~~~~~~~~~~~~~ ~~~~~~~~
                        
                        └─ default value: the OUTER variable,
                           evaluated now, at def time
                └─ type hint: the Kernel class
           └─ parameter name: a NEW local, created per call

The name appears twice because two different things happen to share a spelling. They live in different scopes and are resolved at different times:

Left kernelRight kernel
What it isA parameter being declaredAn expression being evaluated
Which scopeLocal to invokeThe enclosing scope — the loop variable
WhenOn every call to invokeOnce, when the def statement runs
EffectShadows the outer name inside the bodySnapshots the current value

That last row is the mechanism. Because the right-hand side is evaluated while the loop is on that iteration, the value is captured then and stored on the function object. And because the left-hand side introduces a local with the same name, the body reads the captured parameter instead of the outer variable — the closure is gone.

fixed.py
fns = []
for i in range(3):
    def show(i=i):     # left i = new parameter; right i = this iteration's value
        return i
    fns.append(show)

print([f() for f in fns])
$ python fixed.py
[0, 1, 2]

You can see the snapshot on the function object. Rebinding the outer name afterwards changes nothing:

the value is stored, not looked up
x = 10
def f(a=x):
    return a

x = 999
print(f.__defaults__, f(), x)
output
(10,) 10 999
It changes the public signature

show(i=i) is an honest fix but not a free one: show now accepts an argument, and a caller can override the captured value. For an internal callback that is harmless. For a function you hand to someone else, prefer a factory (below) so the capture is not part of the API.

Part 4 · Defaults are evaluated once

The rule underneath

Default argument expressions are evaluated once, when the def statement executes — not on each call. This single rule explains both the fix above and the trap below.

Prove it with a default that announces itself:

once.py
def stamp(value=print("  (default evaluated)")):
    return value

print("  function defined, not called yet")
stamp()
stamp()
$ python once.py
  (default evaluated)
  function defined, not called yet

The message appears before "function defined", and appears only once despite two calls. The default was computed while Python was building the function object, and the result was stored on it.

So def show(i=i) is not a clever trick, it is the ordinary rule applied deliberately: you are asking for an expression to be evaluated now and remembered.

Part 5 · The same rule, as a trap

Mutable default arguments

Evaluated once means the default object is created once and reused by every call. If it is mutable, every call shares it — and the sharing persists for the lifetime of the function.

mutable.py
def append_bad(item, bucket=[]):
    bucket.append(item)
    return bucket

print(append_bad("a"))
print(append_bad("b"))
print(append_bad.__defaults__)
$ python mutable.py
['a']
['a', 'b']
(['a', 'b'],)

The second call did not start from an empty list. The [] in the signature is one list object, created once, now permanently attached to the function — you can see it grow inside __defaults__.

The fix is the None sentinel. Take the default as None and build the real thing inside:

avoid
def f(bucket=[]):
    bucket.append(1)
    return bucket

def g(cache={}):
    ...

def h(when=datetime.now()):
    ...       # frozen at import time!
prefer
def f(bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(1)
    return bucket

def g(cache=None):
    cache = {} if cache is None else cache

def h(when=None):
    when = when or datetime.now()
The subtlest version of this bug

def h(when=datetime.now()) looks like "default to now". It means "default to the moment this module was imported". A long-running process will hand out the same timestamp for days, and the test suite will pass because it imports and calls within the same second.

When sharing is the point

A mutable default is occasionally deliberate — a memo dictionary that must persist across calls. It works, but it is invisible to the reader. Prefer an explicit module-level variable or functools.cache, both of which say what they mean.

Part 6 · Other ways to capture

partial, factories, and which to prefer

A factory function

Give the value a scope of its own by passing it as a parameter to an outer function. The inner closure then captures that parameter, and each call to the factory creates a fresh one.

factory
def make_show(value):
    def show():
        return value       # captures the parameter, not the loop variable
    return show

fns = [make_show(i) for i in range(3)]
print([f() for f in fns])          # [0, 1, 2]

This is the version to reach for in library code: the signature of show stays clean, and the capture is explicit rather than a signature side effect.

functools.partial

partial
from functools import partial

def show(value):
    return value

fns = [partial(show, i) for i in range(3)]
print([f() for f in fns])          # [0, 1, 2]

partial binds arguments immediately and stores them on the resulting object, so there is no closure to go stale. It is the cleanest option when the function already exists and takes the value as a parameter.

Comparison

TechniqueGoodCostUse when
def f(x=x) One token; no restructuring Adds a parameter callers can override Local callbacks, quick lambdas
Factory function Signature stays clean; intent explicit An extra function Library code, anything exported
partial No new scope; composes well Function must accept the value The callable already exists
Bind to an instance attribute Natural when state is already an object A class Several values travel together

Part 7 · Where it bites

Anything that defers the call

The pattern is always the same: a callable is created in a loop and invoked later. Threads make it vivid because the fix and the bug differ by six characters.

threads.py
import threading

results = []
threads = [
    threading.Thread(target=lambda: results.append(name))
    for name in ("a", "b", "c")
]
for t in threads: t.start()
for t in threads: t.join()
print("buggy:", sorted(results))

results = []
threads = [
    threading.Thread(target=lambda name=name: results.append(name))
    for name in ("a", "b", "c")
]
for t in threads: t.start()
for t in threads: t.join()
print("fixed:", sorted(results))
$ python threads.py
buggy: ['c', 'c', 'c']
fixed: ['a', 'b', 'c']

The same shape shows up in:

  • Event handlers. Buttons built in a loop that all act on the last item.
  • Async tasks. asyncio.create_task over a coroutine closing on a loop variable.
  • Handler dictionaries. {name: lambda: dispatch(name) for name in names} — the dict comprehension has its own scope, but the lambdas still close over its variable.
  • Retry and timing wrappers. A benchmark harness that builds one thunk per case and runs them afterwards ends up measuring the last case repeatedly.
  • Deferred logging. log.debug(lambda: f"{item}") style lazy messages.
The measurement version is the nastiest

When the deferred callables are benchmarks, nothing crashes and every number looks plausible — you simply publish the same case's timing under several names. There is no error to notice, only a conclusion that is wrong.

Part 8 · What the type hint does

The middle part, and when it runs

In kernel: Kernel = kernel, the annotation is the only part with no effect on behaviour. Python does not check it, coerce with it, or consult it when binding arguments. It is metadata for readers and for tools such as mypy or pyright.

But it is an expression, and by default it is evaluated at definition time:

annotations are evaluated eagerly
def side_effect():
    print("  (annotation evaluated)")
    return int

def g(a: side_effect() = 1):
    return a

print(g.__annotations__)
output
  (annotation evaluated)
{'a': <class 'int'>}

Add from __future__ import annotations and they are kept as strings instead, never evaluated unless something asks for them:

output with the future import
{'a': 'side_effect()'}          # note: no "(annotation evaluated)" line
(1,)                            # __defaults__ — still evaluated eagerly

Two things worth carrying away. First, deferring annotations lets you reference a class before it is defined, which is why the future import is common in typed code. Second, it changes nothing about defaults — those are always evaluated immediately, which is exactly what the capture idiom relies on.

Hints do not narrow the capture

Writing kernel: Kernel = kernel rather than kernel=kernel documents the parameter and gives a type checker something to verify. The value that gets captured, and when, is identical either way.

Part 9 · Scoping rules, briefly

LEGB, nonlocal, and comprehensions

Python resolves a name by searching four scopes in order:

ScopeIs
LocalNames assigned in the current function
EnclosingLocals of any lexically enclosing function — this is where closures read from
GlobalModule level
Built-inlen, print, and friends

Assigning to a name makes it local for the whole function, which is why reading it before the assignment raises rather than falling through to an outer scope. To assign to an outer name instead, declare the intent:

nonlocal vs global
count = 0

def outer():
    total = 0
    def bump():
        nonlocal total     # assign to outer()'s local
        global count       # assign to the module-level name
        total += 1
        count += 1
    bump(); bump()
    return total

print(outer(), count)      # 2 2

Comprehensions are the one construct that looks like a loop but is not: each has its own scope, so its variable does not leak. That is a small blessing and a common source of surprise:

comprehension scope
squares = [j * j for j in range(3)]
print(squares)             # [0, 1, 4]
print(j)                   # NameError: name 'j' is not defined
But it does not save you

The comprehension's variable is scoped, yet a lambda created inside still closes over it and still reads it late. [lambda: j for j in range(3)] gives three functions that all return 2. Scoping the variable is not the same as snapshotting it.

Part 10 · Letting a linter catch it

B023 and B008

You should not have to spot this by eye. flake8-bugbear and Ruff both ship the checks; enable them once and the class of bug stops reaching review.

RuleCatches
B023
function-uses-loop-variable
A function defined in a loop that references the loop variable — the bug in part 1
B006
mutable-argument-default
def f(x=[]) and friends — the trap in part 5
B008
function-call-in-default-argument
def f(when=datetime.now()) — a call evaluated once at import
pyproject.toml
[tool.ruff.lint]
extend-select = ["B"]      # all of flake8-bugbear, including B006/B008/B023
what it looks like
$ ruff check --select B023,B006,B008 --output-format concise .
b008.py:3:12: B008 Do not perform function call `datetime.now` in argument defaults;
              instead, perform the call within the function, or read the default from
              a module-level singleton variable
late.py:4:16: B023 Function definition does not bind loop variable `i`
mutable.py:1:29: B006 Do not use mutable data structures for argument defaults
Found 3 errors.
B008 has legitimate exceptions

Some frameworks make a call in the default meaningful — FastAPI's Depends() is the usual example. Ruff has extend-immutable-calls for exactly this; add the specific callable rather than switching the rule off.

Wrapping up

Cheat sheet

ExpressionEvaluatedConsequence
Function bodyOn every callFree variables are read late
Default argumentOnce, at defSnapshots values; shares mutables
AnnotationOnce, at def — or never, with the future importNo runtime effect either way
DecoratorOnce, at defWrapping happens at definition time

The short list

  • A closure captures the variable, not the value. If a function outlives the loop that made it, it will see the loop's final state.
  • def f(x=x) works because defaults are evaluated once, at definition. Left is a new local, right is the outer variable read right now. Same word, different things.
  • That same rule is the mutable-default trap. One [], created once, shared by every call. Use None and build inside.
  • Turn on bugbear (B) in Ruff. B023, B006 and B008 cover all three failure modes above, and none of them announce themselves at runtime.

Further reading

Every runnable snippet verified against CPython 3.10.