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
Start with a moment every developer eventually has. You're staring at a stack trace, a hex dump, or a colour code like #ff8800, and someone casually says "oh, that's just 255 in the red channel." You nod — but a small voice asks how did they know that? The answer isn't cleverness; it's that they can read numbers in more than one base as fluently as you read them in ten. That fluency is the whole point of this section, and it turns out to rest on one idea you already learned in grade school without noticing.
Here's that idea. When you write 352 in decimal, nobody taught you it "means" three-hundred-fifty-two by magic — it means 3×100 + 5×10 + 2×1. Each position is worth ten times the one to its right, because decimal is base ten. That's the entire trick of a positional number system: pick a base, and each slot is the next power of it. Change the base and nothing else about the machinery changes — binary just makes each slot worth twice the last (…8 4 2 1), and hex makes each slot worth sixteen times the last. The number 101 is five in binary, sixty-five in decimal, and two-hundred-fifty-seven in hex — same digits, different "value per slot."
The everyday analogy that makes this click is an odometer with a different number of digits on each wheel. A decimal odometer wheel rolls 0→9 then flips the next wheel; a binary "odometer" has wheels that only go 0→1, so they flip constantly; a hex odometer's wheels go 0→f. Counting is identical — you just run out of digits sooner or later depending on how many the wheel has.
The common mistake to name up front: people read a binary or hex string as if it were decimal — seeing 10 and thinking "ten" when in binary it's two and in hex it's sixteen. A number is meaningless until you know its base; the 0b, 0o and 0x prefixes below exist precisely so a value can never be misread. Now the precise version: 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.
10011100 are 156 in decimal and 0x9C in hex — only the "value per slot" differs.
Scan the byte right-to-left, the way place value grows: the rightmost slot is worth 1 and each slot to its left doubles.
- The top row of boxes holds the eight bits of
10011100; the number under each box is that slot's weight —128 64 32 16 8 4 2 1, the powers of two. - The highlighted (box2) slots are the ones holding a
1— the only slots that contribute. Slots holding a0add nothing. - The brand-coloured line sums just the lit slots:
128 + 16 + 8 + 4 = 156. Change the base and this same machinery gives a different total per slot — that is the whole idea of a positional system.
In short: Read any base by multiplying each digit by its slot's power of the base and adding — binary just makes each slot worth twice the one to its right.
| 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
Here's the puzzle that this section quietly solves. Bits are just on/off switches — there is no "minus" switch. So how does a computer store −5? You can't put a little minus sign in front of a byte; all you have is eight 1s and 0s. Every beginner's first instinct is the obvious one: steal the leftmost bit to mean "negative," like a plus/minus flag. That instinct feels right and is wrong in a way worth understanding, because the fix — two's complement — is one of the most elegant ideas in all of computing, and it's used by literally every CPU you'll ever touch.
Think of a clock face, because that analogy is the heart of it. On a 12-hour clock, going back 3 hours from 2 o'clock lands you on 11 — the same place as going forward 9 hours. "Minus 3" and "plus 9" are the same move on a wheel of 12. Fixed-width integers are exactly this: a wheel of 2^N positions that wraps around. Two's complement just agrees to call the top half of the wheel "negative." On our 8-bit wheel of 256 positions, the patterns 0…127 mean themselves, and the patterns 128…255 are re-read as −128…−1 — the far side of the wheel. That's why subtracting is the same as adding a "wrapped-around" number, and why the hardware needs only one adder.
The "simple sign bit" idea (called sign-magnitude) breaks in two ugly ways that two's complement avoids, and naming them makes the win concrete. First, it gives you two zeros — +0 and −0 — which is absurd and forces extra logic everywhere. Second, the arithmetic doesn't "just work": adding a positive and a negative needs a special-case circuit. Two's complement has one zero and makes a − b literally equal to a + (negate b) using the ordinary addition circuit. Now the precise definitions. 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. That two-step recipe — flip every bit, then add one — is worth committing to muscle memory; the diagram below walks it through for 5 → −5 so you can watch the bits change at each step.
Read the boxes left-to-right, following each brand-coloured arrow — every hop is one step of the "negate" recipe.
- The first box is the starting decimal
value 5; the second box writes that same 5 as its 8-bit pattern00000101. - The two middle boxes are the recipe itself: first invert every bit to get the one's complement
11111010, then add one to get11111011. - The final red box is the payoff — that pattern
11111011now reads as−5in two's complement, so "flip all bits, then add 1" is exactly how a byte is negated.
In short: Negating in two's complement is a two-step move — invert all bits, then add one — and the diagram walks 5 → −5 bit by bit.
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
This is the section with real bodies buried in it. In 2014, the view counter on PSY's "Gangnam Style" music video broke YouTube's display — it had blown past the 2,147,483,647 ceiling of a signed 32-bit integer, and YouTube had to widen the counter to 64 bits. In 1996 the Ariane 5 rocket self-destructed 37 seconds after launch, its guidance system tripped by a number that didn't fit the width it was crammed into. Overflow isn't an academic curiosity; it's a bug that ships silently and detonates later, and this section shows you exactly how it happens so you can feel it coming.
The clock-face picture from the last section is the key again — this time turned into a warning. A fixed-width integer is a wheel with only so many positions on it. Keep adding and eventually you sweep past the top and land back near the bottom, the way an old car odometer rolls 99999 → 00000. The scary part isn't that it wraps; it's that it wraps quietly. The number doesn't scream, it just becomes a different, wrong number — often a large positive becoming a negative, or a huge value becoming tiny. Your code keeps running on the lie.
The common mistake, and it's a subtle one: developers reason about integers the way maths does — as an infinite line where numbers only ever get bigger. Real hardware integers live on that finite wheel instead, and forgetting the wheel's size is how you ship a counter that breaks at two billion or a buffer length that goes negative. Python is a special, comforting exception here: its int grows to any size, so it never overflows — which is wonderful for correctness but means you have to deliberately simulate a fixed width (with a mask) to even see the trap that C, Java, Go and Rust programmers live with daily. Now the precise version. 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.
2^N. No error is raised — the number just silently becomes a smaller, wrong one.
Read it left-to-right as a single addition, then follow the curved arrow that closes the loop back to the start.
- The left boxes are the highest values an unsigned byte can hold —
254, then the top value255. - The red "+1" box is the operation that pushes past the ceiling: the ninth bit it would need has nowhere to live in 8 bits, so it is discarded (the "carry out").
- The green "0" box is where you land, and the dashed curved arrow shows the wrap: the wheel rolls from the top straight back to the bottom, exactly like an odometer going
99999 → 00000.
In short: An N-bit integer lives on a wheel of 2^N positions; overflow doesn't crash, it wraps — which is why 255 + 1 becomes 0 and a signed 127 + 1 becomes −128.
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
Everyone hits this bug, usually while feeling clever. You write a shopping-cart total, or a loop that steps by 0.1, and print the result — and out comes 0.30000000000000004 or a price of $19.999999998. Your first thought is that the language is broken. It isn't. It's doing exactly what the international standard says, and the "wrong" answer is baked into how computers store fractions. Understanding why is what separates someone who sprinkles round() everywhere and hopes, from someone who reaches for the correct fix on purpose.
The root cause is a translation problem you already understand from decimal. Try to write 1/3 as a decimal and you get 0.3333… forever — it simply has no finite decimal form, so you round it and accept a tiny error. Computers store fractions in binary, and in binary it's 0.1 that has no finite form: it's a repeating pattern the machine must chop off and round. So the value labelled 0.1 in your program is really "the closest 64-bit binary fraction to 0.1," a hair off. Add several hairs-off values and the errors stack up into a visible wrongness. The number was never exactly 0.1 to begin with.
The analogy that makes floating-point itself click is scientific notation. You already write big numbers as 6.022 × 10^23 — a sign, some significant digits (the mantissa), and an exponent that slides the decimal point around. Floating-point is that exact idea in base 2: sign × 1.mantissa × 2^exponent, and the decimal point "floats" to wherever the exponent puts it, which is how one 64-bit slot can hold both 0.0000001 and 10000000.0. The trade is precision: only so many mantissa digits fit, so very large and very precise can't coexist. The mistake to name: treating floats as if they were exact real numbers — especially testing them with == or using them for money. Now the precise version. 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).
0.1 + 0.2 ≠ 0.3.
Read the bar left-to-right: the three coloured segments are the three fields packed into one 64-bit slot, from most significant bit to least.
- The small red
Sbox is the single sign bit:0for positive,1for negative. - The middle box (11 bits) is the exponent — it slides the binary point around, giving the huge dynamic range; the stored value is biased by 1023, so you subtract 1023 to get the real exponent.
- The wide box (52 bits) is the fraction/mantissa — the significant digits. Its fixed width is the precision limit: only so many binary digits fit, so numbers with no finite binary form get rounded.
- The formula underneath is how the three fields recombine into a number — the exact IEEE-754 definition the decode lab reproduces.
In short: sign · exponent · fraction = one float; the finite 52-bit fraction is precisely why 0.1 can't be stored exactly and float equality fails.
| 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
You've seen the symptom even if you didn't know the disease. A résumé arrives as résumé. A tweet full of emoji shows up as a row of black diamonds with question marks. A CSV opens with ’ where every apostrophe used to be. That garble has a name — mojibake — and it is never random: it's what happens when text is written down using one set of rules and read back using another. This section gives you the mental model to prevent it, which comes down to keeping two ideas that beginners constantly fuse cleanly apart.
Those two ideas are the identity of a character versus the bytes that carry it. Think of it like a phone number versus the wire that carries the call. Unicode is the phone book: it assigns every character on Earth a permanent number called a code point — é is U+00E9, 😀 is U+1F600 — and that number never changes no matter where the character travels. But a code point is an abstract idea; you can't send an idea down a wire. An encoding is the rule for turning that code point into actual bytes, and back again. UTF-8 is one such rule (by far the winning one); UTF-16 and old regional code pages are others. Mojibake is simply encoding with one rulebook and decoding with another.
Why did UTF-8 win the whole web? Because it's backwards-compatible with ASCII and pays only for what it uses. The first 128 characters — plain English letters, digits, punctuation — encode to a single byte identical to old ASCII, so decades of existing text and code "just work." Characters beyond that (accented letters, other scripts, emoji) spill into 2, 3 or 4 bytes as needed. The trap to name explicitly: assuming "one character = one byte." It was true in the ASCII 1970s and it is false today — é is two bytes and 😀 is four, so len(string) (characters) and len(string.encode()) (bytes) are different numbers, and slicing a byte stream in the middle of a multi-byte character produces garbage. Now the precise version. 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.
Read left-to-right along the brand-coloured arrows: one character travels through two translations to become the bytes that actually go down the wire.
- The green box on the left is the abstract character
'é'— an identity, not yet any bytes. - The middle box is its Unicode code point
U+00E9(233 decimal): a permanent number that names the character but is still abstract. - The third box is the UTF-8 encode rule, and the final box is the payoff — the concrete bytes
C3 A9, two of them, that carryéin a file or over the network.
In short: character → code point → UTF-8 bytes are three distinct steps, and UTF-8 is the rule that turns the abstract code point into the actual bytes — here é becomes two.
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
Picture a hotel keycard system, or a Unix file's permissions. A single file can be readable, writable, and executable — three independent yes/no facts. You could store three separate boolean variables, but the elegant move the whole industry uses is to pack all three into the bits of one small integer: bit 0 is "read," bit 1 is "write," bit 2 is "execute." That's exactly what chmod 755 is doing under the hood. Once you see that a single integer is really a row of independent switches, a set of surgical tools for flipping and testing those switches suddenly matters — and those tools are the bitwise operators.
The mental model that makes every bit trick readable is a light switch panel. Each bit is one switch, on (1) or off (0). The operators are just different ways of acting on the whole panel at once against a second panel called a mask — a pattern you design to have 1s exactly where you want to act. AND (&) is a filter: it keeps a switch on only if both panels have it on, so ANDing with a mask "asks which of these switches are set." OR (|) forces switches on. XOR (^) toggles them — flip wherever the mask is 1 — and it has the magic property that applying it twice returns you to the start, which is why it can swap two values with no temporary. Shifts (<<, >>) slide the whole panel left or right, which conveniently multiplies or divides by powers of two.
Why learn this when Python has set, booleans, and clear if statements? Two honest reasons. First, you'll read bitwise code constantly — in flags passed to open(), in network protocol headers, in hardware registers, in the permission integers of every operating system — and you can't debug what you can't parse. Second, interviewers love bit tricks precisely because they reveal whether you understand what's underneath the abstraction. The mistake to avoid: confusing the bitwise operators with their logical cousins — & is not and, and | is not or. The logical ones collapse a whole value to a single true/false; the bitwise ones act on every bit in parallel. Mixing them up is a classic silent bug. The table below is your reference for all six, and the lab that follows turns them into the four moves you'll actually reuse: test, set, clear, toggle.
Start on the left with the two inputs, then follow each arrow to the operator that combines them and the plain-English job it does on the right.
- The left column is fixed: the same
value = 1100and the samemask = 1010feed all three rows. - The three coloured result boxes apply a different operator to those identical inputs —
&,|,^— and each lands on a different bit pattern. - The right-hand labels name the everyday move: AND keeps only bits set in both (used to test a flag), OR forces bits on (set), XOR flips wherever the mask is 1 (toggle, and applying it twice returns to the start — the basis of the XOR swap).
In short: The mask says which bits to touch; the operator says what to do to them — test with &, set with |, toggle with ^.
| 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.