Advanced Python: What's Left, and When You'll Need It
Six features past this course, each with the problem that earns it
In this lesson
“Advanced Python” is usually presented as a locked room. It is not: it is a set of tools with unusually narrow jobs, and the reason you have not met them is that you have not yet had the problems they solve.
This lesson gives you one small example of each, clearly labeled as a preview. You are not expected to master any of it today. You are expected to recognize the smell of the problem later, and know what to look up.
Explain it like I’m 5
These are the specialist tools at the back of the toolbox. You do not carry them around. You go and get one when a specific job turns up.
You already carry most of the advanced toolbox
Start with the confidence beat, because it is deserved. Almost everything that gets called advanced Python, you have done:
- Generators and iterators — Units 5 and 10.
- Decorators and context managers — Unit 10.
- Type hints, dataclasses, and logging — Unit 9.
- async and await, thread pools, and the GIL — Unit 11.
- Packaging, CI, and containers — Unit 15.
That is most of any “advanced Python” syllabus you will find. What follows is the genuine remainder: six things this course has not taught, in roughly the order you are likely to need them, which is also roughly the order of how rarely you will.
__slots__: when a million small objects will not fit
Every ordinary Python object carries a __dict__ — a dictionary of its attributes — which is what lets you add an attribute to an instance whenever you like. It costs memory per object, and you do not notice until there are a lot of objects.
__slots__ declares the attribute names up front, and the class stops carrying that dictionary.
class Point:
"""A point that may only ever have an x and a y."""
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
point = Point(1, 2)
print("x, y:", point.x, point.y)
try:
point.z = 3 # not in __slots__
except AttributeError:
print("blocked: Point has no slot named z")
print("has a __dict__:", hasattr(point, "__dict__"))
x, y: 1 2 blocked: Point has no slot named z has a __dict__: False
Descriptors: you have already used one
Whenever you have written @property, you used a descriptor. A descriptor is any object that defines __get__ or __set__ and is then used as a class attribute: Python routes attribute access through it. property, classmethod, and even plain methods are all descriptors underneath.
You write your own when the same managed-attribute logic is needed on several attributes and a property per attribute would mean copying it.
class Positive:
"""A managed attribute that refuses a negative number."""
def __set_name__(self, owner, name):
self.storage = "_" + name
def __get__(self, instance, owner=None):
return getattr(instance, self.storage)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.storage[1:]} cannot be negative: {value}")
setattr(instance, self.storage, value)
class Order:
quantity = Positive() # one descriptor, guarding every assignment
def __init__(self, quantity):
self.quantity = quantity
order = Order(3)
print("quantity:", order.quantity)
order.quantity = 10
print("updated: ", order.quantity)
try:
order.quantity = -1
except ValueError as error:
print("refused: ", error)
quantity: 3 updated: 10 refused: quantity cannot be negative: -1
Protocols: type-checking duck typing
Python has always cared about what an object can do rather than what it is. If it has a close() method, you can close it, whatever its class. That is duck typing, and it is why so little Python needs inheritance.
The problem was that type hints could not express it: you could write def shut(thing: Connection), which is a lie if anything closable will do. typing.Protocol fixes that — it describes a shape rather than an ancestry.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
"""Anything with a close() method, whatever its class."""
def close(self) -> None: ...
class Connection:
def close(self):
return "closed"
class Report:
def render(self):
return "<html>"
for thing in (Connection(), Report()):
print(f"{type(thing).__name__:11s} closable: {isinstance(thing, Closable)}")
Connection closable: True Report closable: False
Metaclasses: the one you will probably never need
A class is itself an object, and something has to create it. That something is a metaclass, and by default it is type. Writing your own means running code at the moment a class is defined, rather than when it is used.
The classic legitimate use is a registry: every subclass should announce itself somewhere without the author having to remember.
class Registry(type):
"""A metaclass runs when a CLASS is created, not when one is used."""
exporters = {}
def __init__(cls, name, bases, namespace):
super().__init__(name, bases, namespace)
if bases: # skip the base class itself
Registry.exporters[name.removesuffix("Exporter").lower()] = cls
class Exporter(metaclass=Registry):
pass
class CsvExporter(Exporter):
pass
class JsonExporter(Exporter):
pass
print("registered:", sorted(Registry.exporters))
print("csv is: ", Registry.exporters["csv"].__name__)
registered: ['csv', 'json'] csv is: CsvExporter
class Exporter:
"""The same registry, with no metaclass in sight."""
exporters = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Exporter.exporters[cls.__name__.removesuffix("Exporter").lower()] = cls
class CsvExporter(Exporter):
pass
class JsonExporter(Exporter):
pass
print("registered:", sorted(Exporter.exporters))
registered: ['csv', 'json']
Profiling: measure first, then optimize
This is the one on the list you will actually use, and the rule is short: never optimize before measuring. Intuition about which line is slow is wrong often enough to be worthless.
Before reaching for a profiler, though, there is a cheaper insight. Most real slowness is not about how fast each operation runs; it is about how many operations you do. Counting them is something you can do in your head, or here.
NAMES = [f"user{n:04d}" for n in range(2000)]
KNOWN = set(NAMES)
LOOKUPS = ["user0000", "user1000", "user1999", "nobody"]
def comparisons_to_find(names, wanted):
"""How many names a plain list scan has to look at."""
for position, name in enumerate(names, start=1):
if name == wanted:
return position
return len(names)
print("comparisons to find a name in a LIST of 2000:")
for wanted in LOOKUPS:
print(f" {wanted:9s} {comparisons_to_find(NAMES, wanted):5d}")
print("comparisons to find the same name in a SET:")
for wanted in LOOKUPS:
print(f" {wanted:9s} {1:5d} found: {wanted in KNOWN}")
comparisons to find a name in a LIST of 2000: user0000 1 user1000 1001 user1999 2000 nobody 2000 comparisons to find the same name in a SET: user0000 1 found: True user1000 1 found: True user1999 1 found: True nobody 1 found: False
$ python -m cProfile -s cumtime slow.py
5000
10009 function calls in 0.233 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.233 0.233 {built-in method builtins.exec}
1 0.003 0.003 0.233 0.233 slow.py:1(<module>)
1 0.000 0.000 0.230 0.230 slow.py:19(main)
1 0.000 0.000 0.229 0.229 slow.py:11(check_all)
1 0.000 0.000 0.229 0.229 {built-in method builtins.sum}
5001 0.001 0.000 0.228 0.000 slow.py:12(<genexpr>)
5000 0.228 0.000 0.228 0.000 slow.py:6(is_known)
1 0.001 0.001 0.001 0.001 slow.py:15(load)
$ python -m timeit -s "NAMES=[f'user{n:05d}' for n in range(20000)]" "'user19999' in NAMES"
5000 loops, best of 5: 85.2 usec per loop
$ python -m timeit -s "KNOWN={f'user{n:05d}' for n in range(20000)}" "'user19999' in KNOWN"
50000000 loops, best of 5: 7.98 nsec per loop
C extensions, and why you probably will not write one
When a profiler proves that one small function is the whole bottleneck and no algorithm change helps, you can drop out of Python for that function. The usual routes are Cython (Python-like code compiled to C), ctypes or cffi (call an existing C library), the raw CPython C API, and increasingly Rust through PyO3.
Here is the honest position: for numeric work, the answer is almost always NumPy or pandas, which are already C underneath. Somebody already wrote the fast version, tested it on more hardware than you have, and gave it a Python interface. Learning to reach for those is the same win with none of the build system.
What is worth knowing today is just the shape of the escape hatch: Python can call into compiled code, that is how its whole scientific stack is built, and the door is there if you ever genuinely need it.
Fix this preview in place. For each of the six features, the table below already names the problem that earns it — fill in the simpler thing you already know and the unit that taught it, so the feature has something to be compared against.
# The order here is the order you are likely to need them.
ORDER = ["__slots__", "descriptor", "Protocol", "metaclass", "cProfile", "C extension"]
EARNS_IT = {
"__slots__": "millions of tiny objects will not fit in memory",
"descriptor": "every assignment to one attribute must be checked",
"Protocol": "type-check duck typing without a shared base class",
"metaclass": "every subclass must register itself as it is defined",
"cProfile": "it is slow and guessing which line has not worked",
"C extension": "one function is provably the whole bottleneck",
}
# TODO: the simpler thing you already know, and the unit that taught it.
# Choose from: a plain class / property / duck typing /
# __init_subclass__, or a class decorator / timing one block by hand /
# numpy, which is already C
ALREADY_HAVE = {
"__slots__": ("?", 0),
"descriptor": ("?", 0),
"Protocol": ("?", 0),
"metaclass": ("?", 0),
"cProfile": ("?", 0),
"C extension": ("?", 0),
}
for feature in ORDER:
simpler, unit = ALREADY_HAVE[feature]
print(f"{feature:12s} unit {unit:2d} you already have: {simpler}")
print(f"{'':12s} reach past it when: {EARNS_IT[feature]}")
Three of them are answered by ordinary object-oriented Python from Unit 4: a plain class, a property, and duck typing. The metaclass alternative was Unit 10's decorator work, hand-timing a block was Unit 11, and the reason not to write C is Unit 12's NumPy.
# The order here is the order you are likely to need them.
ORDER = ["__slots__", "descriptor", "Protocol", "metaclass", "cProfile", "C extension"]
EARNS_IT = {
"__slots__": "millions of tiny objects will not fit in memory",
"descriptor": "every assignment to one attribute must be checked",
"Protocol": "type-check duck typing without a shared base class",
"metaclass": "every subclass must register itself as it is defined",
"cProfile": "it is slow and guessing which line has not worked",
"C extension": "one function is provably the whole bottleneck",
}
ALREADY_HAVE = {
"__slots__": ("a plain class", 4),
"descriptor": ("property", 4),
"Protocol": ("duck typing", 4),
"metaclass": ("__init_subclass__, or a class decorator", 10),
"cProfile": ("timing one block by hand", 11),
"C extension": ("numpy, which is already C", 12),
}
for feature in ORDER:
simpler, unit = ALREADY_HAVE[feature]
print(f"{feature:12s} unit {unit:2d} you already have: {simpler}")
print(f"{'':12s} reach past it when: {EARNS_IT[feature]}")
__slots__ unit 4 you already have: a plain class
reach past it when: millions of tiny objects will not fit in memory
descriptor unit 4 you already have: property
reach past it when: every assignment to one attribute must be checked
Protocol unit 4 you already have: duck typing
reach past it when: type-check duck typing without a shared base class
metaclass unit 10 you already have: __init_subclass__, or a class decorator
reach past it when: every subclass must register itself as it is defined
cProfile unit 11 you already have: timing one block by hand
reach past it when: it is slow and guessing which line has not worked
C extension unit 12 you already have: numpy, which is already C
reach past it when: one function is provably the whole bottleneck
Common mistake: Learning an advanced feature before having a problem for it
The features have a reputation, so knowing them feels like a level-up.
Read this lesson once for recognition, then leave it. Each of these tools is easy to learn on the day you need it and almost impossible to retain on a day you do not.
Common mistake: Writing a metaclass where a decorator or __init_subclass__ would do
A metaclass is the answer most search results give for “run code when a class is defined”, often from before __init_subclass__ existed.
Try __init_subclass__ first, then a class decorator. If neither can express it, you have one of the rare real cases — and you will be able to say exactly why in one sentence.
Common mistake: Optimizing before profiling
One line looks expensive, and rewriting it feels like progress.
Run cProfile first, every time. It takes ten seconds and it is regularly a different function than the one you suspected — often one you did not know was being called 5,000 times.
Common mistake: Adding __slots__ to every class
It reads as a free optimization and a tidiness win.
It is a real constraint: no new attributes, awkward inheritance, and surprises with libraries that expect to attach data to your objects. Use it where a measurement showed memory pressure from many instances.
Common mistake: Assuming advanced features make code better
They are what distinguishes expert-looking code in examples.
They distinguish expert code because experts reach for them rarely and precisely. The most senior-looking Python you will ever read is mostly plain functions with good names.
Which unit taught you decorators?
Decorators, iterators, itertools, and context managers were all Unit 10. They are frequently listed as advanced topics; you have had them for a while.
What should you do before optimizing a slow program?
Measure first. cProfile for “which part of my program?” and timeit for “which of these two lines?” — guessing has a poor record even among people who wrote the code.
When is a metaclass the right tool?
Nearly every historical use is now better served by __init_subclass__. Checking attributes on assignment is a descriptor or a property, not a metaclass.
What does __slots__ actually cost you?
It removes the per-instance __dict__. That saves memory and takes away the flexibility other code may be relying on, so it is a trade rather than a free win.
Mini exercise (hard)
Write the registry from this lesson the way you actually should: with __init_subclass__ and no metaclass. Every Exporter subclass must add itself to Exporter.registry under its own name minus the Exporter suffix, lower-cased. Then export(format_name, rows) looks the class up, uses it, and reports what is available when the name is unknown.
Give it a shot. Complete the code and press Run; there’s nothing to download or configure.
class Exporter:
"""Base class. Every subclass should register itself automatically."""
registry = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# TODO: register cls under its name minus "Exporter", lower-cased
def render(self, rows):
raise NotImplementedError
class CsvExporter(Exporter):
def render(self, rows):
return "\n".join(",".join(row) for row in rows)
class TsvExporter(Exporter):
def render(self, rows):
return "\n".join("\t".join(row) for row in rows)
def export(format_name, rows):
"""Render rows with the named exporter, or say what is available."""
return "?" # TODO: look format_name up in the registry and use it
# TODO: unknown name -> f"no {format_name} exporter; have: ..."
ROWS = [("name", "city"), ("ada", "oxford")]
print("formats:", sorted(Exporter.registry))
print(export("csv", ROWS))
print(export("tsv", ROWS).replace("\t", " | "))
print(export("pdf", ROWS))
__init_subclass__ receives the new subclass as cls. Call super().__init_subclass__(**kwargs) first, then assign into Exporter.registry — naming Exporter explicitly, not cls, so every subclass writes into the same dictionary. cls.__name__.removesuffix("Exporter").lower() is the key. In export, use .get() so an unknown format returns your message instead of raising KeyError.
class Exporter:
"""Base class. Every subclass should register itself automatically."""
registry = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Exporter.registry[cls.__name__.removesuffix("Exporter").lower()] = cls
def render(self, rows):
raise NotImplementedError
class CsvExporter(Exporter):
def render(self, rows):
return "\n".join(",".join(row) for row in rows)
class TsvExporter(Exporter):
def render(self, rows):
return "\n".join("\t".join(row) for row in rows)
def export(format_name, rows):
"""Render rows with the named exporter, or say what is available."""
exporter = Exporter.registry.get(format_name)
if exporter is None:
return f"no {format_name} exporter; have: {', '.join(sorted(Exporter.registry))}"
return exporter().render(rows)
ROWS = [("name", "city"), ("ada", "oxford")]
print("formats:", sorted(Exporter.registry))
print(export("csv", ROWS))
print(export("tsv", ROWS).replace("\t", " | "))
print(export("pdf", ROWS))
formats: ['csv', 'tsv']
name,city
ada,oxford
name | city
ada | oxford
no pdf exporter; have: csv, tsv
assert sorted(Exporter.registry) == ["csv", "tsv"], "both subclasses register themselves, keyed without the Exporter suffix"
assert "Exporter" not in Exporter.registry, "the base class must not register itself: __init_subclass__ only runs for subclasses"
assert Exporter.registry["csv"] is CsvExporter, "the value is the class itself, not its name"
assert export("csv", [("a", "b")]) == "a,b", "look the class up, make one, and call render"
assert export("pdf", []) == "no pdf exporter; have: csv, tsv", "an unknown format should list what is available"
class YamlExporter(Exporter):
def render(self, rows):
return "rows: 0"
assert "yaml" in Exporter.registry, "a class defined later registers itself too - that is the whole point"
print("\u2713 Looks good!")