What is it? #
The constructor runs when an object is created. Its job is to put the object into a valid, usable state — and nothing else.
That validity guarantee is the point. If a User cannot be created without a valid email, no code downstream has to check whether the email is valid.
Keep constructors cheap. They should assign values and validate them. Making network calls, opening database connections or reading files from a constructor makes the class slow to create and painful to test.
When there are several ways to build an object — from a database row, from JSON, from a CSV line — add named alternative constructors rather than piling optional parameters onto one.
Think of it like this #
A passport office will not issue a passport with a missing photo or an unsigned form. The check happens once, at the counter, when the document is created.
The alternative — issuing anything and asking every border guard to re-verify — is what code looks like when objects can be created in invalid states.
Simple example #
A User must have a non-empty name and a plausible email. It can be created directly, from an API payload, or from a database row. Each route ends up in the same validated state.
Code #
from datetime import date
class User:
def __init__(self, name: str, email: str, joined_on: date | None = None):
name = name.strip()
email = email.strip().lower()
if not name:
raise ValueError("name is required")
if "@" not in email or email.startswith("@") or email.endswith("@"):
raise ValueError(f"invalid email: {email!r}")
self.name = name
self.email = email
self.joined_on = joined_on or date.today()
self._roles: set[str] = set() # sensible empty default
# Alternative constructors — named, explicit, each doing one conversion
@classmethod
def from_api(cls, payload: dict) -> "User":
return cls(
name=payload.get("full_name", ""),
email=payload.get("email_address", ""),
)
@classmethod
def from_row(cls, row) -> "User":
return cls(name=row["name"], email=row["email"], joined_on=row["joined_on"])
def __repr__(self):
return f"User({self.name!r}, {self.email!r})"
user = User(" Ravi Kumar ", "[email protected] ")
print(user) # User('Ravi Kumar', '[email protected]')
api_user = User.from_api({"full_name": "Anita", "email_address": "[email protected]"})
print(api_user)
try:
User("", "nope")
except ValueError as exc:
print("Rejected:", exc) # Rejected: name is required
# What NOT to do in a constructor
class BadUser:
def __init__(self, user_id):
self.data = fetch_from_database(user_id) # slow, needs a DB in every test
send_welcome_email(self.data["email"]) # a side effect on creation
How it works #
The constructor normalises first — trimming whitespace and lowercasing the email — then validates. Normalising before validating means " Ravi " is accepted and stored cleanly rather than rejected or stored with spaces.
Raising ValueError with a specific message stops the object existing at all. There is no half-built User floating around.
self._roles = set() gives a sensible empty default. Note that it is created inside __init__, not as a class attribute — a class-level set would be shared by every user.
from_api and from_row are alternative constructors. Each knows about one input format and then delegates to __init__, so validation happens exactly once in one place. Returning cls(...) rather than User(...) means subclasses get the right type back.
joined_on or date.today() supplies a default without using a mutable default argument — and without freezing the date at import time, which is what joined_on=date.today() in the signature would wrongly do.
The BadUser example shows the anti-pattern. Creating that object requires a database and sends an email as a side effect, which makes it impossible to construct in a test and surprising in production.
Real-world use #
Validation at construction is what lets the rest of a codebase stay simple. Web frameworks apply it at the edge: an incoming request is parsed into a validated object, and every layer after that can rely on it.
Alternative constructors appear throughout standard libraries — datetime.fromisoformat, Decimal.from_float, DataFrame.from_records. Each converts one representation and reuses the core initialiser.
Keeping constructors free of side effects matters most in tests. A class that can be created with a line of code, with no database or network, is a class people will actually write tests for.
For data-heavy classes, Python dataclasses generate the constructor for you, and __post_init__ is the place to put validation.
Common mistakes #
- Allowing objects to be created invalid and validating later, forcing checks everywhere.
- Doing I/O — database, network, file — inside a constructor.
- Using a mutable default like
roles=[]in the signature; it is shared between objects. - Using
date.today()as a default parameter value, which freezes at import time. - Piling up optional parameters instead of adding named alternative constructors.
Practice #
Write an Order class whose constructor requires a non-empty customer ID and at least one order line, and rejects a negative total. Add from_json and from_row alternative constructors. Then write one test proving an invalid order cannot be created.