The big ideas of Python programming
Programming is the craft of describing a process so precisely that a machine can carry it out. Python is a good place to learn it, because the language gets out of the way. Here are twelve ideas that make programs work. Each one comes with an experiment you can run in your browser.
Names point to objects
Assignment never copies. It attaches a label.
Numbers are approximations
Why 0.1 + 0.2 isn't 0.3.
Duck typing
What an object can do matters more than what it is.
Functions & scope
Every name lives somewhere, and Python looks it up in a fixed order.
Recursion
Solve a problem by solving a smaller copy of it.
Iterators & generators
Produce values one at a time, only when asked.
Hashing & dictionaries
Find anything in one step, without searching.
Algorithmic complexity
How the work grows matters more than how fast the computer is.
Testing
Let the computer search for the inputs that break your code.
Objects and names
Before you can reason about a program, you need an accurate picture of what a variable is, what a number is, and what decides whether an operation is allowed. Python's answers are simple, but they differ from many people's first guess.
Names point to objects
A variable isn't a box that holds a value. It's a label attached to an object, and one object can carry many labels.
Everything in Python is an object: numbers, strings, lists, functions, even classes. Objects live in memory. A name like a is just a reference to one of them. The statement b = a doesn't copy anything. It attaches a second label to the same object.
Whether that matters depends on whether the object is mutable. Lists, dicts and sets can change in place, so a change made through b is visible through a. Numbers, strings and tuples are immutable: "changing" y = y + 1 builds a new object and moves the label y to it, leaving every other label alone.
a = [1, 2, 3] b = a # same list, two names b.append(4) print(a) # [1, 2, 3, 4] print(a is b) # True: one object
Function arguments work the same way. Passing a list to a function hands the function another label for the same list. If the function appends to it, the caller sees the change. If the function rebinds its parameter to a new list, the caller doesn't.
History
Guido van Rossum started Python over the Christmas holidays of 1989 and released it in February 1991. Its object model came from ABC and Modula-3: every value is an object with an identity (id(x)), a type (type(x)) and a value. Ned Batchelder's 2015 PyCon talk "Facts and myths about Python names and values" is still the best explanation of this idea.
Watch out
[[0] * 3] * 3 looks like a 3×3 grid, but it's three references to one inner list. Setting one cell changes a whole column. Write [[0] * 3 for _ in range(3)] instead.
Numbers are approximations
Integers in Python are exact and unlimited. Floats are binary fractions with 53 significant bits, so most decimals can't be stored exactly.
Python's int grows as large as memory allows: 2 ** 100 is exact. A float is different. It's a 64-bit IEEE 754 number: a sign bit, 11 bits of exponent and 52 bits of fraction. That gives about 16 significant decimal digits, which is plenty for most measurements.
The catch is that floats are binary. One tenth in binary is 0.000110011001100…, repeating forever, just as ⅓ repeats in decimal. Python stores the nearest binary fraction, which is slightly more than 0.1. When you add two of these slightly-off numbers, the errors can show:
>>> 0.1 + 0.2 0.30000000000000004 >>> 0.1 + 0.2 == 0.3 False >>> import math >>> math.isclose(0.1 + 0.2, 0.3) True
This isn't a Python bug. Every language that uses hardware floating point does the same. When you need exact decimal arithmetic, for money for example, use the decimal module or work in integer cents. When you need exact fractions, use fractions.Fraction.
History
Before 1985, each computer maker rounded floating point differently, and the same program could give different answers on different machines. William Kahan led the design of the IEEE 754 standard, and he received the Turing Award in 1989 for it. Since Python 3.1, repr prints the shortest decimal that converts back to the same float, which is why you see 0.1 and not 0.1000000000000000055511151231257827.
Watch out
Never test floats with == unless you know why it's safe. Compare with a tolerance using math.isclose, and remember that adding many small floats one at a time loses accuracy (math.fsum fixes this).
Duck typing & the data model
"If it walks like a duck and quacks like a duck, it's a duck." Python asks what an object can do, not what class it belongs to.
When you write len(x), Python doesn't check whether x is a list. It looks for a method called __len__ on x's type and calls it. x + y calls __add__, x[0] calls __getitem__, and for v in x calls __iter__. These "dunder" (double-underscore) methods are Python's data model, the protocol every object can join.
So your own classes can behave like built-ins. Define __add__ and + works on your objects. Define __iter__ and they work in for loops, list(), sum() and sorted(). Code that only uses those operations works for any type that supports them. This is duck typing, and it is why Python code can be so general.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, k):
return Vector(self.x * k, self.y * k)
def __iter__(self):
yield self.x
yield self.y
def __eq__(self, other):
return tuple(self) == tuple(other)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
History
The duck test is an old saying. Alex Martelli applied it to Python in a 2000 newsgroup post: don't check whether it is a duck, check whether it quacks. Python 3.5 (2015) added optional type hints, and later typing.Protocol (PEP 544) let static checkers understand duck typing too.
Watch out
An object with no __bool__ or __len__ is always truthy. In the lab, bool(Vector(0, 0)) is True, which surprises people. Also, the error from a missing method appears where the method is used, which may be far from where the wrong object was created.
Click any cell to see which special method Python calls and what happens. Vector is the class on the left.
Abstraction
Programs get big. The tools for keeping them understandable are functions that hide details, recursion that breaks problems into smaller copies of themselves, and iterators that let you process a stream without holding it all at once.
Functions & scope
A function is a named, reusable piece of logic. The names inside it live in their own scope, and Python resolves every name with the same four-step search: Local, Enclosing, Global, Built-in.
Functions are the main tool of abstraction: give a process a name, decide what goes in and what comes out, and then stop thinking about its insides. A good function can be understood, tested and replaced on its own.
To keep functions independent, each call gets its own local scope. When Python meets a name, it searches four places in order, known as the LEGB rule: the Local scope of the current function, then the scopes of any Enclosing functions, then the module's Global scope, and finally the Built-ins like len and print. The first match wins.
One rule surprises everyone once. If a function assigns to a name anywhere in its body, that name is local throughout the whole function. Python decides this when it compiles the function, before any code runs. The lab's second example shows the resulting UnboundLocalError.
Inner functions remember the enclosing variables they use, even after the outer function has returned. That bundle of function plus remembered variables is a closure, and it's how the third example's counter keeps its count.
History
Early Python had only two scopes, local and global, so inner functions couldn't see their enclosing function's variables. PEP 227 added nested scopes in Python 2.1–2.2 (2001), and PEP 3104 added the nonlocal keyword in Python 3.0 (2008) so inner functions could also assign to them.
Watch out
Avoid global for anything but constants. A function that reads and writes globals can't be understood or tested by itself. Pass values in as arguments and return results instead.
Recursion
A function can call itself on a smaller version of the problem. You need a base case that stops, and you need to avoid solving the same subproblem twice.
Some problems contain smaller copies of themselves. A folder contains folders, an expression contains sub-expressions, and the n-th Fibonacci number is the sum of the two before it. Recursion turns that structure directly into code:
def fib(n):
if n < 2: # base case
return n
return fib(n - 1) + fib(n - 2)
This is correct but very slow. fib(5) computes fib(3) twice and fib(2) three times, and the waste grows exponentially: fib(30) makes 2.7 million calls. The fix is to remember answers you've already computed. That is memoization, and in Python it's one line:
from functools import cache
@cache
def fib(n):
...
Now each fib(k) is computed once, and fib(30) takes 59 calls. Storing subproblem answers is the core of dynamic programming, behind spell checkers, route planners and DNA alignment.
History
John McCarthy's Lisp (1958) made recursion a basic tool of programming. The term "memoization" was coined by Donald Michie in 1968. Python added functools.lru_cache in 3.2 and the simpler functools.cache in 3.9.
Watch out
Each recursive call uses a stack frame, and CPython stops at a depth of about 1000 with RecursionError. Python deliberately doesn't do tail-call optimization, so very deep recursions should become loops.
Iterators & generators
Don't build the whole list. Produce one item at a time, only when the consumer asks for it.
A for loop doesn't need a list. It needs an iterator: an object with a __next__ method that hands over the next item, or raises StopIteration when there are none left. Files, range, dictionaries and database cursors are all iterable.
A generator is the easiest way to write one. Use yield instead of return, or write a generator expression in parentheses. The function pauses at each yield and resumes when the next item is requested. Chain generators together and you get a pipeline that is lazy: nothing is computed until someone asks for it.
from itertools import islice numbers = range(1, 10**9) squares = (x * x for x in numbers) evens = (s for s in squares if s % 2 == 0) first5 = list(islice(evens, 5)) # [4, 16, 36, 64, 100]
This finishes instantly, using almost no memory, even though numbers has a billion items. With square brackets (list comprehensions), Python would try to build a billion squares first, which needs tens of gigabytes of memory.
History
PEP 234 (iterators) and PEP 255 (simple generators) arrived together in Python 2.2 in 2001, inspired by the languages Icon and CLU. Python 3 made range, map, zip and dictionary views lazy by default.
Watch out
A generator can be consumed only once. Loop over evens a second time and you get nothing. If you need the items again, store them in a list, or call the generator function again.
Data and algorithms
How you organize data decides how fast you can use it, and how an algorithm's work grows with its input decides whether it finishes at all. Testing decides whether any of it is right.
Hashing & dictionaries
A hash function turns a key into a slot number, so a dictionary can jump straight to the right place instead of searching.
To check whether "kiwi" is in a list of a million fruits, Python compares it with each item in turn, up to a million comparisons. A dict or set does it in about one step, whatever the size.
The trick is a hash function. hash("kiwi") turns the key into a big integer, and the dictionary uses that number to pick a slot in an internal table. To look up a key later, it hashes the key again and goes straight to that slot. Two keys can hash to the same slot, a collision. Then the dictionary probes other slots in a fixed order until it finds the key or an empty slot.
Collisions stay rare because Python keeps the table at most two-thirds full. When it gets fuller, Python allocates a bigger table and reinserts everything. That occasional resize is expensive, but it's rare enough that the average insert stays fast.
stock = {"apple": 3, "kiwi": 12}
stock["fig"] = 7 # insert: hash, find a slot
"kiwi" in stock # lookup: hash, jump, compare
# True
History
Hans Peter Luhn described hashing in an IBM memo in 1953. Python's dictionary is one of its most heavily tuned parts, because Python itself runs on dicts: module globals, object attributes and keyword arguments are all stored in them. Since Python 3.7, dicts are guaranteed to remember insertion order.
Watch out
Only immutable (more precisely, hashable) values can be keys. A list can't be a key, because if it changed after insertion its hash would change and the dict could never find it again. Use a tuple instead.
Algorithmic complexity
What matters is how the work grows as the input grows. A better algorithm beats a faster computer.
Searching a sorted list of a million names one by one takes up to a million comparisons. Binary search looks at the middle, throws away the half that can't contain the name, and repeats. It needs at most 20 comparisons, because 2²⁰ is just over a million.
Computer scientists describe this growth with Big-O notation. Linear search is O(n): double the data, double the work. Binary search is O(log n): double the data, one more step. Simple sorting algorithms like insertion sort are O(n²), and the best general sorts are O(n log n). At a million items that's the difference between seconds and days.
import bisect names.sort() # O(n log n), once i = bisect.bisect_left(names, "Kim") # O(log n), every lookup
Knowing the cost of built-in operations matters just as much. x in some_list is O(n), while x in some_set is O(1) on average (idea 7). Inserting at the front of a list with insert(0, x) shifts every element. collections.deque does it in constant time.
History
Big-O notation comes from number theory, from Paul Bachmann (1894) and Edmund Landau. Donald Knuth popularized it for algorithms in the 1970s. Python's own sort is Timsort, written by Tim Peters in 2002. It is O(n log n) in the worst case and much faster on data that is already partly sorted. Python 3.11 replaced its merge strategy with "powersort".
Watch out
Big-O hides constant factors. For small inputs a "slow" algorithm is often faster, and a loop in pure Python can be 50 times slower than the same work done inside a built-in. Measure with timeit before optimizing.
Testing
Tests turn "I think it works" into "it works on these cases." Property-based testing goes further and makes the computer hunt for the cases you didn't think of.
A test is code that runs your code and checks the answer. With pytest it can be one line: assert is_leap(2024). Tests catch mistakes when you write the code, and keep catching them every time you change it later.
The weakness of example tests is that you only test what you thought of, and the bugs live in what you didn't think of. Property-based testing flips this around. You state a rule that should hold for every input, the library generates hundreds of random inputs, and if one fails it shrinks it to the simplest failing case.
from hypothesis import given, strategies as st
@given(st.lists(st.integers(), min_size=1))
def test_median_matches_statistics(xs):
assert median(xs) == statistics.median(xs)
The lab's three functions all pass their example tests. Run the property tests and see how fast random inputs expose them.
History
Python's unittest (2001) copied JUnit, which Kent Beck and Erich Gamma adapted from Beck's Smalltalk framework. pytest made tests plain functions with plain assert. Property-based testing started with Koen Claessen and John Hughes's QuickCheck for Haskell (2000). David MacIver's Hypothesis brought it to Python in 2013.
Watch out
A passing test suite proves only that those tests pass. 100% line coverage can still miss the one input that matters, such as an empty list, a leap century, or a name with an apostrophe.
Bigger programs
As programs grow, you need ways to add behaviour without rewriting code, to deal with failure in a sensible place, and to keep the computer busy while it waits on the outside world.
Functions are values
Functions can be passed around, returned and wrapped like any other object. A decorator wraps a function in extra behaviour without changing its code.
In Python, a function is an object. You can store it in a variable, put it in a list, pass it to sorted(words, key=len), or return it from another function. A function that takes or returns functions is a higher-order function.
A decorator is a higher-order function used with @. It takes your function and returns a new one that wraps it: logging each call, timing it, caching results, checking permissions, or retrying on failure. The @ line is shorthand for f = decorator(f).
import functools
def log(func):
@functools.wraps(func)
def wrapper(*args):
print(f"calling {func.__name__}{args}")
result = func(*args)
print(f"returned {result}")
return result
return wrapper
@log
@functools.cache
def slow_square(n): ...
Stacked decorators apply from the bottom up, so the one written on top is the outermost wrapper and runs first. The lab shows why order matters: put @log outside @cache and every call is logged; put it inside and only the cache misses are.
History
Functions as values go back to Alonzo Church's lambda calculus (1936) and Lisp (1958). Python got lambda, map and filter in 1994, reportedly contributed by a Lisp fan. The @decorator syntax arrived in Python 2.4 (2004) after a long debate recorded in PEP 318.
Watch out
Without functools.wraps, the wrapped function loses its name and docstring, which breaks debugging and documentation tools. And @cache keeps every result forever, so on functions with many different inputs it can quietly use a lot of memory.
Exceptions
When something goes wrong, raise an exception. It travels up the call stack, running cleanup code along the way, until a handler that knows what to do catches it.
The function that notices a problem is rarely the one that knows how to handle it. A parser deep inside your program finds a bad line, but only the top-level code knows whether to skip the file, ask the user, or give up.
Exceptions connect the two. raise ValueError("bad line 3") stops the current function immediately. Python then unwinds the stack: it leaves each calling function in turn, running its finally blocks, until it reaches a try block with a matching except. If nothing catches it, the program stops and prints a traceback listing every frame it passed through.
try:
config = load_config("app.toml")
except ValueError as err:
print(f"Using defaults: {err}")
config = DEFAULTS
finally:
log.flush() # runs no matter what
Python leans on exceptions more than most languages. Its style is called EAFP, "easier to ask forgiveness than permission": try the operation and handle the failure, instead of checking every precondition first. The with statement packages up the most common finally, closing files and releasing locks.
History
Structured exception handling came from PL/I and CLU in the 1960s and 70s, and Python had it from its first release. Python 3 added exception chaining (PEP 3134), so a traceback shows the original error that caused a later one. Python 3.11 added ExceptionGroup and except* for errors from several concurrent tasks (PEP 654).
Watch out
A bare except: or except Exception: pass swallows every error, including your own bugs, and leaves no trace. Catch the specific exception you expect, as close as possible to where you can do something useful about it.
main calls load_config, which calls read_file, which calls parse. parse raises ValueError. Choose handlers for each frame, then raise.
Concurrency
Programs spend much of their time waiting. Threads and asyncio let the waits overlap. To run Python code on several cores at once, you have traditionally needed separate processes.
A program that downloads 4 web pages one after another spends most of its time waiting for the network. Concurrency means starting the next download while the first is still waiting, so the waits overlap.
Python offers three tools. Threads (threading, concurrent.futures) let the operating system switch between tasks. In standard CPython, though, a lock called the GIL (global interpreter lock) allows only one thread to run Python code at a time, so threads speed up waiting but not computing. asyncio runs many tasks in one thread. Each task says await wherever it would wait, and the event loop runs other tasks in the meantime. Processes (multiprocessing) each get their own interpreter and their own GIL, so they compute in parallel, at the cost of startup time and copying data between them.
import asyncio
async def fetch(url):
await asyncio.sleep(1) # stands in for network I/O
return url
async def main():
return await asyncio.gather(*(fetch(u) for u in urls))
History
The GIL dates from 1992, when it was the simple way to make the interpreter thread-safe. asyncio arrived in Python 3.4 (2014), with async/await syntax in 3.5. After decades of debate, PEP 703 made the GIL optional: free-threaded builds were experimental in Python 3.13 (2024) and became officially supported in 3.14 (2025).
Watch out
In asyncio, one blocking call such as time.sleep or a slow CPU loop freezes every task, because they share a single thread. Try "Async + one blocking call" in the lab.
Three threads run through all twelve ideas
A precise mental model
Names and objects, float representation, scope lookup and exception unwinding (ideas 1, 2, 4, 11) are all exact rules. Most confusing bugs come from a slightly wrong picture of one of them, and they become obvious once the picture is right.
Protocols over types
Duck typing, iterators, decorators and context managers (ideas 3, 6, 10, 11) all work because Python defines small protocols, a few special methods each, that any object can join. Generic code depends on the protocol, not the class.
Cost awareness
Exponential recursion, hash tables, Big-O, lazy pipelines and the GIL (ideas 5–8, 12) are all about knowing what your code actually costs in time and memory before you find out in production.
Further reading
- The official Python Tutorial (docs.python.org). Short, accurate, and written by the people who designed the language.
- Al Sweigart, Automate the Boring Stuff with Python (2nd ed., 2019). The friendliest start for people who want to get real tasks done.
- Luciano Ramalho, Fluent Python (2nd ed., 2022). The data model, iterators, decorators and concurrency, explained in depth.
- Brett Slatkin, Effective Python (2nd ed., 2019). Short, specific rules for writing better Python.
- Harold Abelson & Gerald Jay Sussman, Structure and Interpretation of Computer Programs (1985; Python adaptation: Composing Programs by John DeNero). The classic on abstraction, recursion and higher-order functions.
- Ned Batchelder, "Facts and myths about Python names and values" (PyCon 2015). Twenty-five minutes that clear up idea 1 for good.