What is it? #
The Builder pattern constructs a complex object step by step, then produces the finished result in one final call.
The problem it solves is the constructor with too many parameters, most of them optional. Report(source, None, True, None, 50, False, "csv", None) tells the reader nothing.
A builder names each step. You set only what you need, in whatever order suits, and validation happens once at the end when the full picture is known.
In Python, keyword arguments and dataclasses already solve much of this. A builder earns its place when construction has ordering rules, accumulates a collection, or validates combinations of options.
Think of it like this #
Ordering a sandwich at a counter. You do not shout all ten choices at once. You pick a bread, add fillings, choose sauces, then say "that's it" — and only at that point does anyone check whether the combination is possible.
Simple example #
A database query needs optional filters, sorting, joins and pagination. Some combinations are invalid, such as pagination without an order. A builder makes the construction readable and the validation single.
Code #
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Query:
table: str
columns: tuple = ("*",)
conditions: tuple = ()
order_by: str | None = None
limit: int | None = None
def to_sql(self) -> str:
sql = f"SELECT {', '.join(self.columns)} FROM {self.table}"
if self.conditions:
sql += " WHERE " + " AND ".join(self.conditions)
if self.order_by:
sql += f" ORDER BY {self.order_by}"
if self.limit is not None:
sql += f" LIMIT {self.limit}"
return sql
class QueryBuilder:
def __init__(self, table: str):
self._table = table
self._columns: list[str] = []
self._conditions: list[str] = []
self._order_by: str | None = None
self._limit: int | None = None
def select(self, *columns: str) -> "QueryBuilder":
self._columns.extend(columns)
return self # returning self enables chaining
def where(self, condition: str) -> "QueryBuilder":
self._conditions.append(condition)
return self
def order_by(self, column: str) -> "QueryBuilder":
self._order_by = column
return self
def limit(self, count: int) -> "QueryBuilder":
if count <= 0:
raise ValueError("limit must be positive")
self._limit = count
return self
def build(self) -> Query:
# Validation happens once, when the whole picture is known
if self._limit is not None and self._order_by is None:
raise ValueError("limit without order_by gives unpredictable results")
return Query(
table=self._table,
columns=tuple(self._columns) or ("*",),
conditions=tuple(self._conditions),
order_by=self._order_by,
limit=self._limit,
)
query = (
QueryBuilder("orders")
.select("id", "total")
.where("status = 'paid'")
.where("total > 1000")
.order_by("created_at DESC")
.limit(20)
.build()
)
print(query.to_sql())
# SELECT id, total FROM orders WHERE status = 'paid' AND total > 1000
# ORDER BY created_at DESC LIMIT 20
# Compare with the constructor this replaces
# Query("orders", ("id", "total"), ("status = 'paid'", "total > 1000"),
# "created_at DESC", 20)
When to use it
- many optional parts, or parts that accumulate
- validation depends on combinations, not individual values
- the construction reads better as named steps
When NOT to use it
- two or three parameters — keyword arguments are clearer
- a dataclass with defaults already does the job
- the builder just mirrors the constructor with no added rules
How it works #
QueryBuilder collects state across calls. Each method records something and returns self, which is what makes chaining possible.
where can be called repeatedly and accumulates conditions. That is something a constructor cannot do naturally — you would have to build the list first and pass it in.
Immediate validation still happens where it makes sense: limit rejects a non-positive value straight away, because that check needs no other information.
build performs the checks that require the whole object. The rule "limit without order_by is unreliable" cannot be validated until both are known, which is exactly the kind of rule builders handle well.
The result is a frozen dataclass. The builder is mutable during assembly; the product is immutable afterwards, so nothing can change it later.
Compare the commented constructor call at the bottom. It contains the same information and is far harder to read or modify — and adding a parameter in the middle would break every existing call.
Real-world use #
Query builders in ORMs are the most familiar example: session.query(Order).filter(...).order_by(...).limit(20).all().
HTTP client libraries use the pattern for request construction, and test data builders are widely used to create complex fixtures readably: OrderBuilder().with_paid_status().with_lines(3).build().
Configuration objects for servers, caches and retry policies also suit it, since options accumulate and interact.
In Python specifically, check first whether a dataclass with keyword defaults covers your case — it usually does. The builder is for accumulation and cross-field validation, not for avoiding a five-field constructor.
Common mistakes #
- Writing a builder that just mirrors the constructor and adds nothing.
- Forgetting to return
self, which silently breaks chaining with an AttributeError. - Letting the builder be reused after
build(), so two products share accumulated state. - Doing all validation in each setter, so combination rules cannot be checked.
- Using it where keyword arguments would be shorter and clearer.
Practice #
Build an EmailBuilder with methods for sender, recipient, subject, body, attachments (accumulating) and a build() that rejects an email with no recipient or with attachments totalling over 10 MB. Then write the equivalent constructor call and compare readability.