<!-- Markdown version of https://ocxly.com/python-intermediate.html · auto-generated, may lag the live page -->

# 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](python-for-beginners.html)) 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](#ref-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](#ref-2)

## 02Comprehensions

A **comprehension** builds a list, dict, or set in one readable expression, the workhorse of everyday Python.[3](#ref-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'}
```

## 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](#ref-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'}
```

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 element
```

## 04Generators: 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](#ref-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](#ref-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.12s
```

You'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](#ref-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](#ref-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](#ref-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](#ref-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](#ref-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](#ref-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 port
```

Two 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](#ref-13) Reach for `itertools` when you're combining or chunking iterables,[14](#ref-14) **`pathlib`** for filesystem paths (it replaces fiddly `os.path` string-juggling),[15](#ref-15) `datetime` for dates and times,[16](#ref-16) and `json` to read and write JSON.[17](#ref-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, pretty
```

## 10Type 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](#ref-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](#ref-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 reproduce
```

Commit `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 `pytest` and the built-in `unittest`. 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 `async` when you need it.** For I/O-bound concurrency (many network calls at once), `async`/`await` is Python's answer, but reach for it when you have the problem, not before.
- **Keep the Zen close.** Run `import this` in a shell; “there should be one obvious way to do it” and “readability counts” are the compass for every choice above.[20](#ref-20)
- **Go deeper from the source.** The official tutorial covers all of this and more, precisely.[21](#ref-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.

### References

1. [Python documentation — Built-in Functions: enumerate() and zip()](https://docs.python.org/3/library/functions.html#enumerate)
2. [Python documentation — Glossary: EAFP ("easier to ask forgiveness than permission")](https://docs.python.org/3/glossary.html#term-EAFP)
3. [Python documentation — Data Structures: List, dict and set comprehensions](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions)
4. [Python documentation — More on Defining Functions (defaults, keyword args, *args/**kwargs, lambda)](https://docs.python.org/3/tutorial/controlflow.html#more-on-defining-functions)
5. [Python documentation — Generators (yield) and generator expressions](https://docs.python.org/3/tutorial/classes.html#generators)
6. [PEP 318 — Decorators for Functions and Methods](https://peps.python.org/pep-0318/)
7. [Python documentation — Classes (__init__, self, methods, dunder methods)](https://docs.python.org/3/tutorial/classes.html)
8. [Python documentation — dataclasses (auto-generated __init__/__repr__)](https://docs.python.org/3/library/dataclasses.html)
9. [Python documentation — Built-in Functions: property()](https://docs.python.org/3/library/functions.html#property)
10. [Python documentation — The with statement and context managers (data model)](https://docs.python.org/3/reference/datamodel.html#context-managers)
11. [Python documentation — contextlib.contextmanager](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager)
12. [Python documentation — Errors and Exceptions (try/except/else/finally, custom exceptions, raise)](https://docs.python.org/3/tutorial/errors.html)
13. [Python documentation — collections (Counter, defaultdict, namedtuple, deque)](https://docs.python.org/3/library/collections.html)
14. [Python documentation — itertools (functions for efficient looping)](https://docs.python.org/3/library/itertools.html)
15. [Python documentation — pathlib (object-oriented filesystem paths)](https://docs.python.org/3/library/pathlib.html)
16. [Python documentation — datetime (dates and times)](https://docs.python.org/3/library/datetime.html)
17. [Python documentation — json (encode and decode JSON)](https://docs.python.org/3/library/json.html)
18. [PEP 484 — Type Hints (gradual typing)](https://peps.python.org/pep-0484/)
19. [Python documentation — venv (creating virtual environments)](https://docs.python.org/3/library/venv.html)
20. [PEP 20 — The Zen of Python ("import this")](https://peps.python.org/pep-0020/)
21. [Python documentation — The Python Tutorial](https://docs.python.org/3/tutorial/index.html)

---
*Source: [ocxly.com/python-intermediate.html](https://ocxly.com/python-intermediate.html) — OCXLY, free 100% client-side privacy-first tools. Know the basics and want to write real, idiomatic Python? This intermediate tutorial covers Pythonic idioms, comprehensions, advanced functions, generators, decorators, classes and dataclasses, context managers, proper exception handling, the standard-library toolbox, type hints, and virtual environments. Every feature links to the official Python docs.*
