Engineering Atlas › Low-Level Design

Flyweight pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A collaborative document editor keeps text as runs: a stretch of characters plus its formatting (font, size, weight, colour, link target). The first version gave every run its own Format object. A large document has 400,000 runs, and profiling shows 380,000 Format objects in memory, 160 bytes each in Python with their attribute dictionaries, about 60 MB per open document. Yet the document uses only 23 distinct formats. The server holds 2,000 open documents, so most of its memory is copies of "Inter, 11pt, regular, black".

Split intrinsic from extrinsic

Flyweight starts by sorting fields. Intrinsic state is the same across many instances and does not depend on where the object is used: the format's font, size and weight. Extrinsic state differs per use: the run's text and its position in the document. Intrinsic state goes into a shared, immutable flyweight. Extrinsic state stays in the context object, or is passed into the flyweight's methods when they need it.

class StylePool:
    def __init__(self):
        self._styles = {}

    def get(self, font, size, bold):
        key = (font, size, bold)
        style = self._styles.get(key)
        if style is None:
            style = self._styles[key] = Style(font, size, bold)  # frozen
        return style

Each run now stores its text and a pointer to one of 23 styles. Equality checks become identity checks (run.style is other.style), which also makes "merge adjacent runs with the same format" a pointer comparison.

The flyweight factory

Callers must never construct flyweights directly, or they will create duplicates that defeat the sharing. A factory (see Factory Method) owns a map from intrinsic key to instance and returns the existing one when it can. That map is the pool, and it is where the engineering lives: when entries are created, who may create them, when they can be removed, and how concurrent callers are kept from creating two copies of the same key.

Where you already use flyweights

Flyweight, Singleton, Prototype and caching

A singleton is one instance per type (see Singleton). A flyweight is one instance per distinct value, potentially thousands. Prototype copies an object so the copy can diverge (see Prototype); flyweight shares precisely because nothing will diverge. A cache stores results to save time and may evict an entry and recompute it; a flyweight pool stores instances to save memory, and evicting a live entry would break identity, because the next caller would get a different object for the same value. The State lesson's stateless state objects are a small flyweight: one shared instance per state.

Reviewing agent-written code

Agents model data one object per row, which is right until a profile says otherwise. When asked to "reduce memory", they may add a module-level dict cache with no bound, keyed by something that comes from requests, or they share an object that something later mutates. Check in review that the shared type is immutable (@dataclass(frozen=True), readonly fields, unexported fields in Go), that the pool has a lifetime story (weak references, reference counts or a fixed key space), and that the key space is not controlled by untrusted input.

The same shape elsewhere

Flyweight is the duplicates problem shape applied to memory: the same value is stored many times, so you store it once and refer to it. Content-addressed storage does the same on disk: Git stores each distinct file content once as a blob named by its hash, so a commit that changes one file in a 10,000-file tree shares the other 9,999 blobs with its parent.

Down the stack

Layer 1The contract

The factory promises that for two equal keys it returns the same instance for as long as either caller holds it, so identity comparison is valid. The flyweight promises it never changes after construction, which is what makes it safe to share across threads without locks. If the pool releases entries, the contract states when: on the last release (reference counting) or some time after the last reference is dropped (weak references and GC). It must never drop an entry that someone still holds, or the next get for that key returns a second instance and identity checks silently start failing.

Layer 2The mechanism

Go 1.23's unique package is a general flyweight factory. unique.MakeT comparable returns a Handle[T], which is a pointer to a canonical copy of v; comparing two handles is a pointer comparison, however large T is. Internally the canonical copies live in a concurrent hash-trie map whose values are held weakly, so once no handle refers to an entry, the garbage collector can reclaim it and a background cleanup removes the map slot. The standard library's net/netip uses it to share IPv6 zone strings across addresses.

Java has two flyweight systems for strings. String.intern() looks up a native hash table, the StringTable, whose bucket count is tunable with -XX:StringTableSize. G1's string deduplication (-XX:+UseStringDeduplication) does it lazily: during GC, it hashes the backing arrays of young strings and points duplicates at one shared array, without changing string identity. CPython's interned strings sit in a dictionary keyed by the string itself; since 3.12, statically allocated strings and small integers are immortal objects whose reference counts are never decremented, removing refcount writes on hot shared objects.

V8's hidden classes form a transition tree. Adding property x then y to an empty object moves it from map M0 to M1 to M2, and every object built in that order ends at the same M2. Inline caches at property access sites record "map M2 means y is at offset 16", so sharing the map is also what makes property access fast.

Layer 3The machine

The savings are pointer arithmetic. On a 64-bit machine a reference costs 8 bytes. A Python object with three attributes and a per-instance __dict__ costs roughly 150 to 200 bytes; with __slots__ around 56. Replacing 380,000 private formats with references to 23 shared ones turns about 60 MB into 3 MB of pointers plus a few kilobytes of styles. Go's unique.Handle is one pointer, so a struct that held a 40-byte key now holds 8 bytes, and maps keyed by handles hash 8 bytes instead of the full value.

Smaller objects also mean more of the working set fits in cache. A 64-byte cache line holds 8 pointers but less than half of one Python format object. The cost is an extra dependent load: reading run.style.size follows a pointer to the shared object. Because a few hot flyweights are touched constantly, they stay in L1 or L2, so that load is usually 1 to 4 ns instead of a 60 to 100 ns trip to DRAM.

Concurrency costs live in the pool, not the flyweights. A pool behind one mutex serialises every get; at millions of lookups per second across cores, the lock's cache line bounces between cores. Sharded maps, lock-free tries (as in unique), or a read-mostly design (build the pool at startup, then read without locks) remove that contention. Reference counting on shared objects has the same problem in a different place: every increment writes to the shared object's cache line, which is why CPython made its most shared objects immortal.

Why it's built this way

  1. Memory is finite and its cost scales with instance count, so millions of near-identical objects are expensive in RAM, cache and GC time.
  2. Most of each object is repeated, so storing the repeated part once and referring to it shrinks the footprint to a pointer per instance.
  3. Sharing is only safe if nobody can change the shared part, so flyweights are immutable and per-use data stays outside.
  4. Callers must find the existing instance instead of making a new one, so creation goes through a factory keyed by value.
  5. The pool would otherwise grow forever, so it needs weak references, reference counts or a closed key space.

Diagram

flowchart LR
  R1["Run: 'Hello' at 0"] --> S1["Style: Inter 11 regular"]
  R2["Run: 'world' at 6"] --> S2["Style: Inter 11 bold"]
  R3["Run: 'again' at 12"] --> S1
  D["Document.append"] --> P{"StylePool.get(key)"}
  P -- "hit" --> S1
  P -- "miss: create, refs=1" --> S2
  X["Document.delete"] --> RL["release: refs-1, drop at 0"]

Worked example

A spreadsheet service holds open workbooks in memory for real-time collaboration. A typical large workbook has 5 million non-empty cells. The first implementation gave each cell a CellFormat with font, size, colours, number format and borders, about 220 bytes per format in the service's Go structs (strings included), or 1.1 GB per workbook. Profiling showed 412 distinct formats.

The team interned formats with unique.Make[CellFormat] and stored a unique.Handle[CellFormat] per cell. Per-cell format memory fell from 220 bytes to 8, so 5 million cells use 40 MB plus about 90 KB for the 412 canonical formats. Copy-and-paste of a 100,000-cell range no longer allocated 100,000 formats, and "are these two cells formatted the same?" became a pointer comparison, which made the autofit and conditional-format passes about 3 times faster.

Two things needed care. Edits create a new format and swap the handle, never modifying a shared one. And imports from untrusted files could contain millions of distinct formats; the import path caps distinct formats per workbook at 64,000, the same order of limit spreadsheet applications apply, and rejects files beyond it.

Trade-offs

OptionStrengthsWeaknessesChoose when
One object per instanceSimple; each object can change freelyMemory scales with count times sizeThousands of objects, or values rarely repeat
Flyweight with an unbounded poolSmallest footprint; identity equalityPool grows forever; leaks with open key spacesClosed key space (glyphs, enum-like styles)
Flyweight with refcounts or weak refsBounded memory; entries freed when unusedRefcount writes contend; weak refs need GC supportOpen key spaces, long-running processes
Integer IDs into a table4 bytes per reference; serialises wellLookup step on every access; manual lifetimeFile formats, columnar storage, GPU buffers
Compression or dictionary encodingWorks on bulk data without object identityDecode cost on readAnalytics columns, logs, network payloads

Pitfalls in production

Leadership lens

Check yourself

  1. A map tile renderer draws 2 million building icons, each with a position and one of 40 icon bitmaps. What is intrinsic and what is extrinsic?

    Show answer

    The bitmap (and its size and anchor point) is intrinsic and shared through 40 flyweights; each building's position and rotation is extrinsic and passed in when drawing.

  2. Why must a flyweight pool never evict an entry while it is still referenced, when an ordinary cache may evict anything?

    Show answer

    A cache only saves recomputation, so a miss is slower but correct. Evicting a live flyweight lets the next get create a second instance for the same value, which breaks identity comparisons that callers rely on.

  3. Go's unique.Handle comparison is a pointer comparison. What does that buy you over comparing the values?

    Show answer

    Constant-time equality and hashing regardless of the value's size, plus 8-byte storage per reference instead of a full copy.

  4. Why did CPython make small integers and interned strings immortal?

    Show answer

    These objects are shared by everything, so every reference count update wrote to the same cache lines; immortality skips those writes and makes the objects safe to share without refcount traffic.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →