Python · lesson 33 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Python Metaclasses
Understand Python's type system, metaclasses, __new__ vs __init__, custom metaclasses, __init_subclass__, and class decorators with real executed output.
By SkillByExample · Updated
A metaclass is the class of a class: it controls how classes themselves are built. Output below is from Python 3.13.
Everything Is an Object
In Python, classes are objects too. The type of a class is its metaclass — by default, type.
class Dog:
def bark(self):
return "Woof"
print(type(Dog))
print(type(Dog()))
print(isinstance(Dog, type))
$ python everything_is_an_object.py
<class 'type'>
<class '__main__.Dog'>
True
type is both the metaclass of all classes and a built-in function. It’s the default metaclass.
type() as a Class Factory
You can create classes dynamically with type(name, bases, namespace):
Dog = type("Dog", (object,), {
"sound": "Woof",
"bark": lambda self: self.sound,
})
d = Dog()
print(d.bark())
# Equivalent to:
# class Dog:
# sound = "Woof"
# def bark(self):
# return self.sound
$ python type_factory.py
Woof
This is exactly what Python does internally when it processes a class statement.
new vs init
class Celsius:
"""An immutable temperature that refuses values below absolute zero."""
def __new__(cls, value):
if value < -273.15:
raise ValueError(f"{value} degrees C is below absolute zero")
instance = super().__new__(cls)
return instance
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
c = Celsius(100)
print(c.value)
try:
Celsius(-300)
except ValueError as e:
print(f"ValueError: {e}")
class PositiveInt(int):
def __new__(cls, value):
if value <= 0:
raise ValueError("Must be positive")
return super().__new__(cls, value)
x = PositiveInt(5)
print(x)
try:
PositiveInt(-1)
except ValueError as e:
print(f"ValueError: {e}")
$ python new_vs_init.py
100
ValueError: -300 degrees C is below absolute zero
5
ValueError: Must be positive
__new__ is also used for immutable types (subclassing int, str, tuple) where __init__ is too late — by the time __init__ runs, an int subclass’s value is already fixed, so validation has to happen in __new__.
Writing a Metaclass
A metaclass is a class whose instances are classes. Create one by inheriting from type:
class SingletonMeta(type):
"""Metaclass that enforces the Singleton pattern."""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self, url):
self.url = url
self.connected = False
db1 = Database("postgresql://localhost/app")
db2 = Database("different url")
print(db1 is db2)
print(db1.url)
$ python singleton_metaclass.py
True
postgresql://localhost/app
db2’s constructor call never actually runs a fresh __init__ — SingletonMeta.__call__ intercepts every Database(...) call and returns the cached instance instead, so db2.url would still be the first URL, not "different url".
Metaclass new and init
class ValidatedMeta(type):
"""Enforce that all public methods have docstrings."""
def __new__(mcs, name, bases, namespace):
for attr_name, attr_value in namespace.items():
if callable(attr_value) and not attr_name.startswith("_"):
if not attr_value.__doc__:
raise TypeError(
f"{name}.{attr_name} is missing a docstring"
)
return super().__new__(mcs, name, bases, namespace)
try:
class MyAPI(metaclass=ValidatedMeta):
def get_user(self, user_id):
"""Fetch a user by ID."""
...
def delete_user(self, user_id):
# Missing docstring - raises TypeError at class definition time
...
except TypeError as e:
print(f"TypeError: {e}")
$ python validated_meta.py
TypeError: MyAPI.delete_user is missing a docstring
This check runs when class MyAPI(...) is defined, not when it’s instantiated — a metaclass’s __new__ executes at class-creation time, catching the mistake before anyone ever creates a MyAPI object.
init_subclass: A Simpler Alternative
For many metaclass use cases, __init_subclass__ is cleaner:
class Plugin:
_registry = {}
def __init_subclass__(cls, plugin_name="", **kwargs):
super().__init_subclass__(**kwargs)
if plugin_name:
Plugin._registry[plugin_name] = cls
class JSONPlugin(Plugin, plugin_name="json"):
def process(self, data): ...
class XMLPlugin(Plugin, plugin_name="xml"):
def process(self, data): ...
print(Plugin._registry)
def get_plugin(name):
cls = Plugin._registry.get(name)
if cls is None:
raise ValueError(f"Unknown plugin: {name}")
return cls()
print(type(get_plugin("json")).__name__)
$ python init_subclass.py
{'json': <class '__main__.JSONPlugin'>, 'xml': <class '__main__.XMLPlugin'>}
JSONPlugin
Auto-registration without any metaclass boilerplate — __init_subclass__ runs automatically every time a subclass is defined, no metaclass= keyword required anywhere.
Class Decorators: Often Better Than Metaclasses
import inspect
def enforce_types(cls):
"""Add runtime type checking to all __init__ parameters."""
original_init = cls.__init__
sig = inspect.signature(original_init)
hints = original_init.__annotations__ # capture from the ORIGINAL __init__, not cls.__init__
def checked_init(self, *args, **kwargs):
bound = sig.bind(self, *args, **kwargs)
bound.apply_defaults()
for param, value in list(bound.arguments.items())[1:]: # skip self
if param in hints and not isinstance(value, hints[param]):
raise TypeError(
f"{param} must be {hints[param].__name__}, "
f"got {type(value).__name__}"
)
original_init(self, *args, **kwargs)
cls.__init__ = checked_init
return cls
@enforce_types
class User:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
u = User("Alice", 30)
print(u.name, u.age)
try:
User("Alice", "30")
except TypeError as e:
print(f"TypeError: {e}")
$ python enforce_types.py
Alice 30
TypeError: age must be int, got str
Note the hints line: it reads original_init.__annotations__, not cls.__init__.__annotations__. By the time checked_init runs, cls.__init__ has already been reassigned to checked_init itself — looking up the annotations through cls.__init__ at call time would find an empty dict and silently skip every check.
When to Use What
| Tool | Use When |
|---|---|
__init_subclass__ | Plugin registries, validating subclasses |
| Class decorator | Modifying or wrapping a class without inheritance |
ABC / @abstractmethod | Defining interfaces / abstract base classes — see the Abstraction lesson |
| Custom metaclass | Framework-level class creation (ORMs, schema validators) |
Practice
1. Add a reset() method to SingletonMeta that forgets a class’s instance, then confirm calling Database(...) after reset() creates a genuinely new instance.
Solution
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
def reset(cls):
SingletonMeta._instances.pop(cls, None)
class Database(metaclass=SingletonMeta):
def __init__(self, url):
self.url = url
db1 = Database("url-a")
print(db1 is Database("ignored"))
Database.reset()
db2 = Database("url-b")
print(db1 is db2, db2.url)
$ python singleton_reset.py
True
False url-b
A method defined on the metaclass is callable on the class (Database.reset()), with cls bound to Database, but not on instances. After the cached instance is removed, the next call builds a new one.
2. Modify ValidatedMeta so it also rejects public methods whose name is shorter than 3 characters.
Solution
class ValidatedMeta(type):
def __new__(mcs, name, bases, namespace):
for attr_name, attr_value in namespace.items():
if callable(attr_value) and not attr_name.startswith("_"):
if not attr_value.__doc__:
raise TypeError(f"{name}.{attr_name} is missing a docstring")
if len(attr_name) < 3:
raise TypeError(f"{name}.{attr_name} name too short")
return super().__new__(mcs, name, bases, namespace)
class GoodAPI(metaclass=ValidatedMeta):
def get_user(self, user_id):
"""Fetch a user by ID."""
print("GoodAPI created")
try:
class ShortAPI(metaclass=ValidatedMeta):
def go(self):
"""Too short a name."""
except TypeError as e:
print(f"TypeError: {e}")
$ python validated_names.py
GoodAPI created
TypeError: ShortAPI.go name too short
Both checks run while the class statement executes, so a bad class never comes into existence. GoodAPI passes both rules; ShortAPI has a docstring but fails the length rule.
3. Add a third plugin, CsvPlugin, to the __init_subclass__ example without changing Plugin at all, and print the registry.
Solution
class Plugin:
_registry = {}
def __init_subclass__(cls, plugin_name="", **kwargs):
super().__init_subclass__(**kwargs)
if plugin_name:
Plugin._registry[plugin_name] = cls
class JSONPlugin(Plugin, plugin_name="json"):
def process(self, data): ...
class CsvPlugin(Plugin, plugin_name="csv"):
def process(self, data): ...
print(sorted(Plugin._registry))
print(Plugin._registry["csv"].__name__)
$ python csv_plugin.py
['csv', 'json']
CsvPlugin
Defining the subclass is the registration: __init_subclass__ runs on the parent each time a subclass is created, so Plugin never needs editing to learn about new plugins.
4. Confirm, without adding any code to enforce_types, that it already rejects an unknown keyword argument such as User("Alice", 30, extra="oops").
Solution
import inspect
def enforce_types(cls):
original_init = cls.__init__
sig = inspect.signature(original_init)
hints = original_init.__annotations__
def checked_init(self, *args, **kwargs):
bound = sig.bind(self, *args, **kwargs)
for param, value in list(bound.arguments.items())[1:]:
if param in hints and not isinstance(value, hints[param]):
raise TypeError(f"{param} must be {hints[param].__name__}")
original_init(self, *args, **kwargs)
cls.__init__ = checked_init
return cls
@enforce_types
class User:
def __init__(self, name: str, age: int):
self.name, self.age = name, age
try:
User("Alice", 30, extra="oops")
except TypeError as e:
print(f"TypeError: {e}")
$ python unknown_kwarg.py
TypeError: got an unexpected keyword argument 'extra'
sig.bind() raises before the type-checking loop runs, because extra is not a parameter __init__ declares. inspect.Signature.bind enforces the call shape for free.