Binary Data with struct

Reviewed & published by Brayan K

The struct module converts between Python values and compact C-style bytes, letting you pack numbers into binary and unpack raw bytes from files, packets, and devices.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

When you need to read a binary file header or speak a wire protocol, struct is the precise, byte-accurate tool — a tiny format string describes exactly how the bytes are laid out.

What You'll Learn in This Lesson

1 pack & unpack: Values ⇄ Bytes

struct.pack(fmt, *values) writes values into a bytes object; struct.unpack(fmt, data) reads them back as a tuple. The format string describes the layout — here >hhl means two shorts and a long, all big-endian:

import struct

# Pack two shorts (h) and a long (l), big-endian (>)
packed = struct.pack(">hhl", 1, 2, 3)
print(packed)
# b'\x00\x01\x00\x02\x00\x00\x00\x03'

print(len(packed))   # 8  (2 + 2 + 4 bytes)

# Unpack always returns a tuple
nums = struct.unpack(">hhl", packed)
print(nums)          # (1, 2, 3)
import struct

# Pack 3 unsigned bytes (B) and one int (i), big-endian
packed = struct.pack(">BBBi", 255, 0, 128, 70000)
print("bytes:", len(packed))

r, g, b, value = struct.unpack(">BBBi", packed)
print("rgb:", r, g, b)
print("value:", value)

# ✅ Expected output:
# bytes: 7
# rgb: 255 0 128
# value: 70000

2 Byte Order & Format Characters

The first character of a format sets byte order. The same number 1 packs to different bytes depending on endianness, which is why you must always be explicit when parsing a real format:

import struct

# 4-byte int 1, two ways
little = struct.pack("<i", 1)   # least significant byte first
big    = struct.pack(">i", 1)   # most significant byte first

print("little:", little)   # b'\x01\x00\x00\x00'
print("big:   ", big)      # b'\x00\x00\x00\x01'

# Read each back with the matching order
print(struct.unpack("<i", little)[0])   # 1
print(struct.unpack(">i", big)[0])      # 1

# Floats use f (4 bytes) and d (8 bytes)
floats = struct.pack("<3f", 1.5, 2.5, 3.5)
print(struct.unpack("<3f", floats))     # (1.5, 2.5, 3.5)
<   little-endian        >   big-endian
!   network (big-endian)  =   native, no padding

Network protocols and many file formats use big-endian (> or !). Pick one and stick with it across pack and unpack.

import struct

# Same value, opposite byte orders -> different bytes
value = 258   # = 0x0102
print("big   :", struct.pack(">H", value))
print("little:", struct.pack("<H", value))

# Reading with the WRONG order flips the bytes
wrong = struct.unpack("<H", struct.pack(">H", value))[0]
print("misread:", wrong)

# ✅ Expected output:
# big   : b'\x01\x02'
# little: b'\x02\x01'
# misread: 513

3 Reading a Binary Header & calcsize

Real binary formats start with a fixed header: a magic signature, a version, and some counts. struct.calcsize(fmt) tells you how many bytes that header occupies, so you know exactly how much to read:

import struct

# Layout: 4-byte magic (4s), version (H), record count (I)
HEADER = ">4sHI"
print("header size:", struct.calcsize(HEADER))   # 10

# Build a header as if it were the start of a file
raw = struct.pack(HEADER, b"DATA", 2, 100)
print(raw)   # b'DATA\x00\x02\x00\x00\x00d'

# Parse it back
magic, version, count = struct.unpack(HEADER, raw)
print("magic:", magic.decode("ascii"))   # magic: DATA
print("version:", version)               # version: 2
print("records:", count)                 # records: 100
import struct

# A tiny 3-field record: id (H), temp as a float (f), flag (B)
RECORD = "<HfB"
print("record size:", struct.calcsize(RECORD))

raw = struct.pack(RECORD, 7, 21.5, 1)
rec_id, temp, flag = struct.unpack(RECORD, raw)
print("id:", rec_id)
print("temp:", temp)
print("flag:", bool(flag))

# Expected output:
# record size: 7
# id: 7
# temp: 21.5
# flag: True

🎯 YOUR TURN: Fill in the Blanks

Fill in each ___ so the format string is big-endian and the round-trip prints the original values. (Hint: H = unsigned short, I = unsigned int.)

import struct

# Big-endian: one short then one int
FMT = "___HI"   # replace ___ with the big-endian prefix

raw = struct.pack(FMT, 3, 90000)
print("size:", struct.calcsize(FMT))

# Replace ___ with the function that reads bytes back
year, total = struct.___(FMT, raw)
print("year:", year)
print("total:", total)

# Expected output:
# size: 6
# year: 3
# total: 90000

Common Errors

❌ Wrong number of bytes for the format

struct.unpack(">i", b"\x00\x01")
# struct.error: unpack requires a buffer of 4 bytes

✅ Match the buffer length to calcsize(fmt)

import struct
data = struct.pack(">i", 1)         # exactly 4 bytes
print(struct.unpack(">i", data))    # (1,)

❌ Treating unpacked bytes as a string

magic, = struct.unpack(">4s", b"DATA")
print(magic == "DATA")   # False!  magic is b'DATA', not 'DATA'

✅ Decode bytes to text explicitly

magic, = struct.unpack(">4s", b"DATA")
print(magic.decode("ascii") == "DATA")   # True

❌ Forgetting the byte-order prefix

# No prefix = native order + padding -> sizes can surprise you
struct.calcsize("ci")   # may be 8 (padding inserted), not 5

✅ Add a prefix to get exact, portable sizes

import struct
print(struct.calcsize("<ci"))   # 5  (no padding, predictable)

🎯 Mini Challenge: Parse Sensor Records

A device emits a stream of fixed-size records: an id (H) and a reading (f), big-endian. Pack three of them, then loop through the bytes calcsize at a time to unpack each record.

import struct

RECORD = ">Hf"
size = struct.calcsize(RECORD)

# Build a "stream" of 3 records back-to-back
stream = b"".join([
    struct.pack(RECORD, 1, 19.5),
    struct.pack(RECORD, 2, 22.0),
    struct.pack(RECORD, 3, 18.25),
])

print("record size:", size)
for i in range(0, len(stream), size):
    chunk = stream[i:i + size]
    rec_id, reading = struct.unpack(RECORD, chunk)
    print(f"id={rec_id} reading={reading}")

# ✅ Expected output:
# record size: 6
# id=1 reading=19.5
# id=2 reading=22.0
# id=3 reading=18.25

📚 Keep Learning

Frequently Asked Questions

Lesson complete — you speak binary now!

You can pack values into bytes and unpack them back, read format characters like i, h, f, and s, control endianness with < > ! =, use calcsize to measure layouts, and walk a binary stream record by record.

Practice quiz

What does the struct module do?

  • Defines C-style structs as Python classes
  • Compresses Python objects to disk
  • Converts between Python values and C-style packed bytes
  • Parses JSON into objects

Answer: Converts between Python values and C-style packed bytes. struct packs Python values into compact bytes and unpacks bytes back into values using a format string.

What does struct.unpack always return?

  • A tuple, even for one value
  • A single value
  • A list
  • A dictionary

Answer: A tuple, even for one value. unpack returns a tuple even for a single value: struct.unpack('>i', data) gives (5,), so index [0] or unpack it.

What does the '<' prefix mean in a format string?

  • Big-endian
  • Network byte order
  • Native with padding
  • Little-endian

Answer: Little-endian. '<' is little-endian (least significant byte first); '>' and '!' are big-endian, '=' is native order.

Which prefix means network byte order?

  • <
  • !
  • =
  • @

Answer: !. '!' is network byte order, which is the same as big-endian ('>').

What is the size of the format '>4sHI' according to struct.calcsize?

  • 10
  • 7
  • 8
  • 12

Answer: 10. 4s (4) + H (2) + I (4) = 10 bytes, since the explicit byte-order prefix removes padding.

What does the 'f' format character represent?

  • A 2-byte short
  • An 8-byte double
  • A 4-byte float
  • A flag bit

Answer: A 4-byte float. 'f' is a 4-byte float; 'd' is an 8-byte double, 'h' a 2-byte short, 'i' a 4-byte int.

Why do you get bytes back instead of a str from an 's' format?

  • struct only works on numbers
  • struct works at the byte level, so you decode text yourself
  • It is a bug in struct
  • The encoding was wrong

Answer: struct works at the byte level, so you decode text yourself. The 's' code yields a bytes object; decode it yourself with .decode('utf-8') or 'ascii' when you know the encoding.

What does struct.pack('>H', 258) produce?

  • b'\x02\x01'
  • b'\x00\x00'
  • b'258'
  • b'\x01\x02'

Answer: b'\x01\x02'. 258 is 0x0102; big-endian writes the most significant byte first, giving b'\x01\x02'.

What does struct.unpack('>i', b'\x00\x01') raise?

  • Nothing — it returns (1,)
  • A struct.error, because the buffer must be 4 bytes
  • A TypeError
  • A UnicodeDecodeError

Answer: A struct.error, because the buffer must be 4 bytes. The 'i' format needs exactly 4 bytes; a 2-byte buffer raises struct.error. Match the buffer to calcsize(fmt).

Why should you always include a byte-order prefix in a format string?

  • It makes packing faster
  • It is required syntax
  • Without one, native order plus padding can give surprising sizes
  • It enables compression

Answer: Without one, native order plus padding can give surprising sizes. No prefix means native byte order with alignment padding, so sizes vary by platform; a prefix gives exact, portable layouts.

Continue this course

Frequently asked questions

What does the struct module do?

struct converts between Python values and C-style packed bytes. struct.pack turns numbers and strings into a compact bytes object using a format string, and struct.unpack reads those bytes back into Python values. It's how you read and write binary file formats, network packets, and data from low-level hardware or other languages.

What do the < > ! = characters at the start of a format mean?

They set byte order (endianness) and alignment. '<' is little-endian, '>' is big-endian, '!' is network byte order (same as big-endian), and '=' is native order with no padding. Always specify one explicitly when reading or writing a file format so your code behaves identically on every machine.

What are the format characters like i, h, f, and s?

Each letter maps to a C type: 'h' is a 2-byte short, 'i' is a 4-byte int, 'f' is a 4-byte float, 'd' is an 8-byte double, and 's' is a bytes string (prefixed with a count, e.g. '4s'). A leading number repeats a code, so '3i' means three ints. struct.calcsize tells you how many bytes a format takes.

Why do I get bytes back from unpack instead of a string?

struct works at the byte level, so the 's' format gives you a bytes object, not a str. Decode it yourself with .decode('utf-8') (or 'ascii') when you know the text encoding. This explicit step keeps you in control of encoding, which matters a lot when parsing real binary formats.