Bytes, hex and int¶
Level: 101 → 201 · for Python programmers
One line: Four conversions — bytes.hex, bytes.fromhex, int.from_bytes, int.to_bytes — are the whole toolkit for reading a binary format by hand, and struct is the same four with a template.
b = "é".encode("utf-8")
b.hex() # 'c3a9'
bytes.fromhex("c3a9") # b'\xc3\xa9'
int.from_bytes(b, "big") # 50089
(50089).to_bytes(2, "big") # b'\xc3\xa9'
Two of those four move bytes to a picture of the same bytes. Two move bytes to a number. Telling them apart is the whole lesson.
This is chapter 1, in Python¶
Hex: a number, or a picture of bytes makes the distinction that everything here rests on: the same run of hex digits is two different objects. As a number, width is nothing and a leading zero is noise — 0x1e240 and 0x0001e240 are the same integer. As a byte string, width is the data and a leading zero is a NUL byte you must write.
Python gives you a separate pair of functions for each reading, which is a kindness other languages do not extend:
| you have | you want | the call |
|---|---|---|
| bytes | their hex picture | b.hex() |
| a hex picture | bytes | bytes.fromhex(s) |
| bytes | a number | int.from_bytes(b, order) |
| a number | bytes of a given width | n.to_bytes(width, order) |
Reach for int(s, 16) when you meant bytes.fromhex(s) and you have silently thrown the width away. Grouping is a choice covers hex()'s separator arguments — including the rule that the sign of the group width is the direction — and this page does not repeat it.
The argument that stopped being compulsory¶
int.from_bytes was added in Python 3.2 with no default for byteorder. You could not call it without answering the question, which is the rarer kind of API: one that prevents a bug rather than documenting it.
Python 3.11 gave it one ↗, and the default is 'big':
So the guard is gone and the question is not. Two bytes have two readings, the bytes do not say which one you meant, and now neither does your code — a reader cannot tell a deliberate big-endian from a forgotten argument. Write the byte order down even where the default would have done.
Measured 2026-09-07, and it is a version boundary rather than a platform one:
3.10.21 3.11.16 3.12.14 3.13.15 3.14.7
int.from_bytes(b, 'big') 50089 50089 50089 50089 50089
int.from_bytes(b) TypeError 50089 50089 50089 50089
The 3.10 cell is the whole history in one word, and its message names the thing that went missing: from_bytes() missing required argument 'byteorder' (pos 2). That wording is CPython's and belongs in this fence rather than in an answer key, but the class is the point — a call that could not be made at all is now a call that quietly picks for you.
The program¶
Verified output of bytes_hex_and_int_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. BYTES AND THEIR PICTURE
------------------------------------------------------------------------
bytes.hex() and bytes.fromhex() are inverses, and neither one is a
number: they move between some bytes and a written-down picture of
the same bytes, digit for digit, in order.
EACUTE = 'e-acute'.encode('utf-8') b'\xc3\xa9'
EACUTE.hex() 'c3a9'
bytes.fromhex('c3a9') == EACUTE True
The separator argument groups the picture without changing it, and
the SIGN of the group width is the direction: positive counts from
the RIGHT, negative from the left. On an odd number of bytes that
decides where the short group lands, which the grouping page works
through in full:
b.hex(' ') 00 01 e2 40 ff one byte at a time
b.hex(' ', 2) 00 01e2 40ff short group first
b.hex(' ', -2) 0001 e240 ff short group last
Same five bytes every time. Only the spaces moved, and the spaces
are not in the data.
2. BYTES AND A NUMBER
------------------------------------------------------------------------
int.from_bytes() reads bytes as one integer, and the byte order is
the whole question. Two bytes, two readings:
int.from_bytes(EACUTE, 'big') 50089
int.from_bytes(EACUTE, 'little') 43459
Neither is right. They are answers to different questions, and the
bytes do not say which one you meant -- that is what a format spec
is for.
Going back the other way, the LENGTH is the other half of the same
point. One number, four widths:
(123456).to_bytes(3, 'big') 01 e2 40
(123456).to_bytes(4, 'big') 00 01 e2 40
(123456).to_bytes(8, 'big') 00 00 00 00 00 01 e2 40
(123456).to_bytes(2, 'big') OverflowError
The leading zeros are not decoration. As a NUMBER 123456 has no
width; as a field in a file it has exactly the width the format
says, and the zeros are bytes that must be written.
3. THE ARGUMENT THAT STOPPED BEING COMPULSORY
------------------------------------------------------------------------
From Python 3.2 to 3.10, byteorder had no default. You could not
call int.from_bytes without answering the question, which is the
kind of API that prevents a bug rather than documenting one.
Python 3.11 gave it one, and the default is 'big':
int.from_bytes(EACUTE) 50089
...same as 'big': True
...same as 'little': False
(255).to_bytes() ff
On Python 3.10 and earlier the first line is a TypeError. So the
guard is gone, the question is not, and it is now yours to
remember: write the byte order down even where the default would
have done, because the reader of your code cannot tell a deliberate
big-endian from a forgotten argument.
4. NEGATIVE NUMBERS ARE ONE MORE ARGUMENT
------------------------------------------------------------------------
signed=True is two's complement, spelled as a keyword:
(-1).to_bytes(4, 'big', signed=True) ff ff ff ff
(-2).to_bytes(4, 'big', signed=True) ff ff ff fe
(1).to_bytes(4, 'big', signed=True) 00 00 00 01
And the same four bytes read both ways -- which is why a format
spec has to say signed or unsigned, and why getting it wrong gives
you a number instead of an error:
int.from_bytes(ff, 'big') 4294967295
int.from_bytes(ff, 'big', signed=True) -1
(-1).to_bytes(4, 'big') OverflowError
Unsigned refuses a negative, which is the one place the
conversion does check up on you.
5. struct IS THE SAME FOUR WITH A TEMPLATE
------------------------------------------------------------------------
The first character of a struct format is the byte order, and it is
the first thing to read in any format string you meet:
hex(value) 0x1e240
format packed bytes meaning
<I 40 e2 01 00 little-endian, 4 bytes
>I 00 01 e2 40 big-endian, 4 bytes
<H struct.error the value does not fit
>Q 00 00 00 00 00 01 e2 40 big-endian, 8 bytes
struct.calcsize('<I') 4
struct.calcsize('<HH') 4
struct.unpack('>I', pack('>I', v)) True
Leave the byte-order character off and you get NATIVE order plus
native alignment padding, which is a different size on a different
machine. In a file format that is always a bug:
struct.calcsize('=HI') (no padding) 6
struct.calcsize('HI') (native, padded) 8
struct.calcsize('<HI') (explicit) 6
The middle row is two bytes of padding you did not ask for and
cannot see. Which order 'native' means is sys.byteorder, and it is
a fact about the machine rather than about your data -- which is
why it is named here rather than printed: an answer key that
recorded it would be recording the runner.
6. A REAL HEADER, BUILT AND THEN READ BACK
------------------------------------------------------------------------
The first 16 bytes of a PNG. Everything below is computed here --
the length, the CRC -- so it is a real header rather than a quoted
one:
89 50 4e 47 0d 0a 1a 0a
00 00 00 0d 49 48 44 52
Field by field:
bytes field value
89 50 4e 47.. PNG signature 8 bytes, fixed
00 00 00 0d chunk length (>I) 13
49 48 44 52 chunk type (4 ASCII) IHDR
00 00 00 01 width (>I) 1
00 00 00 01 height (>I) 1
08 bit depth (B) 8
02 colour type (B) 2
90 77 53 de CRC-32 of type+body checks: True
PNG is big-endian throughout, which is why every length above is
'>I'. Read it as '<I' and the 13-byte header announces itself as
218103808 bytes:
struct.unpack('>I', png[8:12]) 13
struct.unpack('<I', png[8:12]) 218103808
7. AND THE SIGNATURE IS AN ENCODING TEST
------------------------------------------------------------------------
Those first eight bytes are not a magic number somebody liked. Six
of the eight are there to catch a file that was TRANSFORMED in
transit, and two of them are this library's subject exactly:
89 high bit set: dies if the path is 7-bit clean
50 P
4e N
47 G
0d CR: gone if LF was translated to CRLF
0a LF: gone if CRLF was translated to LF
1a DOS end-of-file, so TYPE stops here
0a LF again, catching the reverse translation
A PNG that went through an FTP client in text mode arrives with its
CR/LF pair rewritten and fails on byte five, before any decoder has
to guess what went wrong. That is a file format defending itself
against the line-ending problem -- the same one that turns a CSV
into a column of blank rows.
The PNG header is not an arbitrary example¶
The last two sections decode the first bytes of a PNG, and the file is built in the program — length computed, CRC computed with zlib.crc32 — so it is a real header rather than a quoted one.
It earns its place here because its signature is an encoding test. Six of those eight bytes exist to catch a file that was transformed in transit, and two of them are this library's subject exactly: the 0D 0A pair is there so that a client which "helpfully" translated line endings destroys the signature and the file fails immediately, instead of arriving as a plausible-looking image that no decoder can read. The trailing 0A catches the reverse translation, and the leading 89 — high bit set — dies on a 7-bit-clean path.
That is a file format defending itself against the line-ending problem, designed in by people who had been bitten. Most formats do not, which is why the rest of this library exists.
Base64 is a different thing in the same drawer¶
b.hex() and base64.b64encode(b) both turn bytes into text you can put in a JSON field, and they are not the same kind of operation as .decode('utf-8'). Hex and Base64 are text encodings of arbitrary bytes — they never fail, they never need to know what the bytes mean, and they cost you size (hex doubles; Base64 is four characters per three bytes). A character encoding is the opposite: it does need to know what the bytes mean, and it can refuse.
That distinction, the six-bits-per-character arithmetic, and the padding rules are Binary to text. For a hex dump specifically, prefer bytes.hex() over base64.b16encode — same encoding, and b16decode refuses lowercase.
If you are coming from Python or ABAP¶
Python. Three habits. First, struct's byte-order character is not optional in practice: leave it off and you get native order and native alignment padding, so struct.calcsize('HI') is 8 where '<HI' is 6 — a format string that works on your machine and produces a differently-sized record on someone else's. Always lead with <, >, or ! (which is >, spelled "network order"). Second, int.to_bytes raises OverflowError rather than truncating, which is the one place these conversions check up on you — and signed=True is required for a negative, in both directions. Third, for anything with more than a few fields, struct.Struct('<IIH') compiles the format once and is both faster and self-documenting; memoryview lets you slice a large buffer without copying it, which matters exactly when you are parsing a file big enough to care.
ABAP. (Not machine-checked — CI cannot run ABAP.) The type is xstring for a variable-length byte string and x / raw for fixed-length, and the hex picture is not a conversion at all — an xstring written into a character field is already its own hex representation, which is why xstring values print as C3A9 with no function call. The genuine conversions are cl_abap_conv_in_ce / cl_abap_conv_out_ce between xstring and string (newer systems: cl_abap_codepage), and there you must name the code page, exactly as Python's .decode() takes an encoding. The number half needs no call at all, because a plain assignment is from_bytes with the byte order welded shut: moving an x or xstring into an i reads its last four bytes as a signed big-endian number ↗, zero-filled on the left when there are fewer — int.from_bytes(b[-4:].rjust(4, b'\0'), 'big', signed=True) in Python's terms — so '0010' is 16, 'FFFFFFFF' is -1, and a two-byte 'FFFF' is 65535, because the fill is zeros rather than the sign. int8 does the same with eight. Bytes in front of the last four are ignored rather than refused, and a little-endian field has to be byte-reversed before the assignment, because nothing in the statement lets you say otherwise. The assignment back the other way writes big-endian too ↗, and truncates on the left where to_bytes would raise OverflowError. The application server's own order is a separate fact — sys.byteorder is spelled cl_abap_char_utilities=>endian ↗ — and it shows through exactly where struct's native order does, once you stop converting and start reinterpreting: ASSIGN … CASTING lays a type over the bytes where they lie, and SAP warns that the result can depend on the platform ↗, because the internal byte order becomes visible in the cast. So 00 00 00 0D is 13 through an assignment on every server and 218103808 through a cast on a little-endian one — the PNG header's two readings from above, with no > or < in sight to say which one you wrote. Slice fields out with +off(len), which makes the width explicit, and assign them rather than cast them. Bit work is GET BIT / SET BIT and the BIT-AND / BIT-OR / BIT-XOR / BIT-NOT operators ↗, on x and xstring only. Verify any code-page number you find in a document against the system that will run the job.
Try it¶
- Take a binary file you actually have — a PNG, a ZIP, a
.class, a Parquet footer — andhead -c 16 file | xxd, then decode those bytes field by field against the format's spec withstruct.unpack. Getting>versus<wrong is the fastest way to learn which one the format uses. python3 -c "import sys; print(open(sys.argv[1],'rb').read(8).hex(' '))" yourfileagainst a dozen files of different types. Most formats announce themselves in the first four bytes, andfileguesses is doing exactly this with a bigger table.- Round-trip a negative number: pick one,
to_bytes(4, 'big', signed=True), then read it back withoutsigned=True. The number you get is the bug that reaches a database column. git grep -n "int(.*, *16)"in a project you maintain, and check each hit for whether the width mattered. The ones parsing a fixed-width field are the ones to change.
Practice¶
Six bytes, and a format spec you have not been given. The bytes are 00 00 00 0d 49 48. Predict bytes.fromhex round-tripping, then: int.from_bytes of the first four as big-endian and as little-endian, what the last two are as ASCII, and which of struct.unpack('>I', ...) and struct.unpack('<I', ...) on the first four gives the smaller number.
Then the one that is not arithmetic: int.from_bytes(b'\xff\xff\xff\xff') and the same call with signed=True give two answers, both correct. Say what a format spec must state for only one of them to be right, and what happens if it does not.
Answers
Verified output of bytes_hex_and_int_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
SIX BYTES
the bytes 00 00 00 0d 49 48
RAW.hex() '0000000d4948'
bytes.fromhex(RAW.hex()) == RAW True
The round trip is exact because neither call is arithmetic.
fromhex() also skips ASCII spaces, so a dump pasted straight out
of xxd goes back in:
bytes.fromhex('00 00 00 0d') == HEAD True
THE FIRST FOUR AS A NUMBER
int.from_bytes(HEAD, 'big') 13
int.from_bytes(HEAD, 'little') 218103808
struct.unpack('>I', HEAD)[0] 13
struct.unpack('<I', HEAD)[0] 218103808
'>I' agrees with 'big': True
the smaller answer is: >I / big
Big-endian reads the significant byte first, so three leading
zeros mean a small number. Little-endian reads them last, where
they are the TOP three bytes -- so the same four bytes become
thirteen or two hundred and eighteen million, and nothing about
the bytes prefers either reading.
THE LAST TWO AS TEXT
TAIL b'IH'
TAIL.decode('ascii') 'IH'
...and as a number, 'big': 18760
Both readings are available and only one is intended. These six
bytes are the start of a PNG's IHDR chunk: a big-endian length
followed by a four-byte ASCII type, of which we have two letters.
A length field and a name field, side by side, distinguishable
only by a spec.
FOUR BYTES THAT ARE TWO NUMBERS
int.from_bytes(FF, 'big') 4294967295
int.from_bytes(FF, 'big', signed=True) -1
both round-trip to the same bytes: True
A format spec has to state THREE things before four bytes are one
number: the width, the byte order, and whether it is signed.
Miss any one and the bytes still decode -- to a different value,
with no error and no clue. The signed case is the meanest of the
three, because the two answers agree for every value below 2^31
and diverge only once the top bit is set: a counter that has been
correct for years starts reporting -1 the day it crosses over.
2147483647 both ways agree: True
2147483648 both ways agree: False
See also¶
- Packing a record —
structpast one number: a whole record, the same format string read in Rust, C and Perl, and alignment padding as a fact about the ABI rather than about the format - Hex is a shorthand — binary written four bits at a time
- Hex: a number, or a picture of bytes — the distinction these four calls implement
- Grouping is a choice —
hex(sep, bytes_per_sep), and why the sign is the direction - Reading a hex dump — the same job from the other end
- Arithmetic has its own width — why
to_bytesneeds a width at all - Byte order and the BOM — where
<and>come from, and the mark that resolves them - Binary to text — Base64 and friends, and why they are not character encodings
- CRLF vs LF — the translation the PNG signature is built to detect
strvsbytes— the type these conversions live on