A constructor call that keeps growing conditionals around it:
conditions, params = [], []
if region:
conditions.append("region = ?")
params.append(region)
if product:
conditions.append("product = ?")
params.append(product)
sql = "SELECT ... " + (" AND ".join(conditions) if conditions else "") # and so onEvery call site re-implements the assembly rules — clause ordering, the conditions/params zip, edge cases — and any of them can drift. The builder owns those rules once.
- Define the product as a frozen dataclass. Immutability is the payoff: a finished product cannot be half-edited later, and it is safely shareable.
- Give the builder the product's invariants as constructor arguments — what every product must have (the table). Everything optional becomes a step.
- Write each step to validate, accumulate, and
return self. Validate in the step, so an error points at the faulty call, not atbuild(). - Make
build()a snapshot, converting accumulated lists to tuples. The builder stays usable; products built earlier stay untouched. - Keep the builder dumb about execution. It emits a product; running it
(here: handing
sql()/paramsto sqlite) is someone else's job.
from patterns.creational.builder import SelectBuilder
builder = SelectBuilder("orders").columns("id", "amount")
if minimum is not None:
builder.where("amount >= ?", minimum) # staged: only when asked for
query = builder.order_by("id").build()
rows = conn.execute(query.sql(), query.params)- Try keyword arguments first. If every caller can supply everything in
one call,
Query(table=..., columns=...)needs no builder at all. return selfchaining reads fluently, but each step working as a statement too (builder.where(...)on its own line) keeps conditional assembly natural.- Frozen product, plain-list builder — the two-type split is the whole
discipline; resist a
mutable=Falseflag on one class. - Parameters ride with the query. Bundling
sql()andparamsin the product keeps values out of the SQL text — injection discipline for free.
- The half-built object escaping. If code can grab the builder's state
before
build(), the "finished" guarantee is gone — keep accumulators private. - Validation hoarded in
build(). Failing there points at the wrong line; validate in the step that received the bad input. - A Director class. The caller's own code walking the steps is the director; a class for it is imported ceremony.
- Builder reuse surprises. Decide whether the builder may keep growing
after
build()(this one may) and pin it in a test either way.
examples/sql_select_builder/ stages
three analytics queries — including a conditionally-narrowed one — and runs
them against a real in-memory sqlite database:
uv run python -m patterns.creational.builder.examples.sql_select_builder.main