Raw data, clear context.

[
[
[

]
]
]

A document editor can hold thousands of characters that differ in position but reuse the same handful of fonts, sizes and colours. If every character stores a fresh copy of that formatting, the repeated state can consume more memory than the coordinates and character data that are genuinely different.

The Flyweight pattern addresses that specific problem by separating intrinsic state, which can be shared, from extrinsic state, which belongs to each context. The shared state should be immutable; the per-object context keeps values such as a glyph’s character and position.[1] The name is a little theatrical, but the job is practical: keep one copy of repeated data and let many small objects refer to it.

Diagram comparing four glyphs with duplicated Style objects against four glyphs pointing to a factory of shared immutable styles, alongside the measured memory values
The coordinates keep their own seats. The style objects agree to share a table.

Separate what changes from what repeats

In the example below, each Glyph needs a character, an x coordinate, a y coordinate and a Style. The character and coordinates vary for every record. Only four combinations of font family, size, weight and colour appear across 100,000 records.

The baseline constructs a new immutable Style for every glyph. The Flyweight version sends the four style fields through StyleFactory.get(). That factory uses the field values as a dictionary key, returns an existing Style when it finds one and creates a new one only on the first occurrence.

This does not make the 100,000 glyphs vanish. They still need storage, which saves the pattern from performing actual magic. It removes the duplicated style objects and their duplicated strings.

A runnable comparison

The program is self-contained and uses only the Python standard library. Both variants use frozen, slotted data classes, so the comparison is about shared style state rather than one side quietly acquiring instance dictionaries. The fresh() helper recreates text as though each row had arrived from a parser or network payload. Without that step, Python may already reuse some literals, giving the baseline help that many real input pipelines do not provide.

The program checks that both object graphs produce the same checksum. It also counts distinct Style identities, measures retained Python allocations with tracemalloc, and times construction plus a complete read. Python documents tracemalloc as a tool for tracing memory blocks allocated by Python, including block counts and sizes.[2]

flyweight_benchmark.py
Python
from __future__ import annotations
import gc
import platform
import statistics
import timeit
import tracemalloc
from dataclasses import dataclass
from typing import Callable
N = 100_000
REPEATS = 7
STYLE_ROWS = (
("Source Serif Display", 18, "Regular", "Ink Black"),
("Source Serif Display", 18, "Bold", "Ink Black"),
("IBM Plex Mono", 15, "Regular", "Terminal Green"),
("IBM Plex Mono", 15, "Bold", "Warning Amber"),
)
@dataclass(frozen=True, slots=True)
class Style:
family: str
size: int
weight: str
colour: str
@dataclass(frozen=True, slots=True)
class Glyph:
character: str
x: int
y: int
style: Style
class StyleFactory:
def __init__(self) -> None:
self._styles: dict[tuple[str, int, str, str], Style] = {}
def get(self, family: str, size: int, weight: str, colour: str) -> Style:
key = (family, size, weight, colour)
style = self._styles.get(key)
if style is None:
style = Style(family, size, weight, colour)
self._styles[key] = style
return style
def __len__(self) -> int:
return len(self._styles)
def fresh(text: str) -> str:
"""Simulate repeated text arriving from a parser or network payload."""
return bytearray(text, "utf-8").decode("utf-8")
def values(index: int) -> tuple[str, int, str, str]:
family, size, weight, colour = STYLE_ROWS[index % len(STYLE_ROWS)]
return fresh(family), size, fresh(weight), fresh(colour)
def build_baseline(count: int) -> tuple[list[Glyph], None]:
glyphs = []
for index in range(count):
family, size, weight, colour = values(index)
style = Style(family, size, weight, colour)
glyphs.append(Glyph(chr(65 + index % 26), index % 800, index // 800, style))
return glyphs, None
def build_flyweight(count: int) -> tuple[list[Glyph], StyleFactory]:
factory = StyleFactory()
glyphs = []
for index in range(count):
family, size, weight, colour = values(index)
style = factory.get(family, size, weight, colour)
glyphs.append(Glyph(chr(65 + index % 26), index % 800, index // 800, style))
return glyphs, factory
def checksum(glyphs: list[Glyph]) -> int:
total = 0
for glyph in glyphs:
total += (
ord(glyph.character)
+ glyph.x
+ glyph.y
+ glyph.style.size
+ len(glyph.style.family)
+ len(glyph.style.weight)
+ len(glyph.style.colour)
)
return total
def memory_for(builder: Callable[[int], tuple[list[Glyph], object]], count: int):
gc.collect()
tracemalloc.start()
glyphs, extra = builder(count)
gc.collect()
retained, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return glyphs, extra, retained, peak
def best_build_time(builder: Callable[[int], tuple[list[Glyph], object]]) -> float:
samples = timeit.repeat(lambda: builder(N), repeat=REPEATS, number=1)
return min(samples)
def best_read_time(glyphs: list[Glyph]) -> float:
samples = timeit.repeat(lambda: checksum(glyphs), repeat=REPEATS, number=1)
return min(samples)
def mib(value: int) -> float:
return value / (1024 * 1024)
def main() -> None:
baseline, _, baseline_retained, baseline_peak = memory_for(build_baseline, N)
flyweight, factory, flyweight_retained, flyweight_peak = memory_for(build_flyweight, N)
baseline_checksum = checksum(baseline)
flyweight_checksum = checksum(flyweight)
if baseline_checksum != flyweight_checksum:
raise RuntimeError("implementations produced different results")
baseline_styles = len({id(glyph.style) for glyph in baseline})
flyweight_styles = len({id(glyph.style) for glyph in flyweight})
if baseline_styles != N or flyweight_styles != len(STYLE_ROWS) or len(factory) != len(STYLE_ROWS):
raise RuntimeError("unexpected style sharing")
baseline_build = best_build_time(build_baseline)
flyweight_build = best_build_time(build_flyweight)
baseline_read = best_read_time(baseline)
flyweight_read = best_read_time(flyweight)
print(f"Environment: {platform.python_implementation()} {platform.python_version()}, {platform.system()} {platform.release()}, {platform.machine()}")
print(f"Records: {N:,}; distinct style values: {len(STYLE_ROWS)}; timing repeats: {REPEATS}")
print(f"Equivalent checksum: {baseline_checksum == flyweight_checksum} ({baseline_checksum})")
print(f"Distinct Style objects: baseline {baseline_styles:,}; flyweight {flyweight_styles:,}")
print(f"Retained traced memory: baseline {mib(baseline_retained):.2f} MiB; flyweight {mib(flyweight_retained):.2f} MiB; baseline/flyweight {baseline_retained / flyweight_retained:.2f}x")
print(f"Peak traced memory: baseline {mib(baseline_peak):.2f} MiB; flyweight {mib(flyweight_peak):.2f} MiB; baseline/flyweight {baseline_peak / flyweight_peak:.2f}x")
print(f"Best build time: baseline {baseline_build:.4f} s; flyweight {flyweight_build:.4f} s; flyweight/baseline {flyweight_build / baseline_build:.2f}x")
print(f"Best full-read time: baseline {baseline_read:.4f} s; flyweight {flyweight_read:.4f} s; flyweight/baseline {flyweight_read / baseline_read:.2f}x")
print("Memory figures cover Python allocations made while building each retained object graph; they are not whole-process RSS.")
print("Best timings are the minimum of seven one-shot timeit repeats on one machine.")
if __name__ == "__main__":
main()

The captured output came from executing that exact file:

Output
Plain text
Environment: CPython 3.13.5, Linux 6.12.47+rpt-rpi-v8, aarch64
Records: 100,000; distinct style values: 4; timing repeats: 7
Equivalent checksum: True (58874956)
Distinct Style objects: baseline 100,000; flyweight 4
Retained traced memory: baseline 29.94 MiB; flyweight 8.94 MiB; baseline/flyweight 3.35x
Peak traced memory: baseline 29.94 MiB; flyweight 8.94 MiB; baseline/flyweight 3.35x
Best build time: baseline 0.7965 s; flyweight 0.6308 s; flyweight/baseline 0.79x
Best full-read time: baseline 0.0710 s; flyweight 0.0631 s; flyweight/baseline 0.89x
Memory figures cover Python allocations made while building each retained object graph; they are not whole-process RSS.
Best timings are the minimum of seven one-shot timeit repeats on one machine.

What the run measured

On this Raspberry Pi environment, sharing four styles reduced retained traced memory from 29.94 MiB to 8.94 MiB. That is a 70.1 per cent reduction; the baseline used 3.35 times as much traced memory as the Flyweight version. The identity check found 100,000 Style objects in the baseline and four in the Flyweight graph. Both graphs produced the same checksum.

The best construction time fell from 0.7965 seconds to 0.6308 seconds, a 20.8 per cent reduction in this run. The best full-read time fell from 0.0710 to 0.0631 seconds, an 11.1 per cent reduction. Those timing differences belong to this input, interpreter and machine. They do not show that an extra dictionary lookup will always make construction or traversal faster.

The timing method used seven one-shot timeit repeats and reported the minimum. Python’s timeit documentation recommends repeating measurements because other programs can affect wall-clock timings, then using the best result when accuracy matters.[4] A longer benchmark on an otherwise idle machine would give tighter confidence than this compact demonstration.

The memory figure has a similar boundary. It covers Python allocations traced while each retained object graph was built. It is not whole-process resident memory, and it does not include every allocator or operating-system cost. The ratio is useful for this controlled comparison, not a promise about a production service.

Theoretical complexity and the real bill

Let n be the number of glyphs, k the number of distinct styles and s the memory occupied by one style payload. The baseline stores n glyph records plus repeated style state proportional to n × s. The Flyweight graph stores n glyph records, shared style state proportional to k × s, and a factory containing k entries. When k is much smaller than n, the repeated-state term shrinks. The glyph collection remains linear in n, so the overall space complexity is still O(n).

Construction also remains O(n), as does a complete scan. The Flyweight version adds a tuple, hashing and a dictionary lookup for every glyph. It can still win in real time when avoiding object and string retention costs more than those lookups, which is what happened here. A different style distribution, Python implementation or allocator can move that balance.

For repeated strings alone, sys.intern() is a narrower form of canonicalisation. Python says it returns an interned string and can replace some dictionary key comparisons with pointer comparisons, but it is intended for specialised use and callers must retain a reference to the result.[3] A Flyweight factory can canonicalise a whole immutable record instead of one string.

Where Flyweight stops paying rent

The pattern offers little when the object count is small or most styles are unique. If k approaches n, the factory retains nearly as many shared objects as the baseline while still paying for keys and lookups. An unbounded factory can also become the memory problem it was hired to solve.

Shared state must stay immutable or be managed with unusual care. Mutating one shared Style would change every glyph that refers to it. The example prevents that with a frozen data class. Systems that need frequent per-object style changes may be clearer with ordinary objects, compact arrays or an explicit relational model.

The measured decision is therefore narrower than simply using Flyweight everywhere. Count the duplicates, measure retained memory, then benchmark the affected operations. In this run, four immutable styles shared by 100,000 glyphs made the trade useful. With 100,000 unique styles, the same factory would mostly be extra furniture.

Sources

  1. Flyweight pattern
  2. tracemalloc: Trace memory allocations
  3. sys: System-specific parameters and functions
  4. timeit: Measure execution time of small code snippets