Python's shape follows from one fact: a name is a label attached to an object, and nothing more. Assignment binds a label and never copies, so two names can watch the same list change underneath them. Objects supply their own behavior through dunder methods, so len(x), x == y and for i in x are the object answering a protocol rather than the language doing something built in. And the guarantees you might expect — type checking, privacy, atomic updates — are conventions upheld by a tool or by you, not by the runtime. Nearly every Python surprise is one of those three facts arriving unannounced.
Each chapter opens with the short version. Tap one to read the detail.
Names, objects, and the copy that never happens
~2 min
Every value is an object with an identity, and a name is just a label bound to one. Assignment rebinds labels rather than copying, so mutating through one name is visible through every other name for that object. is asks whether two labels point at the same object; == asks the object itself.
Assignment does exactly one thing: it binds a name to an object. It never copies. Two names bound to the same list are two labels on one object.
a = [1, 2]
b = a
b.append(3)
print(a) # [1, 2, 3] — one list, two labels
It matters only for mutable objects: numbers, strings and tuples cannot change, so aliasing them is invisible, while lists, dicts and sets can, which is where the bug usually is.
The trap is is. It compares identity, and CPython reuses cached objects for small integers and short strings, so is appears to work on values — right until the values get bigger and it silently stops. The language reference is explicit that this reuse is implementation-dependent and must not be relied on.
a = 1000
b = int("1000")
print(a == b, a is b) # True False
Equality and hashing are tied for the same reason: equal objects must hash equally, so a class defining __eq__ without __hash__ is made unhashable rather than allowed to corrupt a dict.
Rule: use is only for None and other singletons; everything else is ==.
Arithmetic, with the sharp edges named
~2 min
/ always produces a float and // floors toward negative infinity, so -7 // 2 is -4, not -3. Binary floats cannot hold most decimal fractions, so 0.1 + 0.2 is not 0.3, and round breaks ties to even. Integers, unusually, never overflow.
Division splits in two. / is true division and returns a float even when the division is exact; // floors, rounding toward negative infinity rather than toward zero. % follows from that and takes the sign of the divisor, so a negative numerator gives a positive remainder.
round breaking ties to even is documented behavior, not a bug: it stops a long column of .5 values from drifting upward. It also means round(2.5) is 2 while round(3.5) is 4.
Floats are binary, so decimal fractions are stored approximately and the error is visible: 0.1 + 0.2 prints 0.30000000000000004. That is what the hardware holds, not Python being sloppy. Never test floats with == — compare with a tolerance, or use decimal.Decimal, which is exact in base ten and is what money wants.
Integers are arbitrary-precision, so nothing wraps around: a huge integer costs time and memory, never a silently wrong answer.
Rule: // floors, round breaks ties to even, and anything that must add up exactly is a Decimal.
Lists, dicts and sets — and what they actually store
~2 min
Containers store references, not values, so [[0] * 3] * 3 is one row referenced three times. Dicts and sets are hash tables: keys must be hashable, and keys that compare equal are the same key, so 1, 1.0 and True all land in one entry.
Every container holds references. Multiplying a list repeats the references, not the objects, which is why the obvious way to build a grid is wrong.
Build with a comprehension when the inner objects must be distinct. Slicing then behaves unlike indexing: an out-of-range index raises IndexError, but an out-of-range slice is clamped, so [1, 2, 3][10:20] is []. Methods that mutate in place return None, so xs.sort() gives nothing back while sorted(xs) returns a new list. And removing items while iterating skips elements, because the loop position advances as the list shrinks under it.
Dicts and sets find a key by hashing rather than scanning, which is why keys must be hashable and why equal keys are indistinguishable.
d = {1: "int"}
d[True] = "bool"
d[1.0] = "float"
print(d) # {1: 'float'} — one key, three writes
Insertion order is preserved — guaranteed since Python 3.7, and updating a key does not move it. keys(), values() and items() are live views rather than snapshots, so adding or deleting entries while iterating raises RuntimeError: dictionary changed size during iteration.
Rule: before reaching for a container, ask what its elements are — n distinct objects, or one object referenced n times.
Text is not bytes
~2 min
A str is a sequence of Unicode code points; bytes is a sequence of integers, and indexing one gives you a number rather than a one-byte slice. Converting between them can fail, so name the encoding at every boundary. And strip takes a set of characters, not a prefix.
The two types are not interchangeable. Text is code points; bytes are numbers from 0 to 255. Indexing shows the split: b"abc"[0] is 97, while b"abc"[0:1] is b'a'. Slicing a str, by contrast, always gives a str.
Converting is encode and decode, and the two sides are not the same length.
s = "café"
print(len(s), len(s.encode("utf-8"))) # 4 5
The most-reported string surprise is strip. Its argument names characters to strip from either end rather than a prefix to remove, so "Monty Python".rstrip(" Python") is 'M' — every trailing character appearing anywhere in that argument is eaten. removeprefix and removesuffix do the job people meant.
split has two behaviors: with an explicit separator, consecutive separators delimit empty strings, so "1,,2".split(",") returns three items — while with no argument, runs of whitespace collapse into one.
For caseless comparison, casefold is the aggressive form that lower is not: it maps "straße" to "strasse", so the two spellings compare equal. lower leaves it alone.
Rule: decode at the edge, work in str, encode on the way out — and always say which encoding.
for asks for an iterator and pulls until it stops, and an iterator is consumed exactly once — a generator you have already read is not empty-looking, it is empty. Brackets build a list now; parentheses build something lazy. and and or return an operand, not a bool.
A for loop calls iter() on whatever you give it, then pulls values until the iterator reports it is finished. Lists, dicts and ranges hand out a fresh iterator each time. A generator is the iterator, so it can be walked once and is then spent.
rows = [1, 2, 3]
squares = (r * r for r in rows)
print(sum(squares)) # 14
print(sum(squares)) # 0 — already consumed
That silent second answer is the classic laziness bug: nothing raises, you simply get a zero or an empty report. If you need two passes, call list() once and keep the result. Laziness is still the right default when data is large — a generator streams a file line by line where a list holds all of it.
Two more that catch readers of other languages. and and or evaluate to one of their operands rather than to a boolean, so "" or "fallback" is 'fallback' and 0 or 0.0 is 0.0 — meaning x or default quietly overrides legitimate falsy values. And in a match statement a bare name is a capture, not a comparison: case OK: matches anything and rebinds OK to the subject. Only a dotted name such as case Status.OK: compares.
One itertools rule is worth carrying: groupby groups contiguous runs, starting a new group whenever the key changes, so unsorted input silently yields the same key several times.
Rule: an iterator you have consumed is gone — materialize it with list() if you need it twice.
Functions, defaults and decorators
~2 min
A def is a statement that executes: it evaluates the default values once, at definition time, and binds the result to a name. A mutable default is therefore shared by every call that omits it. A decorator is just name = decorator(name), which is why it can replace the function outright.
Default values are evaluated once, when the def runs — not on each call. With an immutable default nobody notices. With a list, every call that omits the argument shares one object.
def add(item, bucket=[]):
bucket.append(item)
return bucket
print(add(1)) # [1]
print(add(2)) # [1, 2] — the same list
The mistake is reading bucket=[] as "a fresh list each call"; the fix is bucket=None, then build inside the body.
Scope is decided when the function is compiled, not as it runs: assigning to a name anywhere in a function makes it local for the whole function, so reading it before the assignment raises UnboundLocalError rather than falling back to the global you meant. global and nonlocal are how you opt out.
Closures capture the variable, not its value, so [lambda: i for i in range(3)] gives three functions that all return 2. Binding it as a default — lambda i=i: i — freezes the value at definition, the same rule seen from the other side.
A decorator is an ordinary function applied to the one below it, so @deco above def f means f = deco(f). Because the wrapper replaces the original, the function's name and docstring become the wrapper's unless you apply functools.wraps.
Rule: never use a mutable default; write None and build the object inside the body.
Classes, and when a dataclass is enough
~2 min
Anything assigned in the class body belongs to the class and is shared by every instance; only self.x = ... is per-instance. super() follows the resolution order of the actual object, not your class's own parent. For a class that is mostly fields, @dataclass writes the boilerplate.
A class body executes once, and its assignments become class attributes. Instances read them until an instance attribute shadows them — fine for a constant, a bug for anything mutable.
class Dog:
tricks = [] # one list, shared by every dog
def add(self, t):
self.tricks.append(t) # mutates the shared list
Teaching one dog a trick teaches all of them, because self.tricks.append(...) never rebinds anything — it mutates the class's list. Per-instance state belongs in __init__.
Methods are ordinary functions: x.f() is C.f(x) with the instance inserted as the first argument, which is the entire meaning of self.
super() repays study. It does not mean "my parent class" — it walks the method resolution order of the object in hand. In class D(B, C) where both derive from A, a super() call inside B lands in C, not A. That is what makes cooperative multiple inheritance work, and every class in the chain must call super() or the chain stops early.
Privacy is a convention: a __secret attribute is merely renamed to _Vault__secret, which prevents collisions between classes and stops nobody.
When a class is mostly fields, @dataclass generates __init__, __repr__ and __eq__, and rejects a mutable default outright with ValueError: mutable default <class 'list'> ... use default_factory — the same trap as function defaults, caught at class-creation time.
Rule: shared mutable state on a class is a bug waiting; per-instance data goes in __init__.
Failing, and cleaning up
~2 min
An except clause matches the class named and everything derived from it, so except Exception is the honest wildcard — while a bare except: also swallows keyboard interrupts and interpreter exit. finally always runs, and a return inside it discards the exception entirely.
An exception propagates until a clause matches, and a clause matches the named class plus its subclasses. That hierarchy is why the two wildcards differ: keyboard interrupts and SystemExit inherit from BaseException, not Exception, so except Exception leaves them alone while a bare except: catches them and makes the program hard to stop.
finally runs whatever happens, which is exactly why control flow inside it is dangerous.
def f():
try:
raise ValueError("boom")
finally:
return "swallowed"
print(f()) # 'swallowed' — the exception is gone
The tutorial discourages this, and from Python 3.14 the compiler warns about it. When you re-raise deliberately, raise New(...) from exc records the original as the cause, so the traceback keeps both.
Cleanup that must happen belongs in a context manager, not in the lines after the operation, because an exception skips those lines. with calls __enter__, runs the block, and calls __exit__ however the block ended. One detail decides the behavior: if __exit__ returns a true value the exception is suppressed and execution continues after the block — so returning True by accident deletes errors silently. contextlib.contextmanager builds one from a generator, and contextlib.suppress is the readable way to ignore a specific error.
Rule: catch the narrowest exception you can name, and let a with block own the cleanup.
Modules, environments, and which Python you are running
~2 min
An import searches sys.path, executes the module once, and caches it in sys.modules; every later import returns that cached object. The directory you run from is on that path, so a file named random.py shadows the standard library. A virtual environment is that same path mechanism, aimed at your project.
Importing does three things: find the module on sys.path, execute it top to bottom, and store the result in sys.modules. That cache makes a second import instant, and makes module-level side effects run exactly once however many files import the module.
Because the working directory is searched, naming a file after a module you import silently replaces it. The error names the offending file:
AttributeError: module 'random' has no attribute 'random'
(consider renaming '/tmp/proj/random.py' since it has the same name
as the standard library module named 'random' and prevents importing
that standard library module)
The if __name__ == "__main__": guard exists because a file can be run as a script or imported, and only the script role should do top-level work. It stops being cosmetic with multiprocessing: where workers start by spawning a fresh interpreter, each one imports your file, and unguarded top-level work runs again in every child.
A virtual environment applies the same idea to third-party code — its own package directory, isolated from other environments and the system Python, so installing inside it affects only that project. Activating merely prepends its directory to PATH and is not required; running its interpreter by full path works identically. Environments bake absolute paths into their scripts, so moving one breaks it — recreate instead.
Which interpreter you are on is part of every answer, because syntax arrives by version. Features are specified in a Python Enhancement Proposal (PEP), and removal is slow by policy — at least two years of warning first — so a DeprecationWarning is a dated deadline, not noise.
Rule: never name a file after a module you import, and confirm which interpreter and environment you are in before debugging anything else.
Threads, processes and async
~2 min
On CPython one thread runs bytecode at a time, so threads overlap waiting rather than adding compute; processes add compute and pay in copying. async adds neither by itself — it lets one thread interleave waits, and a single blocking call stalls every task on the loop.
Choose by the bottleneck. The global interpreter lock (GIL) means only one thread runs Python bytecode at any instant. Threads therefore help when the work is waiting — on a network, a disk, a database — and not when it is computing. Measured on the machine this was written on: four 0.3-second sleeps took 1.21s in sequence and 0.30s across four threads, while four compute loops took 0.67s in a thread pool and 0.25s in a process pool.
Shared state between threads is where the subtle bugs live. counter += 1 is not one step — it compiles to a load, an add and a store, and nothing promises they happen together. Said honestly: a tight increment loop on CPython 3.13 kept producing the correct total when tested here, which is exactly what makes it a trap — the bug survives your test suite. Never reason about which operations are atomic; take a Lock, or pass work between threads through a Queue.
async is a third model — one thread, many suspended coroutines, switching only at await. Calling a coroutine function does not run it; it builds an object, and you get RuntimeWarning: coroutine 'work' was never awaited.
Concurrency comes from scheduling, so awaiting two calls in sequence is still sequential — asyncio.gather or a task group is what overlaps them. And one blocking call freezes every task on the loop, because nothing can preempt it; asyncio.to_thread is the escape hatch.
Rule: waiting means threads or async, computing means processes, and shared mutable state means a lock or a queue.
Files, encodings, and the batteries around them
~2 min
open makes two decisions for you unless you make them: the mode, where "w" truncates the file the moment it opens, and the text encoding, which defaults to whatever the machine prefers. That pattern repeats across the standard library — the defaults are conservative, not clever.
Opening a file in "w" mode truncates it immediately, before you write a single byte. "a" appends, and "x" refuses to open a file that already exists — the right choice whenever clobbering would be a bug.
Encoding is the other unnamed decision: text mode decodes bytes with a platform default that varies between machines, so a file that reads correctly on your laptop can fail in a container. Passing encoding="utf-8" stops the behavior depending on where the code runs.
pathlib replaces string surgery: Path("logs") / name joins correctly on every platform. One documented rule surprises people — an absolute path on the right wins, so Path("/etc") / "/tmp" is /tmp, which turns unvalidated input into a path traversal.
The same pattern runs through the library. A datetime is naive or aware: a naive one carries no time zone and so pins down nothing, and mixing the two is refused rather than guessed.
Store aware datetimes and convert at the display edge. json round-trips are lossy in the same spirit: dict keys come back as strings and tuples as lists, because that is all the format has.
Read the library index once: a surprising share of what people add a dependency for already ships — time zones, compression, argument parsing, logging.
Rule: name the mode, the encoding and the time zone; a default that varies by machine is a bug you have not hit yet.
Checking what the runtime will not
~3 min
The runtime checks almost nothing you write down: annotations are recorded and ignored, and assert statements disappear entirely under python -O. Both are claims addressed to tools you run separately — a type checker and a test suite — which is why "it ran" and "it is correct" are different statements.
The runtime does not check annotations at all. It records them and moves on.
def repeat(text: int) -> int:
return text * 2
print(repeat("ab")) # abab — no error, ever
The annotation is wrong and nothing anywhere notices. A hint is a claim addressed to a separate tool: never run, it is a comment that can lie; run automatically, it becomes the checking the language declines to do. Two distinctions carry most of the value. Optional[X] means X | None, and has nothing to do with having a default — a parameter defaulting to 0 is not optional in the type sense, as the documentation says explicitly. And Any is not "some type": it switches checking off for that value and everything reached through it, where object accepts anything and permits nothing until you narrow it. Protocol types the shape rather than the ancestry, making duck typing something a checker can verify.
Tests are the other externally-run check, and assert belongs to them rather than to production code, for a concrete reason. Running with -O strips assert statements from the compiled code, so a function that validated its input with one accepts anything in an optimized run.
Validation that protects real users raises a real exception; assert states what a test requires, and assertRaises is how a test asserts that a raise happened. unittest gets its isolation from a documented guarantee: a new test-case instance per test method, so setUp and tearDown run once per test and state cannot leak between them.
Mocking then has one rule that explains nearly every mock bug — patch the module that looks the name up, not the module that defines it. If b.py does from a import Thing, module b holds its own reference, so patching a.Thing changes nothing your code can see; you patch b.Thing.
Rule: raise exceptions for what users can cause, assert what tests must hold, patch where the name is read — and run the checker automatically, or do not bother writing hints.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.