01Running Python
In Chapter 0 we built the machine down to the switch. In this chapter we make it run our code, and I want to go slowly. There's a question hiding in here that most tutorials walk straight past. Here's the plan. First we'll start the Python interpreter in interactive mode and watch it evaluate our lines one at a time. Then we'll drop those same lines into a .py file and run the whole thing at once. The whole way through, we keep asking the one thing that matters: what is CPython doing to your source in the moment before line 1 runs? By the end we've shipped the course's first real program: a two-song playlist that clears the terminal and prints Blinding Lights and Titanium from a single print() call, where we control every byte that hits the screen. None of it is magic. It's just the machine doing exactly what we tell it, once we actually know what we're telling it.
01A conversation with Python
Let's start with the smallest thing that runs. Open a terminal, type python, and hit Enter. Watch what it does. It prints a prompt — three angle brackets, >>> — and then it stops. It blocks: it's literally sitting there waiting on your input, on stdin, doing nothing else. Don't skip past that pause, because it's the whole point. Nothing has executed yet. It's just holding the door open. Now type an expression and press Enter. It evaluates the line right there and prints the result back — no compile step to run first, no build, no wait you can feel. You give it a line; it gives you an answer.
So give it a real one. We have two tracks queued for a set: "Blinding Lights" (200 seconds) and "Titanium" (245 seconds), the pair we'll carry through this whole book. And we have one thing we actually want to know: how long is the playlist? That's just the two durations added. Ask, and it answers before your finger is off the key:
>>> 200 + 245 # you type this; Python answers the next line
445
>>> print("Blinding Lights", "by", "The Weeknd")
Blinding Lights by The Weeknd
>>> "Titanium" # a bare value — echoed back with quotes
'Titanium'
>>> _ # the underscore remembers your last answer
'Titanium'
>>>Look at what it just did that you never asked for. You typed 200 + 245 and it volunteered 445 — a seven-and-a-half-minute set. That number is worth a second glance. It has already outgrown Chapter 0's single byte, which dead-ends at 255. Two durations that each fit in one 8-bit box add up to one that no longer does. You never said "show me," yet the answer appeared. That back-and-forth has a name: the REPL, for read-evaluate-print-loop. The name is just a to-do list of what it does to every line. It Reads the raw characters, Evaluates them (boils the expression down to a single value), Prints that value back as a courtesy, then Loops around and reopens the prompt. Under the hood each line is quietly compiled to bytecode — the compact instruction list we'll build by hand in section 4 — then run, then forgotten. And "forgotten" is exact. RAM is volatile — that was Chapter 0 — so this whole conversation lives only as long as the window does.
python even means. Then open a fresh terminal and type python --version. If a version number answers back — Python 3.12.7 on the machine these words were written on — you're armed. On macOS and Linux the command is usually python3.200 + 245 — travel the loop: read, boil down to 445, echo it, and reopen the prompt. That four-step trip happens on every line you ever type.200 + 245 it echoed a clean 445, but when you typed "Titanium" it echoed 'Titanium' with the quotes still on. Why the quotes? Because the echo isn't your text read out loud — it's Python showing you the value in the exact form you'd type to recreate it. Quotes mean "this is a string," and it wants that to be unambiguous. That instinct — show it the way you'd write it — is a different job from print's, and the difference is about to matter.print()figure'Titanium', quotes and all). On the right print() renders it as clean text for a human to read (Titanium). One is a courtesy; the other is a command — and only the command survives outside the REPL.✗ The myth
"Typing a value and calling print() are the same thing — both just show it on screen."
✓ The reality
Only the REPL echoes bare values, and only as a convenience. Save those exact lines into a .py file and run it, and the bare 200 + 245 and "Titanium" print nothing at all — the values are computed and thrown away. In a real program, the only thing that speaks is print().
_, your answers, everything. The REPL is a brilliant place to think, but it keeps no diary. When you want work that outlives the window, you write it into a file — the very next section's business.The deeper cut — "a courtesy echo" is a labelled simplification; here's the real machinery
Saying Python "echoes the value as a courtesy" is true at the level you need, but it's a tidy stand-in for a specific, nameable mechanism, and it's worth meeting once. When the REPL compiles your line it uses a special "single" mode (as opposed to the "exec" mode used for whole files). That mode slips in one extra instruction: after evaluating a top-level expression, hand the result to a function called sys.displayhook. The default displayhook does three things. It skips the value entirely if it is None. Otherwise it prints the value's repr() — the recreate-me form, which is why strings keep their quotes. And it binds the name _ to that value so you can reuse your last answer. That last detail explains a quiet asymmetry: print("hi") shows hi and then hands back None, so the REPL prints nothing extra and leaves _ untouched. The "courtesy" is real; it just has a job title. And because ordinary .py files compile in exec mode, none of this runs there. That's exactly why bare expressions vanish silently in a script.
The echo was Python's idea. Next you take the microphone yourself — print(), and the one underrated comma that quietly types a space between your words. →
200 + 245 echoes 445, but x = 200 + 245 echoes nothing at all — not even a blank. An assignment is a statement, not an expression; it produces no value, and the REPL only ever echoes values. That silence is your first tell that Python quietly sorts every line into "has a value" and "does a thing."print("Blinding Lights") and then ask for _ — it still holds 445 from before the print, not the song title. Because print() hands back None, and the courtesy echo refuses to store None in _. The underscore is a memory of your last real answer, and it steps right over every print as if it never happened.sys.ps1, and Python swaps in a second prompt, sys.ps2 (the ... you see on an unfinished line), whenever your statement isn't complete. Reassign sys.ps1 and the prompt obeys. The Loop beat simply reprints whatever string is sitting in ps1._ as the very first thing in a brand-new session and Python raises NameError — the underscore doesn't exist yet. It isn't part of Python's startup; it is born the instant the Print beat displays its first value. The echo doesn't just show your answer — it creates the name that remembers it.print() never echoes — and why _ steps right over it.02print() — and the comma that types a space
You have already seen print() flash by. Now meet it properly, because it is the first real function you command, and it hides more cleverness than anyone bothers to mention. A function is a named action. You fire it by writing its name and then a pair of parentheses, and you drop whatever it should work on inside them. Hand print() a single thing and it shows that thing. Hand it several, separated by commas, and it shows them left to right, with exactly one space wedged between each — a space you never typed. Stranger still, the things need not match. A run of text and a bare number ride the same line without a word of complaint.
Run these four lines and watch print() give up its rules. The third line repairs the second, and the fourth snaps a line in half from the inside.
>>> print("Blinding Lights", 200)
Blinding Lights 200
>>> print("Blinding Lights" + 200)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
>>> print("Blinding Lights" + str(200))
Blinding Lights200
>>> print("Blinding\nLights")
Blinding
LightsTypeError: can only concatenate str…); everything above it only retraces where Python was standing when it hit the wall. Skip to the bottom, read the complaint, then climb back up only if you need the address.Walk the four lines slowly, because two of them look alike and behave nothing alike. Line 1 uses commas and prints Blinding Lights 200, free space included. Line 2 swaps the comma for +, and + is a different tool entirely. Between two pieces of text, + means glue: weld them edge to edge, no space, no gap. But here is what the traceback is really objecting to. The number 200 and the text "200" are not the same kind of thing. One is a value the machine does arithmetic on — chapter 0's byte, a quantity. The other is three separate characters, the codes 50 48 48, that merely look like a number to you. + won't bridge two kinds, so it quits with a traceback. (Python calls these kinds types, and you'll pry them open in chapter 2. For now just hold the line between number and text.) Line 3 is the cure. str(200) manufactures the three-character text "200" out of the number, so now both sides are text and + welds happily: Blinding Lights200, no space, because glue adds nothing of its own. Line 4 shows the last trick: \n is a single character — the newline — smuggled inside the text to break the line by hand.
So here is the reveal the heading half-lied about. The comma is not glue, and it does not really "type a space." The comma just hands print() its items as separate deliveries. print then does three quiet things. It runs str() on each delivery so anything can be shown. It slips a separator between neighbours (its sep setting, default a single space). And it stamps one closing character after the last (its end setting, default \n, the newline). The space you never typed is sep. The line break after every print is end. Change them and the comma stays exactly where it is; only the spacing moves.
print() then str()s each one, slips sep (default one space) between them, and stamps end (default \n) after the last. The space and the line break are the machine's doing, not yours.print("Blinding Lights", "The Weeknd", 245, sep=" | ") prints Blinding Lights | The Weeknd | 245. And print("Loading", end="") moves nowhere afterward — the next print continues on the very same line. Two settings, sep and end, and every print you will ever write is just this: your deliveries, sep between them, end after the last.sep — the space is just its default, so sep="" makes it vanish while the commas stay put. Right slider swaps end: "\n" starts a new line, " " or "" keeps the next print flowing on the same one.sep (what goes between) and end (what goes after) — the two settings that quietly shape every line Python prints.sep and not the comma, what happens when there is only one delivery? Nothing for sep to sit between — so it simply never appears. print("solo", sep="-----") prints solo, dashes and all ignored. sep is mortar between bricks: one brick, no mortar.+. The + operator demands both sides already be text and offers no help — mix in a number and it quits on the spot. The comma path never asks: print() runs str() on every delivery before showing it, so a number, a list, anything, becomes showable text automatically. print("Blinding Lights", 200) is really print(str("Blinding Lights"), str(200)) joined by sep. The comma is not punctuation between values — it is a promise that print will translate each value to text first.✗ The myth
The comma and + do the same job — the comma is just the tidier way to smash a few things together, and it throws in a space to be polite.
✓ The reality
They are unrelated. + is glue for two strings and refuses anything else; the comma hands print() separate items, which it str()-converts and joins with sep. One demands matching text and adds nothing of its own; the other accepts anything and inserts a space for free.
The deeper cut — the whole of print in one line, and the space that isn't the comma's
First, the honest refinement of this section's title. The comma types nothing at all. It is pure syntax, the ordinary way you separate arguments to any function. The space is inserted by print's sep setting, whose default merely happens to be a single space. Set sep="" and the commas stay untouched while every space vanishes: print("Blinding", "Lights", 200, sep="") prints BlindingLights200. So "the comma that types a space" is a useful lie. The comma marks the boundaries, and sep is what actually fills them.
Second, the whole of print in a single line, straight from Python: print(*args, sep=' ', end='\n', file=None, flush=False). Everything before the first comma is your pile of deliveries. The four named settings tune how they are shown. That file= even lets print write somewhere other than the screen — a thread we pick up the moment lines start living on disk.
Third, a fact worth pinning down: \n is one character, not two. len("a\nb") is 3, and that newline carries the numeric code 10 — the same kind of one-byte code the space (32) holds. The backslash is not a second character. It is just how you type an otherwise-invisible one. And sep and end must themselves be text: print("a", "b", sep=3) raises TypeError: sep must be None or a string, not int.
Last, the thing almost everyone misses: print() hands back nothing. What you see on screen is a side effect. The function's actual return value is None. Write x = print("hi") and Python shows hi, then leaves x equal to None. Displaying a value and producing a value are two different acts, and that distinction will matter enormously the day you try to capture what a function gives back.
Type a line at the prompt and it works once, then dies with the window. To make Python perform the same lines tomorrow, and the day after that, the lines need a home on disk — a file it reads top to bottom. Scripts: lines that live on disk →
print("Blinding" "Lights") prints BlindingLights — welded, with no space. Two string literals sitting side by side are fused into one by the compiler before print ever sees them, so there's a single delivery and nothing for sep to sit between. The missing comma didn't add a space; it removed the seam.sep. Hand it a newline and one call becomes a stacked list: print("Blinding Lights", "Titanium", sep="\n") prints the two titles on two lines. sep=" · " gives you a bulleted row. Same commas, same deliveries — only the mortar between them changed.print has a fourth hidden dial, file, and the terminal is only its default target. Point it elsewhere and the very same call writes to disk: print("Blinding Lights", file=open("out.txt", "w")) puts the line in a file, not on screen. "Print" was never really about the screen — it's about where the text goes, and the screen just happens to be the factory setting.03Scripts: lines that live on disk
The REPL is a brilliant place to think, but it forgets everything the moment you close the window. Every line you typed is gone, unrecoverable, as if the conversation never happened. A script is the cure. You type your lines into any text editor and save the file as playlist.py, and now those lines are plain characters on a disk — a document that survives being closed, emailed, run tomorrow, run a thousand times. Nothing about the file is magic. Open it in Notepad and you would see exactly what you typed. It is 306 ordinary bytes of text, no more special than a shopping list. The .py on the end is just a label that tells your editor and the people who read it to treat the file as Python source.
To run it, you leave the conversation and speak to the operating system instead. Scripts are launched from the terminal's own prompt — the one that looks like C:\Users\you> — not from inside the REPL. This trips up everyone once. The REPL speaks Python, the terminal speaks commands, and python playlist.py is a command, not Python. So if you still see >>> staring back at you, you are in the wrong room. Type exit() to step out to the terminal first. Then, and only then:
C:\Users\you> python playlist.py.py actually make it Python? No. Rename the file playlist.txt and python playlist.txt runs it identically — the interpreter reads the bytes, not the extension. The .py is a courtesy to humans and editors (so they colour it and import can find it), not a rule the machine enforces.print() them. One room remembers, the other forgets.# playlist.py — a document that outlives the window it was typed in
# a comment runs from '#' to the end of the line; Python skips it entirely
print("MY PLAYLIST") # a comment can ride on the same line as code
200 + 64 # computed in a blink... then silently thrown away
print("Blinding Lights", 200) # print() is the only thing that reaches the screenTwo things change the instant your code moves from the prompt into a file. First, the echo is gone. In the REPL, typing 200 + 64 printed 264 back at you as a favour. Run the same line in a script and the machine still computes 264, then drops it on the floor without a word. A bare expression in a file is a calculation nobody asked to see. From here on, anything you want on screen has to travel through print(). There is no free echo. Second, you gain comments. Everything from a # to the end of that line is invisible to Python. It is a note left for the only person guaranteed to be baffled by this file later: future you. And notice how print("Blinding Lights", 200) handles the comma. It prints Blinding Lights 200, sliding a single space between the two values for you.
Now, how does a saved file actually run? The plain answer — true 95% of the time, and we will sharpen it in a moment — is strictly top to bottom, one line at a time. Line 1, then line 2, then line 3, like a finger dragged down the page. This has a consequence that surprises beginners. If line 4 attempts the impossible, lines 1–3 have already run and their output is already on the screen, permanently. Everything from line 4 onward simply never happens. The program does not rewind. Drag the slider and watch a real run walk straight into a wall:
playlist.py. Lines 1–3 print, line 4 tries to add the string "Blinding Lights" to the number 200 and raises a TypeError, and line 5 is left unreachable.200 + 64, gets 264, and throws it away. Where did that 264 live during its half-second of existence? In RAM — it was handed a real address (something like 0x19725079930 on the run I just did) and then abandoned the instant nothing pointed at it. Chapter 3 will show you that address for real.Here is where the tidy story bends. I told you a script runs strictly top to bottom, one line at a time, and that is a labelled simplification — true enough to reason with, but not the whole truth. Try this. Take a perfectly good file whose first four lines are ordinary print() calls, and put a single typo on line 5. Run it. You might expect the first four lines to print and then Python to choke on line 5. That is not what happens. Nothing prints at all.
✗ The myth
Python starts running immediately at line 1, discovering mistakes only when the finger reaches them — so a broken line 5 would strike only after lines 1–4 had their say.
✓ The reality
Before a single line runs, Python reads and checks the entire file for grammatical sense. A typo on line 5 is caught in that first pass — so line 1 never even gets its turn. The whole run is refused.
So there are really two different moments a program can fail, and they feel completely different. A TypeError like the one in the slider is a runtime failure. The grammar was fine, so Python happily ran lines 1–3, and only tripped when it actually tried to add a word to a number. A SyntaxError from a stray typo is an earlier failure. The sentence didn't parse, so Python rejected the file wholesale before running anything. One lets three lines print; the other lets zero. That gap is your first glimpse of a hidden stage sitting between "save" and "run."
The deeper cut — what "one line at a time" quietly skips over
Refined to the truth: running python playlist.py happens in two phases, not one. First a compile phase parses the complete file into a syntax tree and lowers it into a compact internal instruction set called bytecode. This phase reads all five lines before executing any of them, which is exactly why a syntax error on line 5 blocks line 1. You can even trigger this phase alone: compile(open("playlist.py").read(), "playlist.py", "exec") parses the file and raises the SyntaxError without ever running it. Only after compile succeeds does the second phase, execution, walk that bytecode top to bottom. A TypeError can only surface here, mid-walk, because you have to actually do the addition to discover a word won't add to a number. "One line at a time" is a fair description of phase two. It just politely omits phase one — the phase where Python read your whole file before it ran a word of it.
So in the sliver of time between your Enter keypress and the first character of output, Python quietly read the entire file and turned it into something else — the bytecode it actually runs. What is that translation, and where does the translated version go? →
# is stripped during the compile step, before line 1 runs — a file with 100 comment lines and one print compiles to the byte-for-byte identical bytecode as the print alone. Comments are a letter to future-you that the machine never even opens.SyntaxError anywhere blocks everywhere. It even says so plainly — '(' was never closed — printed before a single character of your own output appears.playlist.py to playlist.txt and python playlist.txt still runs it perfectly; the interpreter reads bytes, not extensions. But the moment you want import playlist, the .py becomes mandatory — import hunts specifically for playlist.py and will never find playlist.txt. Optional to run, required to share.04The hidden compile step
Type python playlist.py and press Enter. It feels like the machine simply starts reading your file, top to bottom, doing each line as it meets it. It doesn't. Two separate things happen, in order. First, CPython reads the entire file and compiles it. It translates your letters and spaces into bytecode: a compact list of tiny instructions, each labelled by an operation code that fits in a single byte. That's where the name comes from — the whole program becomes a string of bytes. Second, a thing called the Python Virtual Machine — a CPU built out of software, living inside the python program — runs that bytecode. And "runs" is a real, nameable loop: the same fetch-decode-execute cycle chapter 0 gave your silicon CPU. The PVM keeps an instruction pointer marking the next opcode. It reads that one byte (fetch), jumps to the handler for that opcode (decode), does what the handler says (execute), then nudges the pointer forward and goes round again. One byte, one jump, repeat — thousands of times a second. Your real silicon CPU never lays eyes on your source code. It runs the PVM, and the PVM runs your bytecode. This is chapter 0's translation ladder caught in the act: the middle rung, made of software instead of metal.
import get their bytecode saved to disk as .pyc.That bytecode is built fresh in RAM and thrown away the instant your program ends, with one thrifty exception. Any file you import gets its bytecode written to disk as a .pyc file inside a __pycache__ folder, tagged with the exact interpreter that made it (on this machine, cpython-312). On the next run, if the source hasn't changed, CPython skips the compile step for that file and loads the cached bytecode straight off the disk. Your top-level script — the one you named on the command line — is compiled every single time. It's the imports, the libraries you lean on again and again, that earn the cache.
Every handler needs somewhere to put the values it works on, and here the PVM makes a deliberate choice worth understanding. Think about what print("Blinding Lights", 200) demands. print and its two arguments must be sitting ready, in order, the instant CALL fires. And CALL has to grab exactly those and nothing else, even when an argument is itself an expression leaving its own half-finished results lying around. The cheapest structure that guarantees "the things I need are the ones most recently produced" is a stack: a scratch pad you only ever add to, or take from, the top — last in, first out (LIFO, the discipline chapter 0 named). That is why the PVM is a stack machine. LOAD instructions push a value onto the top. CALL pops its arguments and the function back off the top, does the work, and pushes the single result. No value ever needs a name; its position on the stack is its name. Step through the one line below and watch the software CPU do exactly that, one tiny instruction at a time.
LOAD pushes one value onto the stack; CALL 2 pops the two arguments and the print function, runs it (that's when your terminal finally shows text), and pushes the result back. The output appears only at step 4 — proof the PVM had to reach that instruction first.print("Blinding Lights", 200) as a stack machine. Two housekeeping instructions the real interpreter emits (RESUME, PUSH_NULL) are trimmed here for clarity — the deeper cut below shows the exact, untrimmed disassembly.✗ The myth
Python reads your source one line at a time, translating as it goes — so a mistake only ever shows up when its own line is reached.
✓ The reality
The whole file is compiled up front. A grammar mistake (a SyntaxError) kills the run before any output appears. Only a legal-but-impossible operation — like "Blinding Lights" + 200 — survives compilation and fails later, on its own line, mid-run.
Those are two genuinely different failures, at two different moments, and you can feel the seam between compile and run by triggering each one. A broken quote never produces a single line of output, because compilation fails before the PVM starts. A type mismatch prints everything above it first, because the PVM was already happily running right up until it hit the impossible line.
"Blinding Lights" + 200 too? It's right there in the text. Because the compiler only checks grammar — is this a well-formed sentence of Python? — not meaning. "Add a string to a number" is perfectly grammatical; it's only impossible, and impossibility can't be judged until the actual values show up at run time. Spelling versus sense: the compiler guards spelling, the PVM discovers sense.The deeper cut — watch the compile step with your own eyes
You don't have to take any of this on faith. The dis module ("disassemble") prints the bytecode of any snippet, and compile hands you the compiled code object directly. Here is the exact, untrimmed output on this machine (Python 3.12.7). The two instructions Fig 04b hid are the first two:
import dis
dis.dis('print("Blinding Lights", 200)')
# RESUME 0 ← interpreter housekeeping
# PUSH_NULL ← calling-convention setup
# LOAD_NAME print ← look up the name "print"
# LOAD_CONST 'Blinding Lights'
# LOAD_CONST 200
# CALL 2 ← call it with 2 arguments
# RETURN_VALUETwo things are worth noticing. First, the bytecode really is a string of bytes, not a metaphor. Ask for it directly and Python confirms it:
code = compile('print("Blinding Lights", 200)', 'demo', 'exec')
type(code.co_code) # <class 'bytes'> — literally a bytes object
len(code.co_code) # 22 — that one line is 22 bytes of instructionsEach instruction is an opcode byte plus a small argument, which is why megabytes of readable source shrink into compact bytecode. Second, look at LOAD_NAME print: even print isn't baked in. It's a name the PVM looks up at run time — a name that could, in principle, point at something else entirely (reassign print and it does). That "names are just labels pointing at things" idea is chapter 3's entire story. Here it's already sitting in the second real instruction of the simplest program you can write.
.pyc is not a secret binary__pycache__ folder is completely safe — worst case, the next run recompiles and quietly rebuilds it. And a .pyc is not a compiled executable or a way to hide your code: it's just cached bytecode, tied to one interpreter version, still trivially disassembled by the same dis you just used. It exists to save start-up time, nothing more.a and b are variables (say 200 and 245) — so the compiler can't fold the answer in the way it folds a bare 200 + 245 literal (that's the callout just below). The add is real, and it costs: your one line becomes ~5 bytecode opcodes; each opcode is ~30 steps of interpreter bookkeeping; the whole thing lands as ~500 native instructions the CPU truly runs — to add two numbers it could add in one. Numbers are order-of-magnitude; the ratio is the point.The PVM will happily run your bytecode all day — but it cannot clear your terminal, open a file, or reach the network on its own. Those powers belong to a bigger authority the PVM has to politely ask. Time to meet it. →
200 + 245 and look inside: the bytecode holds the constant 445 directly — there is no add instruction at all. The compiler did the arithmetic once, up front, and baked the answer in. By the time your program "runs," the Evaluate step has nothing left to compute; it just loads a number that was decided before line 1.print("Blinding Lights", 200) compiles to exactly these 22 numbers: [151, 0, 2, 0, 101, 0, 100, 0, 100, 1, 171, 2, 0, 0, 0, 0, 0, 0, 1, 0, 121, 2]. Each opcode is one byte — 151=RESUME, 101=LOAD_NAME, 100=LOAD_CONST, 171=CALL, 121=RETURN_VALUE — and the whole thing is a real bytes object, the same kind of byte you built in chapter 0..py file. It runs python.exe — the PVM — and the PVM walks the bytecode. Your actual source is read a single time, during compile, then never touched again while the program runs. The code you wrote isn't what executes; a translated string of bytes is. You wrote the sheet music; the CPU only ever hears the PVM play it.05Borrowing the operating system
Here is a humbling truth about the program you are about to write: it owns almost nothing. The screen, the files on your disk, the other windows, the network card — none of it belongs to your code. It all belongs to the operating system, chapter 0's tireless manager, the one that hands out RAM and keeps the ledger. So picture your terminal after you've printed your whole playlist: "Blinding Lights" — 200 s, "Titanium" — 245 s, and a dozen lines of trial and error above them. You want a clean slate. And you discover Python cannot simply wipe the screen. It has no hands. What it can do is ask. It sends a polite request up to the manager and lets the manager do the deed.
That request has a name. The shell is the program already living inside your terminal window: the thing that reads each line you type and gets the OS to carry it out. When Python hands that same shell a command from inside your program, exactly as if you'd typed it yourself, we call the move shelling out. The tool for it is one function: os.system().
os.system() is your program's first taste of that conversation — a way to say "please run this" and wait for the answer.>>> import os
>>> os.system('cls') # ask the shell to wipe the terminal (Windows)
0
>>> os.system('notepad') # ask it to open Notepad — Python blocks until you close it
0Four ideas are packed into that first line, so let's unpack them one at a time. os is a module: a ready-made toolbox of code you bring into your program with import. You don't write it, you borrow it. system is one tool inside that box, and the dot in os.system simply means look inside os and find system. When you call it, three things happen in a heartbeat. Python's thread blocks — it does not "freeze" by magic, it is parked in a wait, running no further instructions — while the OS spins up a subprocess (a brand-new little program of its own, created here by Windows' CreateProcess) to run your command string. When that subprocess ends, the OS reaps its exit status and hands that single number back to the parked thread, which now wakes and runs on. By near-universal agreement, 0 means "finished cleanly." That trailing 0 in the REPL isn't output from cls. It's the exit status coming home, and the REPL echoes it the way it echoes every value you hand it.
clear, not cls. The one-liner that works everywhere — steal it now, meet its if/else machinery in chapter 5 — is os.system('cls' if os.name == 'nt' else 'clear'). On this machine os.name really is 'nt', so it chooses cls.Before we watch the round trip, look at where your program actually sits. When you launched Python, the shell didn't absorb it. It spawned it, as a separate process with its own serial number. Your program is a citizen in a small family tree, and shelling out just adds one more child to it.
Now the round trip itself. It's not one instant but a little five-beat handshake, and Python is asleep for the middle three beats. Drag the step slider and watch a single os.system('cls') travel out to the shell and come back carrying its exit status.
os.system('cls') call, beat by beatinteractiveos.system() is a blocking round trip: hand a string to the shell, sleep while a subprocess runs it, wake up holding the exit status. The 0 that lands back is the whole return value.That returned number is quietly more interesting than the story so far lets on, because 0 is only the happy case. Any other value means the command failed, and the exact value is a clue about how. Ask the shell for a command it can't find and it hands back a plain 1. Run a command engineered to fail with a particular code, and that same code rides home to you:
>>> os.system('blindinglights') # no such command — the shell shrugs
1 # non-zero = something went wrong
>>> os.system('exit 5') # a command that fails on purpose with code 5
5 # the exact failure code comes straight backos.system() hands the shell whatever string you give it, and the shell runs it with every permission your user account has — deleting files, wiping folders, the lot. Never feed it text you didn't write yourself, and especially never a string that was stitched together from someone else's typing. A command is not data; it's an order.python.exe (the PVM from section 4) is a process with its own PID, spawned by the shell, which was spawned by the terminal. When you shell out, one ordinary process is politely asking the OS to start another. There is no magic tier your code lives on. It's processes, all the way down.os.system('cls') doesn't clear the screen — it asks the OS to, and the OS could refuse, defer, or fail. That request-and-verdict shape is every dealing your program will ever have with the outside world: files, networks, the clock. Get comfortable with it here, where the whole reply fits in a single number.✗ The myth
os.system() is the grown-up way to run other programs from Python, so reach for it whenever you need one.
✓ The reality
It's the quickest way, not the safest. It flattens your intent into one string and hands it to a whole shell to re-interpret — which is exactly how a stray space or semicolon becomes a second, unwanted command. When it matters, professionals use subprocess.run(['notepad', 'song.txt']), passing the program and its arguments as a list with no shell in the middle to trick. Same idea — asking the OS — with the loaded gun pointed away from your foot.
The deeper cut — why cls isn't even a program, and what "returns 0" really means
Two things were gently simplified above, and both are worth the refinement. First, I said the shell "runs your command" as if cls were a program sitting on disk. It isn't. Ask your system where the cls program lives and it comes up empty-handed. There is no cls.exe anywhere. cls is a built-in of cmd.exe itself, a verb the shell understands directly and performs on its own. (Compare notepad, which really is a file — notepad.exe — that the shell locates and launches.) That's the deepest reason Python can't clear the screen alone. Clearing the screen isn't an action any standalone program can take from the outside; it's something the shell does to the terminal it's talking through. You're not just borrowing the OS. For cls you're borrowing a trick that only the shell knows.
Second, "returns 0" is a labelled simplification. The value is an exit status, and what the number means (plus how it's packed) is platform-specific. On this Windows machine os.system launches cmd.exe /c <your string> and hands you the command's exit code directly, which is why exit 5 gave a clean 5. On Unix the very same function returns a wider "wait status" word with the exit code tucked into its upper bits, so portable code uses os.waitstatus_to_exitcode(...) to unpack it rather than trusting the raw number. Either way the convention holds — 0 is success, non-zero is a complaint — but now you know the 0 in your REPL is a real signal with a real encoding underneath, not just decoration.
You've been collecting parts for two chapters — bits and bytes, RAM's numbered boxes, the REPL, comments, import, and now a way to reach out and borrow the whole operating system. Every piece is on the bench. Time to bolt them together into something that runs on its own: the first real program. →
n = os.system("ver") prints the Windows version on screen — yet n is just 0. The command's text went straight to the terminal, over your program's head; all you catch is the exit status, a single number. To actually capture what a command says, you need subprocess — os.system only ever reports how it went, never what it produced.os.system("cls") doesn't clear anything itself — it launches an entire fresh cmd.exe /c cls process, waits for it, and collects its exit code. Every call spins up a brand-new child. That's a surprising amount of machinery to wipe a terminal, and exactly why os.system is convenient but never truly cheap.import os; os.getpid() and Python tells you its own serial number — proof it's just another process the OS is babysitting, no grander than Notepad. When you shell out, that numbered process asks the OS for a child, and the child gets its own fresh number. It isn't "your code versus the machine" — it's one citizen of the process tree politely asking for another.06The first real program
Everything this chapter taught collapses into eleven lines you can run right now: that source is just text, that python reads it top to bottom, that a running program is a process and not a file. Open your editor, save this as my_playlist.py, drop into a terminal in that folder, and type python my_playlist.py. (On some systems the command is spelled python3 — same program, longer name.)
# my_playlist.py — the first real program
import os
# wipe the screen — 'cls' is Windows; use 'clear' on macOS/Linux
os.system("cls")
print("MY PLAYLIST")
print("===========")
print("Blinding Lights", "by The Weeknd —", 200, "seconds")
print("Titanium by David Guetta —", 245, "seconds")
print("\nThat's the whole list. For now.")Run it and the screen blanks, then the playlist announces itself. Read it downward, because that is exactly how Python reads it. import os pulls in a toolbox that talks to your operating system. os.system("cls") hands the word cls to the OS's own shell to run — that's the Windows "clear screen" command, and the screen wipes clean. Then five print calls paint the list, each on its own fresh line, because every print quietly ends with a newline. Nothing here is magic; each line does one small, literal thing.
Now look hard at the two song lines, because they are doing the same job two different ways. In the first, the artist, the length 200, and the word "seconds" are three separate values handed to print, spaced apart by commas. In the second, the whole artist phrase is tucked inside one longer string, and only 245 and "seconds" ride along as extras. Both come out with clean, single spaces, because between the values you separate with commas, print drops in exactly one space for you. And that lone \n in the last line? It's a single character meaning "new line," so it buys a blank row before the sign-off without a second print.
cls is a Windows shell word; on macOS and Linux the same job is clear. os.system doesn't understand either word — it just passes it to the operating system and hands back the shell's exit code (0 means "ran fine"). Everything else in the file — the prints, the commas, the \n — behaves identically on every machine Python runs on.python my_playlist.py actually printsfigureprint they were nothing special, just two more values glued into the line.That "glued into the line" is worth slowing down on, because it's the one genuinely new idea here and it's easy to misread. When you separate values with commas, you're not adding spaces yourself and you're not formatting anything. You're handing print a little pile of values and letting it lay them out. Slide through the call below and watch it assemble the line one argument at a time. The green markers are the spaces print inserts between arguments. The spaces already inside a string (like the one between by and The) are yours and were there all along.
print glue theminteractive200 slots in with the same clean space as the words around it. print never asked what kind of value it was.print supplies the glue — one space per seam, regardless of what's on either side..py file doesn't do anything — it's an inert script sitting on disk, in the theatrical sense of the word. Saving it changes nothing on screen. python my_playlist.py hires an actor who reads your script aloud, line by line, top to bottom, doing exactly what each line says and nothing it doesn't. The program is that performance, not the paper it's read from.✗ The myth
print is clever — it recognises that 200 is a number, knows you want it in a sentence, and formats the line for you.
✓ The reality
print is deliberately dumb: it turns each argument into text, staples them together with a single space between, and ends the line with a newline. No judgement, no formatting — just glue and a ruler.
input("Press Enter to stop the music…") as a final line. input() blocks — the program stands perfectly still, not frozen but listening, until you press Enter. And whatever you type comes back as text, always: type 200 and you get the three characters "200", never the number 200. Hold onto that word "text" — it's the hinge the next chapter turns on."Blinding Lights" and "by The Weeknd —", so where did the space between them in the output come from? From the comma. Between two arguments print lays down one space of its own. The space you did type — the one between by and The — lives inside its string and travels with it. Two spaces of identical width, wholly different origins: one is yours, one is print's.The deeper cut — "commas add spaces" is a useful lie; here's what really happens
Saying "the commas add the spaces" is a labelled simplification, true enough to run with. Here's the exact truth underneath. The comma isn't a spacing operator; it's just how you separate arguments in any function call. The spacing is print's own doing. print accepts any number of arguments plus two hidden dials: sep (what to place between arguments, default a single space " ") and end (what to place after the last one, default a newline "\n"). It converts each argument to text with str(), joins them with sep, tacks on end, and writes the result. So the space is sep, not the comma, and you can prove it by turning the dial: print("Blinding Lights", 200, sep=" | ") prints Blinding Lights | 200, while print("no newline", end=" ") keeps the next print on the same line. The default newline in end is also why a bare print() with no arguments prints a blank line: nothing to join, but the newline still fires.
One quiet asymmetry is left dangling. print happily seats "Blinding Lights" and 200 shoulder to shoulder — yet try to glue them yourself with + and Python recoils: TypeError: can only concatenate str (not "int") to str. print converts without a fuss; + refuses to guess. Python has quietly sorted your values into kinds — types — each with its own rules and even its own footprint in RAM: the number 200 fits in 28 bytes, while the text "Blinding Lights" needs 56. What are these kinds, what's inside their boxes, and why does one bend where the other won't? The next chapter, Values & types, pries them open. →
200 was a single byte in RAM. Here, as a live Python object, sys.getsizeof(200) is 28 bytes — twenty-eight times heavier — and "Blinding Lights" weighs 56. That gap is the price of being a full object, with a type and an identity, instead of raw bits. The next chapter pries these boxes open to show what fills all that room."", already weighs 41 bytes — before it holds a single character. Each ASCII letter then adds exactly one, so "Blinding Lights" (15 characters) is 41 + 15 = 56. The content is the cheap part; the wrapper — length, type, hash, encoding — is where the weight lives.import os pulls in 161 names (len(dir(os))) just so you can reach for one of them, os.system. That's the bargain of a module: you borrow the entire box to use a single tool — and pay almost nothing, because import only wires up names, it doesn't copy the code behind them.Before a single loop or function, Python already has a run model: source is read top to bottom, expressions collapse to one value, and names get bound to objects. These fifteen tiny programs make that machinery visible, one Next at a time.