When an object has many fields, most of them optional, constructors become unreadable and error-prone. Builder lets you assemble the object step by step with named methods, validate it, and get back a finished (often immutable) object at the end.
Intent#
Separate the construction of a complex object from its representation, so the same construction process can create different representations. (GoF)
Analogy: a sandwich counter. You go step by step:
- Bread
- Protein
- Cheese
- Veggies
- Sauce
- Wrap
Same process, different combinations, different sandwiches.
The problem: constructor hell#
A constructor with many positional arguments is hard to read and easy to get wrong:
house = House(
"concrete", # foundation
"brick", # walls
"tile", # roof
4, # windows
2, # doors
True, # has_garage
False, # has_swimming_pool
True, # has_garden
False, # has_statues
3, # floors
5, # rooms
)
- What if you only want foundation, walls and roof?
- What does
true, false, true, falsemean at the call site? - It's easy to swap two arguments of the same type.
- Covering the combinations needs many overloaded constructors (telescoping constructors).
Setters fix readability but leave the object mutable and possibly half-built. Builder fixes both.
Structure#
The pattern has up to four participants:
| Participant | Role |
|---|---|
| Product | The complex object being built |
| Builder | Interface (or class) with one method per part |
| Concrete builder | Implements the steps and assembles the product |
| Director (optional) | Knows a fixed sequence of steps and drives a builder |
There are two common styles:
- Fluent builder (most common): a static inner class with chainable methods and a
build()method. No director. - Builder with director: a director owns the order of steps; different builders produce different representations.
Example 1: fluent builder#
Required fields go in the builder's constructor, optional ones get chainable methods, and build() validates:
from dataclasses import dataclass
from typing import Optional
# frozen=True: no attribute can change after construction (immutable)
@dataclass(frozen=True)
class Computer:
# Required
cpu: str
ram: str
# Optional
storage: str
gpu: Optional[str]
cooler: str
has_wifi: bool
has_bluetooth: bool
def __str__(self) -> str:
gpu = self.gpu or "integrated"
return (f"CPU={self.cpu} RAM={self.ram} storage={self.storage} "
f"GPU={gpu} cooler={self.cooler} "
f"wifi={self.has_wifi} bt={self.has_bluetooth}")
class Builder:
def __init__(self, cpu: str, ram: str):
# Required fields go in the builder's constructor
self._cpu = cpu
self._ram = ram
# Optional fields start with sensible defaults
self._storage = "256GB SSD"
self._gpu: Optional[str] = None
self._cooler = "Stock Cooler"
self._wifi = True
self._bluetooth = False
def with_storage(self, storage: str) -> "Computer.Builder":
self._storage = storage
return self
def with_gpu(self, gpu: str) -> "Computer.Builder":
self._gpu = gpu
return self
def with_cooler(self, cooler: str) -> "Computer.Builder":
self._cooler = cooler
return self
def with_wifi(self, on: bool) -> "Computer.Builder":
self._wifi = on
return self
def with_bluetooth(self, on: bool) -> "Computer.Builder":
self._bluetooth = on
return self
# Validate the combination before the object exists
def build(self) -> "Computer":
rtx = self._gpu is not None and "RTX" in self._gpu
if rtx and self._cooler != "Liquid Cooling":
raise ValueError("RTX GPU requires liquid cooling")
return Computer(self._cpu, self._ram, self._storage,
self._gpu, self._cooler,
self._wifi, self._bluetooth)
# Usage
gaming_pc = (Computer.Builder("Intel i9", "32GB")
.with_storage("2TB NVMe")
.with_gpu("RTX 4090")
.with_cooler("Liquid Cooling")
.with_bluetooth(True)
.build())
office_pc = (Computer.Builder("Intel i5", "16GB")
.with_storage("512GB SSD")
.build()) # defaults for everything else
print(office_pc)
Template#
- Product class with read-only fields (here a frozen dataclass).
- A nested
Builderclass with the same fields, required ones in its__init__and optional ones with defaults. - Chainable setters that
return self, andbuild()that validates and returns the product.
Example 2: a SQL query builder#
The same idea builds a SQL query string one clause at a time:
from typing import List, Optional
class SQLQuery:
def __init__(self, table: str, columns: List[str],
where_clauses: List[str], limit: Optional[int]):
# Instance attributes in __init__, never mutable class-level defaults
self.table = table
self.columns = columns
self.where_clauses = where_clauses
self.limit = limit
def __repr__(self) -> str:
cols = ", ".join(self.columns) if self.columns else "*"
sql = f"SELECT {cols} FROM {self.table}"
if self.where_clauses:
sql += " WHERE " + " AND ".join(self.where_clauses)
if self.limit is not None:
sql += f" LIMIT {self.limit}"
return sql
class Builder:
def __init__(self) -> None:
# Temporary state while the caller chains methods
self._table: Optional[str] = None
self._columns: List[str] = []
self._where: List[str] = []
self._limit: Optional[int] = None
def select(self, columns: List[str]) -> "SQLQuery.Builder":
self._columns = columns
return self
def from_table(self, table: str) -> "SQLQuery.Builder":
self._table = table
return self
def where(self, clause: str) -> "SQLQuery.Builder":
self._where.append(clause)
return self
def limit(self, n: int) -> "SQLQuery.Builder":
self._limit = n
return self
def build(self) -> "SQLQuery":
# build() validates AND returns the finished product
if self._table is None:
raise ValueError("No table selected")
return SQLQuery(self._table, self._columns,
list(self._where), self._limit)
query = (SQLQuery.Builder()
.select(["id", "name"])
.from_table("users")
.where("active = 1")
.limit(10)
.build())
print(query) # SELECT id, name FROM users WHERE active = 1 LIMIT 10
Example 3: builder with a director#
Use a director when the sequence of steps is fixed but you want different representations from it.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class House:
foundation: str = ""
structure: str = ""
roof: str = ""
windows: int = 0
has_garage: bool = False
has_pool: bool = False
has_garden: bool = False
class HouseBuilder(ABC):
def __init__(self) -> None:
self._house = House()
@abstractmethod
def build_foundation(self) -> None: ...
@abstractmethod
def build_structure(self) -> None: ...
@abstractmethod
def build_roof(self) -> None: ...
@abstractmethod
def build_windows(self) -> None: ...
@abstractmethod
def build_garage(self) -> None: ...
@abstractmethod
def build_pool(self) -> None: ...
@abstractmethod
def build_garden(self) -> None: ...
def get_house(self) -> House:
return self._house
class LuxuryHouseBuilder(HouseBuilder):
def build_foundation(self) -> None:
self._house.foundation = "Reinforced concrete with basement"
def build_structure(self) -> None:
self._house.structure = "Steel frame with brick"
def build_roof(self) -> None:
self._house.roof = "Solar panel tiles"
def build_windows(self) -> None:
self._house.windows = 20
def build_garage(self) -> None:
self._house.has_garage = True
def build_pool(self) -> None:
self._house.has_pool = True
def build_garden(self) -> None:
self._house.has_garden = True
class SimpleHouseBuilder(HouseBuilder):
def build_foundation(self) -> None:
self._house.foundation = "Concrete slab"
def build_structure(self) -> None:
self._house.structure = "Wood frame"
def build_roof(self) -> None:
self._house.roof = "Asphalt shingles"
def build_windows(self) -> None:
self._house.windows = 6
def build_garage(self) -> None:
self._house.has_garage = False
def build_pool(self) -> None:
self._house.has_pool = False
def build_garden(self) -> None:
self._house.has_garden = True
# The director owns the ORDER of steps; builders own HOW each is done
class ConstructionDirector:
def __init__(self, builder: HouseBuilder):
self.builder = builder
def construct_full_house(self) -> None:
b = self.builder
b.build_foundation()
b.build_structure()
b.build_roof()
b.build_windows()
b.build_garage()
b.build_pool()
b.build_garden()
def construct_basic_house(self) -> None:
b = self.builder
b.build_foundation()
b.build_structure()
b.build_roof()
b.build_windows()
b.build_garden() # skips garage and pool
# Usage
luxury = LuxuryHouseBuilder()
ConstructionDirector(luxury).construct_full_house()
mansion = luxury.get_house()
print(mansion)
Fluent builder vs director#
The two styles trade simplicity for a fixed, reusable step order:
| Aspect | Fluent builder | Builder with director |
|---|---|---|
| Complexity | Simple | More classes |
| Who controls the order | The client | The director |
| Best for | Many optional parameters | Fixed steps, several representations |
| Method chaining | Yes | Usually no |
| Validation | In build() |
In the director or builder |
| How common | The vast majority of modern code | Less common |
Real-world examples#
Builders are common wherever an object is assembled from many optional parts:
- SQLAlchemy Core builds statements step by step:
select(users).where(...).limit(10). - HTTP client request builders, and UI dialog builders on mobile platforms.
- Code-generated message builders (for example, Protocol Buffers in several languages).
- SQL query builders, document builders (PDF/HTML).
When to use it#
A builder is worth it when:
- More than 4–5 parameters, especially optional ones.
- Many valid combinations of configuration.
- You want immutable objects but still need flexible construction.
- Construction needs validation across fields before the object exists.
When not to use it#
Skip it when:
- Only 2–3 parameters, or all of them are required. A constructor is clearer.
- Mutable objects with simple setters are fine for your use case.
- In Python, keyword arguments with defaults (or a
dataclass) often remove the need for a builder entirely.
Pitfalls#
Watch for these mistakes:
- Duplicated fields. The builder mirrors the product's fields; keep them in sync.
- Forgetting validation.
build()is the one place to reject invalid combinations. - Reusing a builder. The builder is mutable even if the product isn't. Copy collections in
build()so later changes to the builder don't leak into products already built. - Builder vs setters. Setters let anyone change the object at any time and can leave it half-initialized; a builder hands out only complete, validated objects.
Builder vs other creational patterns#
Each creational pattern answers a different question:
| Pattern | Question it answers |
|---|---|
| Factory Method | Which type should be created? (one shot) |
| Builder | How is this one complex object assembled, step by step? |
| Prototype | Can I copy an existing object instead of building? |
| Singleton | How do I guarantee one instance? |
Key takeaways#
- Builder replaces telescoping constructors with named, chainable steps.
- Required fields go in the builder's constructor; optional ones get defaults.
build()validates and returns a finished, ideally immutable, object.- Use a director only when the step order is fixed and shared across representations.
- Don't use it for small objects; in Python, keyword arguments often suffice.