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)
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:
#include
#include
#include
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("%08xt%08xt%sn", 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:
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 namedblocklist.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
sqlite3 -readonly ~/Library/Keyboard/VulgarWordUsage.db 'select * from vword_usage'
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.
