Binary files
Bytes as numbers, endianness, struct records, file signatures and checking uploads.
Create a free account Try part of it here
Why it matters
Most of the files a program meets aren't text: photos, PDFs, zip archives, audio, the log a sensor writes, the model weights an AI library saves. They're sequences of bytes laid out to a plan, and the plan is often simple enough to read yourself. That skill pays off whenever a library isn't there or isn't trusted: checking that an upload really is an image, reading a device's data format, or recovering files from a damaged card. It also explains what the libraries you use every day are doing underneath.
What you'll be able to do
- Open a file in binary mode and read its bytes as numbers
- Turn bytes into integers and back, and explain big- and little-endian order
- Read and write fixed-size records with the struct module
- Identify a file's real type from its signature, and use that to check uploads
What it covers
- Bytes are small numbers
- Bigger numbers take several bytes
- Fixed-size records with struct
- Read the counter's log
- A file's real type is in its first bytes
- What kind of file is it really?
- Worked example: an image's size without opening it
data = bytes([137, 80, 78, 71]) print(data[1:4])
Show the answer
b'PNG'
A bytes object prints readable ASCII as letters, so bytes 80, 78 and 71 show as b'PNG'. File formats are bytes laid out to a plan, and this lesson reads those plans.
Bytes are small numbers
A byte is a number from 0 to 255. Opening a file with "rb" (read, binary) gives you its contents as a bytes object with no decoding at all. Indexing a bytes gives a plain int, and .hex() shows the bytes as pairs of hexadecimal digits, the way file-format documents always write them: 89 is 137, ff is 255.
with open("hello.bin", "wb") as f: # write five bytes, given as numbers f.write(bytes([72, 105, 33, 0, 255])) with open("hello.bin", "rb") as f: data = f.read() print(data) # Python shows printable bytes as characters, others as \x.. print(data[0], data[-1]) # one byte is an int print(list(data)) # every byte as a number print(data.hex(" ")) # hexadecimal, the way format specs write bytes print(bytes.fromhex("48 69 21"))
b'Hi!\x00\xff' is Python being helpful: bytes 72, 105 and 33 happen to be the ASCII codes for H, i and !, so it shows them as letters, and writes the rest as \x with two hex digits. Don't let that fool you: the file holds five numbers, not text. That's the difference from the encodings lesson, where bytes were text in disguise.
In plain words: hexadecimal counts in sixteens instead of tens. Its digits run 0 to 9 and then a to f for 10 to 15, so two hex digits cover exactly one byte:
ffis 15 × 16 + 15 = 255,10is 16, and2cis 2 × 16 + 12 = 44. Format documents, debuggers and network tools all show bytes this way, so it's worth reading at a glance. Python writes a hex number in code with0xin front:0xff == 255isTrue.
data = bytes([0, 16, 255]) print(data[1], data.hex(), len(data))
Show the answer
16 0010ff 3
Indexing bytes gives an int, 16. In hex, 0 is 00, 16 is 10 and 255 is ff, two digits per byte. There are 3 bytes.
Bigger numbers take several bytes
One byte only counts to 255, so larger numbers are spread over two, four or eight bytes. The catch is the order. Write 258 in base 256 and it's 1 × 256 + 2, so two bytes, 1 and 2. Big-endian stores the big part first, 01 02, the way we write digits. Little-endian stores the small part first, 02 01, which is what most processors do in memory. Neither is wrong; a file format simply has to say which it uses, and you have to read it the same way.
data = bytes([1, 2]) print(int.from_bytes(data, "big")) # 1 * 256 + 2 print(int.from_bytes(data, "little")) # 2 * 256 + 1 steps = 70_000 print(steps.to_bytes(4, "little").hex(" "), steps.to_bytes(4, "big").hex(" ")) print(int.from_bytes(steps.to_bytes(4, "little"), "little")) # round trip
In plain words: endianness is which end of the number comes first. Read with the wrong one and you get a different, usually absurd, number: a sensor that "counted" 513 hikers when it meant 258.
2c 01. What's the count?print(int.from_bytes(bytes.fromhex("2c 01"), "little"))
Show the answer
300
Little-endian puts the small part first: 0x2c is 44 and 0x01 is 1, so 1 × 256 + 44 = 300. Read as big-endian it would be 44 × 256 + 1 = 11,265.
Fixed-size records with struct
Devices and many file formats write records: the same few fields, in the same order, over and over. The trail club's counters do exactly this. Each record is 8 bytes: a 2-byte station number, a 4-byte timestamp (seconds since 1970), and a 2-byte hiker count, all little-endian. The struct module packs and unpacks such records from a short format string: < for little-endian (> for big), H for a 2-byte unsigned number, I for a 4-byte one, f for a 4-byte floating-point number. The < or > prefix matters for size too: without it, struct pads fields the way the machine's memory does, and struct.calcsize("HIH") is 10, not 8.
import struct RECORD = "<HIH" # station, timestamp, count print(struct.calcsize(RECORD), "bytes per record") raw = struct.pack(RECORD, 3, 1_750_000_000, 42) + struct.pack(RECORD, 7, 1_750_003_600, 15) print(raw.hex(" ")) for station, ts, count in struct.iter_unpack(RECORD, raw): print(station, ts, count)
calcsize confirms the record is 8 bytes, pack turns values into bytes, and iter_unpack walks a run of records for you, one tuple each. If the data's length isn't a whole number of records, iter_unpack raises an error, which is usually a sign the file was cut off or the format string is wrong. Other codes you'll meet: B for one unsigned byte, h and i for signed 2- and 4-byte numbers (which can be negative), and d for an 8-byte floating-point number.
Read the counter's log
Write read_counts(raw): raw is a counter log of 8-byte records in the <HIH layout (station, timestamp, count). Return a dictionary from station number to its total count. A log whose length isn't a multiple of 8 was cut off mid-record: ignore the incomplete tail rather than crashing.
import struct RECORD = "<HIH" def read_counts(raw): station, ts, count = struct.unpack(RECORD, raw[:8]) return {station: count} raw = struct.pack(RECORD, 3, 1_750_000_000, 42) + struct.pack(RECORD, 7, 1_750_003_600, 15) + struct.pack(RECORD, 3, 1_750_007_200, 8) print(read_counts(raw))
A file's real type is in its first bytes
A file name is a label anyone can change. The bytes at the start of most formats, the signature (or "magic number"), are what programs actually trust:
| format | first bytes (hex) | as characters |
|---|---|---|
| PNG image | 89 50 4e 47 0d 0a 1a 0a | \x89PNG\r\n\x1a\n |
| JPEG photo | ff d8 ff | |
| GIF image | 47 49 46 38 | GIF8 |
| PDF document | 25 50 44 46 2d | %PDF- |
| ZIP archive (also .docx, .xlsx) | 50 4b 03 04 | PK\x03\x04 |
That last row is a nice surprise: a Word or Excel file is a zip archive of XML files, so it starts like any zip.
For uploads, this is the check that matters. A user can rename invoice.exe to photo.jpg, and the extension check passes. Reading the first few bytes tells you what the file really is. Combine it with a size limit before reading anything large, and treat a passing signature as "probably that format", not "safe": the rest of the file can still be broken or hostile, so it should go to a well-maintained library to open.
Ignore the file name: only the first bytes count.
What kind of file is it really?
Write sniff(data): return "png", "jpeg", "gif", "pdf" or "zip" from the first bytes of data, using data.startswith(...) with the signatures in the table, or None for anything else. Then allowed_image(name, data): True only when the name ends in .png, .jpg or .jpeg (any case) and the bytes are a PNG or JPEG.
def sniff(data): return None def allowed_image(name, data): return name.lower().endswith((".png", ".jpg", ".jpeg")) print(sniff(b"\x89PNG\r\n\x1a\n...."), allowed_image("photo.jpg", b"MZ\x90\x00"))
Worked example: an image's size without opening it
A PNG's width and height sit at fixed positions near the start: after the 8-byte signature comes a chunk called IHDR, and bytes 16 to 23 of the file hold the width and height as 4-byte big-endian numbers. Build a fake header and read it the way an upload checker would, before deciding whether to accept a 30,000-pixel-wide "photo".
import struct def png_header(width, height): ihdr = struct.pack(">II", width, height) + bytes([8, 2, 0, 0, 0]) # size, then 5 settings bytes return b"\x89PNG\r\n\x1a\n" + struct.pack(">I", len(ihdr)) + b"IHDR" + ihdr def png_size(data): if not data.startswith(b"\x89PNG\r\n\x1a\n") or data[12:16] != b"IHDR": raise ValueError("not a PNG") return struct.unpack(">II", data[16:24]) for w, h in [(1200, 800), (30_000, 30_000)]: data = png_header(w, h) width, height = png_size(data) print(width, height, "ok" if width * height <= 40_000_000 else "too big: refuse before decoding")
>II reads two 4-byte unsigned numbers, big-endian, because the PNG specification says so; the same bytes read with <II would give nonsense. Checking the size from the header matters because a small compressed file can decode into a huge image (30,000 × 30,000 pixels is 900 million, several gigabytes of memory): a cheap look at 24 bytes protects the expensive step. Image libraries apply a similar limit for exactly this reason.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why check an upload's first bytes instead of its file extension?