I Dumped Apple’s Fucking Word List


November 3, 2026

TL;DR

  • ~/Library/Keyboard/VulgarWordUsage.db is 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:

  1. When you start typing, the keyboard engine tells the wrapper which app you’re in and who you’re writing to.
  2. 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.
  3. 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 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


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.




Source link