Skip to content

Counting in hexadecimal

Level: 101 · for anyone starting from zero

One line: Hex is not a different way of counting. It is the same odometer with sixteen symbols on every wheel instead of ten, so the only rule to learn is that the wheel rolls over after F — and that 10 therefore means sixteen.

Sixteen symbols, because ten ran out

Decimal has ten symbols, 0 to 9. That is not a fact about numbers; it is a fact about how many symbols were drawn. Hex needs sixteen, and rather than invent six new shapes it borrowed the first six letters:

value 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
digit 0 1 2 3 4 5 6 7 8 9 A B C D E F

A is not a letter in 0x2A. It is the digit worth ten, exactly as 9 is the digit worth nine, and it is only drawn as a letter because the alphabet was lying around. Reading those six as letters is the single mistake that makes hex feel like a foreign notation instead of an ordinary one.

Carrying is the same thing you already do

In decimal you run out of symbols at 9. To count past it you reset that column to 0 and add one to the column on its left: 9 + 1 = 10. Nothing about that is decimal-specific — it is what a column does when it runs out.

Hex runs out at F. So:

E + 1 = F        still symbols left in this column
F + 1 = 10       out of symbols: reset, carry left. This is SIXTEEN, read "one-zero"
10 + 1 = 11      seventeen

The place people slip is the second wheel, because the first wheel looks decimal for its first ten values:

19 + 1 = 1A      twenty-six. NOT 20 -- that column still has A B C D E F to spend
1A + 1 = 1B      twenty-seven
1F + 1 = 20      thirty-two. NOW that column has run out too, so now it carries

And it scales the way 99 + 1 = 100 scales — the carry runs left through however many Fs it meets, and stops at the first digit that is not one:

9F + 1 = A0      one F reset, the 9 to its left became A
FF + 1 = 100     both wheels full: 255 + 1 = 256

Which is the sentence worth keeping: 99 + 1 = 100 and FF + 1 = 100 are the same event. Both answers are written 100; one of them is a hundred and the other is two hundred and fifty-six, and only the base you were told tells them apart.

Verified output of counting_in_hex_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. SIXTEEN DIGITS. SIX OF THEM ARE DRAWN AS LETTERS
   value   0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
   digit   0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F
   A is not a letter here. It is the digit worth ten, the way 9 is the digit worth nine.
   Decimal ran out of symbols at 9 and had to start carrying. Hex has six more to spend first.

2. COUNTING. THE ONLY NEW RULE IS WHERE THE WHEEL ROLLS OVER
      D + 1 = E      ( 13 + 1 = 14 )
      E + 1 = F      ( 14 + 1 = 15 )
      F + 1 = 10     ( 15 + 1 = 16 )   <- 1 wheel rolled over
     10 + 1 = 11     ( 16 + 1 = 17 )
     11 + 1 = 12     ( 17 + 1 = 18 )
   ...
     18 + 1 = 19     ( 24 + 1 = 25 )
     19 + 1 = 1A     ( 25 + 1 = 26 )
     1A + 1 = 1B     ( 26 + 1 = 27 )
     1B + 1 = 1C     ( 27 + 1 = 28 )
   ...
     1E + 1 = 1F     ( 30 + 1 = 31 )
     1F + 1 = 20     ( 31 + 1 = 32 )   <- 1 wheel rolled over
     20 + 1 = 21     ( 32 + 1 = 33 )
   19 + 1 is 1A, not 20: that column still has six symbols left in it.

3. THE SAME ODOMETER. THE ONLY DIFFERENCE IS WHICH SYMBOL IS LAST
   decimal, last symbol 9        hex, last symbol F
        9 + 1 = 10                   F + 1 = 10       1 wheel reset
       99 + 1 = 100                 FF + 1 = 100      2 wheels reset
      999 + 1 = 1000               FFF + 1 = 1000     3 wheels reset
   Both right-hand answers are written '100'. One of them is 100 and the other is 256.
   A carry stops at the first digit that is not the last symbol:
   0x9F   + 1 = 0xA0     (1 trailing F reset, then the digit to their left went up by one)
   0x1F   + 1 = 0x20     (1 trailing F reset, then the digit to their left went up by one)
   0xAFF  + 1 = 0xB00    (2 trailing Fs reset, then the digit to their left went up by one)

4. WHY THE WHEEL ROLLS AT F: EACH COLUMN IS WORTH SIXTEEN OF THE ONE ON ITS RIGHT
   column        ...   16^2 = 256   16^1 = 16   16^0 = 1
   0x21   = 2x16 + 1x1               = 32 + 1              = 33
   0xFF   = 15x16 + 15x1             = 240 + 15            = 255
   0x100  = 1x256 + 0x16 + 0x1       = 256 + 0 + 0         = 256
   0x2A5  = 2x256 + 10x16 + 5x1      = 512 + 160 + 5       = 677
   Nothing there is special to hex. Swap the 16 for a 10 and it is the arithmetic you already do,
   which is the whole claim of this page: you are not learning to count, only where the wheel rolls.

5. 'MOST SIGNIFICANT' MEANS 'THE COLUMN WORTH THE MOST'. NOTHING ELSE
   33 = 0x21 = 0b00100001
                               symbol   its column is worth   it contributes
   most significant digit        2                       16               32
   least significant digit       1                        1                1
   most significant bit          0                      128                0
   least significant bit         1                        1                1
   The most significant bit of 33 is 0, and it is still the most significant bit: significance is
   what the column is worth, not what happens to be sitting in it.

6. AND IT IS PLACE VALUE, NOT SCREEN POSITION
   0x2A5 as a 2-byte number, written the two ways a file may store it:
   big-endian     02 a5   most significant byte first  -- reads like the number
   little-endian  a5 02   least significant byte first -- and 'a5' is the LEAST significant half
   Same number, same significance, opposite order on screen. In a hex dump the leftmost byte
   is not automatically the most significant one; that is a fact about the file, not about hex.

7. SO SAY IT OUT LOUD CAREFULLY
   base 2   0b100001   thirty-three
   base 8   0o41       thirty-three
   base 10  33         thirty-three
   base 16  0x21       thirty-three
   0x21 is 'two-one', or 'hex twenty-one'. Bare 'twenty-one' is a different number, and mis-saying it
   is how a conversion goes wrong out loud before it goes wrong on paper. The two characters '10'
   are 2 in base 2, 8 in base 8, 10 in base 10 and 16 in base 16 -- four numbers, one spelling.

Where the wheel rolls inside one byte

Three carries in the 0–255 range are worth recognising on sight, because each one is a boundary something in this library cares about.

carry in bits what just happened
0F → 10 0000 1111 → 0001 0000 the low four bits filled up, so the second hex digit moves. Every 16th value does this
7F → 80 0111 1111 → 1000 0000 the top bit turned on. 7F is the last ASCII character; everything from 80 up needs an agreement about what it means
FF → 100 1111 1111 → 1 0000 0000 every wheel in the byte is full, and the carry needs a ninth bit that a byte does not have

That last row is the interesting one, because what happens next depends on the type, not on hex. In Python the integer simply grows. In bash it grows to 64 bits and then wraps to the most negative number. In a Rust u8 there is nowhere for the carry to go at all, and the language makes you say in advance which of four answers you want. The odometer is the same everywhere; the width of the machine it is bolted into is not.

Verified output of counting_in_hex_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

1. printf IS THE ODOMETER. seq TURNS IT

$ seq 13 18 | while read -r n; do printf '%3d = %2X\n' "$n" "$n"; done
 13 =  D
 14 =  E
 15 =  F
 16 = 10
 17 = 11
 18 = 12
   D, E, F, then 10 -- and the decimal column is there to say that 10 is sixteen.

2. THE THREE PLACES A BYTE CARRIES, AND WHAT EACH ONE MEANS
   0F + 1 = 10   ( 15 ->  16)   0000 1111 -> 0001 0000
   7F + 1 = 80   (127 -> 128)   0111 1111 -> 1000 0000
   FF + 1 = 100  (255 -> 256)   1111 1111 -> 1 0000 0000   <- a NINTH bit, and a byte has eight
   0F -> 10   the low nibble is full, so the high one goes up: the second hex digit changes
   7F -> 80   the top bit turns on -- that is where ASCII stops and chapter 2 starts
   FF -> 100  every wheel in the byte is full, and the carry needs a column the byte does not have

3. THE SHELL DOES NOT STOP AT FF -- BUT IT DOES STOP

$ printf '%X\n' $(( 0xFF + 1 ))                 # not a byte, so nothing wrapped
100

$ printf '%d\n' $(( 0x7FFFFFFFFFFFFFFF + 1 ))   # 63 bits and a sign bit: bash uses a signed 64-bit integer
-9223372036854775808
   So the shell has a width too; it is just wide enough that you rarely meet it, and signed,
   so the wheel past the end lands on the most negative number rather than on zero.

4. ARITHMETIC IS DECIMAL. THE BASE IS A DISPLAY CHOICE, MADE TWICE

$ echo $(( 0x19 + 1 ))            # hex in, decimal out -- and 19 + 1 is not 20
26

$ printf '%X\n' $(( 0x19 + 1 ))   # ask for the answer back in hex, and there it is
1A

$ echo $(( 16#FF ))  $(( 2#11111111 ))  $(( 8#377 ))  255
255 255 255 255
   Four spellings, one number. $(( )) always answers in decimal; printf picks the spelling.

MSB and LSB: the abbreviation does not say which

A base converter will usually label the two halves of 0x21 twice, in two different vocabularies: once as High Nibble: 2 | Low Nibble: 1, and once as MSB: 0010 | LSB: 0001. The first pair is unambiguous. The second is three different things sharing two abbreviations:

written expands to how big where you meet it
MSB / LSB most significant bit / least significant bit one bit the sign bit, "the top bit is set", x & 1 to test for odd
MSB / LSB most significant byte / least significant byte one byte byte order — big-endian is defined as most significant byte first
MSB / LSB most significant bits / least significant bits, plural a group informal: the high and low halves of something

Nothing disambiguates them but context, and the two that matter are one bit and one whole byte. So when a converter writes MSB: 0010 over four bits it means the third row — the most significant bits — while a reader who takes the abbreviation at face value reads the first row and gets a different answer: the most significant bit of 0x21 is 0, not 0010. Both readings are defensible from the label, which is the problem with the label. Say "high nibble" and "low nibble" for the halves of a byte; nothing else is short, and nothing else can be misread.

The underlying idea is simpler than either abbreviation and is worth having instead of them. Significant means worth more: a digit's significance is the value of the column it sits in, so the leftmost digit of a written number is the most significant because changing it moves the value furthest. In 0x21 the 2 is worth 2 × 16 = 32 and the 1 is worth 1; in 0b0010 0001 the eight columns are worth 128, 64, 32, 16, 8, 4, 2, 1.

Two consequences fall straight out, and they are the whole reason the vocabulary exists:

  • Significance is about the column, not about what is in it. The most significant bit of 0x21 is bit 7, and it is 0. It is still the most significant bit. ("Which is the highest bit that is actually set?" is a different question, and it is the one leading_zeros answers.)
  • Significance is not screen position. In a little-endian file the least significant byte is written first, so the leftmost byte of a hex dump is the low half of the number. That is a fact about the file's byte order and not about hex — see Byte order and the BOM.

Verified output of counting_in_hex_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

1. THE TWO HALVES OF A BYTE ARE A SHIFT AND A MASK
   n = 33 = 0x21 = 0b00100001
   high nibble   n >> 4     = 2   the 16s digit
   low nibble    n & 0x0F   = 1   the 1s digit
   and back      (hi << 4) | lo = 33
   Those two digits are what a hex dump prints. Nothing is computed to get them;
   the byte was already two hex digits, and the shift only picks which one you want.

2. std WILL NAME THE SIGNIFICANT BIT, BECAUSE IT IS A POSITION AND NOT A VALUE
   0x01 = 0b00000001   leading_zeros 7   highest set bit is bit 0 (worth 1)   ones 1   trailing_zeros 0
   0x21 = 0b00100001   leading_zeros 2   highest set bit is bit 5 (worth 32)   ones 2   trailing_zeros 0
   0x80 = 0b10000000   leading_zeros 0   highest set bit is bit 7 (worth 128)   ones 1   trailing_zeros 7
   0xFF = 0b11111111   leading_zeros 0   highest set bit is bit 7 (worth 128)   ones 8   trailing_zeros 0
   Bit 7 is the most significant bit of a u8 whatever is in it -- 33 has a 0 there and
   it is still the most significant bit. `leading_zeros` reports the highest bit that is SET,
   which is a different question and the one you usually want.

3. FF + 1 IN A BYTE: FOUR ANSWERS, AND THE TYPE MAKES YOU PICK ONE
   u8::MAX                     = 255 = 0xFF
   max.checked_add(1)          = None       there is no answer, and the type says so
   max.wrapping_add(1)         = 0          the carry is discarded: the odometer rolls to 00
   max.overflowing_add(1)      = (0, true)  the answer, and the fact that it overflowed
   max.saturating_add(1)       = 255        stop at the top rather than roll round to it
   Plain `max + 1` is a fifth: it panics in a debug build and wraps in a release one, so it
   is the only one of the five whose meaning depends on how the program was compiled.
   Written as a literal it does not even compile -- `arithmetic_overflow` is deny-by-default.

4. AND 'MSB' MEANS TWO DIFFERENT THINGS, ONE BIT APART FROM ONE BYTE
   w = 0x02A5 = 677
   most significant BIT   w >> 15        = 0          one bit, the 32768s column
   most significant BYTE  (w >> 8) as u8 = 0x02       one byte, the 256s column
   w.to_be_bytes()  = [02, A5]   big-endian:    most significant byte written first
   w.to_le_bytes()  = [A5, 02]   little-endian: least significant byte written first
   Same number, same significance, two orders on disk. Which is why 'the MSB' is only ever
   clear from context: in a bit-twiddling sentence it is one bit, in a byte-order sentence
   it is one byte, and the abbreviation is identical for both.

A note on the octal row

The same converters print an octal row, usually padded to three digits: 33 decimal is 0o041. Octal is the same trick at a different width — one digit per three bits — and three does not divide eight, so those three octal digits have room for nine bits while a byte has eight. The leading digit is therefore not a full digit: it runs 03 and no further, which is why 0o377 is the largest byte. That is one place where the two shorthands genuinely differ rather than merely looking different, and Hex is a shorthand has the argument in full, along with the field where octal is the base that fits and hex is the awkward one.

If you are coming from Python or ABAP

Python. Counting is the part you never have to write: f"{n:X}" renders any integer in hex and int(s, 16) reads one back, so the odometer is a formatting concern rather than an algorithm. Two habits are worth taking from this page anyway. Pad deliberately — f"{n:02X}" for a byte, because an unpadded F and an unpadded 0F are the same number and only one of them is a byte-shaped picture. And remember that a Python int has no width, so 0xFF + 1 is 256 and never 0; the wrap that a byte would perform has to be written by you, as & 0xFF.

ABAP. The counting itself is invisible for the same reason, but the width is not: a TYPE x LENGTH 1 field is a byte, so the arithmetic that Python performs on an unbounded integer has a ceiling here, and hex is the only spelling ABAP offers for the value — in source (VALUE 'FF'), in the debugger, and in a write-out, always two digits per byte with no decimal view to fall back on. Bit-level work goes through GET BIT / SET BIT, which number bits from 1 on the left, so GET BIT 1 reads what this page calls the most significant bit and what the x field's own hex spelling puts in the high nibble — an off-by-one waiting for anyone carrying a zero-indexed >> 7 across from C or Rust. (Not machine-checked — CI cannot run ABAP.)

Try it

cd 01_Bits_and_Bytes/counting_in_hex/examples
python3 counting_in_hex_py.py
bash counting_in_hex_sh.sh
rustc --edition 2024 counting_in_hex_rs.rs -o /tmp/counting && /tmp/counting

Then, out loud and without a converter — the point is to make the wheel turn in your head, so say each one before you check it:

  1. Count from 3E to 45. (Eight values, and exactly one carry.)
  2. What comes after 4F? After FF? After 1FF?
  3. Which is bigger, 0x20 or 29? The trap is that only one of the two says what base it is in, and the other one has two answers.
  4. 0xB in decimal, and 12 in hex.
  5. A byte holding 0x7F is incremented. Give the new value in hex and in binary, and name what changed.

Check the lot in one line — printf will count for you, and disagreeing with it is the useful outcome:

for n in 3E 3F 40 4F FF 1FF B 20; do printf '%5s = %4d   +1 = %X\n' "$n" $((16#$n)) $((16#$n + 1)); done

Then the same drill in the other direction: take three byte values you can see in a hex dump of any file (xxd -l 3 /bin/ls) and say each one in decimal before checking with printf '%d\n' 0x….

Practice

Turn the wheel in your head. Say each of these out loud before you check it — the point is the odometer, not the arithmetic.

  1. Count from 3E to 45. How many carries?
  2. What comes after 4F? After FF? After 1FF?
  3. Which is bigger, 0x20 or 29?
  4. 0xB in decimal, and 12 in hex.
  5. A byte holding 0x7F is incremented. Give the new value in hex and in binary, and name what changed.
Answers

Verified output of counting_in_hex_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. COUNT FROM 3E TO 45
   3E 3F 40 41 42 43 44 45
   Eight values, and exactly ONE carry: 3F -> 40.
   The low wheel rolled past F to 0 and the high wheel stepped 3 to 4.
   Every other step in that run leaves the high digit alone -- which is
   the thing worth feeling, because it is the same odometer you already
   turn in decimal and the only new rule is where F sits.

2. WHAT COMES NEXT
   4F -> 50
   FF -> 100
   1FF -> 200
   FF -> 100 is the same carry twice in one step, which is why a byte
   that holds FF and is incremented lands on 00 and takes the 1 with it
   into a place a byte does not have.

3. WHICH IS BIGGER, 0x20 OR 29?
   0x20 is 32. 29 has two answers: 29 if it is decimal, 41 if it is hex.
   So 0x20 < 29 as decimal, and 0x20 < 0x29 as hex -- the comparison
   goes the same way by luck, not by argument. Only one of the two
   spellings says what base it is in, and that is the whole trap.

4. 0xB IN DECIMAL, AND 12 IN HEX
   0xB = 11
   12  = C   (decimal twelve, so the digit after B)

5. A BYTE HOLDING 0x7F IS INCREMENTED
   7F -> 80      binary 01111111 -> 10000000
   Every bit changed. Seven ones fell to zero and the eighth rose --
   one step on the odometer, and the byte's top bit is now set, which is
   the bit that decides whether a signed reading calls this 128 or -128.

See also