Method Chaining Pattern — Core Concepts

What Is Method Chaining?

Method chaining is a pattern where each method on an object returns the object itself (or a new modified copy), allowing multiple method calls to be linked in a single expression:

result = (
    QueryBuilder("users")
    .where("age", ">", 18)
    .where("active", "=", True)
    .order_by("name")
    .limit(10)
    .execute()
)

Each method returns the builder object, enabling the next call in the chain. The final method (execute) returns the actual result.

The Two Flavors

Mutable Chaining (return self)

Each method modifies the object in place and returns it:

class QueryBuilder:
    def __init__(self, table):
        self.table = table
        self.conditions = []
        self._order = None
        self._limit = None

    def where(self, column, op, value):
        self.conditions.append((column, op, value))
        return self  # Return self for chaining

    def order_by(self, column):
        self._order = column
        return self

    def limit(self, n):
        self._limit = n
        return self

Pros: Simple, no extra objects created. Cons: The original object is mutated. You can’t branch a chain.

Immutable Chaining (return a new copy)

Each method returns a new object with the modification applied:

from copy import copy

class Query:
    def __init__(self, table):
        self.table = table
        self.conditions = []
        self._order = None

    def where(self, column, op, value):
        new = copy(self)
        new.conditions = self.conditions + [(column, op, value)]
        return new  # Return a NEW object

    def order_by(self, column):
        new = copy(self)
        new._order = column
        return new

Pros: Safe to branch. The original query is never modified. Cons: Creates more objects. Slightly more complex to implement.

Django’s QuerySet uses immutable chaining — queryset.filter(...) returns a new QuerySet, leaving the original untouched.

Where You See It in Python

LibraryChaining StyleExample
Django ORMImmutableUser.objects.filter().exclude().order_by()
SQLAlchemyImmutablesession.query(User).filter().limit()
pandasMixeddf.dropna().sort_values().head()
requestsMutable (Session)session.headers.update(...)
pathlibImmutablePath("/") / "home" / "user"
PolarsImmutable (lazy)df.lazy().filter().select().collect()

Design Guidelines

When to Use Chaining

  • Configuration objects: Building complex configurations step by step.
  • Query builders: Constructing database or API queries.
  • Pipeline APIs: Data transformation steps.
  • Test fixtures: Setting up test objects with many optional properties.

When NOT to Use Chaining

  • When methods have important return values. If add_item() should return the added item, don’t make it return self.
  • When operations can fail. Chained methods make error handling awkward — where do you put the try/except?
  • When it hurts readability. A chain of 15 methods is harder to debug than 15 separate statements.

Python’s Stance on Chaining

Python’s standard library generally avoids method chaining. list.append() returns None, not the list. dict.update() returns None. This is deliberate — Python values explicit mutation over fluent interfaces.

Guido van Rossum has stated that returning self from mutating methods “would encourage a style that’s not Pythonic.” The idea is that when a method mutates its object, the None return signals “this was a side effect.”

That said, many popular third-party libraries (Django, pandas, SQLAlchemy) use chaining successfully. The pattern is most natural for builder and query objects where the chain constructs something new rather than mutating in place.

Common Misconception

“Method chaining is just about fewer lines of code.” It’s really about creating a domain-specific language (DSL). A well-designed chainable API reads like a sentence describing what you want, not a sequence of commands. The goal is expressiveness, not brevity.

Debugging Chains

The main downside of chaining is debugging. When a chain fails, you can’t easily inspect intermediate states. Strategies:

  1. Break the chain into separate variables during debugging.
  2. Add a .debug() method that prints state and returns self.
  3. Use logging inside chain methods to trace execution.
class Builder:
    def debug(self, label=""):
        print(f"[DEBUG {label}] {self.__dict__}")
        return self  # Chain continues

# Usage during debugging:
result = builder.where(...).debug("after where").order_by(...).debug("after sort")

One thing to remember: Method chaining creates fluent, readable APIs by having each method return the object (or a copy). Use mutable chaining for simple builders, immutable chaining for safety, and always consider whether the pattern serves readability or just saves lines.

pythondesign-patternsoop

See Also

  • Python Adapter Pattern How Python's Adapter Pattern works like a travel power plug — making incompatible things work together.
  • Python Bridge Pattern Why separating what something does from how it does it keeps your Python code from becoming a tangled mess.
  • Python Builder Pattern Why building complex Python objects step by step beats cramming everything into one giant constructor.
  • Python Composite Pattern How the Composite Pattern lets you treat a group of things the same way you'd treat a single thing in Python.
  • Python Facade Pattern How the Facade Pattern gives you one simple button instead of a confusing control panel in Python.