Classes and Objects
Build Python classes that hold state correctly: __init__ and __new__, instance versus class attributes, bound methods, repr and str, properties, slots, equality and hash, dataclasses and validated models
A support engineer opens the billing queue and finds every ticket carrying the
tag billing. Only one customer ever used that tag. The code looks harmless:
the Ticket class declares tags: list[str] = [] at class level, and each
ticket calls self.tags.append(tag). Nothing in that line says "share this
list with every other ticket", yet that is exactly what Python does.
This is the single most common bug in intermediate Python code. It is not a
typo and it is not a subtle race. It is a consequence of how a class stores
attributes, and once you can see the mechanism, the fix takes one line. The
same mechanism explains bound methods, default values, properties, __slots__
and the equality rules that make objects safe as dictionary keys.
This lesson builds that mental model from the ground up, then turns it into working code. You will write a class that keeps its state private to each instance, give it readable string forms, enforce an invariant with a property, measure what a per-instance dictionary costs, and design a value object that behaves correctly as a dictionary key. The final section compares a hand-written class with a dataclass and a validated model, so you can pick the cheapest tool that meets the requirement.
Key terms
- A namespace that holds attributes and methods, created once when the class statement runs.
- An object created by calling a class, with its own namespace for instance attributes.
- A name bound to a value on an object, looked up through the instance, its class and the base classes.
- A function defined in a class body that receives the instance as its first argument when called on an instance.
- The conventional name for the first parameter of an instance method, bound to the instance at call time.
- A special method with two leading and two trailing underscores, called by Python syntax such as print, == or len.
- A value stored on the class object and shared by every instance that does not shadow it.
- A value stored in one instance namespace, independent of every other instance.
- A data descriptor that runs a getter and an optional setter when an attribute is read or assigned.
- The rewrite of an identifier like __token to _ClassName__token inside a class body.
- A class decorated with dataclasses.dataclass that generates __init__, __repr__ and __eq__ from annotated fields.
- A unique object used as a placeholder when None is a valid value, created in Python 3.15 with sentinel().
Class
Instance
Attribute
Method
self
Dunder method
Class attribute
Instance attribute
Property
Name mangling
Dataclass
Sentinel
1. What a Class Really Is
A class statement is executable code. Python runs the body once, in a fresh
namespace, and binds that namespace to a class object. Functions become methods,
plain assignments become class attributes, and calling the class later runs the
two step creation protocol the data model defines.
The first step is __new__, which allocates and returns a new instance. The
second is __init__, which receives that instance and fills it with state.
__init__ is an initializer, not a constructor: it must return None, and a
non-None return raises TypeError. Most classes never
override __new__. Subclasses of immutable types such as int, str and
tuple are the exception, because their value must be fixed first.
The class below models an invoice line. currency is deliberately a class
attribute, because every invoice in this service bills in one currency.
Everything else is instance state.
class Invoice:
"""One invoice line for one customer."""
currency = "USD" # class attribute: one value shared by all instances
def __init__(self, customer: str, amount_cents: int) -> None:
self.customer = customer # instance attribute
self.amount_cents = amount_cents # instance attribute
self.paid = False # instance attribute
def total(self) -> str:
dollars = self.amount_cents / 100
return f"{dollars:.2f} {self.currency}"
def mark_paid(self) -> None:
self.paid = True
invoice = Invoice("Ada Lovelace", 129_900)
print(invoice.total())
print(invoice.paid)
invoice.mark_paid()
print(invoice.paid)The output is 1299.00 USD, then False, then True. Money stays an integer
count of cents, and conversion happens only for display, which avoids the
rounding surprises of binary floats.
The structure of this class fits in one diagram. The diagram separates shared class state from per-instance state and shows the containment relationship to a line item.
Description
A class diagram of Invoice with a shared currency class field, four instance fields and three methods, plus a composition link to LineItem with one or many items.
The dollar suffix on currency marks a static member, which is the diagram's
way of saying that the value lives on the class rather than on each instance.
Everything else appears on every invoice.
The video below walks through the same creation sequence with a different example, so you can watch an instance being built and called.
Description
A video lesson that defines a simple class, creates instances from it, and calls methods on those instances to show where instance state lives.
Calling a class is not special syntax. Invoice("Ada", 129_900) calls the
class object, which runs __new__ and then __init__. You never write
new in Python.
2. Instance State and Class State
Every instance carries a dictionary named __dict__ unless the class uses
__slots__. That dictionary is the instance namespace. The class also has a
namespace, and attribute lookup walks from the instance to the class and then
through the base classes. The trap starts when a class
attribute is mutable.
The code below reproduces the opening bug in full. Read the output before reading the explanation.
class Ticket:
tags: list[str] = [] # one list, created once, shared by all instances
def __init__(self, subject: str) -> None:
self.subject = subject
def add_tag(self, tag: str) -> None:
self.tags.append(tag)
billing = Ticket("Refund stuck")
billing.add_tag("billing")
login = Ticket("Cannot sign in")
print(login.tags)
print(billing.tags is login.tags)The output is ['billing'] followed by True. The second line is the proof.
login.tags and billing.tags are not two equal lists. They are the same list
object, because neither instance has a tags entry in its own namespace. The
lookup finds the class attribute, and .append mutates it in place.
The fix is to create a fresh list for each instance inside __init__.
class Ticket:
def __init__(self, subject: str) -> None:
self.subject = subject
self.tags: list[str] = [] # a new list for this instance only
def add_tag(self, tag: str) -> None:
self.tags.append(tag)
billing = Ticket("Refund stuck")
billing.add_tag("billing")
login = Ticket("Cannot sign in")
print(login.tags)
print(billing.tags)Now the output is [] and then ['billing']. Each instance owns its list.
There is a second version of the same bug. Assigning through an instance creates
an instance attribute that shadows the class value. If a class declares
total = 0, then self.total += 1 writes 1 into that instance, while every
other instance still reads the class value.
The diagram below shows the order Python uses for a plain attribute read. The data descriptor branch is the reason a property can override an instance dictionary.
Description
Attribute lookup starts with data descriptors on the class, then the instance dictionary, then the class and its base classes, and finally the __getattr__ hook or an AttributeError.
A mutable class attribute is shared state by default. Lists, dictionaries and
sets declared in a class body belong in __init__ unless sharing is the
explicit design. Function default arguments have the same trap.
The ordering exercise below checks the creation path, from the class statement to the first usable instance.
How to answer
Ordering exercise with 5 items. Move them into the correct order, then check your answer.
Order the steps that happen when Python creates and initializes an instance.
3. Methods and the Instance They Carry
A function on a class is a descriptor. Reading it through an instance calls the
function's __get__ and returns a bound method that remembers the instance.
Calling that method prepends the instance to the argument list, which is why
self appears without being passed.
The sequence below follows one call from the caller to the function object.
Description
A sequence diagram of invoice.total: the caller asks the instance, the instance looks up total on the class, the class returns the function, the function binds self, and the caller receives the formatted string.
The equivalence below is worth running once. The second call is legal, and it
shows that self is an ordinary parameter.
invoice = Invoice("Grace Hopper", 250_000)
print(invoice.total())
print(Invoice.total(invoice))Both lines print 2500.00 USD. The second form calls the function through the
class, so no binding happens and the instance must be passed explicitly.
Use self for the first parameter, even though the name is only a
convention. Every Python reader expects it, linters check for it, and
renaming it to this makes the class look like a failed port from Java.
4. Two String Representations
Python gives every object two string forms, and they answer different questions.
Method | Called by | Audience | Contract |
|---|---|---|---|
__repr__ | repr(), the REPL, debuggers | Developer | Should look like a valid expression that could recreate the object |
__str__ | print(), str(), f-strings | User | Should be readable and concise |
print() asks for __str__ first. If a class defines __repr__ but not
__str__, the data model falls back to __repr__ for both.
That fallback is why many small classes only implement __repr__.
The class below stores money as integer cents, then provides both forms. The
!r conversion inside the f-string applies repr() to each value, which keeps
strings quoted and makes the output unambiguous.
class Money:
def __init__(self, cents: int, currency: str = "USD") -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money(cents={self.cents!r}, currency={self.currency!r})"
def __str__(self) -> str:
return f"{self.cents / 100:.2f} {self.currency}"
price = Money(4999)
print(repr(price))
print(str(price))
print(price)The output is Money(cents=4999, currency='USD') followed by 49.99 USD twice.
The first line could be pasted back into a Python session. The other two are for
people.
A default __repr__ prints the class name and a memory address that changes on
every run. Replacing it is one of the highest-value lines you can add.
Write __repr__ for every class you expect to debug. Add __str__ only when
users see the object. A good __repr__ names the class and every field that
matters, and it never performs I/O.
The matching exercise connects each special method to the syntax that calls it.
How to answer
Matching exercise with 6 pairs. Match each item on the left with its answer on the right.
Match each special method to the operation that calls it.
Drag answers from here:
5. Properties and Invariants
A public attribute is a promise. If every reader can assign radius_m = -3, the
class has no invariant, and every method that computes with the radius has to
defend itself. A property keeps the attribute syntax and adds a check.
A property is a data descriptor: it defines __get__ and, when a setter exists,
__set__. Data descriptors take priority over the instance dictionary, which
is why assigning self.radius_m inside __init__ still runs the setter. The
built-in property() type is documented as a managed attribute, and the
decorator form is the usual style.
The formula is why the radius must stay positive. A negative radius produces a positive area, so the bug passes a casual test and fails in production.
import math
class Circle:
def __init__(self, radius_m: float) -> None:
self.radius_m = radius_m # goes through the setter below
@property
def radius_m(self) -> float:
return self._radius_m
@radius_m.setter
def radius_m(self, value: float) -> None:
if value <= 0:
raise ValueError("radius must be positive")
self._radius_m = value
@property
def area_m2(self) -> float:
return math.pi * self._radius_m ** 2
circle = Circle(2.0)
print(round(circle.area_m2, 2))
circle.radius_m = 3.0
print(round(circle.area_m2, 2))The output is 12.57 and then 28.27, both rounded from the exact float. A
call such as Circle(0) raises ValueError before any object escapes. The
private storage name _radius_m is a convention, not a lock, but it tells
readers to use the property.
A read-only property has a getter and no setter. Assigning to it raises
AttributeError, which is the correct behaviour for a computed value such as
area_m2.
The fill in the blank exercise checks the descriptor vocabulary.
How to answer
Fill-in-the-blank exercise with 4 blanks. Type the missing words, then check your answer.
Complete the description of a property.
Decorating a method with blank turns it into a data descriptor. The getter runs when you read the attribute, and the blank runs when you assign to it. Because the descriptor lives on the blank, it takes priority over the instance blank.
Word Bank, drag words into the blanks above:
6. The Real Cost of a __dict__
Every normal instance carries a dictionary. The dictionary is flexible: you can add attributes at runtime, and each name is a hash lookup. That flexibility has a memory cost, and at a million objects the cost becomes the design constraint.
__slots__ replaces the per-instance dictionary with a fixed set of descriptors
on the class. The data model states that this prevents the automatic creation of
__dict__ and __weakref__ for each instance, and that instances can no longer
be given attributes outside the declared names.
The measurement below runs on CPython 3.14.4 on Linux. Exact numbers vary by build and platform, so run it yourself before you quote it.
import sys
class PointDict:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
plain = PointDict(1.0, 2.0)
compact = PointSlots(1.0, 2.0)
print(sys.getsizeof(plain) + sys.getsizeof(plain.__dict__))
print(sys.getsizeof(compact))The output is 344 and 48. At one million points the difference is roughly
344 megabytes against 48 megabytes.
The chart compares the two layouts directly.
Description
Bar chart of bytes per Point instance on CPython 3.14.4: 344 bytes with a per-instance dictionary and 48 bytes with __slots__, about seven times smaller.
The savings are real, and so are the trade-offs. A slotted class cannot accept a
new attribute that is not listed, cannot be weak-referenced unless it declares
__weakref__, and cannot use multiple inheritance with another slotted base
that has non-empty slots. The data model lists these constraints
explicitly.
__slots__ is an optimization, not an access control feature. Slotted
attributes are still readable and writable from outside. Use it when profiling
shows instance dictionaries dominate memory, and measure before and after.
Start with ordinary classes and profile before adding __slots__; for data
records, the dataclass decorator generates the slots with slots=True.
7. Equality, Hashing and Value Objects
Two instances are equal only if the class says so. The default object.__eq__
compares identity, so two separately constructed Money(4999) objects differ by
default. A value object changes that: two objects with the
same fields become interchangeable.
The equality method must respect a contract. Equal objects must have equal
hashes, and a hash must not change while the object is used as a key. That is
why mutable objects should not implement __hash__. The data model puts the rule
plainly: a class that overrides __eq__ and does not define __hash__ gets
__hash__ set to None, and its instances become unhashable.
class Money:
__slots__ = ("cents", "currency")
def __init__(self, cents: int, currency: str = "USD") -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __hash__(self) -> int:
return hash((self.cents, self.currency))
plans = {Money(4999): "Basic", Money(9999): "Pro"}
print(plans[Money(4999)])
print(Money(4999) == Money(4999, "USD"))The output is Basic and True. The dictionary lookup builds a temporary
Money and finds the stored key by hash and equality, even though the two
objects have different identities.
Returning NotImplemented matters. It tells Python that this class does not
know how to compare with that type, so the interpreter can ask the other operand.
Returning False instead would silently block a valid comparison implemented by
the other class.
The data model documents a default for each of these methods, and the matching exercise above covers the most common ones.
If a class needs all four ordering comparisons, define __eq__ and one of
__lt__, __le__, __gt__ or __ge__, then apply
functools.total_ordering. The decorator supplies the rest, which removes three
near-identical methods.
Never hash an object whose fields can change. A key that mutates after
insertion stays in the wrong hash bucket, so the dictionary can no longer find
it, and no error is raised. Use frozen=True on a dataclass or a plain
immutable class for keys.
8. Name Mangling Is a Naming Rule
Python has no private attributes. It has a naming convention and one compiler
rewrite. An identifier with two leading underscores and at most one trailing
underscore, written inside a class body, is rewritten to _ClassName__name.
This is called name mangling.
Form | Meaning | Access from outside |
|---|---|---|
name | Public | Direct |
_name | Internal by convention | Direct, but discouraged |
__name | Mangled to _ClassName__name | Possible if you know the rule |
__name__ | Reserved by Python | Do not invent new ones |
The example below shows the rewrite and its limits.
class Session:
def __init__(self, token: str) -> None:
self.__token = token # stored as _Session__token
def masked(self) -> str:
return self.__token[-4:].rjust(len(self.__token), "*")
session = Session("sk-live-9f2c41")
print(session.masked())
print(session._Session__token)The output is *********9f2c41 and then the raw token. The attribute is
reachable. Mangling exists to avoid name clashes between a class and its
subclasses, not to protect secrets.
Do not store credentials in mangled attributes and call them private. Use a
secrets manager, keep the value out of repr, and treat every attribute as
readable by anyone who can run code in the process.
9. Dataclasses, Pydantic and the Class You Write
Most classes are records with a little behaviour. Writing __init__, __repr__
and __eq__ by hand for a four-field record is noise, and the dataclass
decorator removes it. It generates those methods from annotated fields, plus
__hash__, ordering methods and slots.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Money:
cents: int
currency: str = "USD"
def __str__(self) -> str:
return f"{self.cents / 100:.2f} {self.currency}"
price = Money(4999)
print(price)
print(price == Money(4999))
print(hash(price) == hash(Money(4999)))The output is 49.99 USD, True and True. frozen=True blocks attribute
assignment after creation, and the dataclass documentation states that a frozen
dataclass with eq=True gets a generated __hash__ by default.
slots=True generates a __slots__ attribute and returns a new class, which is
the memory optimization from the previous section without the boilerplate.
A dataclass does not validate. It will happily accept Money(-1) or a string in
an integer field, because annotations are not enforced at runtime. When data
arrives from outside the program, a validating model is the better boundary.
from pydantic import BaseModel, Field
class InvoiceModel(BaseModel):
customer: str
amount_cents: int = Field(gt=0)
invoice = InvoiceModel(customer="Ada Lovelace", amount_cents=129_900)
print(invoice.model_dump())The output is {'customer': 'Ada Lovelace', 'amount_cents': 129900}. Pydantic
builds validation from type hints, with a Rust core in version 2.
A negative amount fails before the object exists, which is what a handler needs.
The table compares the three options on the properties that decide the choice.
Approach | Boilerplate removed | Runtime validation | Best fit |
|---|---|---|---|
Hand-written class | None | Whatever you write | Behaviour, invariants and lifecycle |
@dataclass | __init__, __repr__, __eq__ | None | Internal records and value objects |
Pydantic BaseModel | Parsing, validation, serialization | Yes | Data crossing a trust boundary |
The quadrant below turns that table into a placement exercise. The horizontal axis measures how much behaviour the object carries, and the vertical axis measures how many instances the program creates.
Description
A quadrant chart placing a dict and a tuple in the low behaviour, few instance area, dataclass and Pydantic models in the data heavy many instance area, and classes with methods in the behaviour heavy area.
Comparative Verdict: choose a hand-written class when the object owns behaviour, lifecycle or an invariant. Choose a frozen dataclass for values passed inside your own program, and a validated model where untrusted data enters. Validating again in the core loop is the expensive mistake.
10. When Not to Write a Class
A class earns its cost when it holds state that several methods share, or when it enforces an invariant that callers must not break. Without one of those, a function is shorter, easier to test and easier to cache.
Situation | Simpler choice | Why |
|---|---|---|
One transformation of an input | Function | No state to keep |
A record with no behaviour | Dataclass or tuple | No hand-written boilerplate |
Configuration read once at startup | Frozen dataclass | Immutable and hashable |
A family of algorithms with one entry point | Function passed as a value | No class hierarchy needed |
State shared by many methods | Class | The state has a home |
If a class has an __init__ that stores arguments and a single method that
uses them once, it is probably a function wearing a costume. Rewrite it as a
function and delete the ceremony.
Real-World Case Studies
pathlib: an object replaced a family of functions
The pathlib module was designed as an object-oriented API for filesystem paths,
and PEP 428 explains why the design chose objects over the os.path family of
functions. Path objects are immutable, which makes them hashable and prevents a
class of programming errors. The immutability is the same idea
as the frozen dataclass above, applied to a standard library type.
from pathlib import Path
report = Path("reports") / "2026-11.csv"
print(report.suffix)
print(report.parent)
print(report.stem)The output is .csv, reports and 2026-11. The / operator builds a new path instead of mutating the old one.
Django: __str__ is part of the model contract
Django calls str() on model instances in several places, most visibly in the
admin site and in templates, and the documentation asks every model to return a
readable label from __str__. The model base class supplies
the developer-facing repr, and the admin page needs the human form.
from django.db import models
class Person(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
def __str__(self) -> str:
return f"{self.first_name} {self.last_name}"Without that method, the admin page shows an object address instead of a name, which makes a support tool much harder to use.
📚 Did you know?
- A slotted class cannot be weak-referenced unless it declares
__weakref__, which matters for caches that hold weak keys. - A
frozendictis hashable, so a mapping can itself be a dictionary key in Python 3.15.
2026 Updates (as of November 2026)
- Python 3.15.2 is the current stable documentation set, and 3.16 is in development.
frozendictis a built-in since 3.15: immutable, hashable and usable as a dictionary key.sentinel()is a built-in since 3.15, with copy support andisbased comparison.- The 3.15
__slots__rules allow__dict__and__weakref__slots on any class. - Dataclasses have accepted
slots=Truesince Python 3.10, so the memory win needs no hand-written slots.
Practice Questions
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A support team reports that adding the tag billing to one ticket also adds it to every other ticket. The Ticket class defines tags: list[str] = [] at class level, and each instance calls self.tags.append(tag). Which change fixes the shared state?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Which statement about __new__ and __init__ is correct?
How to answer
Multiple-choice question with 2 options. Select one option, then choose Check Answer.
When a method is called on an instance, Python passes that instance as the first argument automatically.
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A class defines both __str__ and __repr__. Which method does print(order) call first?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Circle.radius_m is a property whose setter raises ValueError for values at or below zero. Circle.__init__ assigns self.radius_m = radius_m. What happens when Circle(0) runs?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
On CPython 3.14.4 a two-float instance with a normal __dict__ measured 344 bytes, while the same class with __slots__ measured 48 bytes. Which explanation matches the data model?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A class defines __eq__ but does not define __hash__. What happens when an instance is used as a dictionary key?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A class stores a token in self.__token. A colleague says the value is now private and safe from other code. Which statement is accurate?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
An internal service passes a four-field record between functions. The record needs value equality, a readable repr, and no mutation after creation, and the team wants no third-party dependency. Which choice removes the most boilerplate?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A script reads a CSV, multiplies one column by 1.2, and writes the result. Which design is the simplest fit?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A configuration loader returns nested dictionaries today. A new feature needs default values, validation and attribute access in twelve modules. Which change is the smallest step that meets those needs?
Key Takeaways
- A class statement builds a namespace once. Calling the class runs
__new__to allocate and__init__to fill, and__init__must returnNone. - Class attributes are shared, so mutable ones belong in
__init__. The shared list bug is a lookup rule, not a typo. - Reading a method through an instance returns a bound method that carries
self.Invoice.total(invoice)is the unbound form of the same call. __repr__is for developers and should look like a constructor call.__str__is for users, andprintprefers it.- Properties are data descriptors on the class, so they run even for
assignments inside
__init__. That is how an invariant survives. __slots__trades flexibility for memory. On CPython 3.14.4 a two-float instance drops from 344 bytes to 48 bytes, and dataclasses can generate it.- Overriding
__eq__without__hash__makes instances unhashable. Value objects need both, and only immutable fields may feed the hash. - Name mangling is a naming rule for subclasses, never a security control.
- Use a frozen dataclass for internal records, a validated model at the trust boundary, and a hand-written class when behaviour and invariants need a home.
References
Every factual claim links to its source, with the access date printed so the source can be rechecked.