00From the metal up
You came here to write Python, and we will — but not on page one. First I want to open the box. Python feels like magic right up until you can see what's under it, and then it feels like machinery — which is far better, because machinery you can reason about. Here's the plan: we start at a single switch, the transistor that's either on or off, and we climb — one bit to a byte, bytes to RAM, RAM to the CPU that turns out to be the only part of the whole box that actually does anything. And the whole way up we keep asking the one thing most tutorials skip right past — what really happens between saving a .py file and the instant it runs? By the end you'll read a binary number cold, picture memory as a street of numbered boxes, and trace your own code all the way down to the metal. Everything in the next fourteen chapters stands on this floor.
01A computer is a box of switches
Let's start with the smallest real thing in the machine. Take away the screen, the keyboard, the glowing logo, and what's left is a box of switches — billions of them. Each switch is a transistor: a tiny piece of silicon that either lets electric current through or blocks it. That's all it can do. There's no third setting, no "half-on," no "maybe." Just off or on. We give these two states names — 0 and 1 — and one switch's on-or-off state is called a bit, short for binary digit. A bit is the smallest piece of information that exists: one wire, either carrying voltage or not. You can't split information any smaller than that.
Here's the thing to sit with. Everything your machine "knows" — this sentence, a photo of your grandmother, your favourite song — is, underneath everything, just a pattern of ons and offs. Nothing else is in there. And that raises a problem this whole chapter is built around. Take one small, real fact from a playlist we'll use throughout the book: the song "Blinding Lights" by The Weeknd runs 200 seconds. Now try to store that using switches. A single switch can only say yes or no. It can't say 200. So right away, on the very first fact we care about, one switch looks far too small for the job. Keep that problem in mind — solving it is the whole trick behind digital computing.
The deeper cut — how one switch became two, and why "off or on" is a useful lie
Saying a transistor is simply "off or on" is a labelled simplification — true enough to build your whole mental model on, and we'll refine it exactly once, here. In reality the voltage on that wire is a smooth, continuous quantity that can sit anywhere between the ground rail and the supply rail. What makes it behave as a clean 0 or 1 is a deliberate design choice: the circuit treats everything below a low threshold as "0," everything above a high threshold as "1," and the ambiguous middle band as forbidden — a no-man's-land the signal is engineered to snap out of within nanoseconds. So the switch is analogue underneath; the two-state behaviour is imposed on top of it. This is why the digital abstraction is so sturdy: even if a real 1 arrives a bit droopy, as long as it lands above the threshold it is read as a perfect 1, and the droop never accumulates. That single trick — quantise, then round — is what lets a billion imperfect physical switches compute a flawless answer. Everywhere else in this book we'll happily say "off or on," now that you know what's really underneath.
So the basic unit is settled: one switch, two possibilities. Everything after this is just combination. Two switches together don't give you three possibilities — they give you four (off-off, off-on, on-off, on-on). Three switches give eight. Every switch you add doubles the count — and it's that doubling, not any single switch, that will eventually be enough to hold a number like 200.
One switch stores exactly two possibilities. So how many switches does 200 need — and arranged how? →
02Counting with two fingers
You already know this trick — you just know it in base ten. Read 743 from right to left: 3 ones, 4 tens, 7 hundreds. Each column is worth ten times the column to its right, and each digit just says how many of that column to count. That's really all a number is — a row of columns with fixed values, and digits saying how many of each to take.
Binary plays the exact same game, just with two instead of ten. Each column is now worth double the column to its right — …8, 4, 2, 1 — and since a binary digit can only be 0 or 1, it stops meaning "how many" and simply means in or out. And that's the part worth noticing: in or out is the one and only thing a transistor can ever report — current is flowing, or it isn't. Ten separate voltage levels would be hard to tell apart reliably; two levels just need "clearly below the line" or "clearly above it." So binary isn't some strange dialect computers were forced into — it's the richest counting system you can build on a switch. Read 1011 this way and it means 8 + 0 + 2 + 1 = 11. Same place-value idea you learned in school, just with a smaller alphabet.
1011 column by columnfigure1011 is 8+0+2+1 = 11. No new math — just base 2, where a lit column contributes its value and a dark one contributes nothing.✗ The myth
"Computers think in ones and zeros" — some alien logic, fundamentally unlike how humans count.
✓ The reality
It's ordinary place value with two symbols instead of ten. The only reason it looks foreign is the small alphabet; the arithmetic underneath is the same doubling-columns idea you already trust in base ten.
Group 8 bits together and you get a byte — the standard unit computer memory is measured in, and the block everything else is built from. Why does one byte max out at 256 patterns? Just count them. One switch gives 2 possibilities (off, on). Add a second switch and every existing pattern splits in two — new switch off, or new switch on — so the count doubles every time you add a switch: 2, 4, 8, 16, 32, 64, 128… By eight switches, that's 2⁸ = 256 distinct patterns — exactly enough to count from 0 to 255. (256 patterns, but the first one is zero, so the highest is 255, not 256 — worth remembering.)
And here's where our playlist fits in. "Blinding Lights" runs 200 s, and 200 = 128 + 64 + 8 = 11001000 — so one byte is enough to hold the song's length, with room left over. "Titanium," at 245 s, is 11110101 — still just one byte. One honest clarification: the song itself — the actual audio — takes up millions of bytes. The one small number that fits in a single byte is just the song's length in seconds, not the sound itself. But those millions of bytes do have a shape: the audio is a waveform — a long run of samples, where each sample is a plain number describing where the speaker cone sits at that instant. A song's length is a small fact a human typed in once; its samples are the real substance the playlist is built from, and we'll come back to this waveform later in the book. Try the slider below — drag it and watch the eight switches spell out whatever number you land on. (Later on, Python even lets you flip individual bits by hand using operators like << — you'll see this in chapter 4.)
0b plus the eight bits.11001000 is only "200" because we decided to read it as a plain count. Feed the very same switches to a different agreement and the meaning shifts underneath you: the pattern 01000010 is the number 66, and 66 is the code the whole system agrees means the capital letter 'B' — Python will hand it straight back with chr(66). Read one byte as a colour channel instead and it's a dim shade of grey. The bits are only in/out; meaning is the interpretation we lay on top. Keep that in your pocket — it's why one file format can't open another's bytes.0b0101110111000000, sixteen of them. Same bits; "a sound sample" is only the reading we lay on top, just as 0b01000010 was either 66 or 'B'. But look at the size — 24000 > 255, so eight switches can't reach it. This one sample needs a 2-byte team: a signed 16-bit integer, range -32768 to 32767, with 0 = cone at rest. There's your second concrete reason multi-byte teams exist — not the song's length this time, but a single one of its millions of samples. Python packs a run of them as array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000]) — the 'h' means two bytes each — and 24000 is the loudest sample in that run.You can check this against Python directly. bin() turns a number into its switch pattern, and typing a binary literal turns it back into a number — no magic, just the same base-2 place value the slider above just showed you:
# a number is just how we read a pattern of switches
print(bin(200)) # 0b11001000 — "Blinding Lights", 200 s
print(bin(245)) # 0b11110101 — "Titanium", 245 s
print(0b11001000) # 200 — Python reads the switches back
print(8 + 0 + 2 + 1) # 11 — the 1011 from Fig 02
print(bin(24000)) # 0b101110111000000 — one sample: 15 bits, too big for a byte → 2-byte 'h'64 bytes from 216.239.38.120" — and look hard at that address: four numbers, every one landing between 0 and 255, because each dotted octet is exactly one byte (that's the whole reason an IP number never reads 256). A high-quality MP3 of "Blinding Lights" is roughly eight million bytes — and that's the compressed file; unpacked to its raw waveform the same song is about 8.8 million samples × 2 bytes ≈ 17 MB of switch patterns. Your phone's storage, that email attachment's size, the "500 MB left" warning — all of it is priced in this one unit. The byte is the atom everything else on a computer is measured in.The deeper cut — why 8, and where hex comes from
Eight bits per byte wasn't handed down by nature; it was a design choice that won. Early machines used 6-, 7-, and 9-bit groupings, but 8 hit a sweet spot: enough patterns (256) to cover every letter, digit, and punctuation mark of early text with room to spare, and a clean power of two that hardware likes.
Writing bytes as eight 0s and 1s is exhausting to read, so programmers group the byte into two halves of four bits and give each half a single symbol — 0–9 then a–f for the values ten through fifteen. That's hexadecimal. One byte becomes two tidy characters: 11001000 is c8, and Python writes it 0xc8. It's the same byte, same 200 — just a denser handwriting. You'll see hex everywhere colours are written, like #5ede78 (that green is three bytes: red, green, blue).
A byte maxes out at 255, and a playlist is thousands of bytes of titles and lengths. Where do they all live, and how does the machine find the right one? →
03RAM: a street of numbered boxes
RAM (random-access memory) is where a running program keeps the data it's working with right now. The honest picture is almost embarrassingly simple: one enormous row of byte-sized boxes, and each box has a serial number called its address. Box 0, box 1, box 2, and so on — an 8 GB machine has about 8.6 billion of these boxes in one unbroken line. The important word is random-access: you can open box 5,000,000 exactly as fast as box 5. You don't walk down the row counting doors — you're simply given the address, and you're there. Same cost, one jump, no matter where in the line.
Store our song and this becomes concrete. The title "Blinding Lights" is fifteen characters long — fourteen letters plus the space between the two words — so it takes up fifteen boxes, one character per box. Even the space is stored as a byte: it holds the number 32. And the box holding the first letter, B, doesn't actually store the shape of the letter — it stores the number 66, a code the whole system has agreed means "B" (more on exactly which code, and why, in chapter 2). The length, 200, fits in one more box, since it's under 255. Anything too big for a single byte — past 255 — needs a team of boxes working together, usually 4 or 8 acting as one wider box. Store a whole list of song lengths this way and their addresses count up like house numbers on a street: 6000, 6004, 6008…, always four apart. That even spacing is the whole trick: to find length #3, the machine doesn't need to search for anything. It just computes start + 3 × 4 = 6012 and jumps straight there. Arithmetic, not hunting.
start + 3 × stride, not a search through the street.One thing worth being precise about: this is the raw-metal picture — the way C and NumPy pack a row of numbers side by side, wall to wall. Python's own list uses this same even-spacing trick, but with a twist: its evenly spaced slots don't hold the actual lengths — they hold 8-byte addresses pointing to where each length really lives, elsewhere in RAM. Same idea, just one extra step (chapter 6 covers this fully). One more thing to know: your program never picks its own boxes. The operating system hands each program its share of RAM and keeps track of who owns what.
6000 + i × 4. That's the difference between an array (instant) and rummaging (slow).It doesn't know. A box just holds a number — no label, no unit, nothing painted on the side to say what it means. The meaning comes entirely from the program reading it: the same number 200 could be a letter-code, a song length, or a shade of grey, depending only on what the code decides to do with it. Keep that in mind — it grows into Python's type system in chapter 2, which really just comes down to keeping track of what each box was meant to represent.
start + i × stride is a single fast multiply. Pick an odd team size and every lookup would need awkward arithmetic — the hardware would rather waste a byte of padding than lose the even stride.The deeper cut
Real tools write addresses in hexadecimal — base 16, digits 0–9 then A–F. The reason is exact, not cosmetic: one hex digit is precisely 4 bits, so one byte is always two hex digits. That's why 255 = 0xFF and our address 5000 = 0x1388 — hex lets you read the raw bits at a glance where decimal blurs them.
And a second layer you'll eventually trip over: the addresses your program sees are virtual, not the real chip locations. Each program gets a private, pretend address space that starts fresh, and the MMU (memory-management unit, hardware sitting right beside the CPU) translates virtual to physical on every single access. That's how two programs can both believe they own address 5000 without a fistfight — their "5000"s quietly map to different physical boxes.
Billions of boxes, patiently holding numbers. But boxes don't add, compare, or copy themselves. Who does the actual work? →
list adds one more hop, its slots holding addresses instead — §3). Laid out inline like this, reading item i never searches: the machine computes base + i × stride in one shot and jumps straight to that box. Press Next and watch the address get built.04The CPU: the only part that does anything
Everything else in the machine just holds data or moves it around. The CPU — the central processing unit — is the one part that actually does something with it. It's the machine's single busy worker, and its whole job is one repeating loop: the fetch–decode–execute cycle. Fetch the next instruction from RAM, decode what it means, execute it — then start again, over and over, until the power turns off. Each instruction is tiny: copy one box into the CPU, add two values, compare two values, write a box back out. The hardware never "understands" your program in any deeper sense — there's only this loop, spinning. (One simplification worth flagging: a real CPU core doesn't actually finish one fetch–decode–execute before starting the next one — it runs several in flight at once, called pipelining, and can even do a few per tick, called superscalar execution. Thinking of it as one instruction at a time is still the right mental model — just don't take the "four instructions ≈ one nanosecond" estimate below as four instructions run strictly one after another.)
Here's the constraint that makes the next piece of hardware necessary: the ALU can't reach into RAM and add two boxes where they sit — it can only work with values already inside the CPU. So before it can add anything, those values have to be copied in first. That's exactly what registers are for: a small number of private scratch cells built right into the CPU — the only place where arithmetic can actually happen. They're also much faster than RAM, for a simple reason: they're etched directly onto the CPU chip, so a value in a register never has to travel down a wire to fetch it — it's already there. The actual adding and comparing happens in a dedicated circuit called the ALU (arithmetic-logic unit), which reads from the registers and writes results back to them. And a quartz clock keeps this whole loop running at roughly 4 GHz — four billion cycles a second, one tick every quarter of a nanosecond.
Let's watch the CPU total up our playlist — and watch a single byte actually run out of room. "Blinding Lights" (200 s) fits in one byte; "Titanium" by David Guetta (245 s) fits in one byte too. But add them together — 200 + 245 = 445 — and the result is bigger than 255, too big for any one byte to hold. That's exactly why the machine stores each song length in a 4-byte team (see §3) instead of a single byte: the extra room leaves space for totals like this. Here's how it plays out: the two lengths sit in RAM, the CPU loads both into registers, the ALU adds them, and the result, 445, gets written back to a third slot. Four instructions in total. Step through it below.
The deeper cut: where does the CPU keep its place?
One register is special: the program counter (PC). It holds the RAM address of the next instruction to fetch. The "fetch" step reads whatever the PC points at; then the PC advances to the following instruction, and the loop repeats. That's the whole trick behind a program having an order.
It's also how a program can make a decision. A "compare" instruction can set the PC to jump somewhere else instead of just stepping forward — "if A is bigger than B, continue from address 7200." Loops and if-statements, all the way up to the branch in your Python code, bottom out as the CPU writing a new number into the program counter. There is no magic control flow underneath; there is only "which box do I read next."
Notice exactly what the CPU was following the whole time: short binary instructions like LOAD, ADD, and STORE, encoded as numbers and specific to this particular chip. It has never seen a single English letter. But you write text that looks like English in a .py file — something like 200 + 245. Something has to bridge that gap.
You write English-ish text; the chip speaks numbered instructions. Someone has to translate — and that someone is the next piece of the machine →
05The ladder from your text to the metal
A CPU only understands one language: its own machine code — raw binary instructions, and a different dialect for every chip family. Your laptop's x86 chip and your phone's ARM chip can both do arithmetic, but the exact byte patterns that mean "add these two numbers" are different on each — hand one chip the other's machine code and it's meaningless. What you actually write is source code: human-readable text that looks the same on every machine. Something has to translate between the two, and there are two classic approaches. A compiler translates the whole program into machine code once, ahead of time, and hands you a finished, runnable file — fast to run, since the translation work is already done (this is how C, C++, and Rust work). An interpreter is itself a running program that reads your code and carries it out on the spot, translating each piece right when it's needed. In its purest form, nothing gets saved, so it repeats that translation work every single run — slower overall, but it starts instantly with no separate build step. (Real Python is a bit more clever than this — it quietly saves some of its translated work, as you'll see in a moment.) Think of it like a novel translated once and printed forever (that's a compiler) versus a live interpreter at a summit, re-translating the same speech from scratch every time it's delivered (that's an interpreter) — and that repeated re-translation is exactly where interpreted programs spend their extra time. (A JIT is a hybrid that avoids choosing: it starts out interpreting, notices which parts of the code run over and over — the "hot" loops — and quietly compiles just those parts to machine code while the program is still running. That's the trick behind the JVM and V8, the engine that runs JavaScript in your browser.)
✗ The myth
You install Python and your CPU "learns Python." From then on the chip runs your.py file directly.✓ The reality
The CPU only ever runs machine code — it never changes.python.exe is a pre-built machine-code program; your .py file is just data — text — that this already-running program opens, reads, and acts out. Deleting Python doesn't un-teach the chip anything; it just removes the actor.Here's Python's actual pipeline, which is more layered than the single word "interpreted" suggests — because it quietly does both jobs. When you run a file, CPython first compiles your text into bytecode: compact, numbered instructions for a made-up, portable CPU that doesn't physically exist anywhere. This is called an intermediate representation — no longer your original text, but not yet your hardware's machine code either. Then CPython's virtual machine — a real program, written in C — behaves like that made-up CPU: it walks through the bytecode and interprets it, one instruction at a time. So there are two separate translation steps stacked on top of each other, and neither one is optional. Two terms are about to matter in the disassembly just below, so let's define them now. A function is a named mini-program: you call it to run it, and once it's done, it returns a value to whoever called it — the caller (you'll write your own functions in chapter 9). And that made-up CPU has a specific shape: it's a stack machine. Instead of the named registers from §4, it keeps one scratch pile called a stack, which you can only touch at the top — push a value on, pop one off, always last-in, first-out. Why a pile instead of registers? Because one simple push/pop pair can handle an expression nested to any depth, without needing a separate name for every intermediate value. It's the simplest possible engine that can still run any code at all.
dis.dis and read the rungs. Every line it prints is one tick of the virtual machine: the exact instructions the VM will walk, in order, when that function runs.import dis
def total_seconds(levels, titanium):
return levels + titanium
dis.dis(total_seconds)
# prints the function as VM instructions:
# LOAD_FAST levels ← push Blinding Lights (200) onto the stack
# LOAD_FAST titanium ← push Titanium (245) on top of it
# BINARY_OP + ← pop both, push their sum (445)
# RETURN_VALUE ← hand 445 back to the callerFour instructions — and you've just seen the whole ladder in miniature. Your one line, return levels + titanium, became a short list of moves on a stack, and the VM runs them left to right. Call total_seconds(200, 245) and those four steps produce 445 — the full playlist, Blinding Lights plus Titanium, added together in seconds.
The deeper cut
__pycache__ folder that appears next to your files, holding names like playlist.cpython-312.pyc. The number is the CPython version that produced it: bytecode is portable across operating systems (the .pyc from your Mac would run on a Linux server) but not across Python versions — a 3.12 .pyc means nothing to 3.13, so the version is stamped right into the filename. Machine code, for contrast, is portable across neither OS nor CPU family; bytecode's portability is exactly the payoff for adding that extra rung. And the "made-up CPU" has a specific shape: it's a stack machine. It keeps a small scratch stack of values;
LOAD_* instructions push values onto it, and operations pop their inputs off the top and push the result back — which is precisely the LOAD / LOAD / add / return dance you just disassembled. Hold onto that push-and-pop picture: the very same last-in-first-out discipline is about to reappear one level up, in how Python keeps track of function calls.So a software CPU is executing your program. While it runs, it has to park thousands of values somewhere in those RAM boxes from the last section. It turns out the parking lot isn't one uniform space — it has two very different districts, with very different rules →
06Two districts of memory: stack and heap
The RAM your program is given doesn't stay one undivided block — but before naming the two pieces it splits into, it helps to feel the actual problem that forces the split. A running function needs a small scrap of private space: room for its arguments, its local names, and one thing you'd never think to ask for — a note of where to jump back to once it's finished. Now imagine one function calling another, which calls a third. Each of those calls needs its own locals and its own return note. And here's the detail that decides everything: nested calls always finish in the exact reverse order they started in — the last one called is always the first to hand control back. That's not a style choice; the nesting forces it. So this bookkeeping is created and destroyed in strict last-in, first-out order.
But a second fact cuts right across the first: some values need to outlive the call that created them. A function that builds a list and hands it back can't let that list disappear just because its own locals are cleared away — the caller is still holding onto it. So a running program has two kinds of lifetime that don't line up: bookkeeping, which disappears in perfect last-in-first-out order, and objects, which live for as long as something, anything, still points at them. One single block of memory can't serve both needs well — so the machine splits its memory into two separate regions, each one shaped to fit the lifetime it has to support. Once you can feel which region you're in, the language stops feeling mysterious.
The stack holds the bookkeeping, and because that bookkeeping is strictly last-in-first-out, it can be stored in the cheapest way possible: one continuous run of numbered boxes with a single marker showing the top. Quick reminder of a word we've been using: a function is a named mini-program living inside your program — something like play_song. You call it to run it, and it returns to whoever called it once it's finished (you'll write your own in chapter 9). Every call that's currently running gets a frame: a block holding its local names and its return address. A call pushes a new frame by moving the top up by the frame's size; a return pops it by moving the top back down — last opened, first closed, which is what people mean by LIFO. And this rigid shape pays off: there's no searching and no ledger of free space to maintain — allocating a frame is just "add to a pointer," and freeing one is "subtract from a pointer." That's the entire reason the stack is fast, and also why it's called a stack: it only ever grows and shrinks at one end, like a spring-loaded stack of plates. It even cleans up after itself for free — when a call returns, the top simply moves back down, and its frame is gone.
The heap is shaped for the opposite kind of lifetime. Objects don't follow last-in-first-out order — a list created early can easily outlive one created much later — so the heap works more like an open warehouse floor, where a block of any size can be claimed at any address and released at any time, in any order. The number 200, the text "Blinding Lights", and every playlist and object you build later all live here. That flexibility comes at a price the stack never has to pay: the allocator has to keep track of which regions are free and search for one big enough whenever it's asked. It's also the reason for an extra step you'll rely on throughout this course — a name sitting in a stack frame doesn't hold the object itself, it holds the address of an object living over on the heap. And cleanup here isn't vague or automatic in some mysterious way: every heap object keeps a reference count — a running total of how many names point at it — and the moment that count reaches zero, the object is freed immediately. (A separate collector only steps in to catch cycles: objects that point at each other and keep each other's count above zero, even though nothing outside the group points in.)
One honest note, because this course tries not to sell you a tidy lie: in CPython (the standard version of Python you'll install in the next section), frames are actually small objects that live in heap memory too. So "the stack" isn't really a separate, fenced-off area of hardware somewhere else — it's the name for the strict last-in-first-out discipline those frames follow. The roles shown in the picture below are exactly right; only the idea of two separate physical locations is a simplification, and now you know exactly where that simplification lives.
Reading a static picture is one thing; watching frames actually move is another. Use the step control below to walk through a tiny run: main calls play_song("Blinding Lights"), that call finishes and returns, and finally the collector cleans up. Notice the two regions move on completely different schedules — the stack snaps back the instant a call returns, while heap objects stick around until nothing points at them anymore.
main is running. Its frame sits alone on the stack; the heap is still empty.It does, and the cost is real: a small integer object costs 28 bytes in CPython — you can check this yourself with sys.getsizeof(200) — and chapter 2 will open one up and show you exactly where those bytes go. That's roughly twenty-eight times more than the raw value needs. Why pay that cost? Because a Python integer isn't just the value 200 — it also carries its type, a reference count, and enough extra machinery to grow past a byte, past a billion, past anything your hardware can natively count, all without you ever having to think about it. That trade-off — space in exchange for flexibility — is a theme that runs through this entire course, and now you've seen it up close.
The deeper cut: why the stack can afford to be so fast
Pushing and popping a frame is about as cheap as computing gets. Because the stack only ever changes at the top, allocating a frame is basically just moving one pointer down, and freeing it is moving that same pointer back up — no searching, no bookkeeping ledger, no collector needed. That's exactly why local names are fast to create and clean themselves up automatically.
The heap pays for its flexibility with exactly the work the stack gets to skip. Because objects can be any size and can die in any order, the heap ends up with gaps whenever things are freed, and something has to keep track of what's still alive versus what's garbage. CPython's main technique is reference counting: every object keeps a running count of how many names point at it, and the moment that count hits zero, the object is freed immediately. A separate cycle-detecting collector only steps in for the rarer case where two or more objects point at each other but nothing from outside points at any of them. You'll work with reference counts directly in chapter 3 — the interactive above is the gentlest possible preview.
Enough theory — time to make the ladder fire for real. Next: install Python, type one line, and watch print("Blinding Lights") travel from your keyboard, down through every rung, to the bare metal and back →
Every program here is a real Python 3 program about the same thing a computer is made of — bits. We start by writing numbers in other bases, wire up the four logic gates, slide bits sideways, then handle raw bytes and the characters they encode. Press Next and watch the bits move.