Flyweight Pattern in Python: Intrinsic vs Extrinsic State

The Flyweight pattern is a structural design pattern focused on one thing: reducing memory consumption by sharing common state between multiple objects. When your application creates thousands or...

Key Insights

  • The Flyweight pattern separates intrinsic state (shared, immutable data stored in flyweight objects) from extrinsic state (context-specific data passed by clients), enabling massive memory savings when dealing with thousands of similar objects.
  • Python offers multiple implementation approaches—from traditional factory classes to functools.lru_cache decorators—each with different trade-offs for complexity and control.
  • Before implementing Flyweight, always measure your actual memory usage; the pattern adds complexity that’s only justified when you’re creating thousands of similar objects with substantial shared state.

Introduction to the Flyweight Pattern

The Flyweight pattern is a structural design pattern focused on one thing: reducing memory consumption by sharing common state between multiple objects. When your application creates thousands or millions of similar objects, storing redundant data in each instance becomes wasteful. Flyweight solves this by extracting shared state into a smaller set of reusable objects.

The Gang of Four introduced this pattern in their seminal 1994 book, drawing the name from boxing’s lightest weight class. The metaphor fits—flyweight objects are stripped down to their essential shared data, making them as lightweight as possible.

You should consider Flyweight when:

  • Your application creates a large number of similar objects
  • Memory costs are a genuine concern
  • Most object state can be made extrinsic (passed in from outside)
  • Object identity isn’t important for your use case

Classic examples include text editors (sharing character glyphs), game engines (sharing tree or particle sprites), and caching systems.

Understanding Intrinsic vs Extrinsic State

The core insight of Flyweight is recognizing that object state falls into two categories:

Intrinsic state is the data that’s constant across all contexts where the object is used. It’s immutable, shareable, and stored inside the flyweight object itself. Think of a character’s font family and style—the letter “A” in Arial Bold looks the same wherever it appears.

Extrinsic state is context-dependent data that varies with each usage. It’s passed to the flyweight by the client code rather than stored internally. For that same “A” character, its position on the page, color, or size might vary—that’s extrinsic.

Here’s a simple example showing both state types:

class Character:
    """Flyweight object storing intrinsic state only."""
    
    def __init__(self, char: str, font_family: str, font_style: str):
        # Intrinsic state - shared across all usages
        self.char = char
        self.font_family = font_family
        self.font_style = font_style
    
    def render(self, x: int, y: int, size: int, color: str) -> str:
        """Render using extrinsic state passed by client."""
        # x, y, size, color are extrinsic - different for each usage
        return (
            f"Rendering '{self.char}' in {self.font_family} {self.font_style} "
            f"at ({x}, {y}), size {size}, color {color}"
        )


# Same flyweight used in different contexts
char_a = Character("A", "Arial", "Bold")

# Different extrinsic state for each render
print(char_a.render(10, 20, 12, "black"))
print(char_a.render(50, 20, 14, "red"))

The Character object stores only the intrinsic state. Position, size, and color are passed in at render time, allowing one Character instance to serve multiple locations in a document.

Implementing the Flyweight Factory

The factory is responsible for creating and managing flyweight instances. It ensures that when you request a flyweight with specific intrinsic state, you get an existing instance if one exists, or a new one if it doesn’t.

from typing import Dict, Tuple

class CharacterFactory:
    """Factory that manages flyweight Character instances."""
    
    _cache: Dict[Tuple[str, str, str], Character] = {}
    
    @classmethod
    def get_character(cls, char: str, font_family: str, font_style: str) -> Character:
        """Return existing flyweight or create new one."""
        key = (char, font_family, font_style)
        
        if key not in cls._cache:
            cls._cache[key] = Character(char, font_family, font_style)
            print(f"Created new flyweight for {key}")
        else:
            print(f"Reusing existing flyweight for {key}")
        
        return cls._cache[key]
    
    @classmethod
    def get_flyweight_count(cls) -> int:
        """Return number of flyweights in cache."""
        return len(cls._cache)


# Usage
factory = CharacterFactory()
a1 = factory.get_character("A", "Arial", "Bold")
a2 = factory.get_character("A", "Arial", "Bold")  # Reuses a1
b1 = factory.get_character("B", "Arial", "Bold")

print(f"a1 is a2: {a1 is a2}")  # True - same instance
print(f"Total flyweights: {factory.get_flyweight_count()}")  # 2

The factory uses a dictionary keyed by the intrinsic state tuple. This ensures object identity—requesting the same intrinsic state always returns the exact same object.

Practical Example: Text Editor Character Rendering

Let’s build a more complete example simulating a text editor that renders documents:

from dataclasses import dataclass
from typing import List, Tuple

@dataclass(frozen=True)
class CharacterFlyweight:
    """Immutable flyweight storing character glyph data."""
    char: str
    font_family: str
    font_weight: str
    
    def render(self, x: int, y: int, size: int) -> None:
        """Render character at given position with given size."""
        # In reality, this would draw to a canvas
        pass


class GlyphFactory:
    _glyphs: dict = {}
    
    @classmethod
    def get_glyph(cls, char: str, font_family: str, font_weight: str) -> CharacterFlyweight:
        key = (char, font_family, font_weight)
        if key not in cls._glyphs:
            cls._glyphs[key] = CharacterFlyweight(char, font_family, font_weight)
        return cls._glyphs[key]
    
    @classmethod
    def cache_size(cls) -> int:
        return len(cls._glyphs)


@dataclass
class CharacterInstance:
    """Represents a character in the document with its extrinsic state."""
    flyweight: CharacterFlyweight
    x: int
    y: int
    size: int
    
    def render(self) -> None:
        self.flyweight.render(self.x, self.y, self.size)


class Document:
    def __init__(self, font_family: str = "Arial", font_weight: str = "Regular"):
        self.characters: List[CharacterInstance] = []
        self.font_family = font_family
        self.font_weight = font_weight
        self.cursor_x = 0
        self.cursor_y = 0
        self.line_height = 20
        self.char_width = 10
    
    def add_text(self, text: str, size: int = 12) -> None:
        for char in text:
            if char == '\n':
                self.cursor_x = 0
                self.cursor_y += self.line_height
                continue
            
            flyweight = GlyphFactory.get_glyph(char, self.font_family, self.font_weight)
            instance = CharacterInstance(flyweight, self.cursor_x, self.cursor_y, size)
            self.characters.append(instance)
            self.cursor_x += self.char_width
    
    def render(self) -> None:
        for char_instance in char_instance:
            char_instance.render()


# Simulate a document with repeated text
doc = Document()
sample_text = "Hello World! " * 1000  # 13,000 characters
doc.add_text(sample_text)

print(f"Total character instances: {len(doc.characters)}")
print(f"Unique flyweights needed: {GlyphFactory.cache_size()}")

With 13,000 characters but only ~10 unique characters (H, e, l, o, W, r, d, !, space), we create just 10 flyweight objects instead of 13,000.

Python-Specific Implementation Techniques

Python offers elegant alternatives to the traditional factory approach. Here’s how to use functools.lru_cache:

from functools import lru_cache
from dataclasses import dataclass

@dataclass(frozen=True)
class TreeSprite:
    """Flyweight for tree rendering in a game."""
    species: str
    texture_path: str
    polygon_count: int


@lru_cache(maxsize=None)
def get_tree_sprite(species: str, texture_path: str, polygon_count: int) -> TreeSprite:
    """Factory function using lru_cache for automatic memoization."""
    print(f"Creating new sprite for {species}")
    return TreeSprite(species, texture_path, polygon_count)


# Usage
oak1 = get_tree_sprite("oak", "/textures/oak.png", 1200)
oak2 = get_tree_sprite("oak", "/textures/oak.png", 1200)  # Cached
pine1 = get_tree_sprite("pine", "/textures/pine.png", 800)

print(f"oak1 is oak2: {oak1 is oak2}")  # True
print(f"Cache info: {get_tree_sprite.cache_info()}")

For additional memory savings, use __slots__:

class SlottedCharacter:
    """Memory-optimized flyweight using __slots__."""
    __slots__ = ('char', 'font_family', 'font_style')
    
    def __init__(self, char: str, font_family: str, font_style: str):
        self.char = char
        self.font_family = font_family
        self.font_style = font_style

Using __slots__ eliminates the per-instance __dict__, saving approximately 100+ bytes per object.

Measuring Memory Impact

Always measure before optimizing. Here’s how to benchmark flyweight effectiveness:

import sys
import tracemalloc

class NaiveCharacter:
    """Non-flyweight implementation for comparison."""
    def __init__(self, char: str, font: str, style: str, x: int, y: int, size: int):
        self.char = char
        self.font = font
        self.style = style
        self.x = x
        self.y = y
        self.size = size


def benchmark_naive(n: int) -> Tuple[float, int]:
    tracemalloc.start()
    characters = [
        NaiveCharacter("A", "Arial", "Bold", i * 10, 0, 12)
        for i in range(n)
    ]
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return peak / 1024 / 1024, len(characters)


def benchmark_flyweight(n: int) -> Tuple[float, int]:
    tracemalloc.start()
    flyweight = CharacterFlyweight("A", "Arial", "Bold")
    instances = [
        CharacterInstance(flyweight, i * 10, 0, 12)
        for i in range(n)
    ]
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return peak / 1024 / 1024, len(instances)


# Run benchmarks
n = 100_000
naive_mb, _ = benchmark_naive(n)
flyweight_mb, _ = benchmark_flyweight(n)

print(f"Naive approach: {naive_mb:.2f} MB")
print(f"Flyweight approach: {flyweight_mb:.2f} MB")
print(f"Memory saved: {((naive_mb - flyweight_mb) / naive_mb) * 100:.1f}%")

In typical runs, you’ll see 30-50% memory reduction depending on how much state is intrinsic versus extrinsic.

Trade-offs and When to Avoid

Flyweight isn’t free. Consider these trade-offs:

Added complexity: You’re splitting object state across two locations. This makes code harder to understand and maintain. If you’re not creating thousands of objects, the complexity isn’t worth it.

Thread safety: Shared mutable state requires synchronization. If your flyweights are truly immutable (as they should be), this isn’t an issue. But if clients can modify shared state, you’ll need locks.

Computation vs memory: Passing extrinsic state means potentially recalculating derived values. You’re trading memory for CPU cycles.

Alternatives to consider:

  • String interning: Python already interns small strings. Use sys.intern() for explicit interning.
  • Object pooling: For objects that are created and destroyed frequently, pooling might be more appropriate.
  • __slots__: Sometimes just eliminating __dict__ is enough without full Flyweight implementation.

Use Flyweight when:

  • You’re creating 10,000+ similar objects
  • Objects share significant immutable state
  • Memory is a measured constraint, not a theoretical concern
  • The shared state is truly immutable

Skip it when you have fewer objects, when most state is extrinsic anyway, or when the added indirection hurts code clarity without meaningful memory benefits. Measure first, optimize second.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.