How it works
The blocklist is a minimal perfect hash table of 32-bit fingerprints. It stores no text, so the phrases were recovered by enumerating every candidate and checking it against Apple's table. This page covers the file format, the runtime check, and the search, for anyone who wants to verify or reproduce it.
Where the files come from
The keyboard's language data reaches the phone two ways.
Built into iOS. English, Chinese and Cantonese ship in the system image at /System (and the matching bundle for each of the others). The easiest way to get a copy is Xcode's iOS Simulator runtime, whose RuntimeRoot contains the same directory tree. Recent device firmware (IPSW) files are encrypted, so the Simulator is the practical source for current built-in tables.
Downloaded. Every other language, and updates to English between iOS releases, arrive as MobileAsset downloads. The phone POSTs its model, OS version and an asset “audience” ID to Apple's catalog at https://gdmf.apple.com/v2/assets, asking for asset type com.apple.MobileAsset.LinguisticData. The reply is a signed JSON Web Signature listing one asset per language and version, each a ZIP on updates.cdn-apple.com. Anyone can make the same request. The blocklist files are a few kilobytes inside archives of tens of megabytes, so the replication package reads them with HTTP range requests rather than downloading whole ZIPs. Older catalogs and their ZIPs are still online, back to 2015, which is how the history was reconstructed.
Assets are versioned by compatibility and content number; the newest is c17-449. Inside either kind of bundle, English has the lexicon (Unigrams-en.dat, Delta-en_US.dat) and a language model directory, en.lm/, holding two blocklists:
| Bundle | Used for | Phrases, iOS 27.0 |
|---|---|---|
blocklist.bundle/ | Predictions and completions | 4,525 |
qp_blocklist.bundle/ | QuickPath (swipe typing) | 2,794 |
Each bundle has blocklist.dat (the hash table) and blocklistidmap.dat (a map from language-model token IDs to the table's own word IDs). The QuickPath list is a strict subset of the default list. English is the only language with a separate swipe list; every other language ships just blocklist.bundle, and the keyboard uses that one list for both suggestions and swipe typing. Chinese, Korean and Japanese use different formats, covered at the end.
The lookup
Before the keyboard offers a candidate, it asks LanguageModeling.framework whether the token sequence it would complete is blocklisted (LMLanguageModelTokenSequenceIsBlocklisted). That runs, for each length n from 2 to 6:
- Map each language-model token to a blocklist word ID through
blocklistidmap.dat. Many tokens share an ID: the English map sends about 2,500 tokens to about 930 IDs, mostly inflections. A sequence containing any unmapped token can't be on the list. - Lay the n IDs out as little-endian
uint32s. That 4n-byte buffer is the key. - Look the key up in the length-n minimal perfect hash (explained below) to get a slot number.
- Compute a 32-bit fingerprint of the same n words with Bob Jenkins's
hashword(), and compare it with the fingerprint stored in that slot. A match sets the candidate's blocklisted flag (0x40000000in TextInputCore) and the candidate is dropped.
Minimal perfect hashing
An ordinary hash table stores its keys so it can tell them apart when two hash to the same place. A minimal perfect hash function is built once for a fixed set of N keys, and sends each of them to a different slot from 0 to N−1 with no collisions and no empty slots. Because there are no collisions to resolve, the keys don't need to be stored at all. In Apple's English tables the function costs two to three bits per key, which is why the whole table fits in under 30 KB.
The catch is that the function only behaves for the keys it was built from. Any other input still lands in some slot. So Apple stores a fingerprint of the real key in each slot, and the lookup only counts as a hit if the fingerprints agree.
Apple uses CHD (“compress, hash and displace”, from a 2009 paper by Belazzougui, Botelho and Dietzfelbinger) as implemented in CMPH, an open-source C library that Apple ships as /usr/lib/libcmph.dylib. CHD hashes keys into small buckets, then places buckets largest first, trying displacement values until every key in a bucket lands on a free slot; the chosen displacement for each bucket is stored compressed. A lookup hashes the key once, reads its bucket's displacement, and computes the slot. “Packed” is CMPH's flat serialized form, which cmph_search_packed() queries in place; Apple embeds that blob directly in blocklist.dat.
For recovery this design is the weak point: the fingerprint confirms any guess, and the slot tells you which entry it matched.
Try it
The fingerprint
The fingerprint is hashword() from lookup3.c, Bob Jenkins's public-domain hash from 2006 (his write-up). It's a fast, non-cryptographic hash for hash tables, also used in the Linux and FreeBSD kernels; hashword() is the variant for arrays of 32-bit words. Apple calls it unmodified, with an initial value of 0:
// k: n blocklist word IDs; returns the stored fingerprint uint32_t hashword(const uint32_t *k, size_t n) { uint32_t a, b, c; a = b = c = 0xdeadbeef + ((uint32_t)n << 2) + 0; // initval 0 while (n > 3) { a += k[0]; b += k[1]; c += k[2]; mix(a, b, c); n -= 3; k += 3; } switch (n) { case 3: c += k[2]; case 2: b += k[1]; case 1: a += k[0]; final(a, b, c); } return c; }
Because the words are added in groups of three, everything before the last group depends only on the prefix. For a four-word key the first three IDs and one mix() are computed once per prefix, and only the fourth ID varies before final(). That keeps the inner loop to a handful of rotates and adds, which vectorizes well.
The file format
blocklist.dat is small, under 30 KB for English. All integers are little-endian.
| Offset | Contents |
|---|---|
0 | Magic 0x87c2472f |
4 | Format version, 3 |
8 | Longest phrase length, 6 |
32 | Base for every offset below |
59 + 20(n−2) | Descriptor for length n: MPH offset and size, row offset and size (four uint32s), then a reserved byte and the bit widths of the fingerprint and two payload fields (32, 8, 8; the second payload is 0 bits at length 6) |
Each row is uint32 fingerprint, uint8 payload, uint8 payload. Only the first payload byte is used in this format; the second is left over from an older one. In the first byte, 0x80 marks the row as active, and a row without it never matches. The low seven bits are a category mask: a row matches only if the caller passes mask 0, or a mask that shares a bit with the row. Inside the keyboard, callers pass 0, 1 or 4. Mask 1 goes with checks on substrings and suffixes, and mask 4 with a check over recent context.
| Payload | Where it occurs |
|---|---|
0x81 | Every row of English and nearly every other language |
0x81–0x87 | Chinese, which mixes the bits. Names (0x85) and violence and contested topics (0x87) carry bit 4. |
0x84 | 71 two-word French rows in c16-413 only: thirteen groups of people each followed by animaux, criminels, menteurs, porcs or voleur, plus six sexual phrases with queue. Without bit 1, the mask-1 checks skip them. All 71 were gone in c17-449. |
One trap: offsets are relative to byte 32, including the row offset. Forgetting the +32 on rows shifts the table by five six-byte rows and still produces plausible-looking output.
The search
The English table uses about 930 word IDs, so the key space is small for short phrases:
| Length | Candidates | Entries in table |
|---|
Two to four words are searched exhaustively. Each hash goes through a cache-resident bit filter of the stored fingerprints, then an exact fingerprint set, and only the rare survivors pay for the real MPH lookup that confirms the slot. The replication package does the whole English table in about ten minutes on eight CPU cores; Metal kernels were used for the larger non-English tables.
Five and six words can't be enumerated, so they were grown from shorter hits by inserting and substituting every ID. Apple's lists are full of templates, so this closes quickly. The last fourteen empty English slots fell to a different trick. Word IDs are assigned in order of first appearance in Apple's source list (“pandemic of such scale destruction” is IDs 1940, 1198, 1942, 1943, 1944), and the only two IDs no known phrase used were 1645 and 1648. Searching five-word keys containing both found {Australia / India / Kenya / Nepal / Pakistan / US} {is / was / …} a very {bad / poor}, which filled exactly those fourteen slots.
False positives
A wrong guess passes with probability 2−32. Over 9304 ≈ 7.5 × 1011 four-word candidates that predicts about 174 coincidental matches; the iOS 27.0 default table produced 176. They're nonsense like lie technicolor bowl hoe, which hits the slot of Covid was a hoax with the same fingerprint, 6717641f. Two checks separate them without judging plausibility by eye:
- Family neighborsReal entries come in templates, so a real phrase has other entries one word away (Covid is a hoax, Covid was a scam). Coincidences don't.
- Independent tablesWord IDs are renumbered between builds, so the same phrase has different key bytes in older tables. A real phrase matches across builds; a coincidence matches only where it was found. In
c17-449, every one of the 158 default and 98 QuickPath contested four-word slots had exactly one candidate confirmed this way.
The keyboard can't tell them apart either. There's no second check: Apple's matcher accepts all 291 discarded c17-449 alternatives, and on an iPhone a coincidence like lie technicolor bowl hoe is blocked just like the real phrase. In practice it hardly matters, since nobody types them.
Naming the IDs
A recovered key is a tuple of word IDs. blocklistidmap.dat maps each ID back to language-model token IDs, and the lexicon spells those. The lexicon is read either through the private Lexicon.framework on macOS (dlopen, then LXLexiconCreate with a plain path string for dataFile; a CFURL crashes it) or with a pure-Python reader in the replication package. When an ID covers several tokens, the site shows the most natural one and lists the rest. About a dozen English IDs have no spelling in any lexicon Apple ships; keys containing them are left out.
Shared IDs explain some odd results. left and right are one ID, so left wing conspiracy and right wing conspiracy are the same key. Until iOS 27.0, conspiracy and propaganda shared one too.
Other languages
- Most languagesThe same format. Newer downloads embed their own word list in
blocklist.bundle, which helps naming, but long German, French and Hindi tables are only partly recovered. - Turkish, Korean, RussianWords are built from stems and suffixes, and their token IDs are computed (
WordRangeMin + stem × SuffixCount + suffix) from a class table inlstsfmap-<locale>.dat. - Chinese and Cantonese
Chinese.lm/blocklist.datis keyed directly by language-model tokens and includes single tokens. Blocked words still appear as pinyin candidates; what's suppressed is the next-word prediction after them. - Korean, JapaneseKorean also has
blocklistV2.dat, a bucketed UTF-16 word list (399 words). Japanese has a 15-entry plain-text list.
Limits
- The table proves membership of an ID tuple, not Apple's source spelling.
- It says nothing about why an entry exists. Many look machine-expanded from seed phrases, so a gap or an odd pairing may reflect the template, not a decision.
- Membership isn't the same as on-screen behavior. The behavior described on this site was checked on iPhones and in the iOS Simulator for selected phrases, not for every entry.
Reproduce it
The replication package fetches the current assets from Apple's servers and recovers a list from scratch. It needs Python 3.9+ and a C compiler, runs on macOS or Linux, and uses no Apple frameworks.
python3 recover.py # English, about 10 minutes on 8 cores python3 recover.py --lang de # any other language python3 recover.py --bundle path/to/en.lm --label ios-27.0 # a local copy