Flyweight pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- Flyweight saves memory when a program holds millions of objects that differ in only a few fields: the repeated part (intrinsic state) lives in one shared, immutable object, and each instance keeps only what is unique to it (extrinsic state) plus a pointer to the shared part.
- A factory or pool hands out the shared objects keyed by their value, so equal values get the same instance. String interning, Java's small
Integercache, Go'suniquepackage and V8's hidden classes are all flyweights. - The two rules that make sharing safe: flyweights are immutable, and nothing that varies per instance is stored inside them.
- The pool is the hard part in production. Unbounded pools leak, pools fed from user input can be grown by an attacker, and a shared mutable map needs a lock. Weak references or reference counts bound the lifetime.
- Only reach for it after measuring. A flyweight trades memory for an extra pointer dereference and a hash lookup at creation; for ten thousand objects it is noise, for fifty million it decides whether the process fits in RAM.
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 styleEach 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
- String interning. Python's
sys.intern(s)returns one canonical string for each value; CPython interns identifiers and attribute names automatically so dictionary lookups can compare pointers first. Java interns string literals in a JVM-wide table. - Small value caches. CPython preallocates integers from -5 to 256, and Java's
Integer.valueOfreturns cached objects for -128 to 127. That is whya is bhappens to be true for small integers and false for large ones. - Hidden classes. V8 gives objects with the same property layout a shared map (hidden class) that describes field names and offsets. Each object stores a pointer to the map, not its own copy of the layout.
- File formats. In an
.xlsxfile, each cell stores a style index (s="3") into a sharedcellXfstable, and strings live once insharedStrings.xml, referenced by number. - GPU instancing. A forest of 100,000 trees uploads one mesh and draws it with one instanced draw call; each tree contributes only a transform matrix.
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
- Memory is finite and its cost scales with instance count, so millions of near-identical objects are expensive in RAM, cache and GC time.
- Most of each object is repeated, so storing the repeated part once and referring to it shrinks the footprint to a pointer per instance.
- Sharing is only safe if nobody can change the shared part, so flyweights are immutable and per-use data stays outside.
- Callers must find the existing instance instead of making a new one, so creation goes through a factory keyed by value.
- 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
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One object per instance | Simple; each object can change freely | Memory scales with count times size | Thousands of objects, or values rarely repeat |
| Flyweight with an unbounded pool | Smallest footprint; identity equality | Pool grows forever; leaks with open key spaces | Closed key space (glyphs, enum-like styles) |
| Flyweight with refcounts or weak refs | Bounded memory; entries freed when unused | Refcount writes contend; weak refs need GC support | Open key spaces, long-running processes |
| Integer IDs into a table | 4 bytes per reference; serialises well | Lookup step on every access; manual lifetime | File formats, columnar storage, GPU buffers |
| Compression or dictionary encoding | Works on bulk data without object identity | Decode cost on read | Analytics columns, logs, network payloads |
Pitfalls in production
- Mutating a shared flyweight, so one document's edit changes every document's text colour. Fix: make the type immutable and create a new flyweight for every change.
- An unbounded intern table keyed by request data, which an attacker grows with unique values until the process runs out of memory. Fix: bound the key space, use weak references, or intern only values from a trusted set.
- Identity checks that work in tests and fail in production, such as Python's
x is 256versusx is 257. Fix: compare with==unless the pool guarantees identity for that type. - A per-key race where two threads miss the pool at once and create two instances. Fix: lock per shard around the miss path, or use a concurrent map with an atomic load-or-store.
- Extrinsic state accidentally stored in the flyweight, such as a cached position, which makes the "shared" object wrong for every other user. Fix: keep per-use fields in the context and pass them in.
Leadership lens
- Ask for the heap profile before approving a flyweight. The pattern is a memory optimisation with costs in clarity and lifetime management, and it needs a number to justify it.
- Ask who controls the key space. A pool keyed by a fixed set of glyphs is safe; one keyed by user input is a denial-of-service vector until it is bounded.
- Make immutability enforceable rather than conventional: frozen dataclasses, readonly types and unexported fields mean reviewers do not have to catch every mutation.
- Budget memory per tenant or per document. Flyweights shrink the common case; limits protect against the pathological file that defeats sharing.
Check yourself
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.
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
getcreate a second instance for the same value, which breaks identity comparisons that callers rely on.Go's
unique.Handlecomparison 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.
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
- Flyweight Design Pattern (Derek Banas): builds a flyweight factory in Java and measures the difference in object count and speed.
- Flyweight Pattern, Design Patterns in C# (SingletonSean): intrinsic versus extrinsic state with a worked .NET example.
Free resources
- The Flyweight Pattern (Brandon Rhodes, python-patterns.guide): how Python itself uses flyweights and how to write a factory for them.
- New unique package (Go blog): interning in Go with weak references, and how
net/netipuses it. - unique package (Go docs): the
MakeandHandleAPI and its guarantees. - Maps (hidden classes) in V8 (V8 docs): how V8 shares object layouts across instances.
- sys.intern (Python docs): what interning guarantees in CPython and when it helps.
- Flyweight (Refactoring.Guru): the structure, intrinsic and extrinsic state, and code in many languages.