Skip to main content
Low-Level Design · lesson 11 of 18
Open the Low-Level Design page →
Low-Level Design intermediate Lesson 11 of 18

The Composite Pattern in Python: Sizing a File Tree Without isinstance

Watch an isinstance-based folder size silently miss a new node type, then fix it with the Composite pattern: one size() that files and folders share.

By SkillByExample · Published

lld design-patterns python composite recursion

A folder can hold files and other folders, to any depth. Code that walks such a tree has to decide, for every node, what to do with it, and that decision is where the bugs live.

Sizing a tree by asking “what are you?”

This version checks each node’s type. It worked until someone added zip archives to the tree.

# sizes_isinstance.py: the size function asks "file or folder?" and misses a new node type
class File:
    def __init__(self, name, size):
        self.name, self.size = name, size

class Folder:
    def __init__(self, name, children):
        self.name, self.children = name, children

class ZipArchive:
    """Added later: a compressed bundle that knows its own size on disk."""
    def __init__(self, name, packed_size):
        self.name, self.packed_size = name, packed_size

def total_size(node):
    if isinstance(node, File):
        return node.size
    if isinstance(node, Folder):
        return sum(total_size(child) for child in node.children)
    return 0                                  # anything else falls through silently

project = Folder("project", [
    File("README.md", 2_000),
    Folder("src", [File("app.py", 12_000), File("db.py", 8_000)]),
    ZipArchive("assets.zip", 500_000),
])
print("total bytes:", total_size(project))
$ python sizes_isinstance.py
total bytes: 22000

The real total is 522,000 bytes; the function reported 22,000. The archive matched neither isinstance check and fell into return 0. No error, no warning, and every other function that switches on node type has the same gap waiting.

Composite: every node answers size() itself

Files and folders share one interface. A file returns its own size, and a folder adds up its children’s, without knowing what kind of children they are.

# composite_size.py: every node answers size() itself, so nobody asks "leaf or branch?"
from abc import ABC, abstractmethod

class Node(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def size(self): ...

class File(Node):
    def __init__(self, name, size):
        super().__init__(name)
        self._size = size
    def size(self):
        return self._size

class Folder(Node):
    def __init__(self, name, children):
        super().__init__(name)
        self.children = children
    def size(self):
        return sum(child.size() for child in self.children)

class ZipArchive(Node):
    def __init__(self, name, packed_size):
        super().__init__(name)
        self._packed = packed_size
    def size(self):
        return self._packed

project = Folder("project", [
    File("README.md", 2_000),
    Folder("src", [File("app.py", 12_000), File("db.py", 8_000)]),
    ZipArchive("assets.zip", 500_000),
])
print("total bytes:", project.size())
print("src only:   ", project.children[1].size())

class Broken(Node):                           # a new type that forgets size()
    pass

try:
    Broken("oops")
except TypeError as e:
    print("caught:", e)
$ python composite_size.py
total bytes: 522000
src only:    20000
caught: Can't instantiate abstract class Broken without an implementation for abstract method 'size'

The archive is counted now, because it implements size() like everything else. Folder.size never names a type: it calls child.size() and lets each child decide. Any branch of the tree answers the same question, so src reports its own 20,000 bytes. The abstract base class catches the remaining risk: a new node type that forgets size() fails as soon as it’s created, with a TypeError that names the missing method.

The same recursion, a second operation

Once nodes share an interface, adding an operation is one method per class. Here show() prints the tree with sizes.

# composite_print.py: the same recursion prints the tree with each node's size
class File:
    def __init__(self, name, size):
        self.name, self._size = name, size
    def size(self):
        return self._size
    def show(self, depth=0):
        print(f"{'  ' * depth}{self.name} ({self.size():,} B)")

class Folder:
    def __init__(self, name, children):
        self.name, self.children = name, children
    def size(self):
        return sum(child.size() for child in self.children)
    def show(self, depth=0):
        print(f"{'  ' * depth}{self.name}/ ({self.size():,} B)")
        for child in self.children:
            child.show(depth + 1)

project = Folder("project", [
    File("README.md", 2_000),
    Folder("src", [File("app.py", 12_000), Folder("tests", [File("test_app.py", 3_500)])]),
])
project.show()
$ python composite_print.py
project/ (17,500 B)
  README.md (2,000 B)
  src/ (15,500 B)
    app.py (12,000 B)
    tests/ (3,500 B)
      test_app.py (3,500 B)

Each folder prints itself, then asks its children to print one level deeper. The indentation comes from depth, passed down the recursion; no code anywhere counts levels from the outside.

This is the trade-off to know about. Composite makes new node types cheap (one class) and new operations a little more expensive (a method on every class). If your tree’s types are fixed and you keep adding operations instead, the Visitor pattern flips that trade-off.

Practice

1. Add a count() method that returns how many files are in a tree. An empty folder contributes 0.
# p16_1.py: count() works the same way as size(): a file is 1, a folder sums its children
class File:
    def __init__(self, name, size):
        self.name, self._size = name, size
    def count(self):
        return 1

class Folder:
    def __init__(self, name, children):
        self.name, self.children = name, children
    def count(self):
        return sum(child.count() for child in self.children)

project = Folder("project", [
    File("README.md", 2_000),
    Folder("src", [File("app.py", 12_000), Folder("tests", [File("test_app.py", 3_500)])]),
    Folder("empty", []),
])
print("files:", project.count())
$ python p16_1.py
files: 3

sum() of an empty generator is 0, so the empty folder needs no special case.

2. Add find(suffix), returning the full paths of files whose names end with it.
# p16_2.py: find(suffix) returns matching paths; folders pass their own name down
class File:
    def __init__(self, name):
        self.name = name
    def find(self, suffix, prefix=""):
        path = prefix + self.name
        return [path] if path.endswith(suffix) else []

class Folder:
    def __init__(self, name, children):
        self.name, self.children = name, children
    def find(self, suffix, prefix=""):
        here = prefix + self.name + "/"
        return [p for child in self.children for p in child.find(suffix, here)]

project = Folder("project", [
    File("README.md"),
    Folder("src", [File("app.py"), File("db.py"), Folder("tests", [File("test_app.py")])]),
])
print(project.find(".py"))
$ python p16_2.py
['project/src/app.py', 'project/src/db.py', 'project/src/tests/test_app.py']

Each folder passes its own path down as the prefix, so files build their full path without any node knowing where it sits in the tree.

3. Add largest(), returning the biggest file below a node as (size, name). What should an empty folder return?
# p16_3.py: largest() returns the biggest file below a node as (size, name)
class File:
    def __init__(self, name, size):
        self.name, self._size = name, size
    def largest(self):
        return (self._size, self.name)

class Folder:
    def __init__(self, name, children):
        self.name, self.children = name, children
    def largest(self):
        found = [child.largest() for child in self.children]
        return max(found) if found else (0, None)

project = Folder("project", [
    File("README.md", 2_000),
    Folder("src", [File("app.py", 12_000), Folder("tests", [File("test_app.py", 3_500)])]),
    Folder("empty", []),
])
print(project.largest())
print(project.children[2].largest())
$ python p16_3.py
(12000, 'app.py')
(0, None)

Tuples compare by their first item, so max() picks the biggest size. An empty folder has nothing to compare, so it returns (0, None) rather than crashing on max([]).

4. Add depth(): 0 for a file, and 1 plus the deepest child for a folder.
# p16_4.py: depth() is 0 for a file and 1 + the deepest child for a folder
class File:
    def __init__(self, name):
        self.name = name
    def depth(self):
        return 0

class Folder:
    def __init__(self, name, children):
        self.name, self.children = name, children
    def depth(self):
        return 1 + max((child.depth() for child in self.children), default=0)

project = Folder("project", [
    File("README.md"),
    Folder("src", [File("app.py"), Folder("tests", [File("test_app.py")])]),
])
print("depth:", project.depth())
print("empty folder:", Folder("tmp", []).depth())
$ python p16_4.py
depth: 3
empty folder: 1

max(..., default=0) handles the empty folder, which is one level deep with nothing inside.

Frequently Asked Questions

What is the Composite design pattern?
Composite lets single objects and groups of objects share one interface, so code can treat a whole tree the same way as one leaf. In a file system, a file and a folder both answer size(): the file returns its own bytes, and the folder returns the sum of its children's size(). The caller never checks which kind of node it has.
Why is isinstance a warning sign in tree code?
A function that branches on isinstance has to list every node type it knows about, so adding a new type means finding and editing every such function. If one is missed, the new nodes usually fall into a default branch and give a wrong answer with no error. Putting the behaviour on each node class removes the list entirely.
How does an abstract base class help with the Composite pattern in Python?
Declaring size() with @abstractmethod on a Node base class makes Python refuse to create any subclass that doesn't implement it. A new node type that forgets size() then fails with a TypeError when it's created, instead of quietly returning a wrong total later. Duck typing works without the base class, but you lose that early check.
When is the Composite pattern a poor fit?
It fits data that really is a tree whose leaves and branches answer the same questions, such as files and folders, UI widgets or organisation charts. It's a poor fit when leaves and groups support very different operations, because the shared interface fills up with methods that half the classes can't meaningfully implement.