struct Module Complexity¶
The struct module handles binary data conversions, packing Python values into bytes and unpacking bytes into Python values using format strings.
Complexity Reference¶
| Operation | Time | Space | Notes |
|---|---|---|---|
Struct() compilation |
O(m) | O(m) | m = format string length; compile once |
pack() |
O(k + B) | O(B) | k = fields, B = total byte width. Output is B bytes however few fields produce it. The module caches compiled formats, so a repeated format is not re-parsed |
unpack() |
O(k + B) | O(k + B) | The tuple holds k elements, but byte-string fields carry their bytes with them |
pack_into() |
O(k + B) | O(1) | Writes into a caller-supplied buffer, so no output is allocated |
unpack_from() |
O(k + B) | O(k + B) | Same result space as unpack() |
calcsize() |
O(m) | O(1) | m = format string length |
Format Strings¶
Character Types¶
import struct
# Format string characters
# 'b' = signed char (1 byte)
# 'B' = unsigned char (1 byte)
# 'h' = signed short (2 bytes)
# 'H' = unsigned short (2 bytes)
# 'i' = signed int (4 bytes)
# 'I' = unsigned int (4 bytes)
# 'l' = signed long (4 bytes)
# 'L' = unsigned long (4 bytes)
# 'q' = signed long long (8 bytes)
# 'Q' = unsigned long long (8 bytes)
# 'f' = float (4 bytes)
# 'd' = double (8 bytes)
# 's' = char[] (variable)
# 'p' = pascal string (variable)
# 'P' = void* (pointer)
# Calculate size - O(m) where m = format length
size = struct.calcsize('i') # 4 bytes
size = struct.calcsize('ihh') # 8 bytes (4+2+2)
size = struct.calcsize('ihhf') # 12 bytes (4+2+2+4)
Byte Order and Alignment¶
import struct
# Byte order prefix (optional, default='@' native)
# '@' = native (default)
# '=' = native (no alignment)
# '<' = little-endian
# '>' = big-endian
# '!' = network (big-endian)
# Native order - O(m) parse
native = struct.calcsize('i') # 4
# Little-endian - O(m) parse
little = struct.calcsize('<i') # 4
# Big-endian - O(m) parse
big = struct.calcsize('>i') # 4
# With alignment - O(m) parse
aligned = struct.calcsize('@ii') # 8 (with padding)
unaligned = struct.calcsize('=ii') # 8 (no padding)
Packing Data¶
Simple Packing¶
import struct
# Pack single value - O(1)
# Format: integer (4 bytes)
bytes_data = struct.pack('i', 42)
print(bytes_data) # b'*\x00\x00\x00' (little-endian)
# Pack multiple values - O(n) for n values
bytes_data = struct.pack('ihh', 100, 200, 300)
# 4 bytes (int) + 2 bytes (short) + 2 bytes (short) = 8 bytes
# Pack with byte order - O(n)
bytes_data = struct.pack('>i', 42) # Big-endian
bytes_data = struct.pack('<i', 42) # Little-endian
String Packing¶
import struct
# Pack fixed-length string - O(n)
text = "Hello"
bytes_data = struct.pack('5s', text.encode()) # 5-byte string
# Pack with padding
name = "Bob"
bytes_data = struct.pack('10s', name.encode()) # Padded to 10 bytes
# Pack multiple strings
bytes_data = struct.pack('5s3s', b"Hello", b"Bob") # 8 bytes total
Pack Into Buffer¶
import struct
# Pack into existing buffer - O(n) for n fields
buffer = bytearray(20)
# Write at offset 0
struct.pack_into('i', buffer, 0, 42)
# Write at offset 4
struct.pack_into('h', buffer, 4, 100)
# Write at offset 6
struct.pack_into('f', buffer, 6, 3.14)
print(buffer[:10]) # First 10 bytes with packed data
Unpacking Data¶
Simple Unpacking¶
import struct
# Pack first
bytes_data = struct.pack('ihhf', 100, 200, 300, 3.14)
# Unpack all - O(n)
values = struct.unpack('ihhf', bytes_data)
print(values) # (100, 200, 300, 3.140000104904175)
# Unpack specific subset
values = struct.unpack('ih', bytes_data[:6]) # Skip last values
print(values) # (100, 200)
Unpack From Buffer¶
import struct
# Create buffer with packed data
buffer = bytearray(20)
struct.pack_into('i', buffer, 0, 42)
struct.pack_into('h', buffer, 4, 100)
struct.pack_into('f', buffer, 6, 3.14)
# Unpack from buffer - O(n)
value1 = struct.unpack_from('i', buffer, 0)[0] # 42
value2 = struct.unpack_from('h', buffer, 4)[0] # 100
value3 = struct.unpack_from('f', buffer, 6)[0] # 3.14
Struct Objects (Compiled Format)¶
Create and Reuse Struct¶
import struct
# Create compiled struct - O(m) once, then O(n) per operation
header_format = struct.Struct('4sI') # 4-char string + unsigned int
# Pack with compiled struct - O(n) for n fields
bytes_data = header_format.pack(b"HEAD", 12345)
# Unpack with compiled struct - O(n) for n fields
header, version = header_format.unpack(bytes_data)
print(header) # b'HEAD'
print(version) # 12345
# Size calculation - O(1)
size = header_format.size # 8
Struct for Network Protocol¶
import struct
# Message format: type(1) + length(2) + timestamp(4) + data
class Message:
HEADER_FORMAT = struct.Struct('!BHI') # Network byte order
def __init__(self, msg_type, timestamp, data):
self.type = msg_type
self.timestamp = timestamp
self.data = data
def serialize(self):
"""Pack to bytes - O(n)"""
header = self.HEADER_FORMAT.pack(
self.type,
len(self.data),
self.timestamp
)
return header + self.data
@classmethod
def deserialize(cls, data):
"""Unpack from bytes - O(n)"""
header_size = cls.HEADER_FORMAT.size
msg_type, length, timestamp = cls.HEADER_FORMAT.unpack(
data[:header_size]
)
payload = data[header_size:header_size + length]
return cls(msg_type, timestamp, payload)
# Usage
msg = Message(1, 1234567890, b"Hello")
bytes_msg = msg.serialize()
msg2 = Message.deserialize(bytes_msg)
Common Patterns¶
Binary File I/O¶
import struct
class BinaryWriter:
"""Write binary data to file"""
def __init__(self, filename):
self.file = open(filename, 'wb')
def write_int(self, value):
"""Write integer - O(1)"""
self.file.write(struct.pack('i', value))
def write_string(self, value, length):
"""Write fixed-length string - O(n)"""
self.file.write(struct.pack(f'{length}s', value.encode()))
def close(self):
self.file.close()
class BinaryReader:
"""Read binary data from file"""
def __init__(self, filename):
self.file = open(filename, 'rb')
def read_int(self):
"""Read integer - O(1)"""
return struct.unpack('i', self.file.read(4))[0]
def read_string(self, length):
"""Read fixed-length string - O(n)"""
return struct.unpack(f'{length}s', self.file.read(length))[0]
def close(self):
self.file.close()
# Usage
writer = BinaryWriter("data.bin")
writer.write_int(42)
writer.write_string("Hello", 10)
writer.close()
reader = BinaryReader("data.bin")
value = reader.read_int()
text = reader.read_string(10)
reader.close()
Network Packet Parsing¶
import struct
class PacketHeader:
"""Parse binary packet header"""
FORMAT = struct.Struct('!HHBBHH') # Network byte order
# Destination port (H)
# Source port (H)
# Sequence (B)
# Flags (B)
# Window (H)
# Checksum (H)
def __init__(self, data):
"""Parse header - O(1)"""
if len(data) < self.FORMAT.size:
raise ValueError("Insufficient data")
self.dst_port, self.src_port, self.seq, self.flags, \
self.window, self.checksum = self.FORMAT.unpack(
data[:self.FORMAT.size]
)
def to_bytes(self):
"""Serialize header - O(1)"""
return self.FORMAT.pack(
self.dst_port, self.src_port, self.seq,
self.flags, self.window, self.checksum
)
# Parse packet
packet_data = b'\x00P\x00P\x01\x00\x00\x10\x12\x34'
header = PacketHeader(packet_data)
print(f"Dst: {header.dst_port}, Src: {header.src_port}")
C Structure Mapping¶
import struct
# Map C struct: typedef struct { int id; float score; } Result;
class CResult:
"""Map to C struct"""
STRUCT_FORMAT = struct.Struct('if') # int + float
def __init__(self, id=0, score=0.0):
self.id = id
self.score = score
def pack(self):
"""Convert to C struct bytes - O(1)"""
return self.STRUCT_FORMAT.pack(self.id, self.score)
@classmethod
def unpack(cls, data):
"""Create from C struct bytes - O(1)"""
id, score = cls.STRUCT_FORMAT.unpack(data)
return cls(id, score)
# Usage
result = CResult(42, 95.5)
data = result.pack()
result2 = CResult.unpack(data)
Performance Considerations¶
Time Complexity¶
All timings on this page are from CPython 3.11 on one Linux machine and are illustrative, not portable.
- pack(): O(k + B) for k fields and B bytes. Field count carries the
larger constant:
'100i'and'400s'both produce 400 bytes, and the hundred-field version measured 5.2x the single-field one - but'40000s'is one field and still costs 9.6x'400s', so B is not negligible - unpack(): O(k + B), and the returned tuple holds the bytes too
- Struct creation: O(m) one-time cost (m = format length)
- pack_into(): O(k + B) time, O(1) extra space
Space Complexity¶
- Packed bytes: O(B) produced by
pack(), or none bypack_into() - Unpacked tuple: O(k + B) returned by
unpack(), since byte-string fields carry their bytes
Optimization Tips¶
The module-level functions keep a cache of compiled formats, so a format you use repeatedly is parsed once whether or not you pre-compile it. Pre-compiling saves the cache lookup, which is worth about 15%.
import struct
# Fine: the format is parsed once and then found in the cache
for i in range(1000):
data = struct.pack('i', i) # 67 ns per call
# Slightly better: no cache lookup either
int_struct = struct.Struct('i')
for i in range(1000):
data = int_struct.pack(i) # 58 ns per call
The saving is larger when the format is not in the cache, but only if the
Struct is reused. Sweeping 58 distinct formats, with _clearcache() called
each time - a forced cold cache, not natural capacity churn:
| µs per sweep | |
|---|---|
| Module call, cold cache | 46.2 |
Build a Struct then pack, per format |
42.9 |
Reuse Struct objects built beforehand |
14.2 |
Pre-compiling a format you use once saves nothing: you pay the same parse,
just at a different moment. The 3x is for reuse. So build your Struct
objects where you build the rest of your constants, and do not bother for a
one-shot format.
Format Modifiers¶
Repetition¶
import struct
# Pack 10 integers - O(n)
data = struct.pack('10i', 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
# Unpack 10 integers - O(n)
values = struct.unpack('10i', data)
print(len(values)) # 10
# With byte order
data = struct.pack('>10H', *range(10)) # 10 big-endian shorts
Error Handling¶
import struct
# Format must match data size
try:
struct.unpack('i', b'AB') # O(m) - size check fails before any unpacking
except struct.error as e:
print(f"Unpack error: {e}")
# Invalid format character
try:
struct.pack('z', 42) # O(m) - rejected while parsing the format
except struct.error as e:
print(f"Pack error: {e}")
# Both failures cost format parsing only. Note that the module-level
# functions cache compiled formats, so a repeated format is not re-parsed on
# every call - pre-compiling a Struct saves the cache lookup, worth about
# 15% here, and saves the parse itself only when the cache misses