02Everything is an object
In Chapter 1 we made the machine run our code. We shipped a program that printed our two-song playlist and controlled every byte on the way to the screen. But we never stopped to ask what those values actually are. That's this chapter. Here's the plan. We type one honest line — seconds = 200 — and pull it apart into the three separate moves Python really makes. Folded into that one = is the idea the whole language is built on. And we keep coming back to the question most tutorials skip straight past — when you write 200, where does its type live, and where do its behaviours live? The answer is the same for every value you will ever type. Each one is an object: a small parcel carrying its own value, its own type, and its own set of moves. By the end you'll know all six core value types — the three kinds of number, the booleans, that deliberate None, and frozen text versus raw bytes. And you'll be able to hand Python any value and have it tell you what it is, everything it can do, and exactly how many bytes it weighs.
01One line, three moves — everything is an object
Let's start with the smallest line that carries the whole idea. Here's one honest fact about the first track on our playlist — "Blinding Lights" by The Weeknd, 200 seconds long — typed the way you'd type anything. Then we turn around and ask Python what it just made:
seconds = 200 # the length of "Blinding Lights"
title = "Blinding Lights"
print(type(seconds)) # <class 'int'>
print(type(title)) # <class 'str'>It reads like a single action — "put 200 into seconds." Watch closely, though. Python actually makes three moves, and their order is the whole secret. Move one, the object. Somewhere in RAM — chapter 0's street of numbered boxes — Python builds an object. It's a small package holding the value 200 and a single pointer to its type, int. That one pointer is the object's whole claim to behaviour. The methods are not copied into the parcel. They live once on the shared int type, and every integer looks them up there on demand. Move two, the name — and move three, the binding. The = creates the name seconds and clips it onto that object in a single stroke. CPython does both as one atomic step (STORE_NAME), so there is no real instant you could catch the tag hanging empty. We split it into two beats only to keep the order visible: the object is built first, and the label is stuck on afterwards. The = never manufactures the value. It only points a label at one that already lives somewhere.
200, "Blinding Lights", True, even nothing-at-all. Each is a bundle of value + type + behaviour travelling together through memory. A name is never the thing; it's a tag you clip onto the thing. Get that split clear and half of Python stops being surprising.= clips a label onto a value it never had to create.seconds = 200 is three moves, not one. Object first, name second, binding last. The name is a tag; the object is the thing.Two sibling rules ride in on the same axiom. Python is case-sensitive. So Title and title are two unrelated names, and asking for one you never bound is an instant error, not a shrug —
title = "Blinding Lights"
print(Title) # NameError: name 'Title' is not definedAnd indentation carries meaning. The blank space at the start of a line is grammar, not decoration, which chapter 5 makes precise. Both rules are downstream of the same discipline: Python takes your words literally and binds them exactly.
seconds = 200 into and you can read the two moves in sequence: LOAD_CONST 200 (make/point at the object) comes first, then STORE_NAME seconds (create the name and bind it). The interpreter literally does object-then-name — it's not a metaphor.int x; — stamped once and fixed for life. Python is the reverse. The name is typeless; the object carries the type. So track = "Blinding Lights" and, a line later, track = 200 is perfectly legal — track was never an int or a str, it was just a label, free to re-clip onto any object you like. Each object knows what it is; the name only remembers where it last pointed.track was never typed — it can hold a str, then an int, then nothing at all.✗ The myth
"A variable has a type. Once track holds a string it's a string variable, and putting a number in it is a type error waiting to happen."
✓ The reality
A variable has no type at all — it's a bare name. The type lives on the object it points to. Rebinding track to a number isn't an error; it's just aiming the same tag at a different, differently-typed object.
So the founding axiom pays off immediately. Because every value is a full object that knows its own type, you never have to declare types up front. You just ask any value what it is. That axiom reaches further than it looks. The type int is itself an object, and its type is type. So is print. So is a whole module. It really is objects most of the way down, bottoming out at type, whose own type is type. For this chapter, though, we only need the everyday values — and here is the map of them.
The deeper cut — three simplifications worth refining
Three things above were true at the level I pitched them, and each sharpens on a second look.
"The name holds the object's address." That's the CPython picture, and a good one. id(seconds) really does hand back 140711682527896 (0x7ff9…5298), the object's location in memory. But the language only promises that id() returns a number unique and stable for the object's lifetime. A different interpreter could implement names and identity another way entirely. Bind to the object — that's the guarantee. "Address" is only the implementation.
"200 fits in one byte." Chapter 0 packed 200 into a single byte, and that byte still lives inside this object. But sys.getsizeof(200) reports 28 bytes, not 1. That's because a Python int is a full object wrapped around that value: a pointer to its type, a reference count, and the digits themselves. The number travels first-class now, with luggage. That overhead is exactly the price of "everything is an object," and chapter 3 shows where it goes.
"A stack of behaviours." Run dir(int) and more than seventy names hang off the type. Most aren't methods you'd call by hand. They're the hidden hooks behind operators (__add__ for +, and so on), which chapter 4 opens up. The handful you would reach for, like (200).bit_length() giving 8, are the visible tip of that machinery.
If every value is an object that knows its own type — what types are there? Python answers the question of "how many is 200" three different ways. Start with the numbers →
print is one too — so you can clip a second name onto it: p = print, then p("hi") works exactly like print("hi"). Ask type(print) and Python answers <class 'builtin_function_or_method'>. The tool you call every day is just another value with a type.int is itself an object, you can put it in a name and carry it around like any value. t = int, then t("245") hands back the number 245 — you just called a type through a different tag. Types can live in variables, ride inside lists, be passed to functions. The blueprint is as much an object as the thing it builds.type(200) is int; type(int) is type; and type(type) is… type again. The tower of "the type of the type of…" doesn't run forever — it loops back on itself at the very top. type is the one object that is its own type, the quiet floor the whole language stands on.seconds = seconds + 45 looks circular — how can a name define itself? It can't, and object-first is why it never has to. Python builds the right side first: it reads the object seconds currently points at, makes a brand-new object for the sum, and only then re-clips the seconds tag onto that new object. The old value was never edited; the name simply moved. Assignment is always object-then-bind, never a name chasing its own tail.02Three kinds of number
You already met the machine's atom in chapter 0 — a switch, a byte, a running time of 200 spelled out in eight lit and unlit boxes. Now Python hands that pile of switches back to you wearing three different faces, and it matters which one you're holding. There is int for whole numbers, float for numbers with a decimal point, and complex for the ones with an imaginary part. They print almost the same, and they add with the same +. Underneath, though, they could hardly be more different. One grows without limit, one lives in a fixed 64-bit box, one carries two coordinates at once. This section is about telling them apart by feel.
+ - * — two ints in, an int out. Put one float anywhere in the line and the answer turns float: 200 * 2.0 is 400.0./ hands back a float every single time, even when it divides evenly. 400 / 2 is 200.0, never 200.// throws the fraction away and keeps the whole part. 200 // 60 is 3 — the minutes in "Blinding Lights".% keeps exactly what // threw away. 200 % 60 is 20 — the leftover seconds. Together they lose nothing.+= is not a new operator. seconds += 45 is seconds = seconds + 45 — a new object built, then the name re-clipped.you type
seconds = 200
bonus = 45
print(seconds + bonus, seconds - bonus)
print(seconds * 2, seconds / 60)
print(seconds // 60, seconds % 60)
print(2 ** 10, abs(-45))
seconds += 45
print(seconds)you see
245 155
400 3.3333333333333335
3 20
1024 45
245- expecting
/to hand back a whole number.400 / 2is200.0, a float, living under the float contract from this section. - reaching for
//on a negative.-7 // 2is-4, not-3— floor divide always rounds down, never toward zero. - gluing text to a number with
+."Length: " + 200raisesTypeError: can only concatenate str (not "int") to str. - typing
=+instead of+=.seconds =+ 45quietly rebindssecondsto 45 and no error ever fires. 2 ** 3 ** 2is 512, not 64.**groups right to left, alone among the operators here.
seconds = 200 # int — whole: "Blinding Lights" runs 200 s
minutes = 200 / 60 # float — 3.3333333333333335, the same song in minutes
z = 3 + 4j # complex — j is Python's imaginary unit, never i
print((10).bit_length()) # 4 → 10 is 0b1010, four bits — not 32
big = 2 ** 100 # 1267650600228229401496703205376
print(big.bit_length()) # 101 → still exact; a Python int never overflows
length = int("245") # int() casts the text "245" → the number 245
print(seconds + length) # 445 → it adds; "200" + "245" would have glued into "200245"That last pair of lines is the quiet hero. Chapter 1 warned you that input() always hands back text, even when the user typed digits — and text refuses to do arithmetic, it only glues. int("245") is the cure: it casts the characters into an actual number, and suddenly + means add instead of concatenate. Half of all beginner bugs are a number still wearing its text costume.
200 and "200" are the same fact in two costumes — and if I ever mix them up, Python will either sort it out or stop me with a TypeError.>>> "200" + "45" >>> "200" > "1000" >>> 200 > 1000
'20045' True False
+ glues and > compares character by character, so "2" beats "1" and 200 "outranks" 1000. The loud TypeError you have been taught to fear is the lucky case; this is the quiet one.int("245") is the cure for input(). Text goes in, a real number comes out, and + finally means add.int(4.9) is 4, not 5. It chops the fraction clean off. Reach for round(4.9) when you want 5.float("245") gives 245.0, and so does float(245). The dot is the entire difference — a different contract, same quantity.str(200) builds the three characters "200". Now + glues instead of adding, which is sometimes exactly what you want.bool(x) never fails on anything you hand it. Every value has an answer — that is the next section's whole business.you type
text = "245"
print(int(text) + 200)
print(float(text) / 60)
print(str(200) + " seconds")
print(int(4.9), round(4.9))
print(int(True), bool(0), bool("0"))you see
445
4.083333333333333
200 seconds
4 5
1 False Trueint("4.9")raisesValueError: invalid literal for int() with base 10: '4.9'.int()reads whole numbers only — go throughfloat()first.int("")raises that sameValueError. An empty answer at a prompt is a crash sitting in wait.- forgetting
int()oninput(). Input always hands back text, so2026 - yearraisesTypeError. round(2.5)is 2, not 3. Python rounds a halfway case to the even neighbour, soround(3.5)is 4.- expecting the conversion to change the original.
int(text)builds a new object;textis still the string"245".
Start with the boundless one. In most languages an integer is parked in a fixed box — 32 or 64 switches, no more. A value that outgrows its box overflows: it wraps back to zero like a six-digit odometer rolling from 999999 to 000000. Python's int flatly refuses to wrap. When a number needs more room, the object simply grows. Python bolts on another chunk of bits and keeps counting, as far as your memory allows. That's why 2 ** 100, a number with 31 digits, prints back exactly, not as some rounded approximation. It is also why you can total the raw byte counts of a whole music library and still land on an exact integer. A 4-minute track at 320 kbps already weighs about 9.6 million bytes, and ten thousand of them sail clean past a 32-bit box's four-billion ceiling without ever wrapping to a lie. Slide the control below and watch the difference with your own eyes. The fixed 8-bit box on top can only ever hold 0–255, so push past 255 and it wraps. The Python int underneath just grows another box.
int calmly grows a ninth box. Notice the lower row only ever draws the boxes it needs — that is exactly what bit_length() counts.int grows instead, so its value is always exact.That bit_length() lands us on a fact worth burning in, because a widely-copied note online gets it wrong: (10).bit_length() is 4, not 32. Ten in binary is 1010 — four significant bits — and Python keeps only those four. There is no hidden 32-bit box padding the number out with leading zeros. The lower row in the figure above literally draws four boxes for ten and stops. bit_length() is Python telling you the smallest number of switches this value needs. That's why 2 ** 100 reports 101 and not some round machine width.
0.1 + 0.2 and Python answers 0.30000000000000004. Not a bug: one tenth has no exact form in binary, the same way ⅓ has no exact form in decimal (0.3333…). The 64-bit float stores the nearest value it can represent and the tiny leftover surfaces when you add. Perfect for song lengths and averages; never for money — reach for Decimal there.Why can't the float just be exact too, the way the int is? Because it made the opposite bargain. A float is always the same 64 switches, no matter what number sits in it — 3.14, or 3×10³⁰⁰, or one tenth. That fixed size is the point. Fixed-size means the arithmetic is wired straight into your CPU, so floats are blisteringly fast and never grow. The price is precision. Sixty-four bits can only name about 15–17 significant decimal digits and a fixed menu of fractions. So any value off that menu — like 0.1 — gets rounded to the closest one on it.
.2f — two digits after the point, always. The ugly 3.3333333333333335 prints as a clean 3.33.>10 <10 ^10 — pad to a fixed width, aligned right, left or centre. Columns come free., groups the thousands. 9600000 prints as 9,600,000, which a human reads at a glance.02d zero-pads an integer to a fixed digit count. 5 prints as 05 — the whole trick behind mm:ss..1% multiplies by 100 and stamps a % on the end. 0.7315 prints as 73.2%.you type
minutes = 200 / 60
mp3_bytes = 9600000
print(f"{minutes:.2f}")
print(f"[{minutes:>10.2f}]")
print(f"[{'Titanium':<12}]")
print(f"{mp3_bytes:,}")
print(f"{7:02d}:{5:02d}")
print(f"{0.7315:.1%}")you see
3.33
[ 3.33]
[Titanium ]
9,600,000
07:05
73.2%- thinking the spec changes the number. It changes only the display —
minutesis still3.3333333333333335afterwards. - using
don a float.f"{3.5:d}"raisesValueError: Unknown format code 'd' for object of type 'float'. - forgetting the
f.print("{minutes:.2f}")prints the braces themselves, and nothing at all warns you. - reversing the order. It is width then precision —
>10.2f, never.2f>10. - expecting
.2fto round the stored value. It rounds the printed characters only; the object never moves.
float trades precision for a fixed, CPU-fast box; an int trades speed for an exact, unbounded chain. Same + sign on the outside, opposite deals underneath.# set_stats.py -- turn raw numbers into a table you would actually ship
one_title, one_sec = "Blinding Lights", 200
two_title, two_sec = "Titanium", 245
kbps = 320
total = one_sec + two_sec
one_bytes = one_sec * kbps * 1000 // 8
two_bytes = two_sec * kbps * 1000 // 8
print(f"{'TRACK':<18}{'SEC':>5}{'MIN':>8}{'BYTES':>14}{'SHARE':>8}")
print("-" * 53)
print(f"{one_title:<18}{one_sec:>5}{one_sec/60:>8.2f}{one_bytes:>14,}{one_sec/total:>8.1%}")
print(f"{two_title:<18}{two_sec:>5}{two_sec/60:>8.2f}{two_bytes:>14,}{two_sec/total:>8.1%}")
print("-" * 53)
print(f"{'TOTAL':<18}{total:>5}{total/60:>8.2f}{one_bytes+two_bytes:>14,}{1.0:>8.1%}")
print()
print(f"raw float, unformatted: {one_sec/60}")
TRACK SEC MIN BYTES SHARE ----------------------------------------------------- Blinding Lights 200 3.33 8,000,000 44.9% Titanium 245 4.08 9,800,000 55.1% ----------------------------------------------------- TOTAL 445 7.42 17,800,000 100.0% raw float, unformatted: 3.3333333333333335
set_stats.py and run it from the terminal. Nothing here is decoration — every column is a format spec doing one job. The names line up because <18 pads each title out to eighteen characters. The numbers line up because >5 and >8 right-align them, which is how a human compares digits. The byte counts get commas from , alone, and the share column is .1% turning 0.449… into 44.9%. Then look at the last line, because it is the honest one. The stored value never changed: one_sec / 60 is still 3.3333333333333335, all seventeen digits of it. :.2f only ever changed the characters that hit the screen. Two things to try. Swap kbps to 128 and watch every byte figure and both shares move from one edit. Then delete a single , and see how much harder 8000000 is to read than 8,000,000.int can never overflow, can a float? Yes — and this is the twist most people miss. Because the float's box is fixed, it has a genuine ceiling: push past roughly 1.8e308 and Python raises OverflowError: (34, 'Result too large'). So the "unlimited" type is the plain integer, and the "scientific" floating-point type is the one that hits a wall. The boundless one is the humble one.The third face, complex, answers a need the first two can't. Some quantities are irreducibly two-dimensional and have to travel as a single value: the phasor of an AC voltage (a magnitude and a phase at once), one bin of an audio FFT (a real part and an imaginary part), or simply a point on the plane. You may go years without reaching for it. But when the problem has two axes, one number must carry both. Write 3 + 4j and you get exactly that: a single value holding a real coordinate and an imaginary one. Python uses j for the imaginary unit, not the mathematician's i (electrical engineers do the same, to avoid clashing with i for current). Ask a complex number about itself and it answers. (3 + 4j).real is 3.0, .imag is 4.0, and abs(3 + 4j) is 5.0 — the length of the arrow to that point, your old friend the 3-4-5 triangle.
4:05. Two operators do the entire job. // gives you the whole minutes, and % gives you what is left over — between them they lose nothing. The leading zero is the catch, and it is where everyone trips. A plain f"{5}" prints 5, so you get 4:5, which is simply wrong. Reach for :02d. Then print the same time as 04:05 with both halves padded, and finally total all three songs — 200, 245 and 203 seconds — into one running time. Do not reach for a clock library; this is two operators and a format spec.show the solution
seconds = 245
print(f"{seconds // 60}:{seconds % 60:02d}")
print(f"{seconds // 60:02d}:{seconds % 60:02d}")
total = 200 + 245 + 203
print(f"{total // 60}:{total % 60:02d}")
# 4:05
# 04:05
# 10:48j and not i? Try 3 + 4i and Python won't even parse it — SyntaxError: invalid decimal literal — because 4i looks like a malformed number. The j has to be glued to a numeral (4j, 1j) for Python to read it as imaginary. It's not a variable named j; it's a suffix, like the 0b you saw on binary literals.int and float are two ways of writing a number." Think two contracts. The int contract promises exactness, forever, and charges you memory and speed to keep it. The float contract promises constant size and CPU speed, and charges you a sliver of precision. A dot in your literal isn't cosmetic — it's you signing a different contract. 200 and 200.0 are the same value living under two different sets of rules.✗ The myth
"Python integers must have a maximum — every language has INT_MAX, so somewhere around 2⁶³ Python's must silently wrap or break too."
✓ The reality
There is no INT_MAX for a Python int. 2 ** 1000 is a perfectly ordinary, exact value. The only ceiling is your machine's memory — and it's the float, not the int, that actually has a hard numeric limit.
The deeper cut — what "grows a box" really means in CPython
The growing boxes in Fig 1 are a labelled simplification. Here's the machinery underneath. CPython stores an int as a small object header plus an array of 30-bit digits, each packed into a 4-byte slot. A one-digit integer like 200 weighs sys.getsizeof(200) = 28 bytes, and every extra 30-bit digit adds exactly 4 more. So 2 ** 100 (101 bits, four digit-slots) reports 40, and 2 ** 1000 reports 160. The value stays perfectly exact. Only the object gets heavier. A float, by contrast, is that same header wrapped around a single raw IEEE-754 double: 1 sign bit, 11 exponent bits, 52 stored fraction bits (plus one implied leading bit, so 53 bits of precision — sys.float_info.dig is 15). That is why float arithmetic runs at silicon speed and never grows. It is also why 0.1 is really stored as 0.1000000000000000055511151231257827021181583404541015625, the closest 53-bit fraction to one tenth.
Three kinds of number, and yet Python is hiding a fourth in plain sight — one wearing the costume of a word. True is quietly worth 1, False is worth 0, and lurking beside them is a deliberate nothing that is not zero at all →
10 ** 100, a 1 followed by a hundred zeros — is a perfectly ordinary Python int. Python prints all 101 digits exactly, no rounding, no "e+100", and the whole colossus weighs just sys.getsizeof(10**100) = 72 bytes. No Python ever "runs out of integer" — it only ever runs out of RAM.float can't even count reliably. Past 2 ** 53 = 9007199254740992 the gaps between representable numbers grow wider than 1, so 2**53 == 2**53 + 1 comes back True — the float genuinely cannot tell those two integers apart. The exact type and the approximate type disagree about whether two whole numbers are equal. That is exactly why money and counters live in int, never float.200 == 200.0 is True, yet they are different objects under different contracts. The plain int 200 weighs 28 bytes; the "fancier" decimal 200.0 weighs only 24 — the float is actually the lighter one, because its box is a fixed 8-byte double while the int drags a header sized to grow. Same number, two prices, and the smaller price buys the shakier precision.(0).bit_length() is 0 — not one, zero. Every other number lights at least one switch; zero is the single value that needs none at all, because there is nothing to turn on. It's the tidy edge case that proves bit_length() counts only the significant bits, never a fixed-width box.03True, False, and a deliberate nothing
Two sections back you watched whole numbers grow out of switches. Now meet the smallest number type of all — so small it holds exactly two values. Python calls it bool, and its two values are True and False, each spelled with a leading capital, always. That capital is not decoration. Python is case-sensitive, so true and TRUE are not booleans. They are just names the language has never heard of, and reaching for one is an instant error. A bool is a single yes-or-no: the software echo of the one bit you met at the very bottom of the machine.
Here is the twist that catches everyone. A bool is not its own separate kind of value — it is a flavour of integer. In Python's family tree, bool is a subtype of int. True is genuinely equal to 1 and False to 0. They occupy the same 28 bytes an ordinary small integer does, and they will do arithmetic without a word of complaint. True + True is 2. Not a party trick, not a coincidence: a boolean simply is a number, printed in friendlier ink.
True is 1 and False is 0, all the way down. Every strange thing you are about to see — booleans that add up, conditions you can sum, a True that indexes a list — falls straight out of that one fact. A bool is an int that has promised to only ever be 0 or 1.# a bool is a flavour of int — same value, same 28 bytes
print(True == 1) # True
print(isinstance(True, int)) # True — bool IS a kind of int
print(True + True) # 2 (yes, really)
print(issubclass(bool, int)) # True
# equal in value is not the same as being the same object
one = 1
print(True is one) # False — == compares value, is compares identity
next_track = None # nothing queued after "Titanium"
print(type(next_track)) # <class 'NoneType'>So a bool and an int are cut from the same cloth. But Python still draws a clean line around the two of them and a third, lonelier value. The map below is worth a slow look: bool nested inside int, and off on its own, a type with a single inhabitant.
bool lives inside int: every True or False is a genuine integer (1 or 0). None stands apart in its own type, NoneType, whose only possible value is the single shared None.Numbers and booleans are not the only things Python can weigh on this scale. Pour any value through bool() and it comes out as exactly one of those two. That squeeze is called truthiness, and in chapter 5 every if and while you write will lean on it. The rule is blunt and worth burning in. A value is falsy when it is empty, zero, or nothing — 0, 0.0, the empty string "", the empty list [], the empty dict {}, and None. It is truthy in every other case. Drag through the zoo below and watch each value fall to one side.
"" and [] and None land on False, while "0", "False", and [200] all land on True — content never matters, only emptiness. The = 1 / = 0 line is the reminder that the answer is really an integer.✗ The myth
bool("0") and bool("False") must be False — the text plainly says zero and false.
✓ The reality
Both are True. A string's truthiness asks one question only — is it empty? — and never reads a single character inside. bool("") is the only string that is False.
Which leaves the strangest value in the language: None. It is Python's deliberate nothing — not zero, not the empty string, but a real object whose whole job is to mean "no value here, and that is on purpose." Our playlist uses it honestly. next_track = None says the queue after "Titanium" is empty, a genuinely different fact from a track whose title happens to be "". Because None carries no data, no two Nones could ever differ. So Python builds exactly one at start-up and hands that same object to everyone who asks — a pattern called a singleton. It is a bare object header, 16 bytes with nothing inside, smaller even than the integer 0 (which needs 28). Ask its type and Python answers <class 'NoneType'>: a type with exactly one possible value.
True and False are names, and Python is case-sensitive. true raises NameError — the capital is load-bearing.200 > 210 is False, and "Titanium" == "titanium" is False too.0, 0.0, "", [], {}, None. Every other value is truthy.bool("0") is True. Truthiness asks only are you empty, and never reads a single character inside.x is None asks "the same object?". Use it for None; use == for ordinary values you want compared.you type
print(True + True, int(False))
print(200 > 210, "Titanium" == "titanium")
print(bool(""), bool("0"), bool([]), bool(0.0), bool(None))
next_track = None
print(next_track is None, bool(next_track))you see
2 0
False False
False True False False False
True False- writing
trueorTRUE. Python answersNameError: name 'true' is not defined— it has never heard of them. - assuming
bool("False")is False. It is True: the string is not empty, and emptiness is the only question asked. - testing with
x == None. It works today and is fragile forever;x is Noneis the idiom that cannot be tricked. - confusing
0withNone. Both are falsy, yet0 == Noneis False — one is a number, one is an absence. - forgetting
-1is truthy.if title.find("z"):fires on failure, because only0is falsy among the integers.
x is None, never x == None. Since there is only one None in the entire program, is (same object?) is both faster and safer than == (equal value?) — and it can't be tricked by some object that has redefined what "equal" means.True is 1, does sum([True, False, True]) equal 2? It does — and that is quietly one of Python's neatest tricks. Ask "how many of my songs run past 210 seconds?" and you can write sum(sec > 210 for sec in lengths): each comparison yields a True or False, and summing them counts the Trues. (That for sec in lengths is a loop — its machinery arrives in chapter 5; for now just read it as "run the test on every length.") Counting, it turns out, is just adding up ones.True/False as a separate species from numbers. A boolean is a number that has agreed to only ever be 1 or 0 — which is why it slots into arithmetic, indexes a list (["off", "on"][True] gives "on"), and sums into a count. None is the mirror image: not a small number, but the pointed absence of one.The deeper cut — when 1 and True are literally the same dict key, and why "True equals 1" is exact rather than loose
"True is 1" is not a friendly approximation you'll later have to unlearn. It is precise. But it is precise about value, not identity. True == 1 is True, while True is 1 is False, because they are equal numbers stored as two distinct objects. Two operators, two different questions: == asks "same value?", is asks "same object?".
Watch how far the equality goes. A dict — the value→value lookup you'll build in chapter 7 — files each key by first folding it to an integer called its hash. It then treats two keys as the same slot when their hashes match and they compare ==-equal. True hashes to 1 and compares equal to 1, so a dict genuinely cannot tell them apart. Write {1: "one", True: "boolean"} and it collapses to a single entry, {1: "boolean"}: the second key landed right on top of the first. Same story with 0 and False. There is no cleaner proof that a bool really is that integer.
And the "flavour of int" label has teeth in the other direction too. bool is so locked down that Python refuses to let you subclass it. class MyBool(bool) raises TypeError, because there must only ever be the two bool objects, True and False, in all of memory. Here's a labelled bit of history, since it explains the whole design: Python had no bool at all until 2003 (version 2.3). Before that everyone used plain 1 and 0 for yes and no. So when the real type finally arrived, it was deliberately made a subtype of int — a promise that not one line of the old code would break. That promise is exactly why True + True still equals 2 today.
Numbers, booleans, a deliberate nothing — all tidy, countable, and cheap. But a playlist is mostly text, and Python's text is carved in stone: you may read every character, yet never reshape one in place. Next we hold a string up to the light — frozen letters above, raw bytes beneath →
0 or None? Nothing wins. sys.getsizeof(0) is 28 bytes; sys.getsizeof(None) is just 16 — a bare object header with no value inside. Python's deliberate nothing is literally lighter than its zero. Absence, it turns out, is cheaper to store than emptiness.= None you will ever write — across every file, every library, a billion times over — points at the same single object Python builds once at start-up. That's what "singleton" means, and it's why the whole program spends a flat 16 bytes on None no matter how often you reach for it. You can't make a second one; even type(None)() just hands you the original back.if in sight. True * 200 is 200; False * 200 is 0. So plays * (length > 210) quietly zeroes out every song that fails the test and keeps the rest untouched — a whole branch collapsed into a single multiplication.0 and None both squeeze down to False, so are they the same? Not remotely — 0 == None is False. Truthiness asks one blunt question ("empty, zero, or nothing?") and both answer yes, but that shared verdict hides two utterly different facts: 0 is a real number you can add to, None is the pointed refusal to hold a value at all. Same side of the line, opposite meanings.04Text is frozen; bytes are raw
A number knows nothing about itself. Back in chapter 0 you watched "Blinding Lights" laid across fifteen boxes in RAM, its first letter B stored not as a shape but as the bare number 66 — with a promise to say later which code, and why. This is later. str is Python's text type, and the refinement is one sentence: a string is a sequence of Unicode code points. Unicode is the single table the whole planet agreed on. Every character that has ever been written — every Latin letter, every kanji, every emoji — gets one permanent number called its code point. B is 66, é is 233, the note 🎵 is 127,925. A string is just those numbers stood in a row, each still wearing its agreed-upon meaning.
And a string is frozen. Once Python builds one, nothing edits it in place — not you, not a method, not a single character of it. Every method that looks like it changes text quietly mints a brand-new string and hands that back, leaving the original untouched. That one rule is that str is immutable. It shapes half of what you'll do with text, so hold it close.
B is 66" and move on.t[0] is "B" — a one-character string, not a character type. Python has no separate char; it is strings all the way down.t[-1] is "s". Negative indices count in from the right, so -1 is last and -2 is second-last.t[0:8] is "Blinding" — eight characters, indices 0 through 7. The stop index is always excluded.t[:8] and t[8:] are the two halves, no gap and no overlap. A blank end means "from the start" or "to the end".t[::2] takes every second character. t[::-1] walks the whole string backwards, which is how you reverse text in Python.you type
t = "Blinding Lights"
print(t[0], t[-1])
print(t[0:8])
print(t[9:])
print(t[:8], t[-6:])
print(t[::2])
print(t[::-1])
print(len(t))you see
B s
Blinding
Lights
Blinding Lights
Bidn ihs
sthgiL gnidnilB
15- expecting
t[0:8]to include index 8. It stops before it — which is exactly whyt[:8]andt[8:]fit together perfectly. - assuming a slice can be assigned.
t[0] = "b"raisesTypeError: 'str' object does not support item assignment. A string is frozen. - walking off the end with a plain index.
t[99]raisesIndexError: string index out of range; the last valid index islen(t) - 1. - expecting a backwards slice to complain.
t[5:2]hands back the empty string""and says nothing — silent, not loud. - reading
t[-1]as an error. Negative indexing is ordinary Python; only a slice forgives out-of-range, and only a slice.
That bottom row is the other sequence type in this section. bytes is a row of raw values 0–255 with no meaning attached at all — the parcels that files on disk, packets on a network, and every MP3 of the song are actually made of. str lives only inside Python. The moment text has to leave — hit a disk, cross a socket — it must be encoded into bytes. "Blinding".encode() gives b'Blinding'. Reach into bytes with an index and you get a plain int back: b'Blinding'[0] is 66, not "B". Indexing a str, by contrast, hands you a one-character string. Two sequences, two temperaments.
.strip() is what you run on anything a human typed. It removes spaces, tabs, and the trailing newline that input() leaves behind..split() with no argument cuts on any run of whitespace. .split(" - ") cuts on exactly that text and nothing else..join() is split run backwards, and the separator is the string you call it on: " / ".join(pieces). That reads oddly once, then never again..find() reports a position, or -1 when the text is missing. For a plain yes-or-no, "z" in title is the safer question.raw.strip().title() strips first, then title-cases the result — each step handing a new string to the next.you type
raw = " blinding lights "
print(raw.strip())
print(raw.strip().upper())
print(raw.strip().title(), raw.strip().lower())
print("The Weeknd - Blinding Lights".split(" - "))
print(" / ".join(["Blinding Lights", "Titanium"]))
print("Blinding Lights".replace("Lights", "Dark"))
print("Blinding Lights".find("Lights"), "Blinding Lights".find("z"))
print("Blinding Lights".startswith("Blind"), "Blinding Lights".endswith("hts"))you see
blinding lights
BLINDING LIGHTS
Blinding Lights blinding lights
['The Weeknd', 'Blinding Lights']
Blinding Lights / Titanium
Blinding Dark
9 -1
True True- calling a method and throwing the answer away.
track.upper()alone changes nothing; you must catch it:track = track.upper(). - expecting
.strip("Lights")to remove that word. It strips those characters from the ends in any order, so"Blinding Lights".strip("Lights")gives"Blinding ". .title()capitalises after every non-letter, so"weeknd's".title()comes back asWeeknd'S. Useful, not clever..join()on numbers." ".join([200, 245])raisesTypeError: sequence item 0: expected str instance, int found..replace()swaps every occurrence, not just the first. Pass a count as a third argument when you want to stop early.
But the tidy one-number-per-letter picture in that figure only holds for plain ASCII. The moment a real playlist carries a name like Beyoncé, that accented é pushes past code point 127 and no longer fits in a single byte. The encoder spreads it across two, three, even four. Drag through a few characters below and watch one code point balloon into a parcel of raw bytes.
B (an ASCII letter, one byte) up to 🎵 (a musical note far out in Unicode, four bytes). The character never changes; only how many bytes it costs on the wire does. This variable width is UTF-8 — the encoding Python uses by default.artist = "The Weeknd" and title = "Blinding Lights". Print TW BL. The first letter is the easy half — that is just [0]. The second letter is the lesson. Find where the space sits with .find(" "), then take the character one index past it. Do it that way first, by hand, because it teaches you what an index actually is. Then do the identical job with .split() and reach into the pieces it hands back. (That result is a list — chapter 6's subject; for now just index it with [0] and [1].) Same answer, half the arithmetic — and now you know what the shortcut is short for.show the solution
artist = "The Weeknd"
title = "Blinding Lights"
a = artist[0] + artist[artist.find(" ") + 1]
t = title[0] + title[title.find(" ") + 1]
print(a.upper(), t.upper())
parts = title.split() # ['Blinding', 'Lights']
print((parts[0][0] + parts[1][0]).upper())
print(f"{a.upper()} - {t.upper()}")
# TW BL
# BL
# TW - BLé is one character but two bytes, how does Python know those two bytes aren't two separate characters when it reads them back? Because UTF-8 is self-describing — and you can read the announcement right in the bits. Encode é and its two bytes come back as 195, 169, which in binary are 11000011 and 10101001. That leading 110 is a flag meaning "a two-byte character starts here"; the 10 on the front of the second byte means "I'm a continuation — more of the one before me." (A four-byte character like 🎵 opens with 11110 and drags three 10… continuation bytes behind it.) The boundaries are baked into the bytes themselves, so .decode() can always re-group them into the exact code points you started with — no lengths stored on the side, no guessing.Now watch immutability in the open. Every string method returns a new string, and the one you called it on is never touched. find reports an index, or -1 for "not here" rather than raising. And a direct edit — track[0] = "B" — is simply forbidden.
# a str is a sequence of code points — and it never changes in place
track = "blinding lights"
print(track.title()) # Blinding Lights — a NEW string
print(track.find("titanium")) # -1 — "not found" is -1, never a crash
print(track.replace("lights", "dark")) # blinding dark — also a NEW string
print(track) # blinding lights — the original never moved
wire = track.encode() # b'blinding lights' — packed for the outside world
print(wire[0]) # 98 — indexing bytes returns a number, not "b"
print(wire.decode()) # blinding lights — bytes read back into text
# track[0] = "B" → TypeError: 'str' object does not support item assignment# song_surgery.py -- one messy line of text, taken apart and rebuilt
raw = " the weeknd - blinding lights "
clean = raw.strip()
print(f"[{raw}]")
print(f"[{clean}]")
cut = clean.find(" - ")
artist = clean[:cut]
title = clean[cut + 3:]
print(cut, "|", artist, "|", title)
parts = clean.split(" - ")
print(parts)
print(parts[0].title(), "|", parts[1].title())
print(" - ".join([title.title(), artist.title()]))
print(clean.replace(" - ", " by ").title())
print(title.title()[::-1])
print(raw)
[ the weeknd - blinding lights ] [the weeknd - blinding lights] 10 | the weeknd | blinding lights ['the weeknd', 'blinding lights'] The Weeknd | Blinding Lights Blinding Lights - The Weeknd The Weeknd By Blinding Lights sthgiL gnidnilB the weeknd - blinding lights
song_surgery.py and run it. The square brackets in the first two lines are there so you can see the whitespace .strip() removes — without them the difference is invisible. Then watch the same cut made twice. By hand, .find(" - ") reports 10, and that number is a real index into the text: clean[:10] takes everything before it, clean[13:] everything after. The + 3 is not a magic number — it is len(" - "), the width of the separator you are stepping over. The second cut is .split(" - ") doing that whole calculation for you and handing back both pieces at once. Now the honest wart: .title() capitalises after every non-letter, so the reversed line reads The Weeknd By Blinding Lights with a capital B on "By". That is not a bug in your code; it is exactly what .title() promises, and worth meeting before it surprises you. Finally, count what we did to raw: eight operations, and the last line prints it back unchanged, filthy spaces and all. Every method built a new string. Not one of them touched the original."Length: " + 200 raises TypeError: can only concatenate str (not "int") to str — Python refuses to silently guess whether you meant text or arithmetic. The fix is an f-string: f"Length: {seconds}s" → Length: 200s. Anything inside the braces is turned into text for you, no manual conversion required..upper() doesn't scrub the print — it shoots a whole new photo and points your name at that. The old photo is still perfectly intact, and anyone else holding it sees the original, unchanged. Immutability isn't a restriction; it's the guarantee that a string you were handed can never be altered behind your back.✗ The myth
"text = text.upper() changes the string" — and anyway a string is really just its bytes, so the two are interchangeable.
✓ The reality
The method builds a new string; the assignment just re-points the name. And a str (code points) is not its bytes (an encoding) — mixing them up is exactly how you get garbled é where an é should be.
The deeper cut — what a character really costs, and the O(n²) trap
CPython stores a string with a flexible representation: 1, 2, or 4 bytes per character, chosen by the widest code point in the whole string. All-ASCII "Blinding Lights" costs 1 byte each. sys.getsizeof reports a 41-byte base plus 15, so 56 bytes in all. Append a single 🎵 and the entire string is re-stored at 4 bytes per character. Those fifteen plain letters now cost 60 bytes between them, and the whole object jumps to 124 bytes. One emoji quadruples the price of every letter around it.
Immutability carries a second, sneakier cost. You'll be taught — correctly, as the rule to remember — that building text with s += piece in a loop is O(n²). A frozen string can't grow, so each += must copy everything so far into a new, bigger string. One honest refinement, so you're not surprised later. On CPython there's a special-case optimization that can sometimes grow the string in place and rescue that particular loop. But it's fragile: build the string by prepending, or hold a second reference, or run on a different Python, and the full quadratic copy roars back. The always-safe idiom is to collect the pieces and call "".join(...) once — linear, no matter the runtime. Lists, and that join, arrive in chapter 6.
Seven types now, each frozen or fluid, each priced differently. So how do you walk up to a value you've never met — ask what it is, whether two of them are truly the same, and exactly what it weighs? →
" tHe WeEknd - BLINDING lights ". Clean it to precisely The Weeknd - Blinding Lights. Three moves, in this order. .strip() kills the outer whitespace. .replace(" ", " ") collapses the doubled spaces in the middle. .title() then fixes every capital in one stroke — it lowercases what it does not capitalise, which is why the shouting BLINDING comes back tame. Chain all three on one line. Print the result inside square brackets so the edges are visible, then print messy again — it should come back exactly as filthy as it started. Print both lengths to prove the original never lost a character.show the solution
messy = " tHe WeEknd - BLINDING lights "
clean = messy.strip().replace(" ", " ").title()
print(f"[{clean}]")
print(f"[{messy}]")
print(len(messy), len(clean))
# [The Weeknd - Blinding Lights]
# [ tHe WeEknd - BLINDING lights ]
# 36 28bytes two different ways and you get two different types. b'Blinding'[0] is the plain int 66, but b'Blinding'[0:1] — the same first byte, taken as a slice — is b'B', a bytes object. One square bracket hands you a number, a colon inside it hands you bytes. The value never moved; only how you asked for it did.é — can have different lengths. One is a single code point (len 1); the other is a plain e followed by a separate "combining accent" code point (len 2). They render pixel-for-pixel the same, yet == says they are unequal. Your eye sees a character; Python counts code points, and the two are not the same thing.str / bytes split isn't only about files you open — it reaches back to the program itself. Python reads your .py as a stream of raw UTF-8 bytes and decodes them into text before it runs a single line. Type a 🎵 in a string literal and it lived on disk as four bytes first. Everything enters as bytes and becomes str only once it's safely inside."Blinding Lights".find("z") returns -1 for "not here" — and that -1 is a trap. Write if title.find("z"): to mean "if found" and it fires on failure, because -1 is truthy (only 0 is falsy) — and -1 is also a perfectly valid index, the last character. The safe test is if "z" in title:, which answers a clean True/False instead of a number pretending to be one.05Interrogate any value — and weigh it
Because every value in Python is an object — a little parcel that carries its own type and its own behaviour — you never have to guess what you're holding. You can ask it. Python hands you a small interrogation kit that works on anything: a number, a string, a song length, a value you've never seen before. Four questions and a scale. type(x) asks "what are you?" isinstance(x, int) asks "are you this kind of thing?" dir(x) asks "what can you do?" id(x) asks "where do you live?" And sys.getsizeof(x) puts the object on a scale and reads off its weight in bytes.
Point the kit at the length of "Blinding Lights" — the number 200 — and it answers int, True, an address, 28 bytes. Point it at the title string and every answer changes, because the object is different. The value isn't looking anything up in a manual. It is the manual. That's the whole payoff of "everything is an object": the answers travel inside the thing you're asking about.
type(x) names the exact class and stops there. It is an answer, not a test — useful for reading, weak for deciding.isinstance(x, int) is the test, and it sees ancestry. That is why isinstance(True, int) comes back True.type(True) == int is False, because True's exact stamp is bool. Two different questions, two honest answers.dir(x) spills the whole toolbox — 81 names on a string, 74 on an int. You never memorise them; you ask.len(x) is a free-standing function you hand the value to. x.upper() is a method the value carries. Same act, two doorways.you type
import sys
title = "Blinding Lights"
print(type(title))
print(type(title).__name__)
print(isinstance(title, str), isinstance(200, str))
print(type(True) == int, isinstance(True, int))
print(len(dir(title)), len(dir(int)))
print(sys.getsizeof(title), sys.getsizeof(200), sys.getsizeof(None))you see
<class 'str'>
str
True False
False True
81 74
56 28 16- testing with
type(x) == int. It misses subtypes, so aboolslips past as "not an int". Reach forisinstanceinstead. - passing a name where a type belongs.
isinstance(x, "int")raisesTypeError: isinstance() arg 2 must be a type, a tuple of types, or a union. - calling
len()on a number.len(200)raisesTypeError: object of type 'int' has no len()— an int has nothing to count. - expecting
dir()to list only the handy methods. Most of those 81 names are dunders, the hidden hooks behind the operators. - forgetting
import sysbeforesys.getsizeof. It lives in the standard library, not the built-in namespace, so it needs the import.
Look closely at two of those questions and you'll spot Python's two shapes for "run some code on a value." len(title) is a function: a free-standing tool, and the object goes in as an argument. title.upper() is a method: behaviour that lives inside the str type itself, reached through the object with a dot. Same idea — code acting on a value — but a different address for the tool. Functions you hand the value to. Methods the value carries with it.
Traceback (most recent call last):
File "now_playing.py", line 6, in <module>
print(seconds.upper())
^^^^^^^^^^^^^
AttributeError: 'int' object has no attribute 'upper'
TITANIUM quite happily, so the method is real — it just does not live where you looked. Abilities hang off the type, not the name: str publishes .upper(), int never did.set_length.py:5: SyntaxWarning: 'int' object is not callable; perhaps you missed a comma?
both_twice = 2(blinding + titanium)
Traceback (most recent call last):
File "set_length.py", line 5, in <module>
both_twice = 2(blinding + titanium)
^^^^^^^^^^^^^^^^^^^^^^
TypeError: 'int' object is not callable
2(a + b) for multiplication. Python reads parentheses after a value as "call this thing", so it dutifully tried to call the number 2 — and an int is not something you can call. Note the warning above the traceback: 3.12 guesses the cause before it even runs.Traceback (most recent call last):
File "save_titles.py", line 4, in <module>
raw = artist.encode("ascii")
^^^^^^^^^^^^^^^^^^^^^^
UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 6: ordinal not in range(128)
str is code points and bytes only exist once you pick an encoding. You picked ascii, which owns exactly 128 slots, and the é at position 6 is not one of them.len(title) hands the object to an outside tool; title.upper() uses a tool the object carries with it. Free-standing function versus dotted method — two doorways to the same room.Here's the whole kit fired at one string. Nothing to install, nothing to configure — just questions and answers:
import sys
title = "Blinding Lights"
print(type(title)) # <class 'str'> — "what are you?"
print(isinstance(title, str)) # True — "are you this kind?"
print(isinstance(True, int)) # True — a bool IS an int underneath
print(len(title)) # 15 — FUNCTION: the value goes in
print(title.upper()) # BLINDING LIGHTS — METHOD: reached with a dot
print(len(dir(title))) # 81 — every name this str carries
print(id(title)) # 140399482229104 (varies) — where it lives
print(sys.getsizeof(title)) # 56 — its weight, in bytes
print(sys.getsizeof(200)) # 28 — one small intThat dir(title) is worth a pause. It spills out 81 names the string carries — 47 of them public methods like .upper(), .find(), .split(), the rest the dunder machinery we'll meet later. You never memorise a string's abilities. You ask it for the list. And id(title) returns a plain integer that is, in CPython, the object's actual address in RAM — the house number from chapter 0, made visible. (That address-equals-id fact is a CPython promise, not a Python one: the language guarantees only that id is a unique, unchanging integer for as long as the object lives. Chapter 3 turns it into a superpower.)
a is b is True: both names land on the one integer object CPython pre-builds. Nudge to 257 and identity shatters — same value, two objects, a is b is False — while a == b never wavers. The cache covers exactly the 262 integers −5 … 256; it's a CPython optimisation, not a language rule. (Type the two lines at the REPL to see it — inside one compiled block the compiler may fold equal literals together.)int; past the cliff, every literal is a fresh object with its own address. Value equality never changes; identity falls off a cliff.Now the scale. Every literal from this chapter has a price in bytes. Step through them and watch the object announce its type, place itself in Python's family tree, and tip the scale:
int in the family tree — that's why isinstance(True, int) is True. Notice text costs per character, a float is a flat 24 B, and None is nearly free.int pill lights whenever the value counts as an int), and its byte weight. The bar is sys.getsizeof.True is really an int, is it just 1 in disguise? Essentially, yes. True == 1, True + True is 2, and ["off","on"][True] indexes position 1 and hands you "on". bool is a subclass of int that adds nothing but two pretty names and a tidy repr. That's not a quirk to fear — it's why counting how many songs pass a test can be as blunt as summing a list of Trues.type, dir, id, getsizeof are just four different doors into the same parcel. Interrogation isn't Python looking your value up. It's the value answering for itself.✗ The myth
To test what something is, compare its type: type(x) == int. If they match, done.
✓ The reality
That test silently misreads subtypes. type(True) == int is False — its type is bool — yet a bool is a kind of int. isinstance(x, int) returns True because it walks the family tree the bench just drew. Ask "is it this kind?", not "is it exactly this stamp?".
The deeper cut — what "weighs the object" quietly leaves out
Calling sys.getsizeof a scale is a labelled simplification, and here's the refinement: it weighs the object shallowly. Ask it for the size of a list of three song lengths, [200, 245, 222], and it answers 88 bytes. But that 88 is only the list's own bookkeeping (its header plus three 8-byte slots). It does not include the three int objects those slots point at. Add their 28 bytes each and the true footprint is 172. The scale weighs the box, not everything the box refers to elsewhere in RAM — the same one-extra-hop layout chapter 0 hinted at for lists.
And the int isn't magic either. A small int is 28 bytes because it stores one 30-bit digit. Cross 2³⁰ and Python bolts on another 4-byte digit: sys.getsizeof(2**40) is 32, and 2**70 is 36. Python integers never overflow precisely because they're allowed to grow a limb at a time. Strings scale the same honest way: a 41-byte base for the ASCII string object, then one flat byte per character. No mystery — just header plus content, every time.
You now have the tools to weigh and identify any object. But one thread from earlier is impossible to ignore: the value 200 was built first, and the name only points at its address. So what happens with b = seconds? Two names, one object — and id() can prove it. Chapter 3 follows the arrows into names & memory. →
- Everything I type is an object — one parcel carrying its value, its type and its abilities — and I never have to guess which:
type(),dir()andsys.getsizeof()ask the object itself. - The name carries no type at all. The type rides on the object, so pointing
trackat a string and then at a number is not an error, just the same tag re-clipped. - An
intis exact and boundless and grows a limb at a time; afloatis a fixed 64-bit box and therefore approximate on purpose, which is the whole reason0.1 + 0.2lands on0.30000000000000004. - Truthiness asks one blunt question — empty, zero, or nothing? — and never reads what a value says, so
bool("False")isTrue; andNoneis a deliberate absence, not a zero. - Text is frozen: every string method builds a new string and hands it back, and that text becomes
bytesonly when I choose an encoding.
print that speaks — this chapter only asked what the things being printed actually are, and found an object under every one of them.# word_count.py -- Ajai's 99-Projects "Word Count", rebuilt with chapter 2 tools
# the original opened a .txt file; we inline the text so this runs anywhere
text = """The Weeknd - Blinding Lights - 200
David Guetta - Titanium - 245
Dua Lipa - Levitating - 203"""
words = text.split()
lines = text.split("\n")
tight = text.replace(" ", "").replace("\n", "")
print("WORD COUNT --- set list")
print("=" * 44)
print(f"{'characters (raw)':<28}{len(text):>16,}")
print(f"{'characters (no whitespace)':<28}{len(tight):>16,}")
print(f"{'words':<28}{len(words):>16,}")
print(f"{'lines':<28}{len(lines):>16,}")
print(f"{'dash separators':<28}{text.count(' - '):>16,}")
print("-" * 44)
print(f"{'average word length':<28}{len(tight)/len(words):>16.2f}")
print(f"{'whitespace share of file':<28}{(len(text)-len(tight))/len(text):>16.1%}")
print(f"{'first word':<28}{words[0]:>16}")
print(f"{'last word':<28}{words[-1]:>16}")
print(f"{'bytes on disk (utf-8)':<28}{len(text.encode()):>16,}")
WORD COUNT --- set list ============================================ characters (raw) 92 characters (no whitespace) 74 words 19 lines 3 dash separators 6 -------------------------------------------- average word length 3.89 whitespace share of file 19.6% first word The last word 203 bytes on disk (utf-8) 92
with open(filename), wrapped the lot in try / except FileNotFoundError, and did the counting inside a def. All three of those are real tools you will write yourself — functions in chapter 9, try/except in chapter 13, files in chapter 15. None of them exist yet, so the text is inlined in a triple-quoted string and the program runs anywhere, with nothing to find on disk. What survived is his actual insight, and it is a good one: text.split() plus len() is a word counter. Nineteen words come out of three lines with no loop and no counter variable. Now read the numbers honestly, because two of them are traps. len(text) is 92 characters but len(words) is 19 — the difference is whitespace, and split() silently ate all of it. And the last word is 203, because split() has no idea what a word means. It cuts on whitespace and hands you the pieces; deciding that 203 is a duration and not a word is your job, not its. Break it now: change a space to a tab and watch len(words) hold steady while len(text) moves. Then paste in a title with an accent and watch the last line — bytes on disk — pull ahead of the character count, exactly as section 4 promised it would.len() is a free-standing function and .upper() is a method, are they really different underneath? Not much — len(x) just turns around and calls the object's own x.__len__(). len("Blinding Lights") and "Blinding Lights".__len__() both give 15. The friendly function is a doorway to a method the value was carrying all along — which is exactly why len(200) is a TypeError: an int has no __len__ to call.len(dir(int)) and Python lists 74 names hanging off it. Most are the hidden hooks behind operators — __add__ is what + secretly calls, __lt__ what < calls — so 200 isn't a dumb quantity; it's an object carrying 74 named abilities. You never memorise them. You ask dir().sys.getsizeof(200) is 28 bytes; sys.getsizeof(2**1000) — a 302-digit monster — is only 160. Barely six times heavier for a number unfathomably larger, because weight tracks the digit-chunks, not the value: a header plus a chain that grows four bytes at a time. The scale weighs the box, and the box stays almost small.isinstance(x, object) — 200, "Blinding Lights", None, even int and type themselves — and the answer is always True. That single unfailing "yes" is this chapter's thesis, provable right at the prompt: there is nothing in Python that isn't an object. The interrogation kit doesn't just describe values; it demonstrates the axiom.In Python, nothing is a plain scalar — every value is an object with a type, an identity, and behaviour. These fifteen tiny programs let you watch that claim hold, one line at a time.