Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Files, Paths & Encodings  ›  Lesson

Bytes, Buffers & struct

Binary Data 24 minReading a binary format field by field, with the byte order stated
You're building a piece ofQuarry — the yard
This piece — read_header(): Parses the binary telemetry header field by field, and refuses anything that isn't one.
Scenario The pump sensors upload a compact binary file every hour. Before Quarry reads thousands of records from one, it checks the 20-byte header says what the file is, who wrote it and how many records follow.
Your task
Build read_header(hex_data) for Quarry's 20-byte telemetry header, laid out little-endian as struct format "<4sBBHI8s": a 4-byte magic that must be b"QRY1", version (uint8), flags (uint8), record count (uint16), timestamp (uint32) and an 8-byte ASCII sensor name padded with NUL bytes. Return the fields as a dict with the sensor name's padding stripped; return {"error": "short header"} for fewer than 20 bytes and {"error": "bad magic"} for the wrong magic. Extra bytes after the header are records — ignore them.

Bytes, Buffers & struct

In short: some files aren't text at all — they're a fixed layout, byte for byte, with no newline to split on. bytes holds integers 0–255; indexing gives an int, slicing gives bytes back. Numbers bigger than one byte raise the byte order question: big-endian writes the most significant byte first (how we write digits), little-endian writes the least significant first (how most CPUs store them) — the same two bytes mean different numbers read the wrong way. struct describes a whole layout as one format string — "<4sBBHI8s" is little-endian (<), a 4-byte tag, two single bytes, a uint16, a uint32, then 8 raw bytes — and pack/unpack convert a tuple of Python values to and from that exact byte layout in one call. Without the </> prefix, struct uses the machine's native mode: its own byte order and padding, so the same format string can even change size from one machine to another.

struct format "<4sBBHI8s" laid over 20 bytes: each format letter brackets the exact bytes it claims — 4s=magic(4), B=version(1), B=flags(1), H=records(2), I=timestamp(4), 8s=sensor(8) — with the < prefix labelled "little-endian, no padding"

Read the pump's 20-byte header

Every hour a pump sensor uploads a compact file: no encoding to guess, just a header followed by records. struct.Struct pre-builds a format once — worth doing when you'll unpack thousands of records — and pack/unpack convert the whole layout together:

python

pump-07 is 7 characters in an 8-byte field: pack pads s fields with NUL bytes on the way out, so you strip them on the way back in. A real file is a header followed by records, so you rarely have exactly one layout's worth of bytes — unpack insists on an exact length and raises if there's anything left over. unpack_from(fmt, buffer, offset) reads from a position and ignores the rest, which is how you walk a buffer record by record:

broken — fix it

Run it and read the struct.error. Then read the first record without trimming the data by hand.

unpack_from(fmt, buffer, offset) is how you walk a buffer: read a record at offset, add the record size, repeat. Step through it:

step through it
1import struct
2 
3RECORD = struct.Struct("<IhB")
4data = RECORD.pack(1790000000, 2150, 40) + RECORD.pack(1790000060, -325, 41)
5offset = 0
6rows = []
7while offset + RECORD.size <= len(data):
8 ts, temp, hum = RECORD.unpack_from(data, offset)
9 rows.append((ts, temp / 100, hum))
10 offset += RECORD.size
11print(rows)

The temperature is stored as a signed short in hundredths — 2150 means 21.50°C, -325 means -3.25°C. For records packed back to back, RECORD.iter_unpack(data) does this loop for you.

Tell big-endian from little-endian

A field only means one number if reader and writer agree on byte order. Mix them up and the bytes don't change — the number they mean does:

What does this print?
print(int.from_bytes(b"\x01\x00", "little"), int.from_bytes(b"\x01\x00", "big"))

That's not a hypothetical: a telemetry header written by a big-endian sensor and read with a little-endian format string doesn't crash — it just silently reports the wrong record count, three readings misread as 768. The < or > at the front of a struct format isn't decoration; it's the contract between whoever wrote the file and whoever reads it, possibly on different hardware, possibly years apart.

Signed numbers use two's complement — you rarely compute it by hand, you just ask for signed=True (or the signed struct code, lower-case) whenever a field can go negative, like a temperature:

python

Pad a struct without meaning to

The </> prefix also turns off something you'd never choose on purpose: native alignment. Without a prefix, struct uses the machine's own rules, which insert padding so bigger fields start on tidy memory boundaries:

What does this print?
import struct

print(struct.calcsize("@BI"), struct.calcsize("<BI"))

struct.pack also checks every value against its field's range, and raises rather than silently wrapping or truncating:

python

A field that's too narrow for the values it needs to hold is an overflow waiting to happen — timestamps are the classic case: seconds since 1970, stored in a fixed number of bits, roll over once 2n2^n seconds have passed. A signed 32-bit field rolls over in 2038; Quarry's header stores the timestamp as an unsigned uint32 (I), which buys until 2106.

Quick practice

What does this print?
import struct

print(struct.pack("<Hh", 65535, -1).hex())
broken — fix it

Run it and read the struct.error about the buffer size. Fix the call, not the data, so unpacking succeeds.

What does this print?
print((-2).to_bytes(2, "big", signed=True).hex())
What does this print?
import struct

HEADER = struct.Struct("<4sB")
print(HEADER.size, struct.calcsize(">4sB"))

🎯 Your turn

Build read_header(hex_data):

  • read_header("5152593101000300803bb16a70756d702d303700") → {"version": 1, "flags": 0, "records": 3, "timestamp": 1790000000, "sensor": "pump-07"}
  • a PNG's first bytes → {"error": "bad magic"}

Hint — check len(data) < HEADER.size, unpack_from, compare the magic with b"QRY1", then sensor.rstrip(b"\x00").decode("ascii").

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upTwo byte orders

Write u16_both(n) returning [little, big]: the number as 2 bytes little-endian and big-endian, each as a hex string. u16_both(258) → ["0201", "0102"].

binary-dataendianness
DrillFlags packed into one byte

The header's flags byte packs four switches: bit 0 compressed, bit 1 encrypted, bit 2 partial, bit 3 test. Write decode_flags(value) returning the names of the bits that are set, in bit order. Unknown higher bits are ignored.

binary-databit-flags
BuildWrite a reading

A reading is packed as "<HhB": sensor id (uint16), temperature in hundredths of a degree (int16, so it can go negative) and humidity percent (uint8). Write pack_reading(sensor_id, temp_c, humidity) returning the packed bytes as hex, or "out of range" if a value doesn't fit its field. pack_reading(7, 21.5, 40) → "0700660828".

binary-datastruct
BossEvery record, and what's left over

After the header come records of "<IhB" — timestamp, hundredths of a degree, humidity — 7 bytes each. Write parse_records(hex_data) returning {"records": [[timestamp, temp_c, humidity], ...], "leftover": n} where temp_c is the temperature in degrees and leftover counts trailing bytes that don't make a full record.

binary-datastruct
CapstoneFrames in a stream

A stream holds frames back to back: b"QF", a uint16 little-endian payload length, the payload, then a one-byte checksum equal to sum(payload) % 256. Write read_frames(hex_stream) returning {"payloads": [hex, ...], "bad": n, "skipped": k, "truncated": bool}: payloads of frames whose checksum matches, a count of those that don't, a count of bytes skipped while hunting for QF, and whether the stream ended mid-frame. Slice through a memoryview so payloads aren't copied until you keep one.

binary-datamemoryviewchecksum
ExplainWhy the format string starts with <

Quarry's header format is "<4sBBHI8s". A teammate drops the < because "it works on my machine". Explain what the < changes — byte order and padding — and what happens when the file is read somewhere else.

read_header.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
read_header(hex_data) → dictThe header's fields, or an error saying why it isn't one.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.