Number systems & representation
How every value a computer holds is really a pattern of bits — bases and conversions, two's complement, IEEE-754 floats (and why 0.1 + 0.2 != 0.3), text encodings, and the bitwise tricks interviewers love.
Learning objectives
- Convert fluently between binary, octal, decimal and hex — by hand and in code.
- Explain unsigned vs signed integers and why two's complement is the universal choice.
- Predict integer overflow and wrap-around from the bit width.
- Decode a real IEEE-754 float bit-by-bit and explain why 0.1 + 0.2 != 0.3.
- Move between bytes and text through ASCII, Unicode code points and UTF-8.
- Use bitwise operators and classic bit tricks (masks, shifts, XOR swap, popcount).
bin(), hex(), struct, and int.from_bytes are all stdlib.1 · Positional number systems & conversions essential
A number system has a base (radix): the number of distinct digits. Each position is a power of the base. Decimal uses 10 digits (0–9); binary uses 2 (0–1); octal 8; hexadecimal 16 (0–9 then a–f). Hardware is binary because a wire is either high or low — one bit. We group bits into hex because each hex digit is exactly 4 bits (a nibble), so hex is a compact human-readable view of raw bits.
| Base | Name | Digits | Prefix in Python | 1 digit = ? bits |
|---|---|---|---|---|
| 2 | binary | 0 1 | 0b | 1 bit |
| 8 | octal | 0–7 | 0o | 3 bits |
| 10 | decimal | 0–9 | (none) | ~3.32 bits |
| 16 | hex | 0–9 a–f | 0x | 4 bits |
Worked conversion — 156 to binary (repeated division by 2, read remainders bottom-up):
| Divide | Quotient | Remainder |
|---|---|---|
| 156 / 2 | 78 | 0 |
| 78 / 2 | 39 | 0 |
| 39 / 2 | 19 | 1 |
| 19 / 2 | 9 | 1 |
| 9 / 2 | 4 | 1 |
| 4 / 2 | 2 | 0 |
| 2 / 2 | 1 | 0 |
| 1 / 2 | 0 | 1 |
Reading remainders from bottom to top gives 10011100. Group into nibbles: 1001 1100 = 0x9C. And 9*16 + 12 = 156. Everything checks out.
bases.pyn = 156
print("bin:", bin(n)) # 0b prefix
print("oct:", oct(n))
print("hex:", hex(n))
# parse a string in any base with int(str, base)
print("0x9C ->", int("9C", 16))
print("0b10011100 ->", int("10011100", 2))
# format without prefix, zero-padded to 8 bits
print("padded:", format(n, "08b"), format(n, "02x"))
bin: 0b10011100
oct: 0o234
hex: 0x9c
0x9C -> 156
0b10011100 -> 156
padded: 10011100 9c
00–ff). Octal packs 3 bits per digit, which doesn't align to a byte — that's why memory dumps, colours (#ff8800) and addresses are hex.2 · Signed vs unsigned & two's complement essential
A fixed-width integer is just N bits. Unsigned reads all N bits as a magnitude: range 0 … 2^N − 1. Signed must also encode negatives. The winning scheme is two's complement: the top bit has weight −2^(N−1) instead of +2^(N−1). For 8 bits the range becomes −128 … +127.
Why two's complement and not a simple sign bit? Because addition and subtraction use the same adder circuit for signed and unsigned — there is exactly one representation of zero, and a − b is just a + (two's-complement of b). To negate: invert all bits, then add 1.
twos.pyBITS = 8
MOD = 1 << BITS # 256
def to_twos(x):
"encode a signed int into its 8-bit two's-complement pattern"
return x % MOD # Python's % handles negatives for us
def from_twos(bits):
"decode an 8-bit pattern back to a signed int"
return bits - MOD if bits >= (1 << (BITS - 1)) else bits
for v in (5, -5, -1, -128, 127):
b = to_twos(v)
print(f"{v:>5} -> {b:08b} -> back {from_twos(b)}")
5 -> 00000101 -> back 5
-5 -> 11111011 -> back -5
-1 -> 11111111 -> back -1
-128 -> 10000000 -> back -128
127 -> 01111111 -> back 127
−1 is all ones in every width (11111111). −128 is the one lopsided value: it has no positive twin because the range is asymmetric (−128…127).3 · Fixed width & integer overflow intermediate
Real hardware integers are a fixed width (8/16/32/64 bits). Add past the top and the value wraps around modulo 2^N — this is the source of countless bugs (the Gangnam-style view counter, the Ariane 5 crash lineage, timestamp rollovers). Python's own int is arbitrary precision so it never overflows — which is exactly why we must simulate a fixed width to see the effect.
overflow.pyMASK = 0xFF # keep only the low 8 bits
def add8_unsigned(a, b):
return (a + b) & MASK
def add8_signed(a, b):
r = (a + b) & MASK
return r - 256 if r >= 128 else r # reinterpret as two's complement
print("255 + 1 unsigned ->", add8_unsigned(255, 1)) # wraps to 0
print("200 + 100 unsigned ->", add8_unsigned(200, 100))
print("127 + 1 signed ->", add8_signed(127, 1)) # overflow to -128
print("Python has no limit:", 2 ** 100)
255 + 1 unsigned -> 0
200 + 100 unsigned -> 44
127 + 1 signed -> -128
Python has no limit: 1267650600228229401496703205376
127 + 1 quietly becomes −128 with no exception. Signed overflow is even undefined behaviour in C. Know your width, or mask deliberately.4 · Fixed-point, floating-point & IEEE-754 advanced
Integers can't hold fractions. Fixed-point puts an imaginary binary point at a set position (money as integer cents). Floating-point stores a number like scientific notation: sign × 1.mantissa × 2^exponent, so the point 'floats'. The universal format is IEEE-754. A 64-bit double is 1 sign bit + 11 exponent bits + 52 fraction bits (exponent bias 1023).
| Type | Total bits | Sign | Exponent | Fraction | Bias |
|---|---|---|---|---|---|
| float32 | 32 | 1 | 8 | 23 | 127 |
| float64 (double) | 64 | 1 | 11 | 52 | 1023 |
Let's decode a real double with struct — pack the float to 8 bytes, read the raw integer, and pull the fields apart:
float_decode.pyimport struct
x = 0.15625 # a value that is exact in binary
bits = struct.unpack(">Q", struct.pack(">d", x))[0] # 64-bit pattern
print("raw bits:", format(bits, "064b"))
sign = bits >> 63
exp = (bits >> 52) & 0x7FF # 11 exponent bits
frac = bits & ((1 << 52) - 1) # 52 fraction bits
print("sign =", sign, " exp =", exp, " (unbiased", exp - 1023, ")")
value = (-1) ** sign * (1 + frac / 2 ** 52) * 2 ** (exp - 1023)
print("reconstructed:", value)
raw bits: 0011111111000100000000000000000000000000000000000000000000000000
sign = 0 exp = 1020 (unbiased -3 )
reconstructed: 0.15625
0.15625 = 1.25 × 2^−3, so the unbiased exponent is −3 and the mantissa is 1.25 — exactly reconstructed. But most decimals are not exact in base 2, which is the famous gotcha:
point_one.pya = 0.1 + 0.2
print("0.1 + 0.2 =", a)
print("== 0.3 ? ", a == 0.3)
print("full digits:", format(a, ".17f"))
from decimal import Decimal
print("what 0.1 really is:", Decimal(0.1))
0.1 + 0.2 = 0.30000000000000004
== 0.3 ? False
full digits: 0.30000000000000004
what 0.1 really is: 0.1000000000000000055511151231257827021181583404541015625
0.1 in binary is a repeating fraction (like 1/3 in decimal), so it's rounded to the nearest 52-bit value — a hair above 0.1. The tiny errors accumulate. Compare with a tolerance: math.isclose(a, 0.3), or use decimal.Decimal / integer cents for money.5 · Encodings: ASCII, Unicode & UTF-8 intermediate
Text is numbers too. ASCII maps 128 characters to 7-bit codes (A=65). Unicode assigns every character a code point (e.g. U+00E9 for é, U+1F600 for 😀) — that's an abstract number, not bytes. UTF-8 is the dominant encoding: it stores a code point in 1–4 bytes, is backwards-compatible with ASCII, and is what the web runs on.
encodings.pys = "café 😀"
print("chars:", list(s))
print("code points:", [hex(ord(c)) for c in s])
raw = s.encode("utf-8")
print("utf-8 bytes:", raw, "len", len(raw))
# decode raw bytes straight from an integer list
print("back:", raw.decode("utf-8"))
# note: 6 characters but more bytes (é=2, 😀=4)
chars: ['c', 'a', 'f', 'é', ' ', '😀']
code points: ['0x63', '0x61', '0x66', '0xe9', '0x20', '0x1f600']
utf-8 bytes: b'caf\xc3\xa9 \xf0\x9f\x98\x80' len 10
back: café 😀
int.from_bytes(b'\x01\x00', 'big') is 256; 'little' gives 1. Endianness is just which byte comes first — the same concept behind network byte order and file formats.6 · Bitwise operators & bit tricks advanced
| Operator | Name | Example (4-bit) | Use |
|---|---|---|---|
& | AND | 1100 & 1010 = 1000 | mask / test a bit |
| | OR | 1100 | 1010 = 1110 | set a bit |
^ | XOR | 1100 ^ 1010 = 0110 | toggle / find difference |
~ | NOT | ~1100 = …0011 | invert all bits |
<< | left shift | 0011 << 1 = 0110 | ×2 per shift |
>> | right shift | 0110 >> 1 = 0011 | ÷2 per shift |
bittricks.py# 1. test / set / clear / toggle bit i with a mask
flags = 0b0000
flags |= (1 << 2) # set bit 2
print("after set bit2:", format(flags, "04b"))
print("bit2 set?", bool(flags & (1 << 2)))
flags &= ~(1 << 2) # clear bit 2
print("after clear:", format(flags, "04b"))
# 2. XOR swap two ints with no temp variable
a, b = 12, 25
a ^= b; b ^= a; a ^= b
print("swapped:", a, b)
# 3. popcount: how many 1-bits (Python 3.10+: int.bit_count)
n = 0b10110101
print("popcount:", bin(n).count("1"))
# 4. classic: is n a power of two? (only one bit set)
def is_pow2(n): return n > 0 and (n & (n - 1)) == 0
print("64 pow2?", is_pow2(64), " 65 pow2?", is_pow2(65))
after set bit2: 0100
bit2 set? True
after clear: 0000
swapped: 25 12
popcount: 5
64 pow2? True 65 pow2? False
n &= n-1 also counts bits in O(number-of-set-bits).✓ Checkpoint — you can move on when you can…
- Convert 156 between decimal, binary, octal and hex without a calculator.
- Negate a number in two's complement (invert + 1) and explain why −1 is all ones.
- Predict the result of 8-bit unsigned and signed overflow.
- Explain, from the bit layout, why 0.1 + 0.2 != 0.3 and what to do instead.
- Encode/decode text through UTF-8 and read a byte string's code points.
- Use masks/shifts/XOR and know the n & (n-1) power-of-two trick.
Knowledge check check yourself
In 8-bit two's complement, what is the bit pattern for −1, and why is the range −128…127 rather than −127…128?
Show answer
11111111 (all ones). The range is asymmetric because one bit pattern (10000000) is spent on −128 and there is a single zero (00000000); the positive side tops out at 127. So you get 128 negatives, one zero, 127 positives = 256 values.Explain in one sentence why 0.1 + 0.2 == 0.3 is False in virtually every language.
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every debugging session eventually shows you a value in hex or binary; converting in your head and confirming in code is table stakes.
Your task: Convert the decimal number 205 to binary, octal and hex, then verify each with Python and parse them back to decimal.
Requirements:
- Show 205 in all three bases using
bin/oct/hex - Parse each string form back with
int(s, base)and confirm you get 205 - Print the binary zero-padded to 8 bits with
format(205, '08b')
💡 Hint: 205 = 128 + 64 + 8 + 4 + 1. Group the 8 bits into two nibbles to read the hex directly.
Show solution
n = 205
print(bin(n), oct(n), hex(n)) # 0b11001101 0o315 0xcd
print(format(n, '08b')) # 11001101
assert int('11001101', 2) == 205
assert int('315', 8) == 205
assert int('cd', 16) == 205
print('all round-trip to', 205)Nibbles 1100 1101 = C D = 0xCD, and 12*16 + 13 = 205. Every representation is the same number in a different base.
Context: Protocols and binary file formats store signed values in a fixed number of bits; you must be able to encode and decode two's complement yourself.
Your task: Write to_i8(x) and from_i8(bits) for 8-bit two's complement and verify the full boundary set.
Requirements:
to_i8maps a signed int in −128…127 to its 8-bit patternfrom_i8maps an 8-bit pattern back to the signed int- Round-trip −128, −1, 0, 1 and 127 and assert equality
- Reject (or document) inputs outside −128…127
💡 Hint: Python's modulo x % 256 already yields the correct unsigned pattern for negatives.
Show solution
def to_i8(x):
if not -128 <= x <= 127:
raise ValueError('out of range for int8')
return x % 256
def from_i8(bits):
return bits - 256 if bits & 0x80 else bits
for v in (-128, -1, 0, 1, 127):
assert from_i8(to_i8(v)) == v, v
print('round-trip OK; -1 ->', format(to_i8(-1), '08b'))
# round-trip OK; -1 -> 11111111The & 0x80 tests the sign bit; if set, subtract the modulus to recover the negative value.
Context: Money and measurement bugs almost always trace back to float rounding; you need to demonstrate the error and pick the right fix.
Your task: Show that 0.1 + 0.2 != 0.3, print the 17-significant-digit value, and demonstrate two correct comparison strategies.
Requirements:
- Print
format(0.1 + 0.2, '.17f')to expose the error - Compare correctly with
math.isclose - Show an exact result using
decimal.Decimal('0.1') + Decimal('0.2') - Explain in a comment why integers-as-cents is the standard money fix
💡 Hint: Decimal constructed from strings is exact; constructed from a float it inherits the float's error.
Show solution
import math
from decimal import Decimal
s = 0.1 + 0.2
print(format(s, '.17f')) # 0.30000000000000004
print(s == 0.3) # False
print(math.isclose(s, 0.3)) # True (tolerant compare)
print(Decimal('0.1') + Decimal('0.2')) # 0.3 (exact, from strings)
# money: store cents as int -> 10 + 20 == 30, no float at allisclose uses a relative+absolute tolerance; Decimal from strings avoids binary rounding entirely; integer cents sidesteps floats altogether.
Context: Reading a raw sensor stream or a binary format means pulling a double out of 8 bytes and interpreting it — the same thing a debugger does.
Your task: Write a function that takes 8 big-endian bytes of an IEEE-754 double and returns (sign, unbiased_exponent, mantissa_fraction, value).
Requirements:
- Use
struct.unpack('>Q', ...)to get the 64-bit integer - Slice out sign (bit 63), exponent (bits 62–52), fraction (bits 51–0)
- Return the unbiased exponent (subtract 1023) and reconstruct the value
- Verify on the bytes of
1.0and-0.15625
💡 Hint: 1.0 has exponent field 1023 (unbiased 0) and a zero fraction; that's your sanity check.
Show solution
import struct
def decode_f64(b8):
bits = struct.unpack('>Q', b8)[0]
sign = bits >> 63
exp = (bits >> 52) & 0x7FF
frac = bits & ((1 << 52) - 1)
value = (-1)**sign * (1 + frac/2**52) * 2**(exp - 1023)
return sign, exp - 1023, frac, value
print(decode_f64(struct.pack('>d', 1.0)))
print(decode_f64(struct.pack('>d', -0.15625)))
# (0, 0, 0, 1.0)
# (1, -3, 1125899906842624, -0.15625)For 1.0 the fraction is 0 and the unbiased exponent is 0, so the value is 1 × 2^0 = 1.0. The reconstruction formula is the IEEE-754 definition itself.
Context: Feature flags, permission bits and hardware registers are stored as packed bitfields; a clean helper avoids error-prone hand-masking scattered through the code.
Your task: Build a tiny BitFlags helper with set, clear, toggle and is_set over named bit positions.
Requirements:
- Store state as a single integer
- Support named flags mapping to bit positions
- Implement set/clear/toggle/is_set using masks and shifts
- Provide a readable binary dump for debugging
- Demonstrate setting two flags and clearing one
💡 Hint: Set = state |= 1<<i, clear = state &= ~(1<<i), toggle = state ^= 1<<i.
Show solution
class BitFlags:
def __init__(self, names):
self.pos = {n: i for i, n in enumerate(names)}
self.state = 0
def _m(self, name): return 1 << self.pos[name]
def set(self, n): self.state |= self._m(n); return self
def clear(self, n): self.state &= ~self._m(n); return self
def toggle(self, n): self.state ^= self._m(n); return self
def is_set(self, n): return bool(self.state & self._m(n))
def dump(self): return format(self.state, f'0{len(self.pos)}b')
f = BitFlags(['read', 'write', 'exec'])
f.set('read').set('exec')
print(f.dump(), f.is_set('read'), f.is_set('write')) # 101 True False
f.clear('read')
print(f.dump()) # 100All state lives in one integer; each operation is a single mask. This is exactly how OS permission bits and hardware control registers are manipulated.
Context: Every storage and network layer detects corruption with a checksum; understanding one at the bit level demystifies CRCs, hashes and error detection.
Your task: Implement the standard Internet checksum (RFC 1071 one's-complement 16-bit sum) over a byte buffer, and show it detects a single-bit flip.
Requirements:
- Sum the data as big-endian 16-bit words
- Fold the carry bits back in (end-around carry) until it fits in 16 bits
- Take the one's complement as the final checksum
- Verify that recomputing over data+checksum yields zero
- Flip one bit and show the verification now fails
💡 Hint: End-around carry: while s >> 16: s = (s & 0xFFFF) + (s >> 16). Padding: if the buffer has an odd length, treat the last byte as the high half of a word.
Show solution
def inet_checksum(data: bytes) -> int:
if len(data) % 2:
data += b'\x00'
s = 0
for i in range(0, len(data), 2):
s += (data[i] << 8) | data[i+1] # big-endian 16-bit word
while s >> 16:
s = (s & 0xFFFF) + (s >> 16) # fold carries
return (~s) & 0xFFFF # one's complement
msg = b'HELLO-WORLD!'
cksum = inet_checksum(msg)
packet = msg + bytes([cksum >> 8, cksum & 0xFF])
print('checksum:', hex(cksum))
print('verify good packet ->', inet_checksum(packet)) # 0 == valid
corrupt = bytearray(packet); corrupt[0] ^= 0x01 # flip one bit
print('verify corrupt ->', inet_checksum(bytes(corrupt))) # non-zeroRecomputing the checksum over data-plus-checksum yields 0 for an intact packet because the sum plus its own complement is all-ones, which one's-complement folds to zero. A single flipped bit changes the sum, so verification returns non-zero — corruption detected. This is the exact algorithm in IPv4, ICMP, TCP and UDP headers.