Flyweight Pattern — Deep Dive

Flyweight factory implementation

The factory ensures each unique combination of intrinsic state is created only once:

from dataclasses import dataclass


@dataclass(frozen=True)
class CharacterStyle:
    """Flyweight — shared intrinsic state."""
    font: str
    size: int
    color: str
    bold: bool = False
    italic: bool = False


class StyleFactory:
    _cache: dict[tuple, CharacterStyle] = {}

    @classmethod
    def get(cls, font: str, size: int, color: str,
            bold: bool = False, italic: bool = False) -> CharacterStyle:
        key = (font, size, color, bold, italic)
        if key not in cls._cache:
            cls._cache[key] = CharacterStyle(font, size, color, bold, italic)
        return cls._cache[key]

    @classmethod
    def cache_size(cls) -> int:
        return len(cls._cache)

A document with 100,000 characters but only 15 unique styles creates 15 CharacterStyle instances instead of 100,000:

# Extrinsic state — position and character value
class DocumentChar:
    __slots__ = ("char", "position", "style")

    def __init__(self, char: str, position: int, style: CharacterStyle):
        self.char = char
        self.position = position
        self.style = style  # shared flyweight reference


# Building a document
factory = StyleFactory()
body_style = factory.get("Helvetica", 12, "#333333")
heading_style = factory.get("Helvetica", 24, "#000000", bold=True)

chars = []
for i, c in enumerate("Hello World"):
    style = heading_style if i == 0 else body_style
    chars.append(DocumentChar(c, i, style))

# Only 2 style objects exist, regardless of document length
print(f"Styles in cache: {factory.cache_size()}")  # 2

Memory savings with __slots__

__slots__ eliminates the per-instance __dict__, which saves 56-112 bytes per object depending on Python version and platform:

import sys

class WithDict:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

class WithSlots:
    __slots__ = ("x", "y", "z")
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

d = WithDict(1, 2, 3)
s = WithSlots(1, 2, 3)

print(sys.getsizeof(d) + sys.getsizeof(d.__dict__))  # ~200 bytes
print(sys.getsizeof(s))                                # ~72 bytes

At a million instances, that’s ~128MB saved. Combined with flyweight sharing of common attributes, the savings compound.

Flyweight with __new__ for automatic interning

Override __new__ to make the class itself act as a flyweight factory:

class Color:
    """Interned color objects — same RGB values always return same instance."""
    __slots__ = ("r", "g", "b", "_hash")
    _cache: dict[tuple[int, int, int], "Color"] = {}

    def __new__(cls, r: int, g: int, b: int) -> "Color":
        key = (r, g, b)
        if key in cls._cache:
            return cls._cache[key]
        instance = super().__new__(cls)
        instance.r = r
        instance.g = g
        instance.b = b
        instance._hash = hash(key)
        cls._cache[key] = instance
        return instance

    def __hash__(self) -> int:
        return self._hash

    def __eq__(self, other) -> bool:
        if not isinstance(other, Color):
            return NotImplemented
        return self.r == other.r and self.g == other.g and self.b == other.b

    def __repr__(self) -> str:
        return f"Color({self.r}, {self.g}, {self.b})"


# Same values → same object
red1 = Color(255, 0, 0)
red2 = Color(255, 0, 0)
assert red1 is red2  # True — same instance

This is exactly how Python interns small integers internally.

Weakref-based flyweight cache

A problem with flyweight caches: they keep objects alive forever, even after all external references are gone. Use weakref.WeakValueDictionary to let the garbage collector reclaim unused flyweights:

import weakref
from dataclasses import dataclass


@dataclass(frozen=True)
class Texture:
    name: str
    resolution: tuple[int, int]
    data: bytes  # large payload

    def __hash__(self):
        return hash((self.name, self.resolution))


class TexturePool:
    _pool: weakref.WeakValueDictionary[str, Texture] = weakref.WeakValueDictionary()

    @classmethod
    def get(cls, name: str, resolution: tuple[int, int], loader) -> Texture:
        key = f"{name}_{resolution[0]}x{resolution[1]}"
        texture = cls._pool.get(key)
        if texture is None:
            data = loader(name, resolution)
            texture = Texture(name, resolution, data)
            cls._pool[key] = texture
        return texture

When no game entity references a texture anymore, the garbage collector frees it. If it’s needed again, the pool reloads it. This balances memory conservation with automatic cleanup.

Flyweight vs array-of-structs

For numerical data, the flyweight pattern is often inferior to a columnar layout:

import numpy as np

# Flyweight approach — Python objects
class Particle:
    __slots__ = ("x", "y", "type_id")
    def __init__(self, x, y, type_id):
        self.x = x
        self.y = y
        self.type_id = type_id

# 1 million particles ≈ 72 bytes × 1M = ~72MB

# Columnar approach — NumPy arrays
xs = np.random.rand(1_000_000).astype(np.float32)      # 4MB
ys = np.random.rand(1_000_000).astype(np.float32)      # 4MB
types = np.random.randint(0, 10, 1_000_000, dtype=np.uint8)  # 1MB
# Total: ~9MB + vectorized operations

When all your objects are homogeneous (same fields, numerical data), NumPy or struct arrays beat flyweights. Flyweights shine when objects are heterogeneous or when you’re working with business logic objects, not raw numbers.

Real-world flyweights in Python

sys.intern()

Explicitly interns strings. Two interned strings with the same value are the same object, saving memory and enabling is comparison instead of ==.

Python’s type objects

Every integer 42 in your code points to the same int type object. The type itself is a shared flyweight.

enum.Enum

Enum members are singletons — Color.RED is Color.RED is always True. Each unique value exists once, making enums natural flyweights.

Django’s ContentType framework

Content types are cached in memory. Multiple queries for the same content type return the same cached instance.

Memory profiling to validate savings

Use tracemalloc to measure before and after:

import tracemalloc

tracemalloc.start()

# Without flyweight
particles_naive = [
    {"type": "fire", "color": (255, 100, 0), "x": i, "y": i * 2}
    for i in range(100_000)
]

snapshot1 = tracemalloc.take_snapshot()

# Reset
del particles_naive
tracemalloc.clear_traces()

# With flyweight
fire_type = ("fire", (255, 100, 0))  # shared

class FlyParticle:
    __slots__ = ("type_info", "x", "y")
    def __init__(self, type_info, x, y):
        self.type_info = type_info
        self.x = x
        self.y = y

particles_fly = [FlyParticle(fire_type, i, i * 2) for i in range(100_000)]

snapshot2 = tracemalloc.take_snapshot()

Always profile before applying the pattern. Premature optimization with flyweights adds complexity without guaranteed benefit.

Tradeoffs

AdvantageDisadvantage
Dramatic memory savings at scaleAdded complexity in state management
Shared immutable state is thread-safeExtrinsic state must be passed around
Faster equality checks via identity (is)Cache management (eviction, weak refs)
Pairs well with __slots__Not useful for objects with mostly unique state

The one thing to remember: Flyweight works by splitting state into shared (intrinsic) and per-instance (extrinsic) parts — combine it with __slots__, frozen dataclasses, and weakref caches to handle millions of similar objects without exhausting memory.

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.