The struct Module and Binary Layout
The
structmodule 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 abytesobject 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 thestructdocumentation, 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('>bhl', ...)"| BY["bytes<br/>b'\x01\x00\x02\x00\x00\x00\x03'"] BY -->|"unpack('>bhl', ...)"| PV2["tuple<br/>(1, 2, 3)"] FMT["format string<br/>'>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”):
| Char | Byte order | Size | Alignment |
|---|---|---|---|
@ | native | native | native |
= | native | standard | none |
< | little-endian | standard | none |
> | big-endian | standard | none |
! | network (big-endian) | standard | none |
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=0x03FFbecomesb'\x03\xff'. Little-endian stores the least-significant byte first:0x03FFbecomesb'\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
sizeofreports for this platform). The classic trap islong: on a 64-bit Linux/macOS build a native Clongis 8 bytes, but on 64-bit Windows it is 4 bytes, andstruct’s standard size forlis always 4. Socalcsize('@l')can be 8 on Linux butcalcsize('=l')is always 4 everywhere. - Alignment is the padding
structinserts 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 thexpad-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:
>hpacks the integer1023as a 2-byte big-endian signed short.1023 = 0x03FF, so the most-significant byte0x03comes first, then0xFF. - Line 4:
<hpacks the same value little-endian, so the least-significant byte0xFFcomes 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”):
| Code | C type | Python type | Standard size |
|---|---|---|---|
x | pad byte | — | 1 |
c | char | bytes len 1 | 1 |
b | signed char | int | 1 |
B | unsigned char | int | 1 |
? | _Bool | bool | 1 |
h | short | int | 2 |
H | unsigned short | int | 2 |
i | int | int | 4 |
I | unsigned int | int | 4 |
l | long | int | 4 |
L | unsigned long | int | 4 |
q | long long | int | 8 |
Q | unsigned long long | int | 8 |
n | ssize_t | int | native only |
N | size_t | int | native only |
e | _Float16 (half) | float | 2 |
f | float | float | 4 |
d | double | float | 8 |
F | float complex | complex | 8 |
D | double complex | complex | 16 |
s | char[] | bytes | (count = len) |
p | char[] (Pascal) | bytes | (count = len) |
P | void * | int | native 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:
@ciis acharthen anint. Theintrequires 4-byte alignment, so after the 1-bytechar(b'#') the cursor is at offset 1 and must skip to offset 4 — three NUL pad bytes appear (\x00\x00\x00) before the little-endianint0x12131415. Total size 8. - Line 5–6:
@icisintthenchar. Theintstarts at offset 0 (already aligned), thecharfollows 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 (andstruct’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')
7pack(fmt, *values)returns a newbytes.unpack(fmt, buffer)returns a tuple and requireslen(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_intowrites100(0x0064) and200(0x00C8) as big-endian shorts into bytes 2–5 of the eight-bytebytearray, 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, recovering200.
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;.sizeexposes the precomputed byte size;.pack/.unpack/.pack_into/.unpack_from/.iter_unpackare 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 internalStructcache (capped at 100 entries inModules/_struct.c), soStructmainly 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 wherestruct’s IEEE floats lose precision.[[The Buffer Protocol]]— the zero-copy memory-sharing contract thatpack_into/unpack_fromuse.[[CPython Bytes and Bytearray Internals]]— thebytes/bytearrayobjectsstructproduces and writes into.[[ctypes and cffi]]— C interop, includingctypes.Structureas a named alternative to positionalstructtuples.[[The pickle Protocol]]— self-describing serialization for arbitrary Python objects.[[Python Internals MOC]]— §18 Selected Standard Library Internals.