OcxlyDev · Tutorial

Advanced Python: the machinery under the language

This is step three. Our beginner tutorial taught you to make things work; the intermediate one taught you to write them the way a Python programmer would. This tutorial is about the layer beneath that fluency — the protocols and machinery that make Python behave the way it does. You'll see how for loops really work, how @property is built from a lower-level primitive, what a metaclass actually is, and how to make code run concurrently. You rarely need all of this at once, but knowing it is the difference between using the language and understanding it.

The iterator protocol

Every for loop in Python is powered by one small contract, the iterator protocol: an object is iterable if it has __iter__(), which returns an iterator — an object with a __next__() that yields the next value and raises StopIteration when it's done.1 Implement those and your own type works everywhere a list does: in for, in comprehensions, in zip().

python
class Countdown:
    def __init__(self, start):
        self.start = start
    def __iter__(self):
        n = self.start
        while n > 0:
            yield n          # a generator IS an iterator
            n -= 1

for x in Countdown(3):
    print(x)      # 3, 2, 1

Notice the shortcut: because a generator function is already an iterator, writing __iter__ as a generator (with yield) saves you from hand-writing __next__ and the StopIteration bookkeeping. Once you think in iterators, the itertools module becomes a toolbox of lazy, composable building blocks — chain, islice, groupby, product — that operate on any iterable without ever building the whole sequence in memory.2

Generators, deeper: delegation

You met generators in the last tutorial. The advanced move is delegation with yield from, which hands off to a sub-generator and transparently passes through its values (and return value).34 It turns nested iteration into one flat, readable line:

python
def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)   # recurse, streaming
        else:
            yield item

list(flatten([1, [2, [3, 4]], 5]))   # [1, 2, 3, 4, 5]

Because it's all lazy, chaining generators gives you memory-flat data pipelines: each stage pulls one item at a time from the one before it, so you can process a file larger than RAM through several transformations without ever materialising it.

Decorators that take arguments, and functools

A plain decorator wraps a function. A decorator with arguments is one level up — a function that returns a decorator — so you can configure the wrapping:

python
import functools

def retry(times):
    def decorator(fn):
        @functools.wraps(fn)          # keep fn's name/docstring
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception:
                    if attempt == times - 1: raise
        return wrapper
    return decorator

@retry(times=3)
def fetch(): ...

Always apply @functools.wraps to your wrapper — it copies the original's name, docstring and signature so introspection and debuggers still see the real function.5 The rest of functools is a highlight reel of advanced tools: lru_cache memoises a function's results, partial pre-binds arguments to make a new callable, and singledispatch gives you type-based function overloading — pick an implementation based on the first argument's type.56

Descriptors: how @property actually works

Here's a thing that surprises people: @property, methods, classmethod, and every ORM's typed field are all built on one protocol you can use yourself — the descriptor. A descriptor is any object that defines __get__ (and optionally __set__), placed as a class attribute; Python then routes attribute access on instances through it.7 The modern __set_name__ hook even tells the descriptor which attribute name it was assigned to.8

python
class Positive:
    def __set_name__(self, owner, name):
        self.attr = "_" + name
    def __get__(self, obj, objtype=None):
        return getattr(obj, self.attr)
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("must be positive")
        setattr(obj, self.attr, value)

class Account:
    balance = Positive()      # validated on every assignment

Write the validation once as a descriptor and reuse it across dozens of fields. This is exactly the mechanism SQLAlchemy, Django models, and dataclasses' field machinery are built on — now you can see through it.

The data model up close: dunders and __slots__

Python's data model is the set of __dunder__ methods that hook your objects into language syntax.9 You've used __init__ and __repr__; the full set lets your objects respond to operators (__add__, __eq__, __lt__), to being called (__call__), to indexing (__getitem__), and to missing-attribute access (__getattr__). Implement the right dunder and your type behaves like a built-in.

When you create many instances of a small class, add __slots__. By default every instance carries a __dict__ for its attributes, which is flexible but memory-hungry; declaring __slots__ replaces that dict with a fixed, compact layout — often a large memory saving and a small speed win, at the cost of not being able to add new attributes on the fly.10

python
class Point:
    __slots__ = ("x", "y")   # no per-instance __dict__
    def __init__(self, x, y):
        self.x, self.y = x, y

Metaclasses — and when not to reach for them

Classes are objects too, and a metaclass is the class of a class: it controls how classes themselves are created, letting you inspect or rewrite a class at definition time.11 They power frameworks — registering subclasses, validating APIs, injecting methods. But they're heavy machinery, and most goals that used to need a metaclass are now better served by __init_subclass__, a hook that runs whenever a class is subclassed, with none of the conceptual overhead.12

python
class Plugin:
    registry = []
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Plugin.registry.append(cls)   # auto-register every subclass
Rule of thumb: if a class decorator or __init_subclass__ can do the job, use it. Reach for a metaclass only when you must control class creation itself — and expect the next reader to need a minute.

Context managers at scale

You know with for a single resource. At scale, contextlib gives you more: the @contextmanager decorator to write one from a generator, suppress() to swallow a specific exception cleanly, and ExitStack to manage a dynamic number of context managers — open an unknown list of files and have every one guaranteed to close, even if the third fails to open.13

python
from contextlib import ExitStack

with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]
    # all files close on exit, in reverse order, whatever happens

Concurrency: the GIL, threads, and processes

To run things at the same time you first have to know about the GIL (Global Interpreter Lock): in the standard CPython interpreter, a single lock allows only one thread to execute Python bytecode at a time.14 That single fact decides your tool:

Getting simpler. Recent CPython is rolling out an optional free-threaded build that removes the GIL, so this advice may loosen over time — but the I/O-vs-CPU distinction remains the right way to reason about which tool fits.

async / await

For high-concurrency I/O — thousands of simultaneous connections — threads get expensive. asyncio offers a different model: a single thread runs an event loop that juggles many tasks cooperatively.18 You mark a function async def and await the slow points; at each await, the task voluntarily yields control so the loop can run another until its result is ready.19

python
import asyncio

async def fetch(url):
    await asyncio.sleep(1)      # stand-in for a network call
    return url

async def main():
    # all three run concurrently, not one-after-another
    return await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))

asyncio.run(main())

The mental model: await is a cooperative pause, not a preemptive one. Async is superb for I/O-bound concurrency and does nothing for CPU-bound work — the event loop is still one thread.

The type system, leveled up

The intermediate tutorial introduced type hints. Advanced code makes them precise. Use generics so a container's element type flows through: a TypeVar lets you say “this function returns the same type it was given.”2021 And use Protocol for structural typing — “any object with a .read() method,” regardless of its base class — which types Python's natural duck-typing without forcing an inheritance hierarchy.22

python
from typing import Protocol, TypeVar

T = TypeVar("T")
def first(items: list[T]) -> T:   # returns the element type
    return items[0]

class Readable(Protocol):
    def read(self) -> str: ...   # anything with read() qualifies

Before you optimise anything

Everything here is power, and power invites premature use. The professional habit is the opposite: measure first. Reach for the cProfile profiler to find where the time actually goes before you touch a line — intuition about performance is wrong more often than it's right, and the real hotspot is rarely the clever bit you were tempted to rewrite.23 Descriptors, metaclasses, and async are the right tools exactly when a concrete problem demands them — and needless complexity the rest of the time.

About this piece. The third tutorial in OcxlyDev's Python series, after the beginner and intermediate guides. Every feature links to the official Python documentation or the relevant PEP; details reflect Python 3 (current release 3.14 as of mid-2026). These are the tools of frameworks and libraries — learn them to read great code, and use them sparingly to write it.

References

  1. Python documentation — Iterator Types (the __iter__ / __next__ protocol and StopIteration)
  2. Python documentation — itertools (lazy, composable building blocks for iterables)
  3. Python documentation — Yield expressions (generators; yield and yield from)
  4. PEP 380 — Syntax for Delegating to a Subgenerator (yield from)
  5. Python documentation — functools (wraps, lru_cache, partial)
  6. Python documentation — functools.singledispatch (type-based single dispatch)
  7. Python documentation — Descriptor HowTo Guide (__get__/__set__; how property and methods are built)
  8. Python documentation — Data model: __set_name__ (descriptor name binding)
  9. Python documentation — Data model: Special method names (the dunder protocol)
  10. Python documentation — Data model: __slots__ (compact instance layout)
  11. Python documentation — Data model: Metaclasses (controlling class creation)
  12. PEP 487 — Simpler customisation of class creation (__init_subclass__)
  13. Python documentation — contextlib (@contextmanager, suppress, ExitStack)
  14. Python documentation — Glossary: global interpreter lock (GIL)
  15. Python documentation — threading (thread-based parallelism, best for I/O-bound work)
  16. Python documentation — multiprocessing (process-based parallelism, sidesteps the GIL)
  17. Python documentation — concurrent.futures (ThreadPoolExecutor / ProcessPoolExecutor)
  18. Python documentation — asyncio (asynchronous I/O and the event loop)
  19. PEP 492 — Coroutines with async and await syntax
  20. Python documentation — typing (generics, TypeVar, Protocol and more)
  21. PEP 484 — Type Hints (generics and type variables)
  22. PEP 544 — Protocols: Structural subtyping (static duck typing)
  23. Python documentation — The Python Profilers (cProfile: measure before optimising)