Magic Numbers and Headers
Binary File Formats in Practice
Chapter 4 ยท Magic Numbers and Headers
A file name is only a label. Rename a text file to photo.png and nothing about its bytes changes. Programs that need to know what a file really is look at its first few bytes, where most binary formats put a fixed signature, often called a magic number. After the signature comes the header: the fields that describe everything that follows. This chapter uses the struct tools from Binary File Formats in Practice 3 to read both.
Magic Numbers
A magic number is a short, fixed byte sequence at a known position, nearly always offset 0. It is chosen to be unlikely to appear at the start of anything else. Some are readable text, some deliberately are not:
| Format | Signature (hex) | As text |
|---|---|---|
| PNG | 89 50 4e 47 0d 0a 1a 0a | .PNG then CR LF, Ctrl-Z, LF |
| JPEG | ff d8 ff | not printable |
| GIF | 47 49 46 38 37 61 or ...39 61 | GIF87a / GIF89a |
25 50 44 46 2d | %PDF- | |
| ZIP | 50 4b 03 04 | PK then two control bytes |
| gzip | 1f 8b | not printable |
These values are widely documented but worth confirming against each format's specification before you rely on them for anything important (check). The PNG signature is a good example of careful design: the high-bit first byte stops it being mistaken for text, the letters name the format, and the CR LF, Ctrl-Z and LF bytes make common text-mode file transfers that mangle line endings show up as a broken signature.
Identifying a File by Its Signature
The technique is a table of signatures and a loop with bytes.startswith. Order matters when one signature could begin another, so put the longest and most specific first:
With a real file you only need to read as many bytes as your longest signature. Sixteen is plenty here, and reading a few bytes is far cheaper than reading the whole file. A file shorter than the signature simply fails to match; startswith never raises an error for short input.
What a Header Holds
Right after the signature a header usually carries a handful of fixed-size fields. The common ones are:
- Version: which revision of the format wrote the file.
- Flags: optional features switched on or off (Binary File Formats in Practice 7 takes these apart bit by bit).
- Counts or sizes: how many records follow, or how long the data is.
- Offsets: where to find the next section, so a reader can jump straight to it.
A header is a fixed-layout record, so a struct.Struct describes it exactly. Here is a small invented format: a 4-byte tag, a major and minor version byte, a 16-bit flags value and a 32-bit record count, 12 bytes in all. The parser checks each assumption before trusting the next:
The order of the checks is deliberate: enough bytes, then the right signature, then a version this code understands. Only after all three does the count mean anything.
Versions
Formats change. A version field lets a reader decide, before it misreads anything, whether it can handle a file. A common convention splits the version in two:
- Major changes when the layout changes in a way old readers cannot cope with. A reader refuses a major version it does not know.
- Minor changes when something is added in a way old readers can safely ignore. A reader accepts any minor version of a major it knows.
That is why parse_header above rejected major 2 but happily accepted minor 3. Refusing clearly is much better than reading garbage and carrying on.
Header Length: Room to Grow
A fixed header size is a trap: add one field and every old reader breaks. Many formats instead store the header's own length, so a reader takes the fields it understands and uses the length to jump to the payload. Fields added later simply sit in the part it skips:
Both files are read by the same code even though the second has four extra header bytes. In real files, treat a stored length as a claim to be checked: it must be at least the size you need and no larger than the data you actually have.
| Design choice | Benefit | Cost |
|---|---|---|
| Magic number | Identify the format without trusting the name | A few bytes of every file |
| Major / minor version | Readers can refuse or accept safely | Everyone must follow the convention |
| Header length field | New fields do not break old readers | One more value to validate |
| Offset to data | Jump straight to a section | Offsets can point outside the file |
Challenges
Write identify_file(path) using the signature table from this chapter. It reads at most 16 bytes and returns the format name, "unknown", or "empty file". In a temporary folder create a PNG-like file, a PDF-like file, a GIF-like file, a plain text file, a text file named e.png, an empty file, a real ZIP made with zipfile and a real gzip file made with gzip. Print the result for each, and confirm the folder is gone afterwards.
Using the 12-byte "<4sBBHI" header, write write_demo(path, major, minor, flags, records) (records are unsigned 16-bit values written after the header) and read_demo(path), which returns ((major, minor), flags, values). Reject a truncated header, a wrong magic, a major version other than 1, and a body shorter than the count claims. Show two good files (minor versions 0 and 7) and all four failures, then delete the temporary file.
Design a header of "<4sBBH" followed by a 16-bit header_length (10 bytes minimum). Write write_file(extra, payload) that sets the length correctly and read_file(raw) that returns the flags, the header length and the payload. Show one file with no extra header bytes and one with a 4-byte extra field, read both with the same function, and reject lengths that are too small or larger than the data.
Quick Reference
- Signature check: read the first bytes, then
head.startswith(magic) - Read only what you need:
f.read(16)is enough for the table in this chapter - Header as a record: a module-level
struct.Struct("<...")with.sizeandunpack_from - Check order: enough bytes, then magic, then version, then trust counts and lengths
- Versions: refuse an unknown major version; accept any minor of a known major
- Header length field: read what you know, skip to the stored length, and validate it first
- Remember: a matching signature is evidence, not proof
zlib proves each chunk arrived intact.