Python, leveled up: an intermediate tutorial
You know variables, loops, and functions. This is the tutorial that takes you from “code that works” to code that a Python programmer would recognise as their own — idiomatic, expressive, and built from the language's real strengths.
There's a moment in learning Python where you can make anything work, but your code still doesn't look like the code you read in good libraries. You're writing for i in range(len(items)) where a Python programmer writes for item in items; you're catching bugs the language could have caught for you; you're rebuilding tools the standard library already ships. This tutorial closes that gap. It assumes you're comfortable with the basics (if not, start with our beginner tutorial) and focuses on the handful of features that separate working Python from fluent Python.
As before: type the examples, don't just read them. The difference between knowing a feature and using it fluently is entirely in your fingers.
01Think in idioms first
Before any big feature, internalise a few idioms — they'll reshape how you write everything else. When you loop and need the index, use enumerate(); to walk two lists together, use zip(); to pull values out of a sequence, use unpacking.1
names = ["Ada", "Grace", "Linus"]
scores = [91, 88, 95]
# index + value together — no range(len(...))
for i, name in enumerate(names, start=1):
print(f"{i}. {name}")
# two sequences in lockstep
for name, score in zip(names, scores):
print(f"{name}: {score}")
# unpacking (and swapping without a temp variable)
a, b = 1, 2
a, b = b, a # now a=2, b=1
first, *rest = [10, 20, 30, 40] # first=10, rest=[20,30,40]Two more habits worth forming. First, lean on truthiness: empty containers, 0, None, and "" are all “falsy,” so write if items: rather than if len(items) > 0:. Second, prefer “easier to ask forgiveness than permission” (EAFP) — try the operation and handle the exception, rather than checking every precondition first. It's the grain the language is cut along.2
02Comprehensions
A comprehension builds a list, dict, or set in one readable expression — the workhorse of everyday Python.3 Once it clicks, you'll reach for it constantly.
nums = [1, 2, 3, 4, 5, 6]
squares = [n * n for n in nums] # [1, 4, 9, 16, 25, 36]
evens = [n for n in nums if n % 2 == 0] # filter with a trailing if
# dict comprehension: name -> length
lengths = {name: len(name) for name in ["Ada", "Grace"]}
# set comprehension: unique first letters
initials = {w[0] for w in ["apple", "apricot", "banana"]} # {'a', 'b'}fors and an if, it's usually clearer as a plain loop. Comprehensions are for simple transformations; readability still wins.03Functions, properly
You can define functions; now use their full range. Parameters can have defaults, be passed by keyword, and collect extras with *args (positional) and **kwargs (keyword).4
def greet(name, greeting="Hello", *, excited=False):
# everything after * is keyword-only — call it as excited=True
mark = "!" if excited else "."
return f"{greeting}, {name}{mark}"
print(greet("Ada")) # Hello, Ada.
print(greet("Ada", "Hi", excited=True)) # Hi, Ada!
def total(*args, **kwargs):
print(args) # a tuple of positional args
print(kwargs) # a dict of keyword args
total(1, 2, 3, mode="sum") # (1, 2, 3) {'mode': 'sum'}def f(items=[]) — that list is created once and shared across every call. Use def f(items=None) and set items = items or [] inside.For a throwaway one-liner, a lambda is an anonymous function — most useful as the key to sorted():
people = [("Ada", 36), ("Grace", 85), ("Linus", 54)]
by_age = sorted(people, key=lambda p: p[1]) # sort by the age element04Generators: work you don't do until you need it
A generator is a function that produces values lazily, one at a time, using yield instead of return. It doesn't build the whole result in memory — it hands you the next value only when asked, which lets you process data far bigger than RAM.5
def countdown(n):
while n > 0:
yield n # pause here, hand back n, resume on the next request
n -= 1
for x in countdown(3):
print(x) # 3, 2, 1 — computed on demand
# reading a huge file line by line, never loading it all:
def non_empty_lines(path):
with open(path) as f:
for line in f:
if line.strip():
yield line.rstrip()There's also a compact form — the generator expression, like a comprehension with parentheses — perfect for feeding aggregates without a temporary list: sum(n * n for n in range(1_000_000)) computes the sum without ever materialising a million squares.
05Decorators
A decorator wraps a function to add behaviour — logging, timing, caching, access checks — without touching the function's own body. It's a function that takes a function and returns a new one, applied with the @ syntax.6
import functools, time
def timed(func):
@functools.wraps(func) # keep func's name/docstring on the wrapper
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000) # slow_sum took 0.12sYou'll use decorators long before you write many: @property, @staticmethod, @functools.cache, and framework decorators like a web route are everywhere. The @functools.wraps line is the one detail beginners miss — without it, the wrapped function loses its real name and docstring.
06Classes, and when to skip them
You met dictionaries for grouping data; classes bundle data and the behaviour that acts on it.7 The __init__ method builds each instance; self is the instance; dunder (double-underscore) methods hook into language syntax.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
return self.balance
def __repr__(self): # how it prints in the shell / logs
return f"Account({self.owner!r}, {self.balance})"
acc = Account("Ada")
acc.deposit(100)
print(acc) # Account('Ada', 100)For a class that's mostly a bag of typed fields, don't hand-write __init__ and __repr__ — use a dataclass, which generates them for you:8
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def distance_to(self, other):
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
print(Point(0, 0).distance_to(Point(3, 4))) # 5.0
print(Point(1, 2)) # Point(x=1, y=2) — free __repr__Use @property to expose a computed value as if it were an attribute, and inheritance for genuine “is-a” relationships.9 But resist over-engineering: if a function will do, write a function. Not everything needs to be a class.
07Context managers: the with statement
You've used with open(...) — that's a context manager, and its job is guaranteed cleanup: whatever happens inside the block, the file is closed on the way out, even if an exception is raised.10 Any time you acquire something that must be released (files, locks, network connections), reach for with.
Writing your own is easy with contextlib:11
from contextlib import contextmanager
import time
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield # the body of the with-block runs here
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timer("work"):
sum(range(10_000_000))
# work: 0.11s (printed even if the block raised)08Exceptions, for real this time
Beginners catch exceptions to stop crashes. Intermediate code uses them deliberately: catch the specific exception you expect, let unexpected ones propagate, and use the full try / except / else / finally shape.12
class ConfigError(Exception): # define your own, subclassing Exception
"""Raised when configuration is invalid."""
def load_port(raw):
try:
port = int(raw)
except ValueError:
raise ConfigError(f"port must be a number, got {raw!r}") from None
else:
# runs only if no exception was raised in the try
if not (1 <= port <= 65535):
raise ConfigError(f"port out of range: {port}")
return portTwo rules that separate the levels: never write a bare except: (it swallows even KeyboardInterrupt); and catch narrowly — except ValueError, not except Exception — so real bugs still surface loudly.
09The standard-library toolbox
Python's real superpower is how much comes in the box. Before installing anything or writing a helper, check whether the standard library already solved it — it usually has.
collections — better containers
from collections import Counter, defaultdict
Counter("mississippi").most_common(2) # [('i', 4), ('s', 4)]
groups = defaultdict(list) # missing keys default to []
for word in ["apple", "avocado", "banana"]:
groups[word[0]].append(word) # {'a': [...], 'b': [...]}collections also gives you namedtuple and deque.13 Reach for itertools when you're combining or chunking iterables,14 pathlib for filesystem paths (it replaces fiddly os.path string-juggling),15 datetime for dates and times,16 and json to read and write JSON.17
from pathlib import Path
import json
config = Path("settings.json")
if config.exists():
data = json.loads(config.read_text()) # parse JSON → dict
config.write_text(json.dumps(data, indent=2)) # write it back, pretty10Type hints: let the tools catch your bugs
Python stays dynamically typed at runtime, but type hints (PEP 484) let you annotate what you expect — and a checker like mypy or your editor then flags mismatches before you ever run the code.18 On any codebase past a few hundred lines, they pay for themselves.
def average(numbers: list[float]) -> float:
return sum(numbers) / len(numbers)
def find_user(uid: int) -> str | None: # returns a str, or None if not found
...The type hints don't change how the program runs — they're documentation the machine can check. Add them at your boundaries first (function signatures), and let them grow inward.
11Virtual environments & dependencies
The moment a project needs a third-party package, isolate it. A virtual environment gives each project its own private set of packages, so two projects can't fight over versions.19
python -m venv .venv # create an isolated environment
source .venv/bin/activate # macOS/Linux (Windows: .venv\Scripts\activate)
pip install requests # installs only into this project
pip freeze > requirements.txt # record exact versions for others to reproduceCommit requirements.txt (or a pyproject.toml), never the .venv/ folder. Anyone can then recreate your environment with pip install -r requirements.txt. Modern tools like uv and Poetry streamline this further, but they're all doing this same job underneath.
11.5Putting it together
Here's a small, realistic script that leans on several of the ideas above — a dataclass, type hints, a generator, a Counter, a comprehension, and pathlib — to summarise a log file:
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Summary:
total: int
by_level: dict[str, int]
def levels(path: Path):
# generator: yields the level word from each non-empty line, lazily
for line in path.read_text().splitlines():
if line.strip():
yield line.split()[0] # e.g. "ERROR", "INFO"
def summarise(path: Path) -> Summary:
counts = Counter(levels(path))
return Summary(total=sum(counts.values()), by_level=dict(counts))
if __name__ == "__main__":
s = summarise(Path("app.log"))
print(f"{s.total} lines")
for level, n in sorted(s.by_level.items()):
print(f" {level}: {n}")Notice how little of this is “plumbing.” The dataclass removes boilerplate, the generator keeps memory flat, Counter does the tallying, and the type hints let an editor verify the shapes. That density — saying a lot with a little, clearly — is what fluent Python feels like. The if __name__ == "__main__": guard, by the way, means “only run this when the file is executed directly, not when it's imported” — the standard way to make a file both a script and an importable module.
12Where to go next
You now have the toolkit that most day-to-day Python is built from. A few honest directions from here:
- Write tests. Learn
pytestand the built-inunittest. Tests are how intermediate programmers move fast without fear — they're the next real level-up. - Read good code. Browse the source of a well-regarded library (
requests,httpx, parts of the standard library). You'll absorb idioms faster than any tutorial teaches them. - Meet
asyncwhen you need it. For I/O-bound concurrency (many network calls at once),async/awaitis Python's answer — but reach for it when you have the problem, not before. - Keep the Zen close. Run
import thisin a shell; “there should be one obvious way to do it” and “readability counts” are the compass for every choice above.20 - Go deeper from the source. The official tutorial covers all of this and more, precisely.21
Fluency isn't a finish line — it's the point where the language stops being in your way and starts amplifying you. From here, the fastest path is simply building things you care about, and reaching for the right tool each time you notice yourself doing something the hard way.