开发者逆向分析 Apple 系统词典,揭示 macOS 键盘敏感词与分级机制
I dumped Apple’s word list
作者 speckx 通过逆向分析 macOS 的 Lexicon.framework 及内置词库,揭示了 Apple 键盘对词语的隐藏分级与分类机制。研究发现系统词典将词语分为粗俗、进攻性和敏感三个层级,并带有涵盖政治、宗教等主题的标志;其中英文敏感词表中仅包含了特朗普等特定政治人物,而多国词典在具体词汇的分级上存在显著差异。
I Dumped Apple's Fucking Word List
November 3, 2026
TL;DR
~/Library/Keyboard/VulgarWordUsage.dbis real. It records which swear words you use, per app and per recipient, so autocorrect learns to stop "ducking" you. On my Mac it is empty, and typing swear words into a native app did not fill it.- The interesting part is upstream. Apple's system dictionary flags every word with one of three tiers (vulgar, offensive, sensitive) and up to eleven topic flags (sex, drugs, religion, politics, violence, …).
- There is no public API for any of it. I read the flags with a few private C functions from
Lexicon.framework. Code below, in C and in Swift. The full dumps for 15 languages are in this gist (uncensored, so mind the content warning). - Highlights: Oedipus is vulgar. Tom and Mick are offensive. pee, poo, niggle and underling are sensitive. And "Trump" is the only living politician in the English sensitive tier, next to Hitler, Mao, Osama bin Laden and the Taliban. Biden, Obama and Putin are clean.
Content note: this article prints the words uncensored, slurs included, because the whole point is to see what Apple flags. The one exception is the n-word: it is in the list in English and several other languages, and I'm not writing it out.
It started with a tweet
Nick posted this, I asked where it lives, and he answered. A database that tracks my profanity is not something I can leave alone, so I opened mine.
The database
$ sqlite3 -readonly ~/Library/Keyboard/VulgarWordUsage.db .schema
CREATE TABLE properties (ROWID INTEGER PRIMARY KEY, key, value, UNIQUE (key));
CREATE TABLE IF NOT EXISTS "vword_usage" (
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
app TEXT, -- app identifier
recipient TEXT, -- recipient identifier
vword TEXT, -- vulgar word
word_reading TEXT, -- the reading form of a word (relevant for Japanese)
usage_count INTEGER DEFAULT 0,
last_use_timestamp REAL DEFAULT 0, -- last use timestamp (seconds since the reference date)
journaled
);
CREATE UNIQUE INDEX app_recipient_vword_word_reading_idx ON vword_usage(app, recipient, vword, word_reading);
So the keyboard can know that you say fuck in Messages to one particular friend, but not in Mail to your boss. The
unique index is on (app, recipient, word, reading), so that is the granularity.
Mine has zero rows. The properties table holds version = 2 and nothing else. The file was last written in April
2024.
What it is for
At WWDC in June 2023, Craig Federighi announced the iOS 17 autocorrect rewrite with this line: "In those moments where you just want to type a ducking word, well, the keyboard will learn it, too." (TechCrunch, NPR)
This database is how it learns. The symbol names in the macOS shared library cache spell out the flow:
TI::VulgarWordUsageDatabaseWrapper::set_current_app_id(CFString)
TI::VulgarWordUsageDatabaseWrapper::set_current_recipient_ids(vector<string>)
TI::VulgarWordUsageDatabaseWrapper::increment_usage_count(word, reading, recipients)
TI::VulgarWordUsageDatabaseWrapper::decrement_usage_count(...)
TI::VulgarWordUsageDatabaseWrapper::can_autocorrect(word, app, recipients)
TI::VulgarWordUsageDatabaseWrapper::can_autocorrect_somewhere(word, reading)
KB::FilterNonVulgarIfTopCandidateVulgar::filter_candidates(...)
KB::Candidate::every_vulgar_word_is_allowlisted(VulgarWordUsageDatabaseWrapper*, ...)
Plus log strings like Passing recipient IDs to the vulgar word usage database wrapper; number of IDs: %lu.
In plain terms:
- When you start typing, the keyboard engine tells the wrapper which app you're in and who you're writing to.
- When you type a vulgar word and keep it, its count goes up for that app and those recipients. Undoing the word brings the count back down.
- When the engine ranks suggestions, it asks whether every vulgar word in a candidate is allowlisted for this context. If it isn't, the clean alternative wins: ducking.
A _KSUserWordsSynchroniser sharedAcrossLanguagesVulgarWordUsageValue symbol suggests it syncs between devices like
your other learned words.
Mine is empty
I tried to fill it. Using agent-desktop, I opened a new TextEdit document
and typed well fuck this shit, damn it., first as a text insert, then as individual key presses. Nothing changed: no
row, no log line, and the file's modification date stayed in 2024.
I don't know why, and I couldn't work it out. Maybe the Mac never records anything here, or maybe it only does so in some apps or settings. If you've got rows in yours, tell me what you did.
That would be a short article. But the database only counts words that are already known to be vulgar. So who decides that?
Where the word list lives
Searching the dyld shared cache for "vulgar" turned up a C API with an LX prefix
(LXVulgarWordUsageDatabaseCreate, LXVulgarWordUsageDatabaseCanAutocorrect, …) and, more usefully, two functions on
dictionary entries:
_LXEntryGetCategoryFlags
_LXEntryGetMetaFlags
LX is Lexicon.framework, a private framework that reads the per-language dictionaries under
/System/Library/LinguisticData/. Every word in those dictionaries is an LXEntry with two 32-bit flag fields. If the
vulgarity rating is anywhere, it's in those bits.
There is no public API
I looked. NSSpellChecker, UITextChecker, NaturalLanguage and UILexicon don't expose vulgarity.
SensitiveContentAnalysis covers images and video, not text. The only places "profanity" shows up on
developer.apple.com are App Store age ratings and review guidelines.
So: private functions. Lexicon has no headers, but the C functions are exported, and their signatures are easy to guess because they follow CoreFoundation conventions. This program opens the dictionary for a locale, walks every entry, and prints both flag fields:
// lx.c — dump every word in a system lexicon with its flags.
// clang -O1 -o lx lx.c -F/System/Library/PrivateFrameworks -framework Lexicon -framework CoreFoundation
#include <CoreFoundation/CoreFoundation.h>
#include <stdbool.h>
#include <stdio.h>
typedef const void *LXLexiconRef, *LXCursorRef, *LXEntryRef;
extern CFStringRef kLXLexiconLocaleKey;
LXLexiconRef LXLexiconCreate(CFDictionaryRef options, CFErrorRef *error);
LXCursorRef LXLexiconCreateRootCursor(LXLexiconRef);
void LXCursorEnumerateEntriesRecursively(LXCursorRef, void (^)(LXEntryRef, bool *stop));
CFStringRef LXEntryCopyString(LXEntryRef);
uint32_t LXEntryGetCategoryFlags(LXEntryRef);
uint32_t LXEntryGetMetaFlags(LXEntryRef);
int main(int argc, char **argv) {
CFStringRef locale = CFStringCreateWithCString(NULL, argc > 1 ? argv[1] : "en_US", kCFStringEncodingUTF8);
const void *keys[] = {kLXLexiconLocaleKey}, *values[] = {locale};
CFDictionaryRef options = CFDictionaryCreate(NULL, keys, values, 1, &kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
LXLexiconRef lexicon = LXLexiconCreate(options, NULL);
if (!lexicon) return 1;
LXCursorEnumerateEntriesRecursively(LXLexiconCreateRootCursor(lexicon), ^(LXEntryRef entry, bool *stop) {
char word[512] = "";
CFStringRef s = LXEntryCopyString(entry);
if (s) { CFStringGetCString(s, word, sizeof word, kCFStringEncodingUTF8); CFRelease(s); }
printf("%08x\t%08x\t%s\n", LXEntryGetCategoryFlags(entry), LXEntryGetMetaFlags(entry), word);
});
}
$ ./lx en_US | wc -l
199019
$ ./lx en_US | awk -F'\t' '$3 == "fuck" || $3 == "table" || $3 == "Trump"'
00000000 00800002 fuck
00000000 00000000 table
00002000 02000081 Trump
The same thing in Swift, loading the functions at runtime with dlopen and dlsym so you don't need to link against
a private framework. It prints only words that carry one of the three tiers:
// lexflags.swift — swiftc -O lexflags.swift -o lexflags && ./lexflags en_US
import Foundation
let lexicon = dlopen("/System/Library/PrivateFrameworks/Lexicon.framework/Lexicon", RTLD_NOW)!
func sym<T>(_ name: String, as _: T.Type) -> T { unsafeBitCast(dlsym(lexicon, name)!, to: T.self) }
typealias LXRef = UnsafeRawPointer
let create = sym("LXLexiconCreate", as: (@convention(c) (CFDictionary, UnsafeMutablePointer<Unmanaged<CFError>?>?) -> LXRef?).self)
let rootCursor = sym("LXLexiconCreateRootCursor", as: (@convention(c) (LXRef) -> LXRef?).self)
let enumerate = sym("LXCursorEnumerateEntriesRecursively", as: (@convention(c) (LXRef, @convention(block) (LXRef, UnsafeMutablePointer<Bool>) -> Void) -> Void).self)
let copyString = sym("LXEntryCopyString", as: (@convention(c) (LXRef) -> Unmanaged<CFString>?).self)
let metaFlags = sym("LXEntryGetMetaFlags", as: (@convention(c) (LXRef) -> UInt32).self)
let category = sym("LXEntryGetCategoryFlags", as: (@convention(c) (LXRef) -> UInt32).self)
let localeKey = dlsym(lexicon, "kLXLexiconLocaleKey")!.load(as: CFString.self)
let locale = CommandLine.arguments.dropFirst().first ?? "en_US"
guard let lex = create([localeKey: locale] as CFDictionary, nil), let root = rootCursor(lex) else {
fatalError("could not open lexicon for \(locale)")
}
let tiers: [(UInt32, String)] = [(0x0080_0000, "vulgar"), (0x0100_0000, "offensive"), (0x0200_0000, "sensitive")]
enumerate(root) { entry, _ in
let meta = metaFlags(entry)
guard let tier = tiers.first(where: { meta & $0.0 != 0 })?.1,
let word = copyString(entry)?.takeRetainedValue() else { return }
print(tier, String(format: "%05x", category(entry)), word, separator: "\t")
}
Both are read-only. They open the same dictionary the keyboard uses and print it.
Decoding the bits
Apple doesn't document these flags, so every meaning below is inferred: I grouped words by bit and read samples until
each group's theme was obvious. The system code helps with one of them. An enum case called
offensiveVulgarSensitive sits right next to the vulgar-word code, and three adjacent meta bits match it.
The three tiers (meta flags)
| Bit | Tier | en_US words | Examples |
|---|---|---|---|
0x00800000 | vulgar | 1,050 | fuck, shit, cunt, cock, crap, wtf, fml, bullshitting, fucktard, shizzle, merde |
0x01000000 | offensive | 663 | kike, wop, dago, fag, Paki, japs, Klansman, retard, Oriental, ass, asshole |
0x02000000 | sensitive | 2,558 | damn, bitch, idiot, LSD, abortion, gun, suicide, anus, pee, poo |
In en_US a word has at most one tier: they never overlap. Out of 199,019 entries, 4,271 have one.
Roughly: vulgar is swearing and sex slang, offensive is slurs aimed at groups of people, and sensitive is everything a keyboard shouldn't offer you unprompted: mild swears, insults, drugs, weapons, extremism, disease and bodily functions.
The topics (category flags)
The other field is a topic bitmask, and a word can have several topics:
| Bit | Topic | en_US words | Examples |
|---|---|---|---|
0x00100 | excretion | 123 | feces, barf, crapper, piddle, pooped |
0x00200 | body parts | 958 | nipples, navel, buttock, perineum, triceps |
0x00400 | insults, identity-loaded | 823 | idiot, cretin, hick, pleb, sexist, homophobic, blacklist |
0x00800 | disease | 93 | Covid, herpes, PTSD, leprosy, monkeypox |
0x01000 | drugs | 222 | heroin, shroom, crackhead, hotbox, OD |
0x02000 | politics, extremism | 266 | Hamas, Goebbels, Leninism, democrat, swastikas, Trump |
0x04000 | religion, damnation | 420 | Sikhism, jihadi, Beelzebub, damn, hell, jeez |
0x08000 | sex | 473 | nude, nookie, lecher, harlots, Pornhub, Hooters |
0x10000 | violence, death | 373 | suicide, lynch, murders, hangings, slave, slaveholder |
0x20000 | weapons, terrorism | 268 | AK-47, grenadier, jihad, sarin, kamikaze, yellowcake |
0x40000 | catch-all "sensitive context" | 1,080 | God, Allah, Jews, gay, trans, bi, crazy, fat, ugly, sex, die |
The topic flags and the tiers are independent. 2,052 English words have a topic but no tier: gay, fat, God, Allah and sex are all tagged "sensitive context" but are not vulgar, offensive or sensitive. My guess is the topic is a hint ("be careful how you rank this") and the tier is the rule.
The lower meta bits are dull: proper nouns, abbreviations, camel-case brand names like iMac and tvOS. Nothing to see there.
The weird ones
This is where it gets fun. Some flags are obviously right, and some made me laugh.
Vulgar
- Oedipus: vulgar. Presumably because of motherfucker-adjacent jokes, or the complex. Sophocles would be thrilled.
- reams: vulgar. As in "to ream someone".
- cooties: vulgar. Your nine-year-old is swearing.
- orgy, sext, merde: fair enough.
Offensive
- Tom and Mick: offensive. Uncle Tom and the slur for Irish people, but also very common first names.
- Oriental: offensive.
- papooses: offensive.
- conservatards: offensive, the one political insult I found in the slur tier.
- niggardly: offensive. It means "stingy" and comes from Old Norse, unrelated to the slur. It has also caused real controversies, which is presumably why it's there.
- ass and asshole: offensive, not vulgar.
Sensitive
- pee and poo: sensitive, with the excretion topic. So are premenstrual, leprosy and herpesvirus.
- niggle (a small worry, of Scandinavian origin) and underling (Old English for a subordinate) are sensitive and share the insult topic. Similar words aren't flagged: snigger is clean but niggle is flagged, and minion, lackey, flunky and stooge are clean but underling is flagged. These look like hand-picked entries, not rules.
- blacklist and whitelist are sensitive with the insult/identity topic. That matches the industry move to
blocklist and allowlist. Apple's own phrase-filter files are named
blocklist.dat. - Braves and Chiefs (team names with Native American mascots), Londonderry (the contested name for Derry), Gentile, Jehovah, Mongol and Frenchier are all sensitive.
- Isis is sensitive with the politics topic. Sorry to the Egyptian goddess.
- slave, slavery and slaveholder are sensitive with the violence topic.
- idiot is sensitive in English. Norwegian is stricter and puts idiot in the offensive tier.
The Trump flag
The sensitive tier has 109 capitalized words. Here is all of it:
Al-Qaeda, Al-Qaida, Aryan, Aryans, Auschwitz, Bacchus, Beelzebub, Birkenau, Brave(s), Buchenwald, Chief(s), Chippendale(s), Christ-y, Christer(s), Crip(s), Dachau, Eichmann, Frenchier, Gentile(s), Gestapo(s), Goebbels, Goering, Hamas, Hezbollah, Himmler, Hitler, Hitlerian, Hitlerism, Hitlerite(s), Hitlerjugend, Hooters, Isis, Islamophobe(s), Islamophobic, Jehovah(s), Jewfro(s), Jezebel(s), Kalashnikov(s), Kristallnacht, Kuomintang, Londonderry, Luftwaffe, Mao, Mauthausen, Mickie(s), Mollie(s), Mongol(s), Mossad, Nazi(s), Nazism, Osama, Pornhub, Quaalude(s), Reich, Reichsmark, Reichstag, Sachsenhausen, Satan, Satanism, Shoah, Sinophobe(s), Sinophobic, Sodom, Stasi, Taliban, Treblinka, Trump, Trumps, Unabomber, Uzi(s)
(Plurals and possessives folded for brevity.)
Every other real person on that list is dead, and nearly all of them are Nazi officials, dictators or terrorists. Then there's Trump:
category meta word
00002000 02000081 Trump sensitive tier + politics topic
00002000 02000081 Trumps
00002000 02000081 Trump's
00000000 00000000 trump (the card-game word: clean)
00000000 00000000 trumped
For comparison:
| Word | en_US |
|---|---|
| Trump | sensitive, politics topic |
| Hitler | sensitive, politics topic |
| Biden | clean |
| Obama | clean |
| Putin | clean |
| Stalin | clean |
| Musk | not in the dictionary |
en_GB matches en_US. Other languages differ: the German dictionary has Trump clean (and puts Hitler in the
offensive tier), and Swedish is the one that flags Putin as sensitive.
What the flag does matters here. As far as I can tell, a sensitive word is one the keyboard shouldn't suggest on its own. You can still type it and it won't be corrected away. Think "keep this out of the QuickType bar unless the user is clearly heading there", which is a lot milder than a ban.
There is a plausible reason Apple would want that for one specific name. In February 2025, iPhone dictation briefly showed "Trump" when people said "racist". It went viral, and Apple called it a "phonetic overlap" in the speech model and shipped a fix within a day (9to5Mac). The dictionary files on my Mac are from July 2025. A rule that keeps the name out of unprompted suggestions would stop that kind of thing from happening again through the keyboard. I can't prove that's why it's there, and I don't have an older dictionary to compare against. But it fits.
Norwegian politics, for comparison
Because I'm Norwegian and had to check:
| Word | Topic | Tier |
|---|---|---|
| FrP, Høyre, Venstre, Rødt, SV, KrF, MDG | politics | none |
| Støre | politics | none |
| Solberg, Erna, Sylvi | none | none |
| Listhaug | n/a | not in the dictionary |
| Breivik, Quisling, Hitler | none | sensitive |
Every party gets the politics topic and none gets a tier. The only Norwegians in the sensitive tier are a mass murderer and a Nazi collaborator.
Other languages
The same three tiers exist in every European dictionary I tried, Cyrillic included. The counts are entries, not lemmas, so heavily inflected languages like Polish and Russian look much worse than they are: every case and conjugation of a swear word is a separate entry.
| Locale | Vulgar | Offensive | Sensitive | Notable |
|---|---|---|---|---|
| en_US | 1,050 | 663 | 2,558 | idiot is sensitive |
| en_GB | 1,055 | 872 | 2,337 | chav is offensive; chink is filed as vulgar, not offensive |
| nb_NO | 2,393 | 1,324 | 6,446 | idiot is offensive |
| da_DK | 1,179 | 3,314 | 8,551 | fjols ("fool") is offensive |
| sv_SE | 637 | 1,247 | 16,193 | idiot is vulgar; Putin is sensitive |
| fi_FI | 569 | 872 | 47 | The entire sensitive tier is kakka ("poop"), kakkakasa ("pile of poop") and saakeli |
| de_DE | 3,218 | 3,518 | 9,446 | Hitler and beknackt ("daft") are offensive; Trump is clean |
| nl_NL | 57 | 1,180 | 3,805 | Only 57 vulgar words, but kanker ("cancer", the classic Dutch swear) is one |
| fr_FR | 2,151 | 262 | 5,562 | boche is offensive |
| es_ES | 3,931 | 313 | 13,431 | sudacas and fachas are offensive |
| it_IT | 3,886 | 377 | 6,543 | terrone is offensive, and so is nazismi |
| pt_BR | 1,590 | 1,891 | 3,332 | machista and velhinhos ("little old people") are offensive |
| pl_PL | 9,698 | 1,436 | 6,190 | The biggest vulgar tier here, mostly inflections (dupczysz, srasz) |
| ru_RU | 10,821 | 3,239 | 24,721 | Same story: мат inflects a lot |
The bits don't seem to mean the same thing everywhere. Arabic and Hebrew return hundreds of thousands of "sensitive" entries, so those dictionaries probably reuse the bits for something else, and I left them out. Korean, Catalan and Hungarian return nothing, and Japanese crashes my dumper.
Samples from Norwegian, since that's what I type in when I'm not typing English:
- Vulgar: faen, jævla, dritt, fitte, kuk, pule, pissehull, rævhull, pulekanin, kåt, morrabrød.
- Offensive: idiot, sigøyner, kinamann, riskokers (literally "rice cookers", a slur for Asians), plus the Norwegian n-word.
- Sensitive: helvete, satan, homo, quisling, Breivik, skinhead, abortnekt, trusene ("the panties").
The tier words are mostly what you'd expect, but the edges are different in each language, and that is where the human-edited parts show. Norwegian calls idiot offensive and English calls it sensitive. German thinks Hitler is a slur.
Phrases, not just words
There is a second, separate filter. Every English language model has a blocklist.bundle, and there's a
qp_blocklist.bundle for QuickPath, the swipe keyboard:
/System/Library/LinguisticData/RequiredAssets_en.bundle/AssetData/en.lm/
├── blocklist.bundle/{blocklist.dat, blocklistidmap.dat}
└── qp_blocklist.bundle/{blocklist.dat, blocklistidmap.dat}
blocklistidmap.dat is a 20-byte header followed by (lexicon token ID, class ID) pairs of 32-bit integers. Mapping
the token IDs back through the dictionary gives 2,544 words in 930 classes. Most classes are inflection groups:
931 fuck | fucked | fucking | fucks
1181 molest | molestation | molested | molester | molesting
1514 died | dies | dying
1611 Holocaust | holocaust | holocausts
1614 denied | denies | deny | denying
923 Semite | Semites | Semitic
1487 ghetto | ghettoes | ghettos
1720 animals | brown | cats
blocklist.dat is a packed trie over those class IDs, so it blocks sequences: "deny" followed by "Holocaust", and
whatever 1720 pairs with. A class that puts brown next to animals and cats suggests the list targets specific
phrasings rather than single words, though which phrasings I can't tell without decoding the trie. The runtime check is LMLanguageModelTokenSequenceIsBlocklisted in
LanguageModeling.framework. I got far enough to learn that its fourth and fifth arguments include an output
pointer, but my probe still crashes intermittently, so I haven't read the actual phrases yet. That's for a follow-up.
Caveats
- Meanings are inferred. Apple documents none of these bits. The tier names come from an enum in the system code and the topics from reading thousands of samples. Both are consistent, but they're still a reading.
- Flagged is not censored. As far as I can tell these flags decide what the keyboard volunteers, not what you can type.
- One machine. Everything here comes from macOS 15.6 (24G84) with LinguisticData build 438 from July 2025. iOS and other macOS versions may ship different dictionaries.
- Private API. The functions can change or disappear in any update. Don't ship this in an app.
Try it yourself
# Your own learned-swears database (probably empty on a Mac)
sqlite3 -readonly ~/Library/Keyboard/VulgarWordUsage.db 'select * from vword_usage'
# Build the Swift version from above and filter by tier
swiftc -O lexflags.swift -o lexflags
./lexflags en_US | awk -F'\t' '$1 == "vulgar" {print $3}' | sort -u | less
./lexflags nb_NO | awk -F'\t' '$3 == "faen" || $3 == "helvete" || $3 == "idiot"'
If you'd rather not build anything, the gist has every tiered word for 15 locales as TSV, plus the code and a script that regenerates them. It's a snapshot of macOS 15.6 and won't be kept up to date.
If you find something funnier than Oedipus, I want to hear about it.
来源:Hacker News-首页 · helgesver.re