The official Python tutorial (version 3.14) retold in everyday words. Each topic gives you a real-life picture, code you can run, and a diagram where a picture beats a paragraph. It goes from first steps up to advanced material, and ends with a cheat sheet.
Search everythingPress / and type. Topics, code and the cheat sheet all filter as you type.
Choose your levelUse the chips at the top to show only Basic, Intermediate or Advanced topics.
Track your progressPress “Mark done” on a topic. Your ticks stay in this browser.
Remember it fastThe cheat sheet packs syntax, methods, formats and big-O into one place.
Basic everyday PythonIntermediate what working developers useAdvanced how Python really works
Tutorial chapter 1
Whetting Your Appetite
What Python is, why people pick it, and what happens when you run a program.
§1
What Python is good for
Basic
Python is a programming language designed to be easy to read. You write short, English-like instructions and the computer follows them. It has built-in tools for lists, text, dates and files, so you rarely start from nothing.
In real lifeThink of a Swiss Army knife that also comes with a recipe book. You can do a quick job (rename 500 photos), a medium job (read a spreadsheet and email a summary), or a big job (run a website or train a machine-learning model) with the same tool.
Machine learning and LLM tooling (PyTorch, scikit-learn)
Glue systems together
Calling other programs, talking to databases and cloud services
Trade-offPython is slower than C or Rust for raw number crunching. The usual answer is to let fast libraries (written in C) do the heavy lifting while your Python code stays short.
§1
How Python runs your code
Intermediate
People call Python “interpreted”, but it does a quick translation step first. Python (the standard version is called CPython) turns your .py file into bytecode, a compact list of simple instructions. A small engine, the Python Virtual Machine, then reads those instructions one by one and carries them out.
In real lifeA chef gets a long recipe in prose. First she rewrites it as a numbered checklist (bytecode). Then she follows the checklist step by step (the virtual machine). She keeps the checklist on a card so next time she can skip the rewriting.
A file is compiled once to bytecode, then the virtual machine runs it. The cached .pyc lets Python skip the compile step on the next run.
import dis
def add(a, b):
return a + b
dis.dis(add) # shows the bytecode steps Python will run
# LOAD_FAST a / LOAD_FAST b / BINARY_OP + / RETURN_VALUE (names vary by version)
Because the virtual machine does the work, the same .py file runs on Windows, macOS and Linux without changes.
Tutorial chapter 2
Using the Python Interpreter
Starting Python, passing it arguments, and trying ideas quickly in the interactive prompt.
§2.1
Invoking the interpreter
Basic
You start Python from a terminal. Typing python3 on its own opens a chat-like prompt. Giving it a file name runs that file. Small flags change the behaviour.
Command
What it does
python3
Opens the interactive prompt (>>>)
python3 app.py
Runs the file app.py
python3 -c "print(2+2)"
Runs a one-line command from the terminal
python3 -m http.server
Runs a library module as a program (here: a tiny web server)
python3 -i app.py
Runs the file, then leaves you at the prompt to poke around
In real lifeYou have a folder of photos on your laptop and want to share it with a phone on the same Wi-Fi. python3 -m http.server turns that folder into a mini website in one line. Stop it with Ctrl+C.
To leave the interactive prompt, press Ctrl+D (macOS and Linux) or Ctrl+Z then Enter (Windows), or type exit().
§2.1.1
Argument passing with sys.argv
Basic
Words you type after the script name are handed to your program as a list called sys.argv. Item 0 is the script name, item 1 is the first word you typed, and so on.
In real lifeIt is like handing a courier a parcel with a note. The note (argv) says who the parcel is for. The courier (your script) reads the note when it starts.
# greet.py
import sys
name = sys.argv[1] if len(sys.argv) > 1 else "friend"
print(f"Hello, {name}!")
REPL stands for Read, Evaluate, Print, Loop. You type one line, Python answers at once, and it waits for the next. It is the fastest way to test an idea before putting it in a file.
In real lifeIt works like a calculator that understands words. Not sure what "hello".title() returns? Type it and see, instead of searching.
>>> 7 * 6
42
>>> _ + 8 # underscore holds the last result
50
>>> "hello".title()
'Hello'
>>> help(str.split) # built-in documentation
>>> dir("hello") # list everything a string can do
Two helpers worth memorising: help(thing) shows its manual and dir(thing) lists its attributes. Since Python 3.13 the prompt also supports colour and multi-line editing, and 3.14 adds syntax highlighting as you type.
§2.2.1
Source code encoding
Intermediate
Python reads your file as UTF-8 text by default, so names, comments and strings can use any language: नमस्ते, café, 日本語. You only need a special first-line comment if your file is saved in a different encoding.
# -*- coding: cp1252 -*-
# Only needed for a file saved as Windows-1252, a legacy encoding.
print("price: 5 €")
Watch outWhen you open files with open(), say the encoding explicitly (encoding="utf-8"). The source-file rule does not apply to the data files you read and write.
Related tip: on macOS and Linux, a first line such as #!/usr/bin/env python3 (the “shebang”) lets you run a script as ./tool.py after chmod +x tool.py.
Tutorial chapter 3
An Informal Introduction
Numbers, text and lists, plus your first small programs.
§3.1
Variables, comments and dynamic typing
Basic
A variable is a name you attach to a value with =. You never declare a type: the value knows its own type and the name can be moved to a different value later. Anything after # on a line is a comment that Python ignores.
In real lifeA name is a sticky label on a box. You can peel the label off a box of books and stick it on a box of shoes. The label (x) and the contents (the value) are separate things.
price = 250 # int
price = 249.99 # now a float; same label, new value
shop_name = "Corner Cafe"
is_open = True
print(type(price)) # <class 'float'>
width = height = 0 # chain assignment: both names point to 0
Use snake_case for variables and functions, CapWords for classes, UPPER_CASE for constants.
Names are case-sensitive: Total and total are different.
Using a name that was never assigned raises NameError.
§3.1.1
Numbers and the calculator
Basic
Python does arithmetic the way you would on paper. Whole numbers are int (they can be as large as memory allows), numbers with a decimal point are float. Division with / always gives a float.
In real lifeSplitting a restaurant bill of 1,250 among 4 people: 1250 / 4 is 312.5 each. How many whole notes of 500 fit in the bill? 1250 // 500 is 2, with 1250 % 500 = 250 left over.
Watch outMoney needs care: 0.1 + 0.2 is not exactly 0.3 (see the floating-point topic later). For prices use decimal.Decimal or whole cents.
§3.1.2
Text (strings)
Basic
Text goes inside single or double quotes; they mean the same. Use a backslash to include special characters, or a raw string (r"...") when backslashes should stay as they are, such as in Windows paths and regular expressions. Triple quotes let text span several lines.
print("It's fine") # a double-quoted string can hold '
print('She said "hi"') # and the reverse
print("Line1\nLine2") # \n starts a new line
print("C:\new") # surprise: \n is a newline here
print(r"C:\new") # raw string keeps it: C:\new
poem = """Roses are red,
Violets are blue.""" # triple quotes: multi-line text
print("ha" * 3 + "!") # hahaha! repeat and join
print("Py" "thon") # side-by-side literals join: Python
print(len("Python")) # 6
RememberStrings are immutable: you cannot change a letter in place. word[0] = "J" raises TypeError. Build a new string instead: "J" + word[1:].
Indexing and slicing
Each character has a position number starting at 0. Negative numbers count from the end. A slice word[a:b] takes characters from position a up to, but not including, b. It helps to picture the numbers as pointing at the gaps between letters.
Slices cut at the gaps. The start is included, the stop is not, so the length of word[a:b] is always b - a.
word = "Python"
print(word[0], word[-1]) # P n
print(word[:2]) # Py (from the start)
print(word[4:]) # on (to the end)
print(word[::-1]) # nohtyP (step -1 reverses)
print(word[::2]) # Pto (every 2nd letter)
§3.1.3
Lists: an ordered, changeable collection
Basic
A list holds many items in order, written in square brackets. Unlike strings, lists can be changed: you can add, remove or replace items. Items can be of mixed types, even other lists.
In real lifeA shopping list on your phone. You add milk, cross off eggs, and the first item is always the first line.
cart = ["rice", "milk", "eggs"]
cart.append("tea") # add at the end
cart[1] = "oat milk" # replace an item
print(cart[-1]) # tea
print(cart[:2]) # ['rice', 'oat milk']
cart[1:3] = [] # delete a slice
print(cart, len(cart)) # ['rice', 'tea'] 2
grid = [[1, 2], [3, 4]] # a list of lists
print(grid[1][0]) # 3
Watch outSlicing a list makes a shallow copy: a new outer list, but the same inner objects. b = a does not copy at all; both names point at one list (more in the Names and Objects topic).
§3.2
First steps: while loops and indentation
Basic
A while loop repeats its block as long as a condition is true. Python marks a block by indentation (4 spaces is the convention), not by curly braces. Everything pushed to the right under the colon belongs to the block.
In real lifeIf you can climb a staircase one or two steps at a time, the number of different ways to climb 1, 2, 3, 4… steps is 1, 2, 3, 5, 8… Each count is the sum of the previous two. That is the Fibonacci pattern, and it fits in four lines.
a, b = 0, 1 # two assignments at once
while a < 100:
print(a, end=" ") # end=" " keeps output on one line
a, b = b, a + b # right side is computed first, then assigned
# 0 1 1 2 3 5 8 13 21 34 55 89
RememberA condition is “true” for any non-zero number and any non-empty string, list or dict. Zero, None, "" and [] count as false.
Tutorial chapter 4
More Control Flow Tools
Making decisions, repeating work, and packaging code into functions.
§4.1
if, elif and else: making decisions
Basic
An if statement runs a block only when its condition is true. Add elif (“else if”) for more choices and else as the catch-all. Python checks from the top and runs only the first branch that matches.
In real lifeA cafe deciding what to suggest: above 30° offer an iced drink, above 15° a light tea, otherwise hot coffee.
Checks run left to right. The first test that is true wins and the rest are skipped.
temp = 22
if temp > 30:
print("Iced drink")
elif temp > 15:
print("Light tea") # this one runs
else:
print("Hot coffee")
# one-line version (conditional expression)
label = "adult" if age >= 18 else "minor"
Combine tests with and, or, not. Python also allows chained comparisons such as 0 < x <= 100.
§4.2
for loops: do something with each item
Basic
A for loop walks through anything you can go through one item at a time (a list, string, dictionary, file) and runs its block once per item. You never manage a counter yourself.
In real lifeA teacher marks a pile of exam papers: pick up one, mark it, put it down, repeat until the pile is empty.
words = ["cat", "window", "defenestrate"]
for w in words:
print(w, len(w))
# cat 3
# window 6
# defenestrate 12
Watch outDo not add or remove items from a collection while looping over it; the loop can skip items or behave oddly. Loop over a copy, or build a new collection.
users = {"amy": "active", "raj": "inactive", "li": "active"}
# safe way 1: make a new dict
active = {u: s for u, s in users.items() if s == "active"}
# safe way 2: loop over a copy of the keys, then change the original
for name in list(users):
if users[name] == "inactive":
del users[name]
§4.3
range(): counting made easy
Basic
range(start, stop, step) produces numbers from start up to but not including stop. It does not build the whole list; it hands out one number at a time, so range(10**9) costs almost no memory.
In real lifeA stopwatch shows the current second without printing every second in advance. range is the stopwatch; list(range(...)) is the printed-out timetable.
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(5, 10))) # [5, 6, 7, 8, 9]
print(list(range(0, 10, 3))) # [0, 3, 6, 9]
print(list(range(-10, -100, -30))) # [-10, -40, -70]
print(sum(range(4))) # 6
colors = ["red", "green", "blue"]
for i, color in enumerate(colors, start=1): # nicer than range(len(...))
print(i, color)
§4.4–4.5
break, continue and the loop’s else
Intermediate
break leaves the loop immediately. continue skips the rest of this round and starts the next one. The unusual part is else on a loop: it runs only if the loop finished without hitting break. Read it as “no break happened”.
In real lifeLooking for a free seat on a bus. You check seats one by one. If you find a free one, you sit (break). If you reach the end without finding one, the else part says “bus is full”.
A break jumps straight past the else. Running out of items takes the else route.
seats = ["taken", "taken", "free", "taken"]
for number, state in enumerate(seats, start=1):
if state == "free":
print("Sit at seat", number) # Sit at seat 3
break
else:
print("Sorry, the bus is full") # only if no break happened
for n in range(1, 8):
if n % 2 == 0:
continue # skip even numbers
print(n, end=" ") # 1 3 5 7
§4.6
pass: a deliberate blank
Basic
Python does not allow an empty block, so pass means “do nothing here, on purpose”. It is how you sketch the shape of a program before filling it in.
In real lifeA building plan with rooms labelled “to be decided”. The walls exist; the interiors come later.
class PaymentGateway:
pass # to be written
def send_invoice(order):
... # "..." (Ellipsis) is another common placeholder
for _ in range(3):
pass # do nothing, three times
§4.7
match: structural pattern matching
Intermediate
match looks like a switch statement from other languages, but it is smarter. Instead of comparing to fixed values only, it checks the shape of your data (a list of two items, a dictionary with a certain key, an object of a certain class) and pulls out the parts you want in the same step.
In real lifeA text-adventure game reads what the player types. “go north” and “pick up sword lamp” have different shapes and each shape triggers a different action.
def handle(command):
match command.split():
case ["go", direction]:
return f"Walking {direction}"
case ["pick", "up", *items]:
return f"Taking {', '.join(items)}"
case ["quit" | "exit"]:
return "Bye"
case []:
return "Say something"
case _: # wildcard: matches anything
return "I don't understand"
print(handle("go north")) # Walking north
print(handle("pick up sword lamp")) # Taking sword, lamp
Classes, dictionaries and guards
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def where(p):
match p:
case Point(x=0, y=0):
return "origin"
case Point(x=0, y=y):
return f"on the y-axis at {y}"
case Point(x, y) if x == y: # 'if' adds an extra condition (guard)
return "on the diagonal"
case _:
return "somewhere else"
def read(response): # works on dictionaries too
match response:
case {"status": 200, "data": data}:
return data
case {"status": 404}:
return "not found"
RememberA bare name in a pattern (like direction above) captures a value; it does not compare against an existing variable. To compare with a constant, use a dotted name such as Color.RED.
Tutorial chapter 4, sections 4.8 to 4.10
Functions in Depth
Reusable blocks of code: how to define them, call them and describe them.
§4.8
Defining functions
Basic
A function is a named recipe. You write the steps once with def, then call it by name whenever you need it. Values you give it are parameters; the value it hands back is set with return. A function with no return gives back None.
In real lifeA vending machine. You put in a coin and a button choice (inputs), something happens inside you do not need to see, and a snack drops out (the return value).
def total_with_tax(price, rate=0.18):
"""Return the price including tax."""
return round(price * (1 + rate), 2)
print(total_with_tax(100)) # 118.0
print(total_with_tax(100, 0.05)) # 105.0
def say_hi():
print("hi") # no return statement
result = say_hi()
print(result) # None
Variables created inside a function are local; they vanish when it ends.
Functions are ordinary values: you can store one in a list or pass it to another function.
Return several things at once by returning a tuple: return low, high.
§4.9.1
Default argument values
Basic
Give a parameter a value with = and callers may leave it out. Defaults are great for options that are usually the same.
def ask(prompt, retries=3, reminder="Please try again!"):
...
ask("Save file?") # uses both defaults
ask("Save file?", 5) # changes retries only
Classic bugThe default is created once, when def runs, not on every call. If it is a list or dictionary, every call shares the same one.
In real lifeA hotel reception leaves one shared notepad on the counter. Guest A writes “a”. Guest B walks up and sees “a” already there.
def add_item(item, basket=[]): # BAD: one shared list
basket.append(item)
return basket
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] surprise!
def add_item(item, basket=None): # GOOD: make a fresh list per call
if basket is None:
basket = []
basket.append(item)
return basket
§4.9.2
Keyword arguments
Basic
When you call a function you can name the arguments, name=value. Names make calls self-explanatory and let you skip options and change the order. Positional arguments must come first.
Two markers in a function’s parameter list control how each parameter may be given. A / says “everything before me must be passed by position”. A * says “everything after me must be passed by name”. Parameters between them can be used either way.
In real lifeOn a form, the first lines are fixed boxes you fill in order (positional-only). Then come labelled fields you can fill in any order (either). The last ones are checkboxes you must tick by their label (keyword-only), such as “gift wrap: yes”.
The slash and the star split a signature into three zones, each with its own calling rule.
def f(a, b, /, c, *, d, e):
return a + b + c + d + e
f(1, 2, 3, d=4, e=5) # OK
f(1, 2, c=3, d=4, e=5) # OK: c can be named
# f(a=1, b=2, c=3, d=4, e=5) TypeError: a, b are positional-only
# f(1, 2, 3, 4, 5) TypeError: d, e must be named
Why use them? Positional-only lets you rename a parameter later without breaking callers (library authors love this; built-ins such as len(obj, /) use it). Keyword-only forces readable calls like connect(host, port, timeout=5) and prevents mixing up two similar flags.
§4.9.4
*args and **kwargs: any number of arguments
Intermediate
*args collects extra positional arguments into a tuple. **kwargs collects extra named arguments into a dictionary. The names args and kwargs are only a convention; the stars do the work.
In real lifeA catering order: “10 plates (required), plus any number of extras: naan, raita, dessert… and special notes like spice=mild.”
This “catch everything and pass it along” pattern is the backbone of decorators (see the advanced chapter).
§4.9.5
Unpacking argument lists
Intermediate
The stars work in reverse at a call site: * spreads a list or tuple into positional arguments, ** spreads a dictionary into named arguments.
bounds = [3, 6]
print(list(range(*bounds))) # [3, 4, 5] same as range(3, 6)
def connect(host, port, secure=False):
return f"{'https' if secure else 'http'}://{host}:{port}"
settings = {"host": "example.com", "port": 8443, "secure": True}
print(connect(**settings)) # https://example.com:8443
print(*["a", "b", "c"], sep=" | ") # a | b | c
§4.9.6
Lambda expressions
Intermediate
A lambda is a tiny nameless function written in one line. It can only hold a single expression, and that expression’s value is returned. Use it where you need a throwaway function, most often as a key= for sorting.
In real lifeSorting a class register by age. You do not write a whole procedure; you just tell the sorter “look at the age on each card”.
people = [{"name": "Asha", "age": 31}, {"name": "Li", "age": 24}, {"name": "Raj", "age": 28}]
youngest_first = sorted(people, key=lambda p: p["age"])
print([p["name"] for p in youngest_first]) # ['Li', 'Raj', 'Asha']
def make_adder(n):
return lambda x: x + n # remembers n (a closure)
add5 = make_adder(5)
print(add5(10)) # 15
GotchaA lambda looks up outside variables when it runs, not when it is created. [lambda: i for i in range(3)] gives three functions that all return 2. Freeze the value with a default: lambda i=i: i.
§4.9.7
Documentation strings
Basic
A string placed as the very first statement of a function, class or module is its docstring. Tools and help() show it to people who use your code.
def area(width, height):
"""Return the area of a rectangle.
width and height must be in the same unit.
"""
return width * height
print(area.__doc__.splitlines()[0]) # Return the area of a rectangle.
help(area) # prints the full manual
Habit to build: one summary line, a blank line, then details. Say what the function does, not how it does it.
§4.9.8
Function annotations and type hints
Intermediate
Annotations are optional notes about the types a function expects and returns. Python itself does not enforce them; editors and checkers such as mypy or pyright use them to catch mistakes before you run the program and to power auto-complete.
def greet(name: str, times: int = 1) -> str:
return ("Hello, " + name + "! ") * times
def first_word(text: str) -> str | None: # a str, or None
parts = text.split()
return parts[0] if parts else None
prices: dict[str, float] = {"tea": 12.5} # variable annotation
print(greet.__annotations__)
New in 3.14Annotations are now evaluated lazily, so a class can mention itself or a class defined later without quotation marks.
§4.10
Coding style (PEP 8)
Basic
Most Python code follows a shared style guide called PEP 8. Following it means anyone can read your code without getting used to your habits.
Indent with 4 spaces. Never mix tabs and spaces.
Keep lines to about 79 characters (many teams allow 88 or 100).
Two blank lines around top-level functions and classes, one between methods.
snake_case for functions and variables, CapWords for classes, UPPER_CASE for constants.
Spaces around operators and after commas: x = f(a, b), not x=f(a,b).
You do not have to do this by hand: formatters like black or ruff format tidy files for you, and linters like ruff or flake8 point out problems.
Tutorial chapter 5
Data Structures
Lists, tuples, sets and dictionaries: choosing the right container for your data.
§5.1
List methods
Basic
Lists come with a toolbox of methods. Most of them change the list in place and return None, so write items.sort(), not items = items.sort() (that would set items to None).
Method
What it does
append(x)
Add one item at the end
extend(iterable)
Add every item of another collection
insert(i, x)
Put x before position i
remove(x)
Delete the first item equal to x (error if missing)
pop([i])
Remove and return the item at i (default: last)
index(x)
Position of the first x (error if missing)
count(x)
How many times x appears
sort(key=…, reverse=…)
Sort in place
reverse()
Reverse in place
copy()
Shallow copy (same as items[:])
clear()
Empty the list
fruits = ["orange", "apple", "pear", "banana", "kiwi", "apple"]
print(fruits.count("apple")) # 2
print(fruits.index("banana")) # 3
fruits.reverse()
fruits.append("grape")
fruits.sort(key=len) # sort by word length
print(fruits.pop()) # removes and returns the last item
ranked = sorted(fruits) # sorted() returns a NEW list, original untouched
§5.1.1–5.1.2
Using lists as stacks and queues
Intermediate
A stack is last in, first out: the newest item leaves first. A list does this perfectly with append() and pop(). A queue is first in, first out: the oldest item leaves first. For queues use collections.deque, because removing from the front of a list is slow (every other item must shift over).
In real lifeStack: a pile of plates, or your browser’s Back button (the last page you visited comes back first). Queue: people waiting at a ticket counter, or print jobs waiting for a printer.
Both structures add at the same kind of place, but they remove from opposite ends.
stack = [3, 4, 5]
stack.append(6)
print(stack.pop()) # 6 (last in, first out)
from collections import deque
queue = deque(["Eric", "John", "Michael"])
queue.append("Terry")
print(queue.popleft()) # Eric (first in, first out)
§5.1.3–5.1.4
List comprehensions
Intermediate
A comprehension builds a new list in one readable line: “give me this, for each of those, but only if…”. It replaces the common loop-and-append pattern.
In real lifeA factory conveyor belt. Items come in, a filter removes the faulty ones, a machine stamps each good one, and the stamped items land in a crate. The recipe reads in the same order: [stamp(item) for item in belt if good(item)].
squares = [x**2 for x in range(10)]
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
prices = [120, 80, 450, 30]
cheap_with_tax = [round(p * 1.18, 2) for p in prices if p < 200]
print(cheap_with_tax) # [141.6, 94.4, 35.4]
pairs = [(x, y) for x in [1, 2, 3] for y in [3, 1, 4] if x != y]
print(pairs) # [(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)]
matrix = [[1, 2, 3], [4, 5, 6]]
flat = [n for row in matrix for n in row] # outer loop first
print(flat) # [1, 2, 3, 4, 5, 6]
print(list(zip(*matrix))) # [(1, 4), (2, 5), (3, 6)] transpose
The same idea works for other containers: {x % 3 for x in nums} builds a set and {name: len(name) for name in names} builds a dictionary.
Rule of thumbIf a comprehension needs more than one condition or more than two loops, switch to a normal for loop. Readability wins.
§5.2
The del statement
Basic
del removes something by position or key rather than by value: a list item, a slice, a dictionary entry, or a whole variable name.
a = [-1, 1, 66.25, 333, 333, 1234.5]
del a[0] # remove the first item
del a[2:4] # remove a slice
print(a) # [1, 66.25, 1234.5]
del a[:] # empty the list but keep the name
prices = {"tea": 12, "coffee": 30}
del prices["tea"]
del a # now the name 'a' no longer exists
§5.3
Tuples: a fixed bundle
Basic
A tuple is like a list that cannot be changed after it is made. Use it for a small group of values that belong together and should stay together, such as coordinates or a record.
In real lifeA boarding pass: seat 14C, gate 22, flight AI-101. The parts make sense together, and nobody should swap one in mid-journey.
point = (3, 4)
x, y = point # unpacking: x=3, y=4
a, b = 1, 2
a, b = b, a # swap without a temp variable
first, *middle, last = [10, 20, 30, 40, 50]
print(middle) # [20, 30, 40]
single = (42,) # the comma makes it a tuple, not the brackets
empty = ()
from collections import namedtuple
Order = namedtuple("Order", "id item qty")
o = Order(7, "tea", 2)
print(o.item, o[2]) # tea 2
Because tuples cannot change, they can be dictionary keys and set members (as long as everything inside is also unchangeable). Lists cannot.
§5.4
Sets: unique items and fast lookup
Basic
A set is an unordered collection with no duplicates. Checking “is this in the set?” is very fast, even with millions of items. Sets also do the maths you learned at school: union, overlap and difference.
In real lifeTwo attendance sheets, Monday and Tuesday. Who came on both days? Who came only on Monday? Sets answer each question in one operator.
monday = {"asha", "li", "raj"}
tuesday = {"li", "sam"}
print(monday | tuesday) # everyone (union)
print(monday & tuesday) # {'li'} came both days (intersection)
print(monday - tuesday) # {'asha', 'raj'} Monday only (difference)
print(monday ^ tuesday) # came on exactly one day (symmetric difference)
emails = ["a@x.com", "b@x.com", "a@x.com"]
print(len(set(emails))) # 2 removes duplicates fast
empty = set() # {} would make an empty dictionary instead
RememberSet order is not guaranteed, so do not rely on it when printing. Items must be hashable (unchangeable): numbers, strings and tuples are fine; lists are not.
§5.5
Dictionaries: look things up by name
Basic
A dictionary stores key → value pairs. Instead of asking “what is item number 3?”, you ask “what is the value for "email"?”. Keys must be unchangeable (strings, numbers, tuples). Dictionaries remember the order in which keys were added.
In real lifeA phone contacts list. You look up a name and get a number. You do not scan from the top.
stock = {"apple": 5, "pear": 0}
stock["mango"] = 12 # add or update
print(stock.get("kiwi", "none")) # none (no error if missing)
del stock["pear"]
print("apple" in stock) # True
print(list(stock)) # ['apple', 'mango']
# count words
counts = {}
for word in "to be or not to be".split():
counts[word] = counts.get(word, 0) + 1
print(counts) # {'to': 2, 'be': 2, 'or': 1, 'not': 1}
squares = {n: n * n for n in range(4)} # dict comprehension
merged = stock | {"apple": 9} # merge; right side wins
Handy helpers from the standard library
from collections import Counter, defaultdict
print(Counter("banana").most_common(2)) # [('a', 3), ('n', 2)]
groups = defaultdict(list) # missing keys start as []
for name, team in [("Asha", "red"), ("Li", "blue"), ("Raj", "red")]:
groups[team].append(name)
print(dict(groups)) # {'red': ['Asha', 'Raj'], 'blue': ['Li']}
Watch outstock["kiwi"] on a missing key raises KeyError. Use .get(), or check with in first.
§5.6
Looping techniques
Basic
Python has ready-made helpers so you rarely need index counters.
knights = {"gallahad": "the pure", "robin": "the brave"}
for name, quality in knights.items(): # key and value together
print(name, quality)
for i, v in enumerate(["tic", "tac", "toe"]): # position and item
print(i, v)
questions = ["name", "quest", "colour"]
answers = ["Lancelot", "the grail", "blue"]
for q, a in zip(questions, answers, strict=True): # walk two lists side by side
print(f"What is your {q}? {a}.")
for n in reversed(range(1, 4)): # 3 2 1
print(n, end=" ")
for fruit in sorted({"pear", "apple", "pear"}): # unique and in order
print(fruit)
strict=True makes zip raise an error if the lists have different lengths, which catches silent data loss.
§5.7
More on conditions
Intermediate
in / not in test membership: "a" in "cat".
== asks “are the values equal?”. is asks “is it the very same object?”. Use is only for None, True and False.
and / orshort-circuit: they stop as soon as the answer is known, and they return one of their operands, not just True/False.
In real life“If the cake exists and it is cool, then slice it.” You never test whether a cake is cool if there is no cake, which is why short-circuiting also protects you from errors.
user = None
if user is not None and user.is_admin: # second test never runs if user is None
print("welcome admin")
name = "" or "guest" # first truthy value wins -> 'guest'
limit = 0 or 10 # careful: 0 is falsy, so this is 10
print(0 < 5 <= 10) # chained comparison: True
data = [1, 2, 3, 4, 5, 6]
if (n := len(data)) > 5: # walrus: assign AND test in one step
print(f"list is long ({n} items)")
Gotchalimit = given or 10 replaces a legitimate 0. When zero is a valid value, test given is None instead.
§5.8
Comparing sequences and other types
Intermediate
Lists, tuples and strings compare like a dictionary sorts words: look at the first items; if they differ, that decides. If they are equal, move to the next. A shorter sequence that matches the start of a longer one is the smaller.
print((1, 2, 3) < (1, 2, 4)) # True decided at the 3rd item
print([1, 2, 3] < [1, 2, 3, 4]) # True shorter prefix is smaller
print("ABC" < "C" < "Pascal" < "Python") # True
print((1, 2, ("aa", "ab")) < (1, 2, ("abc", "a"), 4)) # True
# Different kinds of things cannot be ordered:
# 1 < "2" -> TypeError (but 1 == "1" is simply False)
This is why sorting a list of (score, name) tuples works: it sorts by score first and uses the name to break ties.
Tutorial chapter 6
Modules and Packages
Splitting code into files, and reusing code other people wrote.
§6, §6.1
Modules: one file, reusable anywhere
Basic
A module is simply a .py file. Anything defined in it (functions, classes, variables) can be borrowed by another file with import. This keeps programs organised and lets you reuse work.
In real lifeA kitchen with drawers: one for baking tools, one for knives. When you bake, you open the baking drawer and take only what you need. Each drawer is a module.
# shapes.py
PI = 3.14159
def circle_area(r):
return PI * r * r
# main.py
import shapes # use as shapes.circle_area(...)
from shapes import circle_area # use the name directly
from shapes import circle_area as area # rename it
import math as m # common shorthand
print(shapes.circle_area(2)) # 12.56636
print(m.sqrt(16)) # 4.0
Making a file work as both a module and a program
# shapes.py (bottom of the file)
def main():
print(circle_area(3))
if __name__ == "__main__": # True only when run directly: python3 shapes.py
main() # skipped when another file imports shapes
Python sets __name__ to "__main__" for the file you launch, and to the module’s own name when it is imported. The check stops your test code from running when someone merely imports you.
RememberA module is loaded only once per program, the first time it is imported. Later imports reuse the same object.
§6.1.2–6.1.3
Where Python looks for a module
Intermediate
When you write import spam, Python walks a fixed checklist. It first checks whether it has already loaded it, then whether it is built in, and finally searches a list of folders called sys.path (the script’s folder first, then PYTHONPATH, then installed packages).
Python stops at the first check that succeeds. A cached module means a second import costs almost nothing.
Classic trapIf you name your own file random.py or email.py and keep it next to your script, your file is found first and hides the real standard module. Pick unique names.
import sys
print(sys.path[:3]) # the folders Python searches, in order
print("json" in sys.modules) # False until something imports it
import importlib
import spam
importlib.reload(spam) # re-read the file after editing (interactive use)
Compiled files. To start faster, Python saves the bytecode of imported modules in a __pycache__ folder (for example spam.cpython-314.pyc). It re-compiles automatically when the source changes. These files are safe to delete and should not be committed to Git.
§6.2–6.3
Standard modules and dir()
Basic
Python ships with hundreds of ready-made modules (the “standard library”): maths, dates, files, JSON, web requests, tests. Check there before installing anything. The built-in dir() lists the names a module or object offers.
import math
print([n for n in dir(math) if not n.startswith("_")][:5])
# ['acos', 'acosh', 'asin', 'asinh', 'atan']
import builtins
print(len(dir(builtins)) > 100) # True: print, len, range, ... all live here
§6.4
Packages: folders of modules
Intermediate
A package is a folder that groups related modules, so names are written with dots: shop.billing.invoice. Packages let a big project stay tidy.
shop/ # the package
├── __init__.py # runs on import; can be empty
├── catalog.py
├── billing/ # a sub-package
│ ├── __init__.py
│ ├── invoice.py
│ └── tax.py
└── utils.py
# inside shop/billing/invoice.py
from . import tax # sibling module (relative import)
from .tax import gst_rate # a name from it
from ..utils import slugify # one level up
from shop.catalog import Product # absolute import: clearest, preferred
Rememberfrom package import * imports only what the package lists in its __all__ variable. Avoid star imports in your own code; they hide where names come from.
Tutorial chapter 7
Input and Output
Printing neat text, reading and writing files, and saving data as JSON.
§7.1.1
f-strings: text with values inside
Basic
Put an f before a string and write any expression inside { }. After a colon you can add a format spec that controls decimals, width, alignment and more.
In real lifePrinting a receipt: item names left-aligned, prices right-aligned with two decimals, totals with thousand separators.
You will meet older styles in existing code. They all do the same job as f-strings.
print("{} costs {:.1f}".format("tea", 12.456)) # tea costs 12.5
print("{1} before {0}".format("work", "play")) # play before work
print("{who} owes {amt}".format(who="Raj", amt=40)) # named placeholders
print("%s has %d items" % ("cart", 3)) # old printf style
for n in (1, 10, 100):
print(str(n).rjust(4), str(n).zfill(4)) # manual alignment
print("title".center(11, "*")) # ***title***
print(str("a\nb"), repr("a\nb")) # str shows it, repr shows how to type it
str(x) is for people; repr(x) is for developers and shows the exact value, including quotes and escape characters.
§7.2
Reading and writing files
Basic
open() gives you a file object. Always use it with with, which guarantees the file is closed when the block ends, even if an error happens halfway.
In real lifeBorrowing a library book. You must return it, or the next reader cannot get it. with is a friend who returns it for you, whatever happens on the way home.
The file may be used only while open. A with block moves it back to Closed for you, even after an error.
with open("notes.txt", "w", encoding="utf-8") as f: # "w" = write (overwrites!)
f.write("first line\n")
f.write("second line\n")
with open("notes.txt", encoding="utf-8") as f: # default mode "r" = read
for line in f: # one line at a time: fine for huge files
print(line.rstrip())
with open("notes.txt", "a", encoding="utf-8") as f: # "a" = append to the end
f.write("third line\n")
Mode
Meaning
"r"
read text (default). Error if the file is missing
"w"
write text. Creates or erases the file
"a"
append text to the end
"x"
create new file, error if it already exists
"rb""wb"
binary (images, zip files, anything that is not text)
pathlib: file paths as objects
from pathlib import Path
p = Path("reports") / "2026" / "summary.txt" # / joins parts on any OS
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("done\n", encoding="utf-8")
print(p.exists(), p.suffix, p.stem) # True .txt summary
for f in Path("reports").rglob("*.txt"): # find files recursively
print(f.name)
§7.2.2
Saving structured data with JSON
Basic
JSON is a plain-text format that nearly every language understands. Python converts dictionaries, lists, strings, numbers, booleans and None to JSON and back.
In real lifeA game saving your settings (theme, volume, last level) so they are still there tomorrow. Or two programs, written in different languages, passing data to each other.
import json
settings = {"theme": "dark", "volume": 7, "tags": ["a", "b"], "beta": None}
text = json.dumps(settings, indent=2) # dict -> JSON text (string)
with open("settings.json", "w", encoding="utf-8") as f:
json.dump(settings, f) # dict -> file
with open("settings.json", encoding="utf-8") as f:
loaded = json.load(f) # file -> dict
print(loaded == settings) # True
print(json.loads('{"ok": true}')) # {'ok': True} text -> dict
SecurityPython’s pickle format can store almost any object, but loading a pickle can run code. Never unpickle data from a source you do not trust. Prefer JSON for data you exchange.
Tutorial chapter 8
Errors and Exceptions
Reading error messages, handling problems gracefully, and cleaning up afterwards.
§8.1–8.2
Syntax errors and exceptions
Basic
There are two kinds of mistakes. A syntax error means Python cannot even read your code (a missing colon or bracket); nothing runs. An exception happens while the program runs, for example dividing by zero. If you do not handle it, the program stops and prints a traceback.
Traceback (most recent call last):
File "shop.py", line 9, in <module>
print(total / count)
~~~~~~^~~~~~~
ZeroDivisionError: division by zero
Read a traceback from the bottom up: the last line names the problem, the lines above show the path of calls that led there, ending at the line that failed.
Exception
Typical cause
NameError
Using a variable that was never defined (often a typo)
TypeError
Wrong kind of value, e.g. "3" + 4
ValueError
Right kind, unsuitable value, e.g. int("abc")
KeyError / IndexError
Missing dictionary key / list position out of range
AttributeError
The object has no such attribute: None.upper()
FileNotFoundError
Opening a path that does not exist
ZeroDivisionError
Dividing by zero
§8.3
Handling exceptions: try, except, else, finally
Basic
Wrap risky code in try. If something goes wrong, Python jumps to the matching except block instead of crashing. else runs only when nothing went wrong. finally runs no matter what.
In real lifeA delivery driver tries to hand over a parcel (try). If nobody is home (error), she leaves a note (except). If the handover works, she gets a signature (else). Either way she scans the parcel back into the system at the end of the stop (finally).
Exactly one of the three outcomes happens (else, a matching except, or the error travels on). Then finally runs in every case.
def read_int(text):
try:
number = int(text)
except ValueError:
print(f"{text!r} is not a whole number")
return None
else:
print("conversion worked")
return number
finally:
print("done checking") # prints even though we returned above
read_int("42") # conversion worked / done checking
read_int("4x2") # '4x2' is not a whole number / done checking
try:
risky()
except (TypeError, KeyError) as err: # one handler for several types
print("problem:", err)
AvoidA bare except: or a catch-all except Exception: pass hides real bugs. Catch the narrowest error you expect, and do something useful with it (log it, retry, show a message).
Python programmers usually “ask forgiveness, not permission” (EAFP): just try the operation and handle the error, rather than checking every condition first. It also avoids race conditions, such as a file being deleted between “does it exist?” and “open it”.
§8.4
Raising exceptions
Basic
You can raise an exception yourself when your function receives something it cannot work with. This is better than returning a quiet wrong answer.
def set_age(age):
if age < 0:
raise ValueError("age cannot be negative")
return age
try:
set_age(-5)
except ValueError as err:
print("Rejected:", err) # Rejected: age cannot be negative
raise # a bare raise passes the same error upward
assert condition, "message" is a sanity check for developers (“this must be true”). It can be switched off with python -O, so never use it to validate user input.
§8.5
Exception chaining
Intermediate
Sometimes a low-level error should become a clearer high-level one, but you still want the original clue. raise NewError from original links them, and the traceback shows both.
def load_config(text):
try:
return int(text)
except ValueError as exc:
raise RuntimeError("config value must be a number") from exc
# raise ... from None hides the original when it would only confuse the reader
§8.6
User-defined exceptions
Intermediate
Create your own error type by subclassing Exception. A descriptive name tells the reader what went wrong, and callers can catch exactly that case.
In real lifeA bank app. “Insufficient funds” needs a different response from “network timeout”: one shows a message to the customer, the other retries quietly.
class BankError(Exception):
"""Base class for all errors from this module."""
class InsufficientFunds(BankError):
def __init__(self, balance, needed):
super().__init__(f"balance {balance} is less than {needed}")
self.balance = balance
self.needed = needed
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amount
try:
withdraw(100, 250)
except InsufficientFunds as err:
print("Short by", err.needed - err.balance) # Short by 150
except BankError:
print("some other bank problem")
§8.7–8.8
Clean-up actions: finally and with
Intermediate
Whatever is in finally runs on the way out, whether the block succeeded, failed, returned, or hit break. Use it to release things you took: files, network connections, locks. Many objects already know how to clean up and work with with, which is shorter and harder to get wrong.
# manual clean-up
f = open("data.txt", encoding="utf-8")
try:
process(f.read())
finally:
f.close() # always runs
# the same, without the ceremony
with open("data.txt", encoding="utf-8") as f:
process(f.read())
AvoidDo not put return or break inside finally. It silently swallows any error that was on its way up.
§8.9
Exception groups and except*
Advanced
When many tasks run at the same time, several can fail at once. An ExceptionGroup bundles unrelated errors into one object. except* picks out the kinds you can handle; the rest keep travelling.
In real lifeUploading 100 photos in parallel. Three fail for different reasons (too large, bad format, network). You want to report all three, not just whichever failed first.
def import_rows():
raise ExceptionGroup("batch failed", [
ValueError("bad value in row 3"),
TypeError("bad type in row 7"),
ValueError("bad value in row 9"),
])
try:
import_rows()
except* ValueError as group:
print("value problems:", [str(e) for e in group.exceptions])
except* TypeError as group:
print("type problems:", [str(e) for e in group.exceptions])
# value problems: ['bad value in row 3', 'bad value in row 9']
# type problems: ['bad type in row 7']
You will mostly meet these when using asyncio.TaskGroup (see the async topic).
§8.10
Adding notes to exceptions
Advanced
Catch an error, attach extra context with add_note(), and re-raise it. The notes appear under the original message in the traceback, so whoever debugs it sees both what happened and where in your process it happened.
def process_order(order_id, price):
try:
return 100 / price
except ZeroDivisionError as err:
err.add_note(f"while processing order {order_id}")
raise
process_order(42, 0)
# ZeroDivisionError: division by zero
# while processing order 42
Tutorial chapter 9
Classes
Bundling data with the code that works on it, and sharing behaviour through inheritance. Includes iterators and generators.
§9.1
Names and objects: variables are labels
Intermediate
In Python a variable does not contain a value; it is a label pointing at an object that lives elsewhere. Writing b = a does not copy anything. It sticks a second label on the same object. This matters when the object can be changed (lists, dictionaries, sets).
In real lifeTwo people share one Google Doc link. When one edits the document, the other sees the change, because there is only one document. Sending someone a photocopy (.copy()) gives them their own.
== asks whether the contents match. is asks whether both names point at the same object.
a = [1, 2]
b = a # a second label on the SAME list
c = a.copy() # a NEW list with equal contents
print(a is b, a is c, a == c) # True False True
b.append(3)
print(a) # [1, 2, 3] changed through b!
print(c) # [1, 2] unaffected
import copy
nested = [[1], [2]]
shallow = nested.copy() # new outer list, SAME inner lists
deep = copy.deepcopy(nested) # everything copied
nested[0].append(99)
print(shallow[0], deep[0]) # [1, 99] [1]
RememberStrings, numbers and tuples are immutable, so sharing them is safe: “changing” one really creates a new object and moves your label to it.
§9.2
Scopes and namespaces (LEGB)
Intermediate
A namespace is a table of names. A scope is the region of code where a table is searched. When you use a name, Python looks in four places, from the inside out: Local, Enclosing, Global, Built-in.
In real lifeYou ask “where are the scissors?”. You check your desk first (local), then the shared drawer of your room (enclosing), then the whole office (global), and finally the building’s store room (built-in). The first place that has them wins.
The first match wins. Assigning to a name inside a function creates a local name unless you declare otherwise.
def scope_test():
def do_local():
spam = "local spam" # a new local name
def do_nonlocal():
nonlocal spam # use the enclosing function's name
spam = "nonlocal spam"
def do_global():
global spam # use the module-level name
spam = "global spam"
spam = "test spam"
do_local(); print("after local: ", spam) # test spam
do_nonlocal(); print("after nonlocal:", spam) # nonlocal spam
do_global(); print("after global: ", spam) # nonlocal spam
scope_test()
print("in global scope:", spam) # global spam
GotchaReading a global inside a function is fine. But if you assign to the same name anywhere in that function, Python treats it as local for the whole function, and reading it earlier raises UnboundLocalError.
§9.3
A first look at classes
Basic
A class is a blueprint. Each object made from it (an instance) has its own data, and shares the blueprint’s methods. __init__ runs when an object is created and sets it up. The first parameter of every method, conventionally self, is the object the method is working on.
In real lifeA cookie cutter and cookies. One cutter (class), many cookies (instances). Each cookie can get its own icing (instance data) while all share the same shape (class behaviour).
class Dog:
species = "Canis familiaris" # class variable: shared by every dog
def __init__(self, name):
self.name = name # instance variables: one set per dog
self.tricks = []
def add_trick(self, trick):
self.tricks.append(trick)
def __repr__(self): # how the object prints in the console
return f"Dog({self.name!r})"
fido = Dog("Fido")
buddy = Dog("Buddy")
fido.add_trick("roll over")
print(fido.tricks, buddy.tricks) # ['roll over'] []
print(fido.species == buddy.species) # True
print(fido) # Dog('Fido')
Watch outDo not create self.tricks = [] at class level (outside __init__). Then every dog would share one list, the same trap as mutable default arguments.
fido.add_trick("x") is shorthand for Dog.add_trick(fido, "x"). Python passes the object in as self for you.
§9.4, §9.6, §9.7
Private names, properties and dataclasses
Intermediate
Python has no strict “private” keyword. It uses conventions: a single leading underscore (_balance) says “internal, please do not touch”. A double underscore (__pin) makes Python rename the attribute to _ClassName__pin so subclasses do not clash with it by accident.
A property lets you run code when someone reads or sets an attribute, while it still looks like a plain attribute from outside.
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self): # read: acct.balance
return self._balance
@balance.setter
def balance(self, value): # write: acct.balance = 50
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = value
acct = Account(100)
acct.balance = 150 # runs the setter and checks the rule
# acct.balance = -5 # ValueError
Dataclasses: the “record” shortcut
If a class mostly holds data, @dataclass writes __init__, __repr__ and __eq__ for you.
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True) # frozen: unchangeable; slots: lighter memory
class Employee:
name: str
dept: str
salary: int = 0
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list) # safe mutable default
e = Employee("John", "computer lab", 1000)
print(e) # Employee(name='John', dept='computer lab', salary=1000)
print(e == Employee("John", "computer lab", 1000)) # True
§9.5
Inheritance: reuse and specialise
Intermediate
A class can be built on top of another. The new class (child) gets everything from the old one (parent) and can add or replace parts. Use super() to call the parent’s version.
In real lifeA “Vehicle” has wheels, a speed and a move(). A “Bicycle” and a “Car” are both vehicles, so they inherit all that, and each replaces move() with its own way of moving.
class Vehicle:
def __init__(self, name):
self.name = name
def move(self):
return f"{self.name} moves"
class Bicycle(Vehicle):
def move(self): # override
return f"{self.name} is pedalled"
class Car(Vehicle):
def __init__(self, name, fuel):
super().__init__(name) # let the parent set up name
self.fuel = fuel
def move(self):
return super().move() + f" on {self.fuel}"
for v in [Bicycle("Hero"), Car("Swift", "petrol")]:
print(v.move()) # same call, different behaviour (polymorphism)
# Hero is pedalled
# Swift moves on petrol
print(isinstance(v, Vehicle), issubclass(Car, Vehicle)) # True True
Rule of thumbUse inheritance for “is a” (a car is a vehicle). Use composition, holding another object as an attribute, for “has a” (a car has an engine). Composition is usually more flexible.
§9.5.1
Multiple inheritance and the MRO
Advanced
A class may have several parents. When both parents share a grandparent (the “diamond”), Python needs a clear order for searching methods. It computes one called the MRO (method resolution order), and super() simply means “the next class in the MRO”, not necessarily the direct parent.
In real lifeA guest of two hosts who both follow the same family customs. A rule decides whose instructions are checked first, and each host passes on to the family elders only once.
Python merges the parents’ orders so that every class appears before its own parents and the left-to-right order of the bases is kept.
class A:
def hello(self): return "A"
class B(A):
def hello(self): return "B>" + super().hello()
class C(A):
def hello(self): return "C>" + super().hello()
class D(B, C):
def hello(self): return "D>" + super().hello()
print(D().hello()) # D>B>C>A each class runs once
print([k.__name__ for k in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']
Mixins are the safe everyday use: small classes that add one ability (JsonMixin adds .to_json()) and are listed alongside the main parent.
§9.8
Iterators: how a for loop really works
Intermediate
An iterable is anything you can loop over. A iterator is the helper that remembers where you are. A for loop calls iter() once to get the iterator, then calls next() again and again until StopIteration is raised.
In real lifeA bookmark. The book (iterable) can be read by many people; each reader has their own bookmark (iterator) that moves forward one page per next().
it = iter(["a", "b"])
print(next(it), next(it)) # a b
# next(it) # StopIteration: nothing left
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # this object is its own iterator
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
print(list(Countdown(3))) # [3, 2, 1]
§9.9
Generators: pause and resume functions
Advanced
A function that uses yield is a generator. Calling it does not run it; it returns an object you pull values from. Each next() runs the function until the next yield, hands that value back and freezes right there with all its local variables intact. The next next() unfreezes it.
In real lifeStreaming a film instead of downloading it first. You watch the next minute as soon as you ask for it, and the whole film never has to fit in your phone’s memory. The same trick lets Python read a 50 GB log file line by line.
A generator alternates between Running and Suspended. It ends in Closed, and later next() calls then raise StopIteration.
def countdown(n):
while n > 0:
yield n # pause here and hand back n
n -= 1
gen = countdown(2)
print(next(gen), next(gen)) # 2 1
for x in countdown(3):
print(x, end=" ") # 3 2 1
def read_big_file(path):
with open(path, encoding="utf-8") as f:
for line in f:
if "ERROR" in line:
yield line.rstrip() # only one line in memory at a time
def chain_all(*lists):
for items in lists:
yield from items # hand over to another iterable
Generators can be chained into pipelines: read lines → filter → transform → write. Each stage pulls from the one before, so data flows through without ever being stored in full.
§9.10
Generator expressions
Intermediate
A generator expression is a list comprehension written with round brackets. It produces values one at a time instead of building a whole list first, which saves memory when you only need to consume the values once (for sum, max, any, all, join).
print(sum(i * i for i in range(10))) # 285
print(sum(i * i for i in range(10**6))) # no giant list in memory
lines = ["short", "a longer line", "mid"]
print(max(len(l) for l in lines)) # 13
print(any(l.startswith("mid") for l in lines)) # True
print(", ".join(str(n) for n in range(5))) # 0, 1, 2, 3, 4
squares_list = [n * n for n in range(5)] # real list: reusable, has len()
squares_gen = (n * n for n in range(5)) # one-shot stream
print(list(squares_gen), list(squares_gen)) # [0, 1, 4, 9, 16] [] already used up
GotchaA generator can be used once. A second pass gives nothing. If you need to loop twice or need the length, build a list.
Tutorial chapter 10
Standard Library Tour, Part I
“Batteries included”: tools that ship with Python, so you do not have to install anything.
§10.1–10.2
Files, folders and wildcards: os, shutil, glob
Basic
These modules let your program do what you would do in a file manager: look around, make folders, copy and move files, and find files by pattern.
In real lifeEvery Sunday you want to copy this week’s reports into a backup folder. Ten lines of Python do it, and it can run by itself.
import os, shutil, glob
print(os.getcwd()) # where am I?
os.makedirs("backup", exist_ok=True) # make a folder if missing
for path in glob.glob("reports/*.txt"): # wildcard search
shutil.copy(path, "backup") # copy each match
shutil.move("old.txt", "archive/old.txt")
print(os.environ.get("HOME")) # read an environment variable
For new code, pathlib.Path (see the Input and Output chapter) is the friendlier way to handle paths.
§10.3–10.4
Command-line tools: argparse, stderr and exit codes
Basic
A well-behaved command-line program reads options with argparse, prints normal results to stdout, prints warnings and errors to stderr, and ends with an exit code (0 means success). Other tools use those codes to know whether your program worked.
import argparse, sys
parser = argparse.ArgumentParser(prog="top", description="Show top lines of files")
parser.add_argument("filenames", nargs="+")
parser.add_argument("-l", "--lines", type=int, default=10)
args = parser.parse_args()
for name in args.filenames:
try:
with open(name, encoding="utf-8") as f:
print("".join(f.readlines()[: args.lines]), end="")
except FileNotFoundError:
print(f"warning: {name} not found", file=sys.stderr)
sys.exit(1) # non-zero = failure
§10.5
Pattern matching in text: re
Intermediate
A regular expression is a mini-language for describing text patterns, like a wildcard search with superpowers. Always write them as raw strings (r"...") so backslashes stay intact.
In real lifeYou have a long email export and want every order number and delivery date out of it, without reading it line by line.
import re
text = "Order #123 shipped on 2026-10-08, order #456 pending"
print(re.findall(r"#(\d+)", text)) # ['123', '456']
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", text)
print(m.groups()) # ('2026', '10', '08')
print(re.sub(r"\s+", " ", "too many spaces")) # too many spaces
pattern = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.]+$") # a simple email shape check
print(bool(pattern.match("asha@example.com"))) # True
Piece
Meaning
\d\w\s
a digit / word character / whitespace
. * + ?
any char / zero or more / one or more / optional
{n}{n,m}
exactly n / between n and m repeats
^$
start / end of the text
( )|
capture group / either-or
If a simple string method does the job (str.replace, startswith, split), prefer it. It is easier to read.
§10.6
Maths, randomness and statistics
Basic
import math, random, statistics
print(math.sqrt(144), math.ceil(4.1), math.floor(4.9)) # 12.0 5 4
print(round(math.pi, 4)) # 3.1416
random.seed(7) # same seed = same "random" numbers
print(random.choice(["red", "green", "blue"])) # one item
print(random.sample(range(1, 50), 6)) # 6 different numbers (a lottery ticket)
print(random.random()) # a float from 0.0 up to 1.0
data = [2, 4, 4, 4, 5, 5, 7, 9]
print(statistics.mean(data), statistics.median(data)) # 5 4.5
print(round(statistics.stdev(data), 3)) # 2.138
Securityrandom is predictable. For passwords, tokens and anything secret use the secrets module: secrets.token_urlsafe(16).
§10.7
Talking to the internet
Intermediate
The standard library can fetch web pages (urllib.request) and send email (smtplib). Most people use the friendlier third-party package requests (or httpx) for web APIs, but the built-in way needs no installation.
from urllib.request import urlopen
import json
with urlopen("https://httpbin.org/json", timeout=10) as response:
data = json.load(response) # an API reply parsed from JSON
print(type(data)) # <class 'dict'>
# with the popular third-party package (pip install requests):
# import requests
# r = requests.get("https://httpbin.org/json", timeout=10)
# r.raise_for_status()
# print(r.json())
RememberAlways set a timeout. Without it a stuck server can freeze your program forever.
§10.8
Dates and times
Basic
In real lifeHow many days until a deadline? What date is 30 days after an invoice? Which weekday is the 1st of next month?
Best practiceStore times in UTC (or with a time zone attached) and convert only for display. “Naive” datetimes with no zone are a common source of bugs around daylight-saving changes.
§10.9–10.11
Compression, timing and testing
Intermediate
Compression
import zlib, zipfile
s = b"witch which has which witches wrist watch"
t = zlib.compress(s)
print(len(s), len(t)) # 41 37
print(zlib.decompress(t) == s) # True
with zipfile.ZipFile("backup.zip", "w") as z: # make a zip archive
z.write("notes.txt")
Measuring speed
Do not guess which version is faster; measure. timeit runs a snippet many times. cProfile shows where a whole program spends its time.
from timeit import timeit
print(timeit("a, b = b, a", setup="a = 1; b = 2", number=100_000)) # seconds, varies
# python3 -m cProfile -s cumtime my_program.py
Testing
Tests are small programs that check your code, so you notice breakage when you change something later. doctest runs examples found in docstrings; unittest is the built-in framework; pytest is a popular third-party one with less boilerplate.
import unittest
def average(values):
"""Return the mean of a list.
>>> average([20, 30, 70])
40.0
"""
return sum(values) / len(values)
class TestAverage(unittest.TestCase):
def test_basic(self):
self.assertEqual(average([20, 30, 70]), 40.0)
def test_empty_list(self):
with self.assertRaises(ZeroDivisionError):
average([])
if __name__ == "__main__":
unittest.main()
Tutorial chapter 11
Standard Library Tour, Part II
Output formatting, templates, threads, logging, and precise arithmetic.
§11.1–11.2
Pretty output and templates
Basic
import pprint, textwrap
from string import Template
config = {"server": {"host": "10.0.0.5", "ports": [80, 443, 8080]}, "debug": False, "tags": ["a", "b"]}
pprint.pprint(config, width=40) # wraps nested data readably
paragraph = "Python is easy to learn and powerful, and its standard library is big."
print(textwrap.fill(paragraph, width=30)) # re-wrap to 30 columns
t = Template("Hello $name, your order $order_id has shipped.")
print(t.substitute(name="Asha", order_id=981))
print(t.safe_substitute(name="Li")) # missing keys are left as they are
string.Template is a good fit when non-programmers write the text, because the placeholders are simple and cannot run code.
§11.3
Binary data: struct
Advanced
When you read file formats or network protocols, data arrives as raw bytes with a fixed layout. struct converts between Python values and that byte layout. The format string says what each field is (H = 2-byte unsigned integer, I = 4-byte unsigned integer, < = little-endian).
import struct
data = struct.pack("<HHI", 1, 2, 3) # three numbers packed into 8 bytes
print(len(data), data.hex()) # 8 0100020003000000
print(struct.unpack("<HHI", data)) # (1, 2, 3)
§11.4
Multi-threading basics
Intermediate
A thread lets your program do several things at the same time, such as downloading three files at once. Threads are best when your program mostly waits (for networks, disks, databases). The advanced chapter explains why threads do not speed up heavy calculation in the standard build, and what to use instead.
In real lifeA cook puts rice on to boil, then chops vegetables while it cooks. Waiting time is used for other work.
import threading, time
def fetch(name, seconds):
time.sleep(seconds) # pretend to wait for a slow server
print(f"{name} done")
threads = [threading.Thread(target=fetch, args=(f"job{i}", 1)) for i in range(3)]
for t in threads:
t.start() # all three start together
for t in threads:
t.join() # wait until each has finished
# about 1 second in total, not 3
lock = threading.Lock()
counter = 0
def add_many():
global counter
for _ in range(100_000):
with lock: # only one thread at a time may change counter
counter += 1
Watch outTwo threads changing the same variable can overwrite each other’s work (a race condition). Protect shared data with a Lock, or pass work through a queue.Queue instead of sharing variables.
§11.5
Logging: a better print()
Basic
Logging records what your program is doing, with a severity level and a timestamp. You can turn detail up or down without editing the code, and send messages to a file.
In real lifeA ship’s logbook. Routine entries (“departed port”) and warnings (“storm ahead”) go in the same book, each marked with a level and time. After a problem, the crew reads it to see what happened.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
filename="app.log", # omit to print to the screen
)
log = logging.getLogger(__name__)
log.debug("detail only developers want") # hidden at INFO level
log.info("server started on port %s", 8080) # pass values as arguments
log.warning("disk 90%% full")
try:
1 / 0
except ZeroDivisionError:
log.exception("calculation failed") # includes the traceback
Level
Use it for
DEBUG
fine detail while diagnosing
INFO
normal events: started, saved, finished
WARNING
something unexpected, but the program continues
ERROR
an operation failed
CRITICAL
the program may not be able to continue
§11.6–11.7
Weak references, heaps and sorted insertion
Advanced
A weak reference points at an object without keeping it alive. It is used for caches: you remember a result only as long as something else still needs it, and the memory is freed automatically afterwards.
import weakref
class Big:
pass
cache = weakref.WeakValueDictionary()
obj = Big()
cache["report"] = obj
print("report" in cache) # True
del obj # last normal reference gone
print("report" in cache) # False (in CPython, freed immediately)
Other tools for lists: heapq keeps the smallest item instantly available (a priority queue), and bisect keeps a list sorted as you insert.
In real lifeA hospital emergency room does not treat patients in arrival order; it always picks the most urgent next. That is a priority queue.
Always build a Decimal from a string (Decimal("0.1")). Decimal(0.1) copies the float’s tiny error along with it.
Tutorial chapter 12
Virtual Environments and Packages
Giving each project its own private set of libraries.
§12.1–12.3
Virtual environments and pip
Basic
Third-party libraries (from PyPI, the Python Package Index) are installed with pip. If every project shared one pile of libraries, a project needing an old version of a library would clash with one needing the new version. A virtual environment is a private folder of libraries for one project.
In real lifeEach project gets its own toolbox. The toolboxes can hold different versions of the same tool, and none of them touches your system’s main toolbox.
The two projects keep different library versions side by side. Only the Python interpreter is shared.
# 1. create the environment inside your project folder
python3 -m venv .venv
# 2. switch it on
source .venv/bin/activate # macOS / Linux
.venv\Scripts\activate # Windows
# 3. install what you need (now it goes into .venv only)
pip install requests
pip install "requests==2.31.0" # an exact version
pip list # what is installed here?
# 4. record and re-create the set of libraries
pip freeze > requirements.txt
pip install -r requirements.txt # on another machine or in CI
deactivate # switch it off
Good habitsKeep .venv/ out of Git (add it to .gitignore) and commit requirements.txt instead. Many teams now use faster tools such as uv (uv venv, uv pip install) or declare dependencies in pyproject.toml. The ideas are the same.
Tutorial chapters 13 to 16
What Now, and the Fine Print
Where to go next, working comfortably in the prompt, why decimals misbehave, and the appendix.
§13
What now?
Basic
Reading is only the first step. The fastest way to improve is to build small things you care about and look things up as you go.
The glossary explains terms like iterable and hashable.
PyPI lists hundreds of thousands of installable packages.
Project ideasA script that renames your photos by date. A budget tracker that reads your bank’s CSV export. A command-line quiz. A bot that checks a page daily and tells you when the price drops.
§14
Comfortable interactive editing
Basic
The prompt remembers what you typed. Press ↑ to bring back earlier lines, Tab to complete names (type math.s then Tab), and Ctrl+R to search your history. Multi-line blocks can be edited as a whole.
If you want more, other prompts add colour, richer completion and inline help: IPython, bpython, and notebooks such as Jupyter, which mix code, charts and notes on one page and are very popular for data work.
§15
Floating-point: why 0.1 + 0.2 is not 0.3
Intermediate
Computers store decimals in binary. Some simple decimal fractions, like 0.1, have no exact binary form, just as 1/3 has no exact decimal form (0.3333…). The stored value is a very close approximation, and tiny errors can show up.
In real lifeMeasuring with a ruler that only has marks every 1/1024 of an inch. You can get very close to 1/10 of an inch, but never exactly on it.
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True compare floats with a tolerance
print((0.1).as_integer_ratio()) # (3602879701896397, 36028797018963968) the real stored value
print(round(2.675, 2)) # 2.67 (2.675 is stored as slightly less)
from fractions import Fraction
print(Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10)) # True exact
Need
Use
Science, graphics, general maths
float, compare with math.isclose
Money, tax, invoices
decimal.Decimal (or integer cents)
Exact ratios
fractions.Fraction
This is not a Python bug. Every language using standard hardware floats (JavaScript, Java, C) behaves the same way.
§16
Appendix: scripts and the startup file
Intermediate
Executable scripts. On macOS and Linux, start a script with a shebang line and mark it executable, and you can run it like any other command.
Error handling in the prompt. After an error the prompt stays open and shows the traceback. Press Ctrl+C to cancel the current line and return to the primary prompt. Use python3 -i script.py to land at the prompt with your script’s variables still alive for inspection.
The startup file. Point the PYTHONSTARTUP environment variable at a file, and Python runs it every time you open the interactive prompt. People use it to pre-import favourite modules. Site-wide customisation lives in sitecustomize and usercustomize modules.
The tutorial stops at the doorway. These topics are what separates writing Python from understanding it: decorators, context managers, the data model, concurrency, memory and typing.
Advanced
Decorators: wrapping a function with extra behaviour
Advanced
A decorator is a function that takes a function and returns a new one, usually adding something before or after the original runs. The @name line above a def is just shorthand for func = name(func). It builds on two ideas you already met: functions are values, and inner functions remember outer variables (closures).
In real lifeGift wrapping. The gift (your function) stays the same, but the wrapper adds something around it: a ribbon on the way in (check the login), a receipt on the way out (log the result).
The caller thinks it is calling the original. It actually enters the wrapper, which calls the original in the middle.
import functools, time
def timed(func):
@functools.wraps(func) # keep the original name and docstring
def wrapper(*args, **kwargs): # accept anything, pass it along
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
return result
return wrapper
@timed # same as: slow_sum = timed(slow_sum)
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000) # slow_sum took 0.1xx s (varies)
A decorator that takes its own settings
Add one more layer: the outer function receives the settings and returns the real decorator.
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times:
raise # out of tries: let the error through
return wrapper
return decorator
@retry(times=3)
def fetch_data():
...
You already use decorators from the library: @property, @staticmethod, @classmethod, @dataclass, @functools.cache.
Advanced
Context managers: write your own with-blocks
Advanced
You have used with open(...). Any object with __enter__ and __exit__ can be used the same way. Python calls __enter__ on the way in and __exit__ on the way out, even if the block raised an error.
In real lifeA hotel room. You get the key at check-in (__enter__). At check-out (__exit__) the room is cleaned and the key is returned, whether your stay went well or you left in a hurry.
__exit__ is the guaranteed clean-up step. Returning True from it would swallow the error; returning False lets it continue.
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # becomes the name after "as"
def __exit__(self, exc_type, exc, tb):
print(f"took {time.perf_counter() - self.start:.2f}s")
return False # do not hide exceptions
with Timer() as t:
sum(range(5_000_000))
The shortcut: contextlib
import os
from contextlib import contextmanager, suppress
@contextmanager
def working_directory(path):
old = os.getcwd()
os.chdir(path) # runs on the way in
try:
yield # your with-block runs here
finally:
os.chdir(old) # always restores, even after an error
with working_directory("/tmp"):
print(os.getcwd())
with suppress(FileNotFoundError): # "ignore this one specific error"
os.remove("temp.txt")
Advanced
The data model: special (dunder) methods
Advanced
Methods with double underscores on both sides are hooks that Python calls for you. When you write a + b, Python runs a.__add__(b). When you write len(x), it runs x.__len__(). By defining them, your own objects behave like built-in types.
RememberDefine __repr__ on every class you write. It makes debugging and test failures far easier to read. If you define __eq__ you should also define __hash__, or Python makes the object unhashable.
Advanced
How dicts and sets are so fast: hashing
Advanced
Looking up a key in a dictionary does not scan all keys. Python turns the key into a number with hash(), uses it to jump straight to a slot in an internal table, and checks that slot. The time barely changes whether there are ten keys or ten million.
In real lifeA library that files every book by a code computed from its title. To find “Moby Dick” you compute the code and walk to that exact shelf instead of reading every spine.
Two different keys can land in the same slot (a collision). Python then probes other slots, which is why a good hash function matters.
Hashable means the hash never changes during the object’s life. That is why keys can be strings, numbers and tuples of those, but not lists or dicts.
Equal objects must have equal hashes: hash(1) == hash(1.0).
Membership test cost: x in my_list is O(n), x in my_set is O(1) on average. Convert a big list to a set before many lookups.
print(hash("apple") == hash("apple")) # True within one run
# hash("apple") differs from run to run on purpose (security), so never store it
big = list(range(1_000_000)); lookup = set(big)
print(999_999 in big) # scans up to a million items
print(999_999 in lookup) # one jump
Advanced
functools and itertools: power tools
Advanced
functools
@cache remembers results so the same call is never computed twice (memoisation). partial pre-fills some arguments. reduce folds a sequence into one value.
from functools import cache, partial, reduce
import operator
@cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(80)) # 23416728348467685, instantly (naive recursion would take ages)
from_binary = partial(int, base=2)
print(from_binary("1010")) # 10
print(reduce(operator.mul, [1, 2, 3, 4])) # 24
itertools
Building blocks for working with streams of data without making big lists.
from itertools import count, islice, chain, combinations, product, accumulate, batched, groupby
print(list(islice(count(10, 5), 3))) # [10, 15, 20]
print(list(chain([1, 2], "ab"))) # [1, 2, 'a', 'b']
print(list(combinations("ABC", 2))) # [('A', 'B'), ('A', 'C'), ('B', 'C')]
print(list(product([1, 2], "ab"))) # [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]
print(list(accumulate([1, 2, 3, 4]))) # [1, 3, 6, 10] running total
print(list(batched(range(7), 3))) # [(0, 1, 2), (3, 4, 5), (6,)]
orders = [("tea", "Asha"), ("tea", "Li"), ("coffee", "Raj")] # must be sorted by key first
for item, group in groupby(orders, key=lambda o: o[0]):
print(item, [name for _, name in group]) # tea ['Asha', 'Li'] / coffee ['Raj']
Advanced
Type hints in depth: generics, protocols and more
Advanced
Beyond int and str, the typing system can describe flexible code: functions that work for any element type (generics), objects that merely need certain methods (protocols), and dictionaries with known keys (TypedDict). A checker such as mypy or pyright reads these hints and reports mistakes before you run anything.
In real lifeAirport security checks a boarding pass against your ID before you reach the gate, not after the plane has left. Type checking is that early check for code.
from typing import Protocol, TypedDict, Literal, Callable
def first[T](items: list[T]) -> T: # generic function (3.12+ syntax)
return items[0]
class Greeter(Protocol): # structural typing: "has a greet() method"
def greet(self) -> str: ...
class English: # no inheritance needed, it just fits
def greet(self) -> str:
return "Hello"
def welcome(g: Greeter) -> None:
print(g.greet())
class Movie(TypedDict): # a dict with known keys
title: str
year: int
Mode = Literal["r", "w", "a"] # only these exact values allowed
type Pair = tuple[int, int] # type alias statement (3.12+)
def run(handler: Callable[[int], str], value: int) -> str:
return handler(value)
welcome(English()) # Hello
Abstract base classes
Use abc.ABC when you want to force subclasses to implement certain methods. Trying to create an object from a class with missing methods fails immediately.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Square(Shape):
def __init__(self, side): self.side = side
def area(self): return self.side ** 2
# Shape() TypeError: can't instantiate abstract class
print(Square(3).area()) # 9
Advanced
Enums: a fixed set of named choices
Intermediate
When a value can only be one of a few options (order status, traffic light, user role) an Enum is safer than loose strings. A typo in "shiped" goes unnoticed; Status.SHIPED fails immediately.
from enum import Enum, auto
class Status(Enum):
PENDING = auto()
SHIPPED = auto()
DELIVERED = auto()
s = Status.SHIPPED
print(s.name, s.value) # SHIPPED 2
print(Status["PENDING"], Status(3)) # Status.PENDING Status.DELIVERED
print(s is Status.SHIPPED) # True
for st in Status: # iterate all choices
print(st.name)
def describe(status: Status) -> str:
match status: # works nicely with match
case Status.PENDING: return "waiting"
case Status.SHIPPED: return "on its way"
case Status.DELIVERED: return "arrived"
Advanced
Text versus bytes: Unicode done right
Intermediate
A str is a sequence of characters. A bytes object is a sequence of raw numbers (0 to 255). Files and networks carry bytes, so text must be encoded on the way out and decoded on the way in. The standard choice is UTF-8.
In real lifeSending a message by telegraph. Your words (text) become dots and dashes (bytes) using a code book (the encoding). The receiver needs the same code book, or the message turns into nonsense.
text = "café ₹"
data = text.encode("utf-8")
print(len(text), len(data)) # 6 9 six characters, nine bytes
print(data) # b'caf\xc3\xa9 \xe2\x82\xb9'
print(data.decode("utf-8") == text) # True
# data.decode("ascii") UnicodeDecodeError: wrong code book
print(data.decode("ascii", errors="replace")) # shows ? for bytes it cannot read
import unicodedata
a, b = "é", "é" # one character vs e + combining accent
print(a == b) # False, though they look identical
print(unicodedata.normalize("NFC", b) == a) # True
RuleDecode bytes to text as soon as they enter your program, work with str inside, and encode only when you leave. Always pass encoding="utf-8" to open().
Advanced
Threads, processes and asyncio: which one when?
Advanced
Python gives you three ways to do many things at once. Pick by asking: is my program mostly waiting (network, disk), or mostly calculating?
In real lifeA restaurant kitchen. Threads: several cooks share one stove and take turns. Processes: each cook has their own kitchen. asyncio: one cook who starts the rice, and while it boils, starts the curry, and checks both when they are ready.
Threads and asyncio help when tasks spend time waiting. Processes are for heavy calculation across several CPU cores.
Your workload
Best tool
Why
Thousands of network calls
asyncio
very light tasks, one thread
A few blocking calls (files, databases, old libraries)
ThreadPoolExecutor
simple, waiting overlaps
Heavy calculation (images, simulations)
ProcessPoolExecutor
uses all CPU cores
Heavy numeric work on arrays
NumPy and similar
fast C code does the loop
The GIL in one paragraph
In the standard build, CPython has a Global Interpreter Lock: only one thread executes Python bytecode at any instant. It makes the interpreter simpler and safe, and it is why threads do not speed up pure-Python calculation. Waiting for I/O releases the lock, so threads still help for downloads. An optional free-threaded build without the GIL exists (experimental in 3.13, officially supported but still optional from 3.14), and some libraries need updating to use it.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import urllib.request
def size_of(url):
with urllib.request.urlopen(url, timeout=10) as r:
return len(r.read())
with ThreadPoolExecutor(max_workers=8) as pool: # waiting overlaps
sizes = list(pool.map(size_of, ["https://example.com", "https://example.org"]))
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # required for processes on Windows/macOS
with ProcessPoolExecutor() as pool: # one worker per core by default
print(list(pool.map(heavy, [10**6] * 4)))
Advanced
async and await
Advanced
A function defined with async def is a coroutine: calling it creates a paused job instead of running it. The event loop runs many such jobs in one thread. When a job reaches await something, it says “I am waiting, someone else can go” and the loop switches to another job.
In real lifeA café with one barista. She starts the latte machine (2 s), and while it runs she starts the espresso and the tea. Three drinks finish in about two seconds, not five, even though only one person works.
import asyncio
async def make_drink(name, seconds):
print(f"{name}: started")
await asyncio.sleep(seconds) # hand control back while waiting
print(f"{name}: ready")
return name
async def main():
async with asyncio.TaskGroup() as tg: # starts them together, waits for all
t1 = tg.create_task(make_drink("latte", 2))
t2 = tg.create_task(make_drink("espresso", 1))
t3 = tg.create_task(make_drink("tea", 1))
print("all done:", t1.result(), t2.result(), t3.result()) # after ~2 s
asyncio.run(main()) # start the event loop
await only works inside async def, and only on things built for it (asyncio.sleep, async HTTP clients, async database drivers).
A normal blocking call such as time.sleep(5) or requests.get() inside a coroutine freezes the whole loop. Move it to a thread with await asyncio.to_thread(func, ...).
If tasks in a TaskGroup fail, you receive an ExceptionGroup (see the exceptions chapter).
Limit waiting with async with asyncio.timeout(5):.
Advanced
Memory management: reference counting and the garbage collector
Advanced
Every Python object keeps a count of how many names and containers point at it. When the count reaches zero, the object is freed at once. The one gap is a reference cycle (A points to B and B points to A): their counts never reach zero, so a separate garbage collector periodically hunts for such islands and frees them.
In real lifeHotel room keys. When the last key to a room is returned, the room is cleaned right away (count hits zero). Two guests who each hold the other’s key and never check out need the manager to step in and sort it out (the garbage collector).
import sys, gc
a = []
print(sys.getrefcount(a)) # 2: the name a, plus the temporary argument itself
b = a
print(sys.getrefcount(a)) # 3
del b # back to 2
class Node:
def __init__(self):
self.other = None
x, y = Node(), Node()
x.other, y.other = y, x # a cycle: each keeps the other alive
del x, y # names gone, but the objects still point at each other
print(gc.collect() > 0) # True: the collector found and freed the island
Memory “leaks” in Python are usually objects you still reference: a growing global list, a cache with no limit, an event handler never removed.
Big lists of small objects cost memory. __slots__ or @dataclass(slots=True) remove the per-object dictionary.
Use generators and iterators, not giant lists, for data that streams through.
Advanced
Descriptors, __init_subclass__ and metaclasses
Advanced
Everything is an object, including classes. This opens deep customisation. Learn it to understand how frameworks (Django, SQLAlchemy, dataclasses, pydantic) work. Use it rarely in your own code.
Descriptors: reusable attribute rules
A descriptor is an object stored on a class that controls reading and writing an attribute. property is one. Write your own to share a rule across many attributes.
class Positive:
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name[1:]} must be positive")
setattr(obj, self.name, value)
class Product:
price = Positive() # one rule, reused
quantity = Positive()
def __init__(self, price, quantity):
self.price, self.quantity = price, quantity
p = Product(250, 3)
# p.price = -1 ValueError: price must be positive
__init_subclass__: react when a class is subclassed
class Plugin:
registry = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry[cls.__name__.lower()] = cls # auto-register
class Csv(Plugin): ...
class Json(Plugin): ...
print(list(Plugin.registry)) # ['csv', 'json']
Metaclasses: the class of a class
print(type(3), type(Product)) # <class 'int'> <class 'type'>
Dog = type("Dog", (), {"speak": lambda self: "woof"}) # build a class at run time
print(Dog().speak()) # woof
AdviceA metaclass changes how classes are created. Reach for __init_subclass__, a class decorator or a descriptor first. They solve nearly every problem with far less surprise.
Advanced
Project layout, packaging and pytest
Intermediate
Once a script grows into a project, give it a standard shape so tools, teammates and installers know where everything lives.
# pyproject.toml
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31"]
[project.scripts]
myapp = "myapp.cli:main" # installs a "myapp" command
# then, in a virtual environment:
pip install -e . # editable install: edits take effect at once
pytest # run all tests
# tests/test_core.py
import pytest
from myapp.core import average
def test_average():
assert average([20, 30, 70]) == 40
def test_empty_list():
with pytest.raises(ZeroDivisionError):
average([])
@pytest.mark.parametrize("values, expected", [([1, 2, 3], 2), ([10], 10)])
def test_many(values, expected): # one test, many cases
assert average(values) == expected
Useful companions: ruff (linting and formatting), mypy or pyright (type checking), and a CI job that runs all three on every commit.
Advanced
Highlights of Python 3.14
Advanced
This tutorial describes Python 3.14. These are the language and library changes most worth knowing. Check the official What’s New in Python 3.14 page for the complete list.
Feature
What it means for you
Template strings (t"...")
Like f-strings, but give you a structured object instead of a finished string, so libraries can safely escape values (SQL, HTML)
Deferred annotations
Type hints are evaluated lazily, so forward references work without quotes
Free-threaded build
The no-GIL build is officially supported (still optional)
Multiple interpreters
Several isolated interpreters in one process, available in the standard library
compression.zstd
Zstandard compression in the standard library
Syntax highlighting in the REPL
The interactive prompt colours your code as you type
name = "world"
greeting = t"Hello {name}" # a Template object, not a str (3.14+)
print(type(greeting).__name__) # Template
print(greeting.strings) # ('Hello ', '')
print(greeting.interpolations[0].value) # world
TipRun python3 --version to see what you have. Code using 3.14-only features will not run on older versions.
Quick reference
Cheat Sheet
Everything to glance at when you forget a name. Each card is searchable, so type a method name in the search box above.
Cheat
Syntax at a glance
Basic
Types
int float complexnumbers: 7, 7.5, 7+2j
str bytestext and raw bytes
bool NoneTrue / False, “nothing”
list [ ]ordered, changeable
tuple ( )ordered, fixed
dict { k: v }key → value
set { }unique, unordered
Operators
+ - * / // % **add … power
== != < > <= >=compare values
and or notlogic (short-circuit)
in, not inmembership
is, is notsame object? (use for None)
:=walrus: assign inside an expression
d1 | d2merge dicts (right wins)
Assignment
a = b = 0same value to both
a, b = b, aswap
x += 1also -= *= /= //= %= **=
first, *rest = seqstar collects the rest
x: int = 5with a type hint
Control flow
if / elif / elsechoose one branch
for x in items:loop over each item
while cond:loop while true
break / continueleave / skip one round
for … elseelse runs if no break
match x: case …pattern matching
a if cond else bone-line choice
Functions
def f(a, b=1):default value
*args, **kwargsextra positional / named
f(a, /, b, *, c)a: position only; c: name only
lambda x: x * 2tiny anonymous function
return / yieldgive back / produce one at a time
@decoratorwrap the function below
Imports and errors
import mathuse math.sqrt
from math import sqrtuse sqrt
import numpy as nprename
try / except / else / finallyhandle errors
raise X("msg") from eraise, with a cause
with open(p) as f:auto-close
Cheat
Built-in functions you will use daily
Basic
Sizes and maths
len(x)number of items
sum(it)total
min(it)max(it)smallest / largest (key= allowed)
abs(x)distance from zero
round(x, 2)round to 2 places
divmod(a, b)(a // b, a % b)
pow(a, b, m)a ** b % m, fast
Loops and sequences
range(a, b, step)numbers, b excluded
enumerate(it, start=1)(index, item) pairs
zip(a, b)walk lists in step
reversed(seq)backwards view
sorted(it, key=, reverse=)new sorted list
map(f, it)filter(f, it)lazy transform / keep (comprehensions are clearer)
iter(x)next(it)manual iteration
Truth tests
any(it)is at least one truthy?
all(it)are all truthy?
isinstance(x, T)is x a T?
callable(x)can it be called?
hasattr(o, "n")does it have attribute n?
Conversion
int("42")float("1.5")text → number
str(x)repr(x)for people / for developers
list(x)tuple(x)set(x)dict(x)build containers
bool(x)truthiness
ord("a")chr(97)character ↔ code point
bin(5)hex(255)oct(8)'0b101', '0xff', '0o10'
Input, output, introspection
print(*a, sep=, end=)show output
input("prompt")read a line (always a string)
open(path, mode, encoding=)file object
type(x)id(x)kind / identity
dir(x)help(x)vars(x)explore an object
getattr(o, "n", default)attribute by name
Cheat
String methods
Basic
Strings never change in place. Each method returns a new string.
Case and cleaning
s.lower()s.upper()change case
s.title()s.capitalize()Each Word / First word
s.casefold()aggressive lower, for comparing
s.strip()lstrip()rstrip()trim whitespace (or given characters)
s.removeprefix(p)removesuffix(x)cut a start / end if present
Split and join
s.split()split on any whitespace
s.split(",", 1)at most 1 split
s.splitlines()list of lines
", ".join(items)glue strings together
s.partition("=")(before, "=", after)
Search and replace
s.replace(a, b)swap every a for b
s.find(x)index, or -1
s.index(x)index, or ValueError
s.count(x)how many times
s.startswith(x)endswith(x)tuple of options allowed
"r" "w" "a" "x"read / overwrite / append / create-new
"rb" "wb"binary
json.dump(obj, f)json.load(f)file ↔ object
json.dumps(obj)json.loads(s)string ↔ object
Path(p).read_text()quick whole-file read
re.findall(r"\d+", s)all numbers in text
Cheat
Exception family tree
Basic
Catching a parent catches all its children. except LookupError handles both KeyError and IndexError.
BaseException
├── KeyboardInterrupt, SystemExit # do not catch these by accident
└── Exception # catch from here down
├── ArithmeticError → ZeroDivisionError, OverflowError
├── LookupError → KeyError, IndexError
├── OSError → FileNotFoundError, PermissionError, TimeoutError
├── ValueError → UnicodeError
├── TypeError
├── AttributeError
├── NameError → UnboundLocalError
├── ImportError → ModuleNotFoundError
├── RuntimeError → RecursionError, NotImplementedError
├── StopIteration
└── AssertionError
Cheat
How fast are the common operations? (Big-O)
Intermediate
O(1) means “about the same time however big the data is”. O(n) means “time grows with the number of items”. Pick the structure whose common operation is cheap.
Operation
Cost
Note
list.append(x), list.pop()
O(1)
at the end
list.insert(0, x), list.pop(0)
O(n)
everything shifts. Use deque
x in list, list.index(x)
O(n)
scans the list
list[i], len(x)
O(1)
direct jump
sorted(x), list.sort()
O(n log n)
stable; already-sorted input is fast
d[k], k in d, x in set
O(1) average
hashing
deque.append, deque.popleft
O(1)
both ends
heapq.heappush, heappop
O(log n)
smallest item first
s[a:b] (slice)
O(b − a)
copies that many items
s += "x" inside a loop
can be O(n²)
build a list and "".join() it
Cheat
Mutable or immutable?
Basic
Can change in place (mutable)
Cannot change (immutable)
list, dict, set, bytearray, most of your own classes