The struct Module and Binary Layout

The struct module is CPython’s bridge between Python objects and the raw byte layout of C structures. It packs Python values (int, float, bytes, bool, complex) into a bytes object whose every byte is laid out according to a compact format string, and unpacks bytes back into a tuple of Python values. It is the standard tool for reading and writing binary file formats, speaking wire protocols, and exchanging data with C code. The single most important idea: a format string encodes both what the fields are and how they are laid out in memory — byte order, sizes, and alignment — and getting that layout description to match the bytes on the wire is the whole game. (Per the struct documentation, verified against CPython 3.14.5.)

Mental Model

Think of struct as a typed cursor that walks a flat run of bytes. A format string such as '<10sHHb' is a script for that cursor: “interpret the next 10 bytes as a byte string, then 2 bytes as an unsigned short (little-endian), then another unsigned short, then 1 byte as a signed char.” unpack runs the script in read mode and yields a tuple; pack runs it in write mode and produces bytes. There is no schema object stored alongside the data — the format string is the schema, supplied separately every time, and if it disagrees with the bytes you simply get wrong numbers with no error.

flowchart LR
    PV["Python values<br/>(1, 2, 3)"] -->|"pack('&gt;bhl', ...)"| BY["bytes<br/>b'\x01\x00\x02\x00\x00\x00\x03'"]
    BY -->|"unpack('&gt;bhl', ...)"| PV2["tuple<br/>(1, 2, 3)"]
    FMT["format string<br/>'&gt;bhl'"] -.->|"describes layout"| BY
    subgraph layout["byte layout dictated by format"]
      direction LR
      B0["b: 1 byte<br/>signed char"] --> B1["h: 2 bytes<br/>short, big-endian"] --> B2["l: 4 bytes<br/>long, big-endian"]
    end

Figure: the format string is an external schema applied in both directions. Insight: the bytes carry no type information of their own — correctness depends entirely on the format string matching the data, which is why a mismatched byte-order prefix silently corrupts results instead of raising.

The Format-String Mini-Language

A format string is an optional byte-order/size/alignment prefix followed by a sequence of type codes, each optionally preceded by a repeat count. '4h' means four shorts (equivalent to 'hhhh'); the count is a true repeat for every code except s and p, where it is a length (see below).

The prefix: native vs standard mode

The first character may be one of five prefix characters that set byte order, sizing, and alignment for the entire string (struct docs, “Byte Order, Size, and Alignment”):

CharByte orderSizeAlignment
@nativenativenative
=nativestandardnone
<little-endianstandardnone
>big-endianstandardnone
!network (big-endian)standardnone

If no prefix is given, @ (native, native, native) is assumed — and this default is the single most common source of bugs, discussed below. The terms decompose as follows:

  • Byte order (endianness) is the order in which the bytes of a multi-byte integer are stored. Big-endian (also “network order”) stores the most-significant byte first: the 16-bit value 1023 = 0x03FF becomes b'\x03\xff'. Little-endian stores the least-significant byte first: 0x03FF becomes b'\xff\x03'. Most modern desktop/server CPUs (x86-64, and ARM as usually configured) are little-endian, so “native” usually means little-endian, but you must never rely on that. Network protocols (TCP/IP headers) conventionally use big-endian, which is exactly why the ! alias exists — to spell “network order” without thinking about which end is which.
  • Size is standard (fixed sizes defined by the module, identical on every platform) or native (whatever the C compiler’s sizeof reports for this platform). The classic trap is long: on a 64-bit Linux/macOS build a native C long is 8 bytes, but on 64-bit Windows it is 4 bytes, and struct’s standard size for l is always 4. So calcsize('@l') can be 8 on Linux but calcsize('=l') is always 4 everywhere.
  • Alignment is the padding struct inserts so each field starts at an address that is a multiple of its natural alignment — but only in native mode (@). In all standard modes (=<>!) there is no automatic padding; you insert it yourself with the x pad-byte code.

Demonstrated concretely with the documentation’s own example:

>>> import struct
>>> struct.pack('>h', 1023)   # big-endian short
b'\x03\xff'
>>> struct.pack('<h', 1023)   # little-endian short
b'\xff\x03'
  • Line 2: >h packs the integer 1023 as a 2-byte big-endian signed short. 1023 = 0x03FF, so the most-significant byte 0x03 comes first, then 0xFF.
  • Line 4: <h packs the same value little-endian, so the least-significant byte 0xFF comes first. The two outputs are byte-reversed mirror images of each other — this is endianness, visible in two lines.

The type codes

Each non-prefix character names a C type. The standard sizes (used by =<>!) are fixed by the module; native sizes (used by @) are sizeof(...) on the build platform (struct docs, “Format Characters”):

CodeC typePython typeStandard size
xpad byte1
ccharbytes len 11
bsigned charint1
Bunsigned charint1
?_Boolbool1
hshortint2
Hunsigned shortint2
iintint4
Iunsigned intint4
llongint4
Lunsigned longint4
qlong longint8
Qunsigned long longint8
nssize_tintnative only
Nsize_tintnative only
e_Float16 (half)float2
ffloatfloat4
ddoublefloat8
Ffloat complexcomplex8
Ddouble complexcomplex16
schar[]bytes(count = len)
pchar[] (Pascal)bytes(count = len)
Pvoid *intnative only

Several codes carry caveats worth internalizing. n and N (the C ssize_t/size_t index types) and P (a raw pointer, surfaced as a Python int) exist only in native mode — they have no portable standard size, so using them with </>/!/= raises struct.error. e is IEEE 754 binary16 “half precision,” added in Python 3.6 (struct docs, note 6). F and D, the complex-number codes, are new in Python 3.14 (struct docs, note 10); in Modules/_struct.c (CPython tag v3.14.5) they appear in the native table as {'F', 2*sizeof(float), _Alignof(float), nu_float_complex, np_float_complex} and the matching 'D' entry, packing the real then imaginary part as two consecutive floats/doubles.

The floating types e/f/d always use the IEEE 754 binary16/binary32/binary64 representation regardless of the platform’s native float format, so a packed d is portable even though it is a “float.”

s and p — where count means length, not repeat

For s the leading number is the byte length of a single string field, not a repeat. '10s' is one 10-byte field; contrast '10c', which is ten separate 1-byte char fields. On packing, a too-long bytes is truncated and a too-short one is NUL-padded to the field width; '0s' is a legal empty string. p is a Pascal string: byte 0 holds the length (0–255), the rest holds the data padded to the field width — a layout some legacy binary formats use.

Mechanical Walk-through

Internally (Modules/_struct.c, tag v3.14.5) the module is table-driven. Three formatdef tables exist — native_table, bigendian_table, lilendian_table — each an array of {format-char, size, alignment, unpack-fn, pack-fn} rows. The native rows record real sizeof/_Alignof values, e.g. {'h', sizeof(short), _Alignof(short), nu_short, np_short}; the endian rows pin standard sizes and set alignment to 0, e.g. {'h', 2, 0, bu_short, bp_int}. A whichtable() routine reads the prefix character and picks the table; an init_endian_tables() optimization copies the native pack/unpack functions into the endian tables when the sizes already coincide (so a little-endian build need not byte-swap for a little-endian format).

When you call pack/unpack, the format string is parsed once by prepare_s() into an array of formatcode records. Padding in native mode is computed by an align() helper: extra = (alignment - 1) - (size - 1) % alignment rounds the current offset up to the next multiple of the field’s alignment, inserting pad bytes. This is exactly the rule a C compiler applies when laying out a struct, which is why @-mode output is binary-compatible with a C struct of the same fields — and why it is not portable across compilers/platforms with different alignment rules.

The alignment behavior is best seen, again from the docs:

>>> struct.pack('@ci', b'#', 0x12131415)
b'#\x00\x00\x00\x15\x14\x13\x12'
>>> struct.calcsize('@ci')
8
>>> struct.calcsize('@ic')
5
  • Line 1–2: @ci is a char then an int. The int requires 4-byte alignment, so after the 1-byte char (b'#') the cursor is at offset 1 and must skip to offset 4 — three NUL pad bytes appear (\x00\x00\x00) before the little-endian int 0x12131415. Total size 8.
  • Line 5–6: @ic is int then char. The int starts at offset 0 (already aligned), the char follows at offset 4 needing no padding. Total size 5 — the same fields in the other order pack to a different size. This re-ordering effect is the heart of why C struct layout (and struct’s @ mode) is order-sensitive.

Functions, the buffer protocol, and struct.Struct

The module-level functions all take the format as their first argument:

>>> struct.pack('>bhl', 1, 2, 3)
b'\x01\x00\x02\x00\x00\x00\x03'
>>> struct.unpack('>bhl', b'\x01\x00\x02\x00\x00\x00\x03')
(1, 2, 3)
>>> struct.calcsize('>bhl')
7
  • pack(fmt, *values) returns a new bytes. unpack(fmt, buffer) returns a tuple and requires len(buffer) == calcsize(fmt) exactly. calcsize(fmt) returns the byte size.

pack_into and unpack_from work in place against any object that supports the buffer protocol — CPython’s C-level contract for sharing a contiguous block of memory without copying (see [[The Buffer Protocol]]). pack_into(fmt, buffer, offset, *values) writes directly into a writable buffer (a bytearray, a memoryview over one, an array.array, a NumPy array) starting at offset; in Modules/_struct.c it acquires the target with PyArg_Parse(args[0], "w*", &buffer) — the w* code demands a writable buffer and fails on an immutable one — then calls s_pack_internal to write at (char*)buffer.buf + offset. unpack_from(fmt, buffer, offset=0) reads from any readable buffer at an offset, requiring only that enough bytes remain after offset (not that the buffer be exactly the format size). iter_unpack(fmt, buffer) (added 3.4) returns a lazy iterator yielding one tuple per fixed-size record, requiring len(buffer) to be a multiple of the record size — the idiomatic way to stream an array of records.

>>> buf = bytearray(8)
>>> struct.pack_into('>hh', buf, 2, 100, 200)   # write 2 shorts at offset 2
>>> buf
bytearray(b'\x00\x00\x00d\x00\xc8\x00\x00')
>>> struct.unpack_from('>h', buf, 4)
(200,)
  • pack_into writes 100 (0x0064) and 200 (0x00C8) as big-endian shorts into bytes 2–5 of the eight-byte bytearray, leaving bytes 0–1 and 6–7 untouched as zeros. No new object is allocated — the existing buffer is mutated, which is the whole point for performance-sensitive code that fills a pre-allocated frame.
  • unpack_from(..., 4) reads the short at offset 4, recovering 200.

Because pack_into writes into mutable memory, it is the bridge from struct to bytearray/memoryview ([[CPython Bytes and Bytearray Internals]]) and to C-owned buffers exposed via [[ctypes and cffi]].

For repeated use of the same format, compile it once with struct.Struct:

>>> record = struct.Struct('>IHHB')
>>> record.size
9
>>> packed = record.pack(305419896, 100, 200, 7)
>>> record.unpack(packed)
(305419896, 100, 200, 7)
  • Struct('>IHHB') parses and validates the format string a single time at construction; .size exposes the precomputed byte size; .pack/.unpack/.pack_into/.unpack_from/.iter_unpack are the same operations without re-parsing the format on every call. The module-level functions are in fact thin wrappers that look the format up in a small internal Struct cache (capped at 100 entries in Modules/_struct.c), so Struct mainly wins when your format would otherwise blow that cache or when you want an explicit, named record type.

Failure Modes and Common Misunderstandings

The dominant bug is forgetting the prefix. A bare format like 'IHHB' means @ — native byte order, native sizes, and native alignment with padding. Code that round-trips fine on the developer’s little-endian laptop then reads garbage on a big-endian box, or inserts unexpected pad bytes that shift every subsequent field, or computes a different calcsize on Windows (long = 4) versus Linux (long = 8). For any data that crosses a machine boundary — a file, a socket, a saved blob — you should always use an explicit standard-mode prefix (<, >, or !). Reserve @ strictly for talking to a C struct in the same process/ABI (e.g. via [[ctypes and cffi]]).

Other recurring errors: passing a str where a code expects bytes (s, c, p take bytes, not str — you must .encode() first); an integer out of range for its code raises struct.error: 'h' format requires -32768 <= number <= 32767; calling unpack with a buffer whose length is not exactly calcsize(fmt) raises struct.error (use unpack_from when there may be trailing bytes); and assuming '10s' means ten strings rather than one ten-byte string.

Alternatives and When to Choose Them

struct is for fixed-layout binary where you control every byte. When the schema is richer or self-describing, reach elsewhere: [[The pickle Protocol]] serializes arbitrary Python object graphs (but is Python-specific and unsafe on untrusted input); array.array stores homogeneous numeric arrays and has its own tobytes/frombytes; [[ctypes and cffi]] define actual C struct types (ctypes.Structure) when you are calling into C and want a named, attribute-accessed layout rather than a positional tuple; memoryview plus slicing handles ad-hoc byte surgery without a format string; and for cross-language schemas with evolution, Protocol Buffers / FlatBuffers / json are the higher-level answers. struct wins precisely when the layout is rigid, small, and you need raw speed and zero ceremony — exactly the situation of a network header or a fixed-record file.

Production Notes

struct underpins a great deal of Python’s own ecosystem: the standard library’s zipfile, wave, tarfile, and dbm modules parse their on-disk headers with it, and database drivers and network libraries lean on it to assemble wire packets. The performance idiom in hot loops is to (1) build one struct.Struct per record type at module load, (2) pack_into/unpack_from against a reused bytearray to avoid per-call allocation, and (3) prefer iter_unpack to a manual loop over slices when reading an array of records. For exact numeric work that struct’s float codes cannot give — money, accumulation without rounding error — struct is the wrong tool; see the sibling note [[The decimal and fractions Modules]], which covers exact decimal and rational arithmetic.

See Also

  • [[The decimal and fractions Modules]] — sibling §18 note; exact arithmetic where struct’s IEEE floats lose precision.
  • [[The Buffer Protocol]] — the zero-copy memory-sharing contract that pack_into/unpack_from use.
  • [[CPython Bytes and Bytearray Internals]] — the bytes/bytearray objects struct produces and writes into.
  • [[ctypes and cffi]] — C interop, including ctypes.Structure as a named alternative to positional struct tuples.
  • [[The pickle Protocol]] — self-describing serialization for arbitrary Python objects.
  • [[Python Internals MOC]] — §18 Selected Standard Library Internals.