Hex is a shorthand¶
Level: 101 · for anyone starting from zero
One line: Hexadecimal is not a different kind of number. It is binary written four bits at a time, so one byte is always exactly two hex digits, and C3 A9 is a picture of sixteen switches you do not have to count.
One digit, four bits¶
Sixteen patterns fit in four bits, and hex gives each one a single character: 0–9 for the first ten, A–F for the last six. Split a byte down the middle and each half is one digit:
0100 0001 the byte for 65
4 1 left nibble 0100 = 4, right nibble 0001 = 1
0x41 the same byte, as most tools will show it
Decimal cannot do this. 65 tells you nothing about the bits, 200 and 255 look nothing like 1100 1000 and 1111 1111, and a byte in decimal is one, two, or three digits, so a row of them has no rhythm. In hex a byte is always two digits, so a hex dump is a grid: sixteen bytes per line, every byte the same width, and the seam between bytes never falls inside a digit. That property is the entire reason the format exists.
The spellings¶
The same byte has several spellings, and each tool picks one:
| You see | It means | Where |
|---|---|---|
0x41 |
the number 65, written in hex | Python, Rust, C, bash arithmetic |
\x41 |
the byte 65 inside a string or bytes literal | Python b'\x41', Rust b"\x41", printf |
41 |
one byte, in a hex dump or an ABAP x field |
xxd, od -tx1, TYPE x |
U+0041 |
the Unicode code point 65, which is not a byte at all | the Unicode standard; chapter 2 |
%41 |
the byte 65 in a URL | web addresses |
Case does not matter — 0xc3 and 0xC3 are the same byte — and the 0x prefix is for humans: int('0x41', 16) works in Python because Python is forgiving, but Rust's from_str_radix refuses it, and so does printf '%d' 0b….
In Python¶
Verified output of hex_is_a_shorthand_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. ONE HEX DIGIT IS EXACTLY FOUR BITS (a 'nibble')
bits hex decimal
0000 0 0
0001 1 1
0010 2 2
0011 3 3
0100 4 4
0101 5 5
0110 6 6
0111 7 7
1000 8 8
1001 9 9
1010 A 10
1011 B 11
1100 C 12
1101 D 13
1110 E 14
1111 F 15
2. SO A BYTE IS EXACTLY TWO HEX DIGITS, ALWAYS
65 = 0100 0001 = 41 (left nibble 4, right nibble 1)
200 = 1100 1000 = C8 (left nibble C, right nibble 8)
255 = 1111 1111 = FF (left nibble F, right nibble F)
0 = 0000 0000 = 00 (left nibble 0, right nibble 0)
15 = 0000 1111 = 0F (left nibble 0, right nibble F)
16 = 0001 0000 = 10 (left nibble 1, right nibble 0)
Compare decimal: 65 / 200 / 255 have no visible relation to their bits.
3. THE SPELLINGS PYTHON GIVES YOU
hex(65) -> '0x41' (0x prefix, lowercase, no padding)
format(65, '02x') -> '41' (two digits, no prefix)
format(5, '02x') -> '05' (padding matters: '5' would not be a byte)
f'{65:#04x}' -> '0x41' (# adds the prefix, 04 counts the prefix)
int('41', 16) -> 65 (text -> number, base 16)
0x41 -> 65 (a hex literal in source)
bytes.fromhex('41') -> b'A' (text -> the byte itself)
b'A'.hex() -> '41' (the byte -> text)
4. THE TRAP: THE TEXT '41' IS NOT THE BYTE 0x41
'41'.encode() -> b'41' which is the bytes 34 31
Two characters, '4' and '1', are two bytes: 0x34 and 0x31.
bytes.fromhex('41') -> b'A' one byte, value 0x41 = 65 = 'A'
A hex dump SHOWS you '41'; the file CONTAINS one byte. Never confuse the picture with the thing.
5. WHY 0xFF IS THE NUMBER EVERYONE REMEMBERS
0xFF = 255 = 11111111 = every switch on = the biggest byte
0x100 = 256 = 100000000 = the first number that needs a second byte
Section 4 is the one to stare at. A hex dump shows you 41, and it is easy to start thinking of a file as being made of those two characters. It is not. The file contains one byte, and '41' typed into a text editor is two other bytes, 0x34 and 0x31, which are the ASCII digits four and one. bytes.fromhex('41') turns the picture back into the thing; .hex() turns the thing into a picture. Everything in a hex dump is a picture.
In the terminal¶
Verified output of hex_is_a_shorthand_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. printf CONVERTS BETWEEN BASES
$ printf '%x\n' 65 # decimal in, hex out
41
$ printf '%02X\n' 5 # two digits, uppercase
05
$ printf '%d\n' 0x41 # hex in, decimal out
65
2. BASH ARITHMETIC DOES KNOW EVERY BASE: base#digits
$ echo $(( 16#41 )) $(( 2#01000001 )) $(( 8#101 ))
65 65 65
3. THE PICTURE VERSUS THE THING
$ printf '41' | xxd # the TEXT 41: two bytes, 0x34 and 0x31
00000000: 3431 41
$ printf '\x41' | xxd # the BYTE 0x41: one byte, shown as 41, read as A
00000000: 41 A
printf '%x' and $(( 16#41 )) convert numbers; xxd shows bytes. The last two commands are section 4 again, from the shell: the text 41 dumps as 3431, the byte \x41 dumps as 41.
In Rust¶
Verified output of hex_is_a_shorthand_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. FOUR FORMAT SPECIFIERS, ONE VALUE
{:x} -> 41 lowercase hex, no padding
{:02X} -> 41 uppercase, two digits — the byte-shaped one
{:#04x} -> 0x41 prefix included, width counts the prefix
{:08b} -> 01000001 the same eight bits, one per digit
2. THREE LITERALS THAT ARE ALL 65
0x41 == 0b0100_0001 == b'A' -> true
3. PARSING NEEDS THE BASE, AND REFUSES THE PREFIX
u8::from_str_radix("41", 16) -> Ok(65)
u8::from_str_radix("0x41", 16) -> Err(ParseIntError { kind: InvalidDigit })
(the prefix is for humans; the parser wants digits only)
4. A BYTE STRING PRINTED AS HEX, TWO DIGITS PER BYTE
"café" -> 63 61 66 c3 a9
five bytes for four letters; the last two are the é. Next chapter.
{:02x} is the spelling to reach for when the thing being printed is a byte: two digits, padded, so a run of them stays a grid. {:x} on a value below 16 prints one digit and quietly breaks the alignment. The Rust library's Why hexadecimal ↗ has that trap and two more, plus a kata.
Writing the literal¶
The table above is about how a byte is shown to you. This is the other direction — how you write one in source — and it is the same idea with one extra rule: the prefix names the base, and every base except decimal has one.
Verified output of writing_the_literal_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. FOUR BASES, ONE NUMBER, IN SOURCE
0b1100_0011 = 195 binary, 8 digits, one per bit
0o303 = 195 octal, 3 digits
195 = 195 decimal, the only base with no prefix
0xC3 = 195 hex, 2 digits -- the byte-shaped one
Same value four ways. The prefix is not decoration; it is the only thing
in the line that says which base the digits are in.
2. THE UNDERSCORE IS A COMMENT YOU CAN PUT INSIDE A NUMBER
0b1100_0011 = 195 grouped by nibble, so you can read the two hex digits off it
0xC3_A9 = 50089 grouped by BYTE, which is the grouping that matters here
1_000_000 = 1000000 grouped by thousand, the habit you already have
The parser drops them, so they cost nothing and change nothing. Since 3.6.
int('1_000') -> 1000 -- and int() takes them too
int('0b1010_1010', 0) -> 170 -- base 0 means 'read the prefix'
3. A LEADING ZERO IS NOT A BASE. PYTHON 3 MADE SURE OF IT
Writing 0755 in source is a SyntaxError -- refused on purpose, not by accident.
(The message names the fix; it is not quoted here because CPython rewords it.)
compile('x = 0755') -> SyntaxError confirmed on this interpreter
And the same seven characters as a STRING, which is where it still bites:
int('0755') -> 755 base 10 by default; the zero is just a zero
int('0755', 8) -> 493 you said the base, so the zero is padding
int('0755', 0) -> ValueError base 0 reads a prefix, and '0' is not one
int('0o755', 0) -> 493 the prefix, spelled the way Python spells it
Three answers from one field of seven characters. If it came out of a config
file or a form, the base is a decision somebody has to make in the open.
Section 2 is small and worth adopting today. The underscore is a separator the parser throws away, so it costs nothing and lets the literal draw its own grouping: 0b1100_0011 groups by nibble, so you can read the two hex digits straight off it, and 0xC3_A9 groups by byte, which is the grouping the octal section below is about. A number that shows its own structure is one you can check by eye.
Section 3 is the trap, and it is the picture-versus-thing confusion one level up. A leading zero is not a base — but it used to be, and in one language it still is.
Verified output of writing_the_literal_c.c — regenerated by tools/run_examples.py, never hand-typed.
1. IN C, A LEADING ZERO *IS* THE OCTAL PREFIX
0755 = 493 <- octal. The prefix is a single '0', and it is easy to miss.
755 = 755 <- decimal. One character apart, 262 apart in value.
0x1ED = 493 <- the same 493 in hex, where the base is spelled out loud.
This is the rule Python 3 removed and Rust never adopted, and it is why
chmod(path, 0755) is correct while chmod(path, 755) compiles and is not.
2. WHICH MAKES THE ZERO THE ONLY PREFIX YOU CANNOT SEE
0x / 0b / 0o are two characters and one of them is a letter. C's octal
prefix is one character that is also a digit, in a language where a
zero-padded number is a completely ordinary thing to write.
3. AND C11 HAS NO BINARY LITERAL AT ALL
0b1100_0011 is not C. Neither is the underscore.
C++14 added both first, and picked the APOSTROPHE as its separator --
0b1100'0011 -- because '_' was already taken by user-defined literals.
C23 then matched C++, nine years later. Same idea, a third spelling.
So the same seven characters, 0755, are 493 in C, 755 in Rust, and a compile error in Python 3 — three answers, each printed above or below by that language's own program, from a literal a reader would call obvious. C's octal prefix is a single 0, which is both the shortest prefix and the only one that is also a digit, in a language where writing a zero-padded number is ordinary. Python 3 removed the rule rather than keep quietly disagreeing with C about 0755, and made the old spelling a syntax error instead of a different number — which is the right way to retire a trap: fail, do not reinterpret.
The live version is int() on text you did not write. int('0755') is 755, int('0755', 8) is 493, and neither is wrong; the string simply does not say. That is the parsing side of the same question, and it has a page of its own — Hex: a number, or a picture of bytes puts Python, Rust, C and bash on one malformed input and gets four different answers, one of them silent. If those seven characters came out of a config file, a form, or a spreadsheet column, somebody has to choose the base in the open, and the choice belongs next to the code that reads the field rather than in a habit.
Verified output of writing_the_literal_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. THE SAME FOUR BASES, THE SAME PREFIXES
0b1100_0011 = 195 0o303 = 195 195 = 195 0xC3 = 195
Rust took Python's prefixes unchanged, so this half transfers with no edits.
2. THE UNDERSCORE IS LOOSER HERE: ANYWHERE, ANY NUMBER OF TIMES
0xC3_A9 = 50089 grouped by byte, the reading that matters
0x_C3_A9_ = 50089 legal too -- leading and trailing, and Rust does not mind
1_000_000 = 1000000
3. AND A LITERAL MAY NAME ITS OWN TYPE, WHICH PYTHON HAS NO WAY TO DO
0xC3u8 = 195 a u8, so the width is in the literal
0xC3_A9u16 = 50089 a u16, and the compiler checks it fits
That suffix is the whole difference in one character: in Python 195 is an
integer of no particular width, and in Rust you have said which byte count
you meant before the program runs.
4. AND THE LEADING ZERO? IT IS JUST A ZERO
0755 = 755 decimal, not octal -- Rust has no leading-zero rule at all
One seven-character literal, and each language on this page gives it a
different reading -- each from its own program, above and below. The
prefix exists so that sentence can stop being true of new code.
Rust's addition is the type suffix. 0xC3u8 is a byte and 0xC3_A9u16 is two, checked before the program runs — so where Python's 195 is an integer of no particular width, Rust's literal has already said how many bytes you meant. That is the same information a hex dump gives you by counting digits, moved into the source text.
Why hex and not octal¶
Octal is the same trick at a different width: one digit for three bits, 0–7. It was the standard once, and the tooling still carries it — od is octal dump, printf '\303\251' writes two bytes, git status escapes a non-ASCII filename in octal. So why did hex win?
For no reason about hexadecimal at all. Three does not divide eight. A byte is 8 bits, so it is exactly two hex digits and never a whole number of octal digits — and once digits stop lining up with bytes, a dump stops being a grid.
Verified output of why_not_octal_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THE BASE HAS TO DIVIDE THE WORD
one octal digit = 3 bits one hex digit = 4 bits
a byte is 8 bits: 8 / 4 = 2 exactly 8 / 3 = 2.67 <- does not divide
That one fact is the whole argument. Everything below is it, seen from somewhere.
2. SO THE LEADING OCTAL DIGIT OF A BYTE IS NOT A WHOLE DIGIT
Split 255 the way each base splits it, and look at the group sizes:
hex 1111 1111 = 0xFF 4 + 4
F F
octal 11 111 111 = 0o377 2 + 3 + 3
3 7 7
Three octal digits would be 9 bits. A byte has 8, so the top digit is short one bit:
it runs 0-3 and no further. 0o377 = 255 is the biggest byte; 0o400 = 256 is past it.
Both hex digits are full digits, 0-F. Neither is a special case you have to remember.
3. TWO BYTES: HEX SHOWS THE SEAM, OCTAL HIDES IT
'é' in UTF-8 is 2 bytes: c3 a9
as bits 11000011 10101001 <- the gap is the byte boundary
as hex c3 a9 <- two digits each, and the gap survives
as octal 1 100 001 110 101 001 = 0o141651
^^^
this digit (110 = 6) is part of BOTH bytes at once
In hex no digit is ever shared, because 4 divides 8. That is the entire reason a hex dump
can print bytes in a grid and an octal one cannot.
4. OCTAL IS NOT WRONG. IT FITS A THREE-BIT FIELD PERFECTLY
Unix permissions are three rwx triples = 9 bits, and 9 / 3 = 3 exactly.
octal 111 101 101 = 0o755 one digit per triple
7 5 5
hex 0001 1110 1101 = 0x1ED no digit lines up
1 E D
Same nine bits. `0o755` says rwx r-x r-x out loud; `0x1ED` says nothing at all.
So the base is not a matter of taste — it is whichever one divides the field you are reading.
5. WHICH IS WHY THE ANSWER CHANGED WHEN THE CHARACTER DID
6-bit character (Fieldata, BCDIC, DEC SIXBIT)
octal 6 / 3 = 2.00 exactly
hex 6 / 4 = 1.50 NOT evenly
8-bit byte (System/360 onward)
octal 8 / 3 = 2.67 NOT evenly
hex 8 / 4 = 2.00 exactly
Two octal digits held one 6-bit character. Two hex digits hold one 8-bit byte.
Same property, a different width — the notation followed the character, not the fashion.
Section 3 is the one that settles it. Both bases can spell the two bytes of é; only one lets you see where the first byte ends. In c3 a9 the seam is visible because every digit belongs to exactly one byte. In 0o141651 the digit 6 is three bits taken from both bytes, so there is nowhere to put the gap — which is why no octal tool has ever had the shape of a hex dump.
Section 4 is the other half, and it is what keeps octal honest: octal is not obsolete, it is specialised. Unix permissions are three rwx triples — nine bits, and 9 ÷ 3 = 3 exactly — so 0o755 says rwx r-x r-x out loud where 0x1ED says nothing at all. Where the field is three bits wide, octal is the base that fits and hex is the awkward one.
Which makes the history a consequence rather than an accident. The machines that counted in octal had characters six bits wide — Fieldata, BCDIC, DEC SIXBIT — and 6 ÷ 3 = 2, so one character was exactly two octal digits: the same tidy property hex has today. When IBM's System/360 settled the byte at eight bits in 1964, the width changed and the base that divided it changed with it. Octal did not lose an argument. Its word size stopped being manufactured.
Verified output of why_not_octal_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. THE TOOL NAMED AFTER OCTAL STILL DEFAULTS TO IT
'od' is octal dump, and with no flags it prints 16-bit WORDS in octal,
in the CPU's own byte order. Both CI runners are little-endian, so:
$ printf 'caf\303\251\n' | od | squeeze
0000000 060543 141546 005251
0000006
The file is the six bytes 63 61 66 c3 a9 0a, and not one of those three
numbers is a byte: 0o060543 is 0x6163, which is 'ca' read backwards.
That default is a fossil of a machine whose unit was the word, not the byte.
2. -b IS THE FLAG THAT SHOWS BYTES IN OCTAL
$ printf 'caf\303\251\n' | od -b | squeeze
0000000 143 141 146 303 251 012
0000006
Six bytes, three digits each: 18 octal digits, room for 54 bits, holding 48.
The same six bytes in hex are twelve digits holding 48 bits -- exactly the file,
which is what 'the base divides the word' buys you.
$ printf 'caf\303\251\n' | xxd -p
636166c3a90a
3. THE ESCAPE THAT SURVIVED: \NNN IS POSIX, \xHH IS BASH
$ printf '\303\251' | xxd # octal escape: in POSIX printf, so it works in any shell
00000000: c3a9 ..
$ printf '\xc3\xa9' | xxd # hex escape: a bash extension, absent from POSIX
00000000: c3a9 ..
Same two bytes, the e-acute. When a script must run under /bin/sh, the octal
escape is the portable one -- which is the last place octal is not just legacy.
od's bare default is the fossil — 16-bit words, in octal, in the CPU's byte order, which is three things a byte-oriented reader wants none of — preserved because POSIX froze the behaviour of a tool written for a machine that counted in words. It is the same swap plain hexdump still performs, one base along. Section 3 is the live reason to still know octal, though: \NNN is in POSIX printf and \xHH is not, so a script that has to run under /bin/sh writes its bytes in octal.
If you are coming from Python or ABAP¶
Python. Everything here is stdlib: hex(), int(s, 16), format(n, '02x'), bytes.fromhex(), bytes.hex(). The one thing worth adding to what you know is bytes.hex(' ') — the separator argument, since 3.8 — which is a one-call hex dump and makes section 4 of every later lesson readable. Octal is there too — 0o755, oct(), format(n, '03o') — and it is worth reaching for exactly once, on the mode argument of os.chmod.
ABAP. ABAP made hex the only spelling for bytes. A value of TYPE x or xstring is written in source as a quoted run of hex digits — DATA b TYPE x LENGTH 1 VALUE '41'. — and displayed the same way in the debugger, two digits per byte, always. There is no decimal view and no character view of an x field; if you want the character, you convert. And xstrlen( xs ) counts bytes while strlen( s ) counts characters — the two counts this whole library is about, already distinct in the language. There is no octal literal in ABAP at all, which is the section above stated as a language design: the only base ABAP offers for a byte is the one that divides it. (Not machine-checked — CI cannot run ABAP.)
Try it¶
cd 01_Bits_and_Bytes/hex_is_a_shorthand/examples
python3 hex_is_a_shorthand_py.py
bash hex_is_a_shorthand_sh.sh
rustc --edition 2024 hex_is_a_shorthand_rs.rs -o /tmp/hex && /tmp/hex
python3 why_not_octal_py.py
bash why_not_octal_sh.sh
python3 writing_the_literal_py.py
rustc --edition 2024 writing_the_literal_rs.rs -o /tmp/lit && /tmp/lit
cc -std=c11 -Wall -Wextra writing_the_literal_c.c -o /tmp/litc && /tmp/litc
Then by hand: convert 0xC3 and 0xA9 to bits (one nibble at a time), and 1110 1001 to hex. Check with format(0xC3, '08b') and hex(0b11101001).
Then the same two bytes in octal, and find the digit that belongs to both of them. Check with format(0xC3A9, 'o').
Practice¶
Four bits at a time, both directions. By hand, one nibble at a time: convert 0xC3 and 0xA9 to bits, and 1110 1001 to hex.
Then the part that shows why the shorthand works. Write C3 A9 in octal, and find the byte boundary in what you wrote. If you cannot find it, say why not.
Answers
Verified output of hex_is_a_shorthand_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
HEX TO BITS -- split the byte, convert each half
0xc3 -> C and 3 -> 1100 and 0011 -> 11000011
0xa9 -> A and 9 -> 1010 and 1001 -> 10101001
BITS TO HEX -- same move, the other way
1110 1001 -> E and 9 -> 0xe9
THE TWO BYTES TOGETHER
C3 A9 -> 1100 0011 1010 1001
Sixteen switches, four hex digits, and no arithmetic anywhere: each
digit is a picture of exactly four bits and never borrows from its
neighbour. That is the whole reason hex won.
NOW THE SAME BYTES IN OCTAL, WHICH DOES BORROW
0xC3A9 = 0o141651
Three bits per digit against sixteen bits, so the digits do not line
up with the bytes: the boundary between C3 and A9 falls INSIDE an
octal digit. Read the two bytes separately and you get
0o303 and 0o251, whose digits are nowhere in 0o141651 except by accident.
Sixteen is a power of two that divides eight. Eight is not.
See also¶
- A byte is eight bits — the eight switches this is a shorthand for
- Reading a hex dump — where you will read these two-digit pairs sixteen at a time
- Why hexadecimal ↗ — the Rust library's page: the same argument, with
from_str_radixand two's-complement traps - Anki: hexadecimal — this page as flashcards, every snippet compiled and run first