Creating complex objects can be messy. You’ve probably written constructors with lots of parameters, struggled with optional arguments, or created objects that require multiple setup steps. The builder pattern solves these problems by separating object construction from representation.
In this tutorial, I show you how to implement the Builder pattern in Python. I’ll also explain why it’s useful, and show practical examples you can use in your own projects.
You can find the code On GitHub.
Conditions
Before we begin, make sure you have:
Python 3.10 or higher is installed
Understanding of Python classes and methods
Acquaintance with Object oriented programming (OOP) concepts
Let’s begin!
Table of Contents
Understanding the Builder Style
The Builder pattern solves the problem of building complex objects. Instead of funneling all the construction logic into the constructor, you create a separate constructor class that constructs the object incrementally..
Consider creating a SQL query. Could be a simple question SELECT * FROM usersbut there are mostly questions WHERE clauses, JOINS, ORDER BYfor , for , for , . GROUP BYand LIMIT clauses you can do Pass them all as constructor parameters, but it gets faster. The builder pattern breaks you down piece by piece.
The pattern separates two concerns: What should be the final product (product) and how to build it (builder). This separation gives you flexibility because you can now have multiple builders that create the same type of object in different ways, or one builder that creates different variations.
Python code is simpler and more flexible in that, which means we can implement builders more elegantly than in languages ​​like Java or C++. We will explore both traditional and Python methods.
Problem: Complex object construction
Let’s start with an issue that demonstrates why builders are useful. We’ll construct an HTTP request – something complex enough to be heavily weighted without pattern values.
class HTTPRequest:
def __init__(self, url, method="GET", headers=None, body=None,
timeout=30, auth=None, verify_ssl=True, allow_redirects=True,
max_redirects=5, cookies=None, proxies=None):
self.url = url
self.method = method
self.headers = headers or {}
self.body = body
self.timeout = timeout
self.auth = auth
self.verify_ssl = verify_ssl
self.allow_redirects = allow_redirects
self.max_redirects = max_redirects
self.cookies = cookies or {}
self.proxies = proxies or {}
request = HTTPRequest(
"",
method="POST",
headers={"Content-Type": "application/json"},
body='{"name": "John"}',
timeout=60,
auth=("username", "password"),
verify_ssl=True,
allow_redirects=False,
max_redirects=0,
cookies={"session": "abc123"},
proxies={"http": "proxy.example.com"}
)
print(f"Request to: {request.url}")
print(f"Method: {request.method}")
print(f"Timeout: {request.timeout}s")
Output:
Request to:
Method: POST
Timeout: 60s
This constructor is difficult to use. You need to remember the parameter order, pass None For things you don’t want, and it’s not clear what the defaults are. When creating an application, you cannot tell which parameters are required without checking the documentation. This is where the builder pattern comes in handy.
Implementing the Basic Builder pattern
Let’s rebuild it using the builder pattern. Builder provides ways to set each property, making construction clear and readable.
First, we define the Product class, which we want to target.
class HTTPRequest:
"""The product - what we're building"""
def __init__(self, url):
self.url = url
self.method = "GET"
self.headers = {}
self.body = None
self.timeout = 30
self.auth = None
self.verify_ssl = True
self.allow_redirects = True
self.max_redirects = 5
self.cookies = {}
self.proxies = {}
def execute(self):
"""Simulate executing the request"""
auth_str = f" (auth: {self.auth(0)})" if self.auth else ""
return f"{self.method} {self.url}{auth_str} - timeout: {self.timeout}s"
Now we create the builder class. Each method modifies the request and returns itself to enable procedure chaining.
class HTTPRequestBuilder:
"""The builder - constructs HTTPRequest step by step"""
def __init__(self, url):
self._request = HTTPRequest(url)
def method(self, method):
"""Set HTTP method (GET, POST, etc.)"""
self._request.method = method.upper()
return self
def header(self, key, value):
"""Add a header"""
self._request.headers(key) = value
return self
def headers(self, headers_dict):
"""Add multiple headers at once"""
self._request.headers.update(headers_dict)
return self
def body(self, body):
"""Set request body"""
self._request.body = body
return self
def timeout(self, seconds):
"""Set timeout in seconds"""
self._request.timeout = seconds
return self
def auth(self, username, password):
"""Set basic authentication"""
self._request.auth = (username, password)
return self
def disable_ssl_verification(self):
"""Disable SSL certificate verification"""
self._request.verify_ssl = False
return self
def disable_redirects(self):
"""Disable automatic redirects"""
self._request.allow_redirects = False
self._request.max_redirects = 0
return self
def build(self):
"""Return the constructed request"""
return self._request
Let’s use the builder to create an application:
request = (HTTPRequestBuilder("")
.method("POST")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body('{"name": "John", "email": "john@example.com"}')
.timeout(60)
.auth("username", "password")
.disable_redirects()
.build())
print(request.execute())
print(f"\nHeaders: {request.headers}")
print(f"SSL verification: {request.verify_ssl}")
print(f"Allow redirects: {request.allow_redirects}")
Output:
Headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}
SSL verification: True
Allow redirects: False
Builder makes construction very clear. Each method describes what it does, and method chains create a fluent interface that reads like English. You just specify what you need – everything else becomes sensible defaults. The construction process is clear and self-documenting.
Note that every constructor method returns self. This enables method chaining where you can call multiple methods in sequence. The final build() The method returns the constructed object. This separation between the building and the final product is the core of the pattern.
Another helpful example: SQL Query Builder
Let’s build something more useful and helps us understand how the pattern works: a SQL query builder. It’s a practical tool that you can really use in projects.
First, we define the SQL query product class:
class SQLQuery:
"""The product - represents a SQL query"""
def __init__(self):
self.select_columns = ()
self.from_table = None
self.joins = ()
self.where_conditions = ()
self.group_by_columns = ()
self.having_conditions = ()
self.order_by_columns = ()
self.limit_value = None
self.offset_value = None
def to_sql(self):
"""Convert the query object to SQL string"""
if not self.from_table:
raise ValueError("FROM clause is required")
columns = ", ".join(self.select_columns) if self.select_columns else "*"
sql = f"SELECT {columns}"
sql += f"\nFROM {self.from_table}"
for join in self.joins:
sql += f"\n{join}"
if self.where_conditions:
conditions = " AND ".join(self.where_conditions)
sql += f"\nWHERE {conditions}"
if self.group_by_columns:
columns = ", ".join(self.group_by_columns)
sql += f"\nGROUP BY {columns}"
if self.having_conditions:
conditions = " AND ".join(self.having_conditions)
sql += f"\nHAVING {conditions}"
if self.order_by_columns:
columns = ", ".join(self.order_by_columns)
sql += f"\nORDER BY {columns}"
if self.limit_value:
sql += f"\nLIMIT {self.limit_value}"
if self.offset_value:
sql += f"\nOFFSET {self.offset_value}"
return sql
Now we create a query builder with methods for each SQL clause:
class QueryBuilder:
"""Builder for SQL queries"""
def __init__(self):
self._query = SQLQuery()
def select(self, *columns):
"""Add columns to SELECT clause"""
self._query.select_columns.extend(columns)
return self
def from_table(self, table):
"""Set the FROM table"""
self._query.from_table = table
return self
def join(self, table, on_condition, join_type="INNER"):
"""Add a JOIN clause"""
join_clause = f"{join_type} JOIN {table} ON {on_condition}"
self._query.joins.append(join_clause)
return self
def left_join(self, table, on_condition):
"""Convenience method for LEFT JOIN"""
return self.join(table, on_condition, "LEFT")
def where(self, condition):
"""Add a WHERE condition"""
self._query.where_conditions.append(condition)
return self
def group_by(self, *columns):
"""Add GROUP BY columns"""
self._query.group_by_columns.extend(columns)
return self
def having(self, condition):
"""Add a HAVING condition"""
self._query.having_conditions.append(condition)
return self
def order_by(self, *columns):
"""Add ORDER BY columns"""
self._query.order_by_columns.extend(columns)
return self
def limit(self, value):
"""Set LIMIT"""
self._query.limit_value = value
return self
def offset(self, value):
"""Set OFFSET"""
self._query.offset_value = value
return self
def build(self):
"""Return the constructed query"""
return self._query
Let’s use the builder to generate queries:
simple_query = (QueryBuilder()
.select("id", "name", "email")
.from_table("users")
.where("status="active"")
.order_by("name")
.limit(10)
.build())
print("Simple Query:")
print(simple_query.to_sql())
Output:
Simple Query:
SELECT id, name, email
FROM users
WHERE status="active"
ORDER BY name
LIMIT 10
Join now and perform a more complex query with aggregates:
complex_query = (QueryBuilder()
.select("u.name", "COUNT(o.id) as order_count", "SUM(o.total) as total_spent")
.from_table("users u")
.left_join("orders o", "u.id = o.user_id")
.where("u.created_at >= '2024-01-01'")
.where("u.country = 'US'")
.group_by("u.id", "u.name")
.having("COUNT(o.id) > 5")
.order_by("total_spent DESC")
.limit(20)
.build())
print("Complex Query:")
print(complex_query.to_sql())
Output:
Complex Query:
SELECT u.name, COUNT(o.id) as order_count, SUM(o.total) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2024-01-01' AND u.country = 'US'
GROUP BY u.id, u.name
HAVING COUNT(o.id) > 5
ORDER BY total_spent DESC
LIMIT 20
This SQL Builder demonstrates that the builder pattern is useful. Constructing SQL queries is programmatically complex. There are many optional clauses that must appear in a specific order. Builder handles all this complexity, giving you a clean API that prevents errors like putting WHERE After GROUP BY.
The builder ensures that you can’t create invalid queries (like forgetting FROM clause) while keeping the API flexible. You can chain methods in any order during construction, and to_sql() The method handles ordering the clauses correctly. This separation of construction from representation is exactly what the Builder pattern provides.
Validation and error handling
Good architects validate data during construction. Let’s improve our HTTP request builder with validation.
class HTTPRequestBuilder:
"""Enhanced builder with validation"""
VALID_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
def __init__(self, url):
if not url:
raise ValueError("URL cannot be empty")
if not url.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
self._request = HTTPRequest(url)
def method(self, method):
"""Set HTTP method with validation"""
method = method.upper()
if method not in self.VALID_METHODS:
raise ValueError(f"Invalid HTTP method: {method}")
self._request.method = method
return self
def timeout(self, seconds):
"""Set timeout with validation"""
if seconds <= 0:
raise ValueError("Timeout must be positive")
if seconds > 300:
raise ValueError("Timeout cannot exceed 300 seconds")
self._request.timeout = seconds
return self
def header(self, key, value):
"""Add header with validation"""
if not key or not value:
raise ValueError("Header key and value cannot be empty")
self._request.headers(key) = value
return self
def body(self, body):
"""Set request body"""
self._request.body = body
return self
def build(self):
"""Validate and return the request"""
if self._request.method in {"POST", "PUT", "PATCH"} and not self._request.body:
raise ValueError(f"{self._request.method} requests typically require a body")
return self._request
Now check the validation:
try:
valid_request = (HTTPRequestBuilder("https://api.example.com/data")
.method("POST")
.body('{"key": "value"}')
.timeout(45)
.build())
print("✓ Valid request created successfully")
except ValueError as e:
print(f"✗ Error: {e}")
try:
invalid_request = (HTTPRequestBuilder("https://api.example.com/data")
.method("INVALID")
.build())
except ValueError as e:
print(f"✓ Caught error: {e}")
try:
invalid_request = (HTTPRequestBuilder("https://api.example.com/data")
.method("POST")
.build())
except ValueError as e:
print(f"✓ Caught error: {e}")
Output:
✓ Valid request created successfully
✓ Caught error: Invalid HTTP method: INVALID
✓ Caught error: POST requests typically require a body
Validation in Builder catches initial errors during construction, rather than execution time. This is much better than discovering problems when you try to use the object. The builder becomes a gatekeeper that ensures only the correct objects are built.
Each builder method immediately validates its input. The final build() The method performs cross-field validation, which requires checking multiple features simultaneously. This layered validation approach catches errors at the most appropriate point.
Python Builder Sample
Python’s flexibility allows for more comprehensive builder implementations. Here’s a Python version using the keyword arguments (**kwargs) and context managers.
First, let’s define our EmailMessage class:
class EmailMessage:
"""Email message with builder pattern using kwargs"""
def __init__(self, **kwargs):
self.to = kwargs.get('to', ())
self.cc = kwargs.get('cc', ())
self.bcc = kwargs.get('bcc', ())
self.subject = kwargs.get('subject', '')
self.body = kwargs.get('body', '')
self.attachments = kwargs.get('attachments', ())
self.priority = kwargs.get('priority', 'normal')
def send(self):
"""Simulate sending the email"""
recipients = len(self.to) + len(self.cc) + len(self.bcc)
attachments = f" with {len(self.attachments)} attachment(s)" if self.attachments else ""
return f"Sending '{self.subject}' to {recipients} recipient(s){attachments}"
Now we create a builder that collects the parameters:
class EmailBuilder:
"""Pythonic email builder"""
def __init__(self):
self._params = {}
def to(self, *addresses):
"""Add TO recipients"""
self._params.setdefault('to', ()).extend(addresses)
return self
def cc(self, *addresses):
"""Add CC recipients"""
self._params.setdefault('cc', ()).extend(addresses)
return self
def subject(self, subject):
"""Set email subject"""
self._params('subject') = subject
return self
def body(self, body):
"""Set email body"""
self._params('body') = body
return self
def attach(self, *files):
"""Attach files"""
self._params.setdefault('attachments', ()).extend(files)
return self
def priority(self, level):
"""Set priority (low, normal, high)"""
if level not in ('low', 'normal', 'high'):
raise ValueError("Priority must be low, normal, or high")
self._params('priority') = level
return self
def build(self):
"""Build the email message"""
if not self._params.get('to'):
raise ValueError("At least one recipient is required")
if not self._params.get('subject'):
raise ValueError("Subject is required")
return EmailMessage(**self._params)
Let’s use it to create and send an email:
email = (EmailBuilder()
.to("alice@example.com", "bob@example.com")
.cc("manager@example.com")
.subject("Q4 Sales Report")
.body("Please find the Q4 sales report attached.")
.attach("q4_report.pdf", "sales_data.xlsx")
.priority("high")
.build())
print(email.send())
print(f"To: {email.to}")
print(f"CC: {email.cc}")
print(f"Priority: {email.priority}")
print(f"Attachments: {email.attachments}")
Output:
Sending 'Q4 Sales Report' to 3 recipient(s) with 2 attachment(s)
To: ('alice@example.com', 'bob@example.com')
CC: ('manager@example.com')
Priority: high
Attachments: ('q4_report.pdf', 'sales_data.xlsx')
It uses the Python version **kwargs To pass parameters to the product, making the builder more flexible. The builder collects the parameters in a dictionary and passes them all together build(). This approach is neat for Python.
The key here is that Python doesn’t require the boilerplate code that other languages ​​require. We can achieve the same benefits with less boilerplate while still maintaining the core benefits of the Builder pattern: readable construction, validation, and separation of concerns.
When using the Builder pattern
The builder pattern is useful in certain situations. Understanding when to use it helps you avoid over-engineering.
When using the builder pattern:
You are creating objects with many optional parameters. If your constructor has more than 3-4 parameters, especially if many are optional, consider a builder. This pattern makes the construction clear and self-documenting.
Building an object requires multiple steps or a specific order. If you need to arrange an object through multiple method calls in a particular order, a builder can implement and simplify the process.
You need to create different variations of an item. Builders can create different representations of the same type, such as different SQL query types or different HTTP request configurations.
However, don’t use builders when:
Your stuff is simple. If a regular constructor with 2-3 parameters works fine, don’t add constructor complexity. Python’s keyword arguments already make simple construction readable.
You are just setting the attributes. Python objects can have direct set properties. If there is no validation or complex construction logic, a builder adds unnecessary complexity.
Patterns are useful for complex layout objects, query builders, document generators, or anything that requires careful step-by-step construction. For simple data containers, stick with straightforward constructors.
The result
I hope you find this tutorial useful. The Builder pattern separates object construction from representation, making complex objects easier to create and maintain. You’ll learn how to implement builders from the traditional approach to more Python traditional approaches using the language’s dynamic features.
Remember that the builder pattern is a tool, not a requirement. Use it when the construction is really complex and the clarity of the pattern increases. For simple objects, Python’s flexibility provides a simple solution. Choose the right tool for your specific problem, and you’ll write clearer, more maintainable code.
Happy coding!