LZ78 is the dictionary half of the Lempel-Ziv family. It was published by Jacob Ziv and Abraham Lempel in 1978, a year after the sliding-window method now called LZ77. Instead of pointing back into recent text, LZ78 builds an explicit dictionary of phrases while it reads. Each new phrase is an old phrase extended by one symbol. The output is a sequence of (phrase index, next symbol) pairs. Terry Welch's 1984 refinement, LZW, drops the explicit symbol. LZW became the compressor behind Unix compress, GIF, and optional modes in TIFF and PDF.
Today LZ77 descendants such as Deflate, zstd and LZ4 dominate general-purpose compression, so it is fair to ask why LZ78 deserves study. There are three reasons. It is the cleanest example of adaptive dictionary coding, where encoder and decoder build the same model in lockstep without transmitting it. You will still meet it in GIF, TIFF and PDF files, where decoders must handle its corner cases. And its failure modes, dictionary-full policy, bit-width synchronisation and memory bounds, are the same ones every adaptive codec faces. This article builds the encoder and decoder, traces them, derives LZW and its one tricky decoder case, and measures both against zlib.
The parse: every phrase is an old phrase plus one symbol
The encoder keeps a dictionary that starts with the empty phrase at index 0. It reads symbols and extends the current phrase w for as long as w plus the next symbol is already in the dictionary. When the extension is new, it emits (index of w, symbol), adds the extended phrase as the next entry, and restarts with an empty w. Each emitted pair therefore defines a phrase that has never been seen before. The parse splits the input into distinct phrases, and that property drives the theory later.
def lz78_encode(data: bytes):
dict_ = {b"": 0}
out, w = [], b""
for b in data:
wc = w + bytes([b])
if wc in dict_:
w = wc # keep extending a known phrase
else:
out.append((dict_[w], b)) # longest known prefix + one new byte
dict_[wc] = len(dict_)
w = b""
if w: # input ended inside a known phrase
out.append((dict_[w], None))
return out
def lz78_decode(pairs):
phrases, out = [b""], bytearray()
for idx, b in pairs:
ph = phrases[idx] + (bytes([b]) if b is not None else b"")
out += ph
if b is not None:
phrases.append(ph)
return bytes(out)Encoding abababcabababcabab gives eight pairs, and they parse the input as a | b | ab | abc | aba | ba | bc | abab. The pair for phrase 4 is (3, c): phrase 3 is ab, plus c. The decoder needs no dictionary in the stream. It appends one phrase per pair, so it rebuilds the encoder's table in lockstep. The explicit symbol is why LZ78 never meets an undefined code. Note the final-phrase case. If the input ends while w is a known phrase, there is no next symbol, so the encoder needs an end marker or an explicit length. Forgetting this drops the tail of the input or emits garbage.
The trie view is how real encoders run. A hash map keyed by whole byte strings, as in the teaching code, costs O(length) per lookup. A trie, or a hash map keyed by (parent index, byte), makes every input byte cost O(1) expected time, so encoding is linear. The trie article covers node layouts. The decoder only needs a parent pointer and a last byte per entry. It rebuilds each phrase by walking parents and reversing, or by writing the bytes backwards into the output.
LZW: dropping the explicit symbol
LZW makes two changes. First, the dictionary starts with all 256 single bytes, so every phrase is guaranteed a known prefix. Second, the encoder emits only the code for the longest match, never a raw symbol. The symbol that broke the match becomes the first byte of the next phrase. The decoder can infer that symbol, because it is the first byte of whatever the next code decodes to.
def lzw_encode(data: bytes):
table = {bytes([i]): i for i in range(256)}
w, out = b"", []
for b in data:
wc = w + bytes([b])
if wc in table:
w = wc
else:
out.append(table[w])
table[wc] = len(table)
w = bytes([b])
if w:
out.append(table[w])
return out
def lzw_decode(codes):
table = [bytes([i]) for i in range(256)]
prev = table[codes[0]]
out = bytearray(prev)
for code in codes[1:]:
if code < len(table):
entry = table[code]
elif code == len(table): # KwKwK: encoder used the entry it just created
entry = prev + prev[:1]
else:
raise ValueError(f"corrupt stream: code {code}")
out += entry
table.append(prev + entry[:1])
prev = entry
return bytes(out)The classic test string TOBEORNOTTOBEORTOBEORNOT encodes to 16 codes: nine literals, then 256 258 260 265 259 261 263. The decoder runs one entry behind the encoder, and that creates the one case every LZW decoder must handle. When the input has the shape cScSc, where c is a byte and S a string such that cS is already in the table, the encoder adds cSc and then immediately emits it. The decoder receives a code it has not defined yet. Only one string fits: the previous entry plus its own first byte. The shortest example is aaaaaaa, which encodes to 97 256 257 97. Both 256 and 257 arrive before the decoder has defined them. A decoder without the code == len(table) branch fails on runs of a repeated byte. Any code larger than the table size is corruption and must be rejected. Both coders round-tripped 3,000 random strings over a two-letter alphabet, which hit this case constantly.
Code widths and full dictionaries
Codes are written in a variable number of bits. With 8-bit symbols a stream begins with 9-bit codes and widens by one bit each time the table outgrows the current width. The encoder and decoder must switch at exactly the same code, and because the decoder runs one entry behind, formats disagree on when. TIFF and PDF switch one code earlier than GIF. PDF exposes this as the EarlyChange parameter of LZWDecode, which defaults to 1. A decoder with the wrong convention decodes the first few hundred bytes correctly and then produces garbage. That is the signature of a width mismatch.
The table cannot grow forever, so every format chooses a policy for when it is full:
- GIF caps codes at 12 bits, which is 4,096 entries. It reserves a clear code equal to 2^(minimum code size) and an end-of-information code one above it. When the table is full, the encoder may emit clear to start over, or keep using the frozen table.
- Unix compress allows codes up to 16 bits (set with
-b). Once the table is full it keeps going, monitors the compression ratio, and emits a clear code to reset when the ratio starts falling. - Alternatives in the literature include LRU replacement and more elaborate deletion schemes. They track drifting data better, but they cost bookkeeping that both sides must replicate exactly.
The policy matters even on modest inputs. On about 102 KB of Python standard-library source, unbounded LZW emitted 24,582 codes. With a 12-bit cap, freezing the full table needed 36,250 codes. Resetting it whenever it filled needed 33,370 codes, with 8 resets. A frozen table keeps the statistics of the start of the file. A reset throws away good phrases but adapts to whatever comes next.
Measured against zlib
The table compares LZ78 and LZW, with simple variable-width bit accounting, against zlib level 9, which is Deflate: LZ77 plus Huffman coding. The inputs are 104,522 bytes of Python source (os.py, random.py and heapq.py), 100,000 random bytes, and 100,000 copies of one letter:
| Input | LZ78 phrases | LZ78 bytes | LZW codes | LZW bytes | zlib -9 bytes |
|---|---|---|---|---|---|
| Python source, 104,522 B | 19,821 | 52,889 | 24,582 | 42,251 | 29,476 |
| Random, 100,000 B | 44,178 | 124,342 | 71,804 | 136,519 | 100,041 |
| One letter, 100,000 B | 447 | 886 | 447 | 526 | 121 |
Three lessons follow. On text, LZW beats plain LZ78 because it never spends 8 bits on a raw symbol, but Deflate is about 30% smaller still. It encodes matches of any length at any recent offset and entropy-codes the result. On random data both dictionary coders expand the input by 24% to 37%, while zlib falls back to stored blocks and adds only 41 bytes. A production LZW pipeline needs the same escape hatch. On the run of one letter, the phrases grow by one byte each, a, aa, aaa and so on, so n bytes need about √(2n) phrases. Here that is 447, exactly √200,000 rounded. LZ77 encodes the whole run as one overlapping match. The Huffman and arithmetic coding articles cover the entropy stage that LZW lacks.
Why it works, and why slowly
Ziv and Lempel proved that LZ78 is universal. For any stationary ergodic source, the compressed bits per symbol converge to the source's entropy rate as the input length grows, and the coder needs no prior model. The proof rests on the distinct-phrase property. A string of length n can be split into at most about n / log n distinct phrases, and each phrase costs about log n bits to name.
The catch is the convergence rate. The excess over entropy shrinks only on the order of 1 / log n. Doubling the input barely helps, and at practical file sizes LZ78 sits well above the entropy of good models, as the measurements show. Universality is a statement about the limit. It is not a ranking at 100 KB. That is why the modern winners combine a match finder with a strong entropy coder rather than relying on the dictionary alone.
Operational guidance
- Bound memory on both sides. An unbounded dictionary grows with the input. Cap entries at a width such as 12 or 16 bits and choose freeze or reset explicitly. The decoder must enforce the same cap, or a crafted stream can force unbounded allocation.
- Treat streams as hostile. Reject codes above the table size. Cap the output size: LZW can expand a short stream a great deal, since each code can name a phrase up to the table depth long. Validate the clear and end codes. Most historical GIF and TIFF decoder bugs were in these paths.
- Add a stored-block fallback. If the encoded size exceeds the input, store the raw bytes with a flag. Random and already-compressed data will otherwise grow.
- Pin the width convention in tests. Keep golden files that cross the 9-to-10, 10-to-11 and 11-to-12 bit boundaries, produced by the reference tools of the target format.
- Know the history when auditing old code. Unisys held the US LZW patent, 4,558,302, which expired on June 20, 2003. Its European counterparts expired on June 18, 2004, the Japanese one on June 20, 2004, and the Canadian one on July 7, 2004. Licensing pressure in the 1990s is why PNG uses Deflate and why some old tools shipped with LZW disabled.
Failure modes
- Missing KwKwK branch. The decoder crashes or emits garbage on runs such as
aaaa. - Off-by-one width switch. Output is correct for a while and then turns to garbage. Check the EarlyChange convention.
- Dropped final phrase. The encoder forgets to flush w at end of input, so the last few bytes vanish. Round-trip tests on every length from 0 to 50 catch it.
- Empty input. The teaching decoder reads
codes[0]and fails on an empty list. Handle zero codes explicitly. - Assuming it compresses. High-entropy data expands by tens of percent, as measured above.
Trade-offs
| Choice | Gains | Costs |
|---|---|---|
| LZ78 or LZW over LZ77 | No search window, simple linear-time trie encoder | Slow adaptation, no long matches, weaker ratios |
| LZW over LZ78 | No raw symbols in the stream | KwKwK case, width synchronisation |
| Freeze when full | Stable, cheap | Stale phrases on drifting data |
| Reset when full | Adapts to new content | Relearning cost after every reset |
| Deflate or zstd instead | Better ratio, mature tools | More complex encoder, needs a match finder |
For new systems, pick an LZ77-family codec such as zstd. Implement LZW when a format requires it, and then implement it defensively.
What to do next
- Run both coders on the strings above and confirm the pair list and the
97 256 257 97trace by hand. - Add variable-width bit packing with a 12-bit cap and a clear code, and round-trip every length from 0 to 5,000 with random two-letter and random 256-letter data.
- Fuzz your decoder with truncated and mutated streams, asserting that it rejects them without crashing and that output stays under your cap.
- Compare your encoder's output size with zlib on your real data before shipping, and add the stored-block fallback.
- Read the zstd article to see what a modern match-plus-entropy pipeline does differently.