<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tomer N</title>
    <description>The latest articles on DEV Community by Tomer N (@tomer_n).</description>
    <link>https://dev.to/tomer_n</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4206398%2F0d23c795-8bac-490f-a10c-98721968a44f.png</url>
      <title>DEV Community: Tomer N</title>
      <link>https://dev.to/tomer_n</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tomer_n"/>
    <language>en</language>
    <item>
      <title>Why Roblox Turns Your Messages Into ###, and What a Game’s Chat Filter Should Do Instead</title>
      <dc:creator>Tomer N</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/tomer_n/why-roblox-turns-your-messages-into-and-what-a-games-chat-filter-should-do-instead-kfk</link>
      <guid>https://dev.to/tomer_n/why-roblox-turns-your-messages-into-and-what-a-games-chat-filter-should-do-instead-kfk</guid>
      <description>&lt;p&gt;Picture a player typing a quick warning to a friend: there's a monster right behind you. They throw in a swear word, because it's urgent. Their friend sees &lt;code&gt;###########&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's not my example. It's Roblox's own, from a post its safety team published in March 2026 about why it's moving away from hash marks. And it's the best summary I've seen of what goes wrong when a game filters chat badly: the rule gets enforced, and the conversation dies. &lt;em&gt;(Drafted with AI, sources checked by hand.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've played or built a game with chat, you've probably run into these. What does ### mean in Roblox? Why can't I say Discord? Why did Kahoot rename me? So I went through what Roblox and Kahoot actually say their filters do, case by case, and what each one teaches you if you're building chat for your own game.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ### means
&lt;/h2&gt;

&lt;p&gt;Roblox's help center answers it in one line: when you see hashtags in place of words, the word or phrase "was blocked by our filters."&lt;/p&gt;

&lt;p&gt;The filter isn't one list. In the March post, Roblox describes three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rules&lt;/strong&gt;, which can block new terms within minutes when something happens in the real world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small specialized models&lt;/strong&gt;, trained to catch the words Roblox doesn't allow. Roblox says these are good at that task but "can't reason very well," so they miss language that changes fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large models&lt;/strong&gt;, added this year. When the small ones aren't sure, the text goes to a bigger model that can weigh more context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roblox says the combination catches more leetspeak (letters swapped for numbers or symbols) and cut missed attempts to share personal information, like phone numbers and social handles, by 20 times.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2esg54m4ob4vqzjp8yvk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2esg54m4ob4vqzjp8yvk.webp" alt="Roblox's help center answering why players see so many hashtags in chat: the word or phrase was blocked by its filters, and some messages are rephrased instead" width="799" height="192"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: Roblox's Experience Chat help article&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Where it goes wrong, case by case
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. The whole line disappears
&lt;/h3&gt;

&lt;p&gt;The monster example is the problem in one picture. The filter found something it didn't allow, and the reader lost the entire message, including the part that mattered for the game.&lt;/p&gt;

&lt;p&gt;Roblox's fix, since March, is to rephrase instead of hash, starting with profanity. Its example: "Hurry tf up!" becomes "Hurry up!" Everyone in the chat is told the message was rephrased, and the swearing still counts against the sender. More serious messages still go through the rest of its safety system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hv4pwpce1hnjw8ol173.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hv4pwpce1hnjw8ol173.webp" alt="One chat line three ways: the whole message hidden as hash marks, only the swear words masked, and the message rephrased so the warning about the monster still arrives" width="800" height="280"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Roblox's monster example as it hid it, then masked and rephrased by me&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You don't need a language model to do better than hiding the whole line. Masking only the words that broke a rule keeps the warning readable. Rephrasing is nicer, but it's a bigger build, and Roblox itself says it "won't always rephrase things perfectly."&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Nobody says why
&lt;/h3&gt;

&lt;p&gt;A row of hash marks doesn't tell the sender which word was the problem, or whether they're in trouble. Roblox's answer was real-time warnings, added in May 2025, that tell players when they've broken a rule. In its experiments, warnings and time-outs cut filtered messages by 5% and consequences from abuse reports by 6%.&lt;/p&gt;

&lt;p&gt;That's the cheapest fix on this list. If your filter blocks something, tell the sender what kind of thing it was.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. "Discord" isn't a swear word
&lt;/h3&gt;

&lt;p&gt;This one confuses players the most. Roblox's filter isn't only about language. Its Community Standards say you may not share links to outside sites except through the Social Links feature on a game, group or profile page, which needs an age check and an age of at least 16. Any other effort "to direct users off of Roblox" is prohibited. Its safety page says filtered chat blocks personal information "and instructions on how to move off the platform."&lt;/p&gt;

&lt;p&gt;So "add me on Discord" isn't blocked because Discord is rude. It's blocked because it's directions off the platform, which the rules forbid.&lt;/p&gt;

&lt;p&gt;The lesson for your game: decide what you're filtering for, and write it down. Swearing, slurs, threats, personal information and off-platform contact are different rules, and players accept them better when they can tell them apart.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. The list catches innocent words
&lt;/h3&gt;

&lt;p&gt;Kahoot's nickname filter is a classic word list. Kahoot keeps a list of words it deems universally inappropriate, checks every nickname against it, and changes a match to something neutral. Its help page even warns teachers that a group of players joining with names like "router" may be the filter at work.&lt;/p&gt;

&lt;p&gt;To its credit, Kahoot gives teachers a way back: they can ask support to allow a wrongly filtered nickname, or add a missed one to the deny list. It also offers a nickname generator in Classic mode, where players spin for a friendly two-word name like "kind tiger" instead of typing one.&lt;/p&gt;

&lt;p&gt;That's the honest trade-off. A list will always block some innocent names, so you need a path to fix it, and for young players, picking from safe names beats filtering typed ones.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Everyone gets the same filter
&lt;/h3&gt;

&lt;p&gt;A ten-year-old and an adult shouldn't see the same chat. Roblox's safety page says players 12 and younger also have personal information filtered out, and that in restricted games marked for strong language, users 18 and older may be allowed to swear, as long as it isn't used to harass, threaten or discriminate.&lt;/p&gt;

&lt;p&gt;If your players span ages, make the strictness a setting, per server or per player.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Banter isn't abuse
&lt;/h3&gt;

&lt;p&gt;This is the hardest one. In a game, "nice try loser" is usually a taunt, and "I know where you live" never is. A word list can't tell them apart, because the difference isn't in the words. It's in whether the line is about the game or about a person.&lt;/p&gt;

&lt;p&gt;Full disclosure, I build Unprofane, and this is the problem its &lt;a href="https://unprofane.com/docs/contexts/" rel="noopener noreferrer"&gt;&lt;code&gt;gaming&lt;/code&gt; context&lt;/a&gt; was made for: in its docs, &lt;code&gt;gg ez, uninstall&lt;/code&gt; and &lt;code&gt;nice try loser&lt;/code&gt; are allowed there, while &lt;code&gt;kys noob&lt;/code&gt; and &lt;code&gt;I know where you live&lt;/code&gt; are blocked.&lt;/p&gt;
&lt;h2&gt;
  
  
  If you're building on Roblox
&lt;/h2&gt;

&lt;p&gt;Roblox filters its own chat for you, but not everything else. Its creator docs say you're responsible for filtering any text you display that you don't control, like signs players write on, pet names saved in data stores, words your game generates at random, or content fetched from an outside server. If Roblox finds a game that doesn't filter, it removes the game until you add filtering.&lt;/p&gt;

&lt;p&gt;The call is &lt;code&gt;TextService:FilterStringAsync&lt;/code&gt;, run on the server with the text and the &lt;code&gt;UserId&lt;/code&gt; of whoever wrote it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;TextService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;game&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;GetService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TextService"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;filterForEveryone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;pcall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TextService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;FilterStringAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="c1"&gt;-- show nothing when the filter fails&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ok2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;pcall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;GetNonChatStringForBroadcastAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ok2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;filtered&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;GetNonChatStringForBroadcastAsync&lt;/code&gt; for text everyone sees and &lt;code&gt;GetNonChatStringForUserAsync&lt;/code&gt; for text shown to one player, which filters for that player's age. And the docs ask you not to filter on every keystroke: filter once the player submits.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building your own
&lt;/h2&gt;

&lt;p&gt;Here's the short version of everything above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mask the word, not the line.&lt;/strong&gt; Keep the rest of the message readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell the sender why.&lt;/strong&gt; A category is enough: swearing, personal info, a link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the rules separate.&lt;/strong&gt; Language, threats and off-platform contact are different checks with different answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give players a way back.&lt;/strong&gt; A report button for wrong blocks, and an allow list you actually update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set strictness by age.&lt;/strong&gt; Stricter for kids, looser for adults in adult spaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what happens when the check fails.&lt;/strong&gt; Show nothing, like Roblox's sample does, or hold the message for a moment. Don't let a timeout quietly turn the filter off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offer safe choices for the youngest players.&lt;/strong&gt; Kahoot's nickname generator is a good model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this needs a big team. Most of it is a decision you write down before launch instead of after the first angry thread.&lt;/p&gt;

&lt;p&gt;Have fun building, and may your players never see ########### again :)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I work on Unprofane, a profanity and abuse filter for user text, including game chat.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>programming</category>
      <category>roblox</category>
      <category>moderation</category>
    </item>
    <item>
      <title>How to Move Off Perspective API in Python Before December 31</title>
      <dc:creator>Tomer N</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/tomer_n/how-to-move-off-perspective-api-in-python-before-december-31-70</link>
      <guid>https://dev.to/tomer_n/how-to-move-off-perspective-api-in-python-before-december-31-70</guid>
      <description>&lt;p&gt;Somewhere in a lot of Python codebases there's a call to &lt;code&gt;discovery.build("commentanalyzer", "v1alpha1", ...)&lt;/code&gt;. It scores comments for toxicity, it's worked for years, and after December 31, 2026 Google's Perspective API won't answer it anymore.&lt;/p&gt;

&lt;p&gt;Google's notice says the service stays up until that date, there's no extension, and they won't offer migration help. So I'm going to walk through the move myself, in the order I'd do it, with the code. &lt;em&gt;(Written with AI help; facts checked.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;By the end you'll have the same Python client making the same calls, pointed at a Perspective-compatible API, with a separate key for staging and production, a threshold you picked on your own data and a one-line way back.&lt;/p&gt;

&lt;p&gt;Full disclosure, I built Unprofane, the API this guide switches to. I'll also say where something else is the better pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Find every place that calls Perspective
&lt;/h2&gt;

&lt;p&gt;Before changing anything, list the calls. A plain search over your Python files usually turns them all up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"commentanalyzer"&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"comments()"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for three things in what it prints: the &lt;code&gt;discovery.build&lt;/code&gt; call, every &lt;code&gt;comments().analyze(...)&lt;/code&gt; and &lt;code&gt;comments().suggestscore(...)&lt;/code&gt;, and any batch built with &lt;code&gt;new_batch_http_request()&lt;/code&gt;. Also note which attributes you request. That matters in step 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Get a key for each environment
&lt;/h2&gt;

&lt;p&gt;Make two keys in Unprofane's dashboard: a test key that starts with &lt;code&gt;up_test_&lt;/code&gt; for staging and a live key that starts with &lt;code&gt;up_live_&lt;/code&gt; for production.&lt;/p&gt;

&lt;p&gt;The test key is free and answers 100 checks a month across the account, with no card. A live key needs a paid plan, and the smallest is $9 a month for 25,000 checks.&lt;/p&gt;

&lt;p&gt;Keep your Google key. Until December 31 it's your rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Change the build call
&lt;/h2&gt;

&lt;p&gt;The Google client doesn't hardcode Google's host. It builds itself from a discovery document, and Unprofane serves one that names its own host. So the change is one argument, &lt;code&gt;discoveryServiceUrl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;googleapiclient&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;discovery&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;discovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;commentanalyzer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1alpha1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;developerKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNPROFANE_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# up_test_YOUR_KEY on staging, up_live_YOUR_KEY in production
&lt;/span&gt;    &lt;span class="n"&gt;discoveryServiceUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.unprofane.com/$discovery/rest?version=v1alpha1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;static_discovery&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you are a clown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requestedAttributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOXICITY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;languages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doNotStore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributeScores&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOXICITY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summaryScore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the key from an environment variable means staging and production run the same code and differ only in the value. &lt;code&gt;static_discovery=False&lt;/code&gt; makes the client fetch the discovery document from the URL you pass, instead of looking for a copy bundled inside the library.&lt;/p&gt;

&lt;p&gt;Everything after &lt;code&gt;build&lt;/code&gt; stays as it is. The path, the request body and the &lt;code&gt;?key=&lt;/code&gt; parameter are Google's.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What it returns
&lt;/h2&gt;

&lt;p&gt;The response has Perspective's shape: &lt;code&gt;attributeScores&lt;/code&gt; with a &lt;code&gt;summaryScore&lt;/code&gt; and &lt;code&gt;spanScores&lt;/code&gt; for each attribute, then &lt;code&gt;languages&lt;/code&gt; and &lt;code&gt;detectedLanguages&lt;/code&gt;. Asking for TOXICITY and INSULT looks like this (the numbers show the shape, not a score to expect):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attributeScores"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"TOXICITY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"spanScores"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"begin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PROBABILITY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"summaryScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PROBABILITY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"INSULT"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"summaryScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PROBABILITY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spanScores"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"languages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detectedLanguages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Errors come back the way Google sent them too: an &lt;code&gt;HttpError&lt;/code&gt; with Google's status and Google's wording. So the &lt;code&gt;except&lt;/code&gt; blocks you already wrote keep working.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Run it on staging
&lt;/h2&gt;

&lt;p&gt;Point staging at the test key and run the tests you already have for your moderation code: a clean comment, a nasty one, an empty one, a very long one, and a bad key. The refusals come back as the same &lt;code&gt;HttpError&lt;/code&gt; shapes, so if your tests asserted on Google's error messages, they should still pass. If one fails, the next section is usually why.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Check what doesn't carry over
&lt;/h2&gt;

&lt;p&gt;Here's the part to read before you ship, not after.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seven production attributes are scored:&lt;/strong&gt; TOXICITY, SEVERE_TOXICITY, IDENTITY_ATTACK, INSULT, PROFANITY, THREAT and SEXUALLY_EXPLICIT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The New York Times attributes aren't.&lt;/strong&gt; If your code asks for SPAM, LIKELY_TO_REJECT, INFLAMMATORY or the others, you get the same error Google gave for an attribute it didn't know. Remove them, or set &lt;code&gt;dropUnsupportedAttributes&lt;/code&gt; to leave them out quietly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google's account errors have no match.&lt;/strong&gt; A key restricted to a referrer or an IP address, "API not enabled", billing: none of that exists here. A key is valid or it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batches have limits Google didn't have.&lt;/strong&gt; A batch answers at most 100 parts, 30,000 characters of comments together and 256 KiB. Past that it's refused whole with &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;, so split big batches. Each part counts as one check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;suggestscore still answers &lt;code&gt;{}&lt;/code&gt;.&lt;/strong&gt; The moderator score you send is stored as a correction on your account, and it doesn't change any score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one difference runs the other way, which is easy to miss. Some comments Google refused now get a score:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;comments in languages Google detected but didn't score, like Hebrew and Turkish,&lt;/li&gt;
&lt;li&gt;SEXUALLY_EXPLICIT outside English (Google scored it in English only),&lt;/li&gt;
&lt;li&gt;HTML comments, which are scored without their tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your code caught Google's error for an unsupported language and published the comment anyway, those comments now go through your threshold like everything else. That's probably what you wanted all along, but your moderation queue may grow on day one, so tell your moderators.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Pick a new threshold
&lt;/h2&gt;

&lt;p&gt;This is the step that actually takes time, and the one I'd never skip.&lt;/p&gt;

&lt;p&gt;A threshold belongs to the engine that produced the scores. 0.8 on Perspective isn't 0.8 anywhere else, and even on Perspective itself a small move changes a lot. On my own benchmark (run on September 28, 2026, 2,672 toxic and 3,528 clean texts labeled blind by AI judges), Perspective at 0.7 caught 33.0% of the toxic texts and flagged 1.0% of the clean ones. At 0.479 it caught 58.4% and flagged 7.0%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3csaxoiws77qx516rvbs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3csaxoiws77qx516rvbs.png" alt="Two bars for Perspective API on the same texts: at threshold 0.7 it catches 33.0% of toxic texts and flags 1.0% of clean ones; at 0.479 it catches 58.4% and flags 7.0%" width="800" height="310"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Perspective API on Unprofane's own head-to-head benchmark, run September 28, 2026&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So don't copy the old number. Two things help:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Map it.&lt;/strong&gt; The &lt;a href="https://unprofane.com/perspective/replay/" rel="noopener noreferrer"&gt;replay tool&lt;/a&gt; takes a CSV of recent texts with the scores Perspective gave them, and shows, for each old threshold, the threshold on Unprofane that flags the same share and how often the two agree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then judge it on decided comments.&lt;/strong&gt; The same queue size doesn't mean the same comments, and agreeing with Perspective also means copying its mistakes. Run a sample your moderators already approved and removed through staging. For each candidate threshold, count the removed comments it catches and the approved ones it flags, read the disagreements, and then choose.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  8. Keep a way back
&lt;/h2&gt;

&lt;p&gt;Rolling back is the same argument, pointed home, with your Google key back in the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;discoveryServiceUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works until December 31. After that there's nothing to roll back to, which is the best reason to switch in November and not on the last week of December.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you'd rather use something else
&lt;/h2&gt;

&lt;p&gt;The honest version: if Perspective was only giving you a rough harassment and hate signal and you want to pay nothing, OpenAI's moderation endpoint is free. It isn't a drop-in, though. You'd rewrite the call and the parsing, its categories are harassment, hate, illicit, self-harm, sexual and violence (no profanity), and on my benchmark, at the same 7.0% clean-flagged rate, it caught 40.8% of the toxic texts.&lt;/p&gt;

&lt;p&gt;Unprofane caught 89.0% at that rate on the same texts. That's my own benchmark on a set I built, so weigh it that way, and run your moderators' sample through whatever you pick. Its weak spots: it's a young product, it reads text only, and past the free test key you're on a paid plan.&lt;/p&gt;

&lt;p&gt;The full guide with the test notes is &lt;a href="https://unprofane.com/perspective/python/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it goes wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HttpError 400&lt;/code&gt; naming an attribute:&lt;/strong&gt; you're requesting one that isn't scored (step 6).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It still talks to Google:&lt;/strong&gt; check that &lt;code&gt;discoveryServiceUrl&lt;/code&gt; is passed to the &lt;code&gt;build&lt;/code&gt; you actually run, and that &lt;code&gt;static_discovery=False&lt;/code&gt; is there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt;:&lt;/strong&gt; a test key's monthly or daily cap is used up, or your plan's month is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A batch refused with &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;:&lt;/strong&gt; it's over 100 parts, 30,000 characters or 256 KiB. Send several smaller ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole move. The code part takes a few minutes, and the threshold part deserves a few days.&lt;/p&gt;

&lt;p&gt;Good luck with the switch, and I hope your January is quiet :)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I make Unprofane, which checks what people type for swearing and abuse.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>api</category>
      <category>moderation</category>
    </item>
    <item>
      <title>Why Your Profanity Filter Blocks Scunthorpe (And How to Fix It)</title>
      <dc:creator>Tomer N</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/tomer_n/why-your-profanity-filter-blocks-scunthorpe-and-how-to-fix-it-32jn</link>
      <guid>https://dev.to/tomer_n/why-your-profanity-filter-blocks-scunthorpe-and-how-to-fix-it-32jn</guid>
      <description>&lt;p&gt;In April 1996 a man named Doug Blackie tried to sign up for AOL. He typed his name, then his hometown, Scunthorpe, and the form wouldn't take it. After some back and forth with AOL's people in Dublin, the way he finally got in was to say he lived in "Sconthorpe".&lt;/p&gt;

&lt;p&gt;You can probably see why. Four letters inside the town's name spell a word AOL's filter was told to refuse, and the filter never looked further than that. Thirty years later, it's still one of the easiest bugs to ship in a signup form, and it has a name: the Scunthorpe problem. &lt;em&gt;(Drafted with AI help; every fact is sourced.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I want to walk through why it keeps happening, and the four ways people usually write the check, with real code and what each one gets wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not just one town
&lt;/h2&gt;

&lt;p&gt;Once you start looking, the list is long. Wikipedia keeps a page of real cases, and a few of them stuck with me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In 2004 Hotmail refused Craig Cockburn's surname, and at first told him to spell it C0ckburn, with a zero.&lt;/li&gt;
&lt;li&gt;The Horniman Museum in London lost email in 2004 because filters read its name as "horny man".&lt;/li&gt;
&lt;li&gt;In 2010 Xbox Live suspended a player for saying he lived in Fort Gay, West Virginia.&lt;/li&gt;
&lt;li&gt;In 2018 a grocery store's cake form wouldn't print "summa cum laude", and the customer's cake came out with dashes where "cum" should have been.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is the same mistake: a word was found inside a longer word, and nobody checked what the longer word was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;A filter has two jobs that pull against each other.&lt;/p&gt;

&lt;p&gt;It has to catch the bad word when it's glued to something else: "dumbass", "shithead", a username like "xXfuckfaceXx". Those don't have spaces around the bad part, so the filter has to look inside words.&lt;/p&gt;

&lt;p&gt;And it has to leave innocent words alone, which means not looking inside words. Scunthorpe, Cockburn, classic and cockpit all contain a bad word, and none of them is one.&lt;/p&gt;

&lt;p&gt;You can't fully do both with a list of strings. Every fix below moves the line between the two, and you get to pick where.&lt;/p&gt;

&lt;p&gt;To keep things concrete, all four versions use this short list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cunt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;penis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fuck&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. Look for the bad word anywhere
&lt;/h2&gt;

&lt;p&gt;This is the version most of us write first, and it's what AOL had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findProfanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;BAD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It catches "dumbass", "shithead" and "xXfuckfaceXx". It also blocks Scunthorpe, Craig Cockburn, "a classic mistake" and "cockpit". It's thorough in both directions, which is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Only match whole words
&lt;/h2&gt;

&lt;p&gt;The usual next step is a word boundary in a regular expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WHOLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BAD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findProfanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;WHOLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Scunthorpe goes through, and so does everything else. "dumbass" isn't the word "ass", "shithead" isn't "shit", and a username has no word boundaries at all. On the eight texts in the grid further down, it blocked nothing innocent and caught none of the four rude ones.&lt;/p&gt;

&lt;p&gt;That's fine for some products. If your users write sentences and mostly swear in whole words, this might be enough. For usernames it does almost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Strip an allow list, then look anywhere
&lt;/h2&gt;

&lt;p&gt;So you go back to version 1 and add exceptions. The tempting way is to delete the allowed words from the text before checking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scunthorpe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cockburn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hancock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clitheroe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;classic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cockpit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findProfanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;BAD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say "bass" is on there because a music forum's users kept getting blocked. Now look at "dumbass": the last four letters are "bass", they get deleted, and the insult goes through clean. Stripping substrings has the same bug as matching them, just pointed the other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check word by word, with a dictionary
&lt;/h2&gt;

&lt;p&gt;The version I'd actually ship splits the text into words first, and only looks inside a word when that word isn't a real one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DICTIONARY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/usr/share/dict/words&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OURS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scunthorpe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cockburn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hancock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clitheroe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findProfanity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{L}+/gu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BAD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DICTIONARY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;OURS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BAD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are going on here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A word that's on the bad list by itself is blocked, even though "ass" and "cock" are also in the dictionary.&lt;/li&gt;
&lt;li&gt;A real dictionary word is left alone, so "classic", "cockpit", "assassin", "cockatoo" and even "Penistone" pass without you listing them. macOS ships that file with 235,976 words, and Debian and Ubuntu install one with the wamerican package.&lt;/li&gt;
&lt;li&gt;Anything else, like "dumbass" or "xxfuckfacexx", gets the substring check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb79yqini6gc0e7z6wzkr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb79yqini6gc0e7z6wzkr.png" alt="A grid of eight texts checked four ways: substring, whole words, stripped allow list and dictionary. The dictionary version blocks no innocent text and catches three of four rude ones" width="800" height="410"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The four versions on the same eight texts, run on October 27, 2026&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The dictionary doesn't know names, though. Scunthorpe, Cockburn, Hancock and Clitheroe aren't in it, which is why &lt;code&gt;OURS&lt;/code&gt; exists. That's where you put the towns, surnames and product names your own users type. It's a much shorter list than an allow list for every English word, and it only grows when a real person hits it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of these fix
&lt;/h2&gt;

&lt;p&gt;Every version above missed "f.u.c.k off". Splitting on letters turns it into four single letters, and none of them is on the list. Disguises like dots, digits and look-alike letters from other alphabets are a whole separate problem, and a list of strings doesn't solve it on its own. You have to normalize the text first, and that's a post of its own.&lt;/p&gt;

&lt;p&gt;The other gap is context. "Cock" is a rooster on a farming forum and an insult in game chat. No list can tell those apart, because the difference isn't in the word.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do
&lt;/h2&gt;

&lt;p&gt;If I were adding a check to a signup form tomorrow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start from version 4, with a real dictionary and a short list of your own names.&lt;/li&gt;
&lt;li&gt;Test it on text you know is clean: your users' towns, surnames and product names. Every block there is a bug.&lt;/li&gt;
&lt;li&gt;Don't reject when you're unsure. Hold the name for a quick human look instead, so someone from Clitheroe waits a minute instead of leaving.&lt;/li&gt;
&lt;li&gt;Give people a way to tell you. Every report is one more entry for &lt;code&gt;OURS&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doug Blackie's story is thirty years old, and the bug is still easy to write in an afternoon. The good news is the fix is too.&lt;/p&gt;

&lt;p&gt;Thanks for reading, and good luck with your filter :)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build Unprofane, a profanity filter API. Its &lt;a href="https://unprofane.com/guides/scunthorpe-problem/" rel="noopener noreferrer"&gt;Scunthorpe guide&lt;/a&gt; lists the innocent words filters trip on most.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>beginners</category>
      <category>regex</category>
    </item>
    <item>
      <title>7 Profanity Filters for Developers in 2026 (Perspective API Is Shutting Down)</title>
      <dc:creator>Tomer N</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:34:15 +0000</pubDate>
      <link>https://dev.to/tomer_n/7-profanity-filters-for-developers-in-2026-perspective-api-is-shutting-down-1klj</link>
      <guid>https://dev.to/tomer_n/7-profanity-filters-for-developers-in-2026-perspective-api-is-shutting-down-1klj</guid>
      <description>&lt;p&gt;If you have ever shipped a signup form, you know the moment. Somebody registers a username you really don't want on your leaderboard, and it's sitting there in the top ten by Friday.&lt;/p&gt;

&lt;p&gt;So you add a word list. A week later they're back as sh1t, f.u.c.k or with a Greek letter swapped in, and when you tighten the list, somebody from Scunthorpe can't sign up anymore. &lt;em&gt;(Written with AI help; every number here is checked against the source I link.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And if you handed this problem to Google's Perspective API, there's a date in your calendar you may not know about: Jigsaw says the service runs until December 31, 2026, and its FAQ says access can't be extended past that.&lt;/p&gt;

&lt;p&gt;So I went through the options developers actually reach for. Five are APIs, one is an npm package, one is on its way out. A few are free, and I'll say which. I'll also say where each one falls short, because nothing annoys me more than a "best tools" list where everything is perfect.&lt;/p&gt;

&lt;p&gt;Let's start the list:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Unprofane
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0t0puk5tc2eskrw5fxk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0t0puk5tc2eskrw5fxk.webp" alt="The Unprofane playground reading fuck typed with a Greek upsilon: verdict review, strong, tagged homoglyph and mixed_script, censored to four asterisks" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The unprofane.com playground&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Unprofane is a profanity filter API built for the two problems at the top of this post. Full disclosure, I built this one.&lt;/p&gt;

&lt;p&gt;You send it a username, a chat message or a review, and it answers allow, review or block, with the evidence: which words it found, where they were, how each one was disguised, and a censored copy to display. It undoes 13 measured kinds of disguise, from a swapped digit to a look-alike letter out of another alphabet.&lt;/p&gt;

&lt;p&gt;It also answers on Perspective's path, with Perspective's JSON and Perspective's errors, so a Python or Node client moves over by changing one line, and Coral Talk or Discourse by changing two.&lt;/p&gt;

&lt;p&gt;On the numbers: in my own head-to-head, read once on 2026-09-28 against 9,029 texts labeled blind by AI judges, it caught 89.0% of the 2,672 toxic ones while flagging 7.0% of the clean ones. Perspective, read at the threshold where it flags that same 7.0%, caught 58.4%. On the public split of the Scunthorpe Test (run of 2026-10-19) its rules flagged 0 of 549 innocent words and names, though that list is the engine's own, so it was built not to flag them. Your own innocent words are not in it.&lt;/p&gt;

&lt;p&gt;Where it falls short: there is no free plan, only free test keys at 100 checks a day for building. It reads text, not images or video. It's a young product. And the benchmark above is mine, with labels from AI judges, not an independent lab's.&lt;/p&gt;

&lt;p&gt;Plans start at $9 a month for 25,000 checks, half price for the first three months until December 31, 2026.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;17 languages, each with its own words and spellings&lt;/li&gt;
&lt;li&gt;Catches insults and threats written in ordinary words, not only listed ones&lt;/li&gt;
&lt;li&gt;Typed SDKs for JavaScript and Python&lt;/li&gt;
&lt;li&gt;An MCP server, so a coding agent can write the call itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://unprofane.com/bench/" rel="noopener noreferrer"&gt;unprofane.com/bench&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. OpenAI Moderation
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finxhttoz8e401yrop123.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finxhttoz8e401yrop123.webp" alt="OpenAI's moderation guide on developers.openai.com" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: OpenAI's moderation guide&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;OpenAI's moderation endpoint is free to use, and if you already have an OpenAI key, it's one request away. That's a real advantage worth taking seriously.&lt;/p&gt;

&lt;p&gt;The current model, omni-moderation-latest, classifies text and images into thirteen categories across harassment, hate, illicit, self-harm, sexual and violence, with their subcategories. You get a flagged boolean, plus a flag and a score for each category.&lt;/p&gt;

&lt;p&gt;The catch for this particular job: none of those thirteen categories is profanity. It's built to find harm, not swearing. On my test set, read at the threshold where it flags the same 7.0% of clean text as the others, it caught 40.8% of the toxic texts. Its own flagged verdict catches more, 62.2%, but flags 20.6% of the clean text, which is a lot of false alarms to hand your moderators.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free&lt;/li&gt;
&lt;li&gt;Reads images as well as text&lt;/li&gt;
&lt;li&gt;OpenAI's data controls list /v1/moderations as not used for training&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://developers.openai.com/api/docs/guides/moderation" rel="noopener noreferrer"&gt;OpenAI moderation guide&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Perspective API
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9bkzaut989eergc0e59e.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9bkzaut989eergc0e59e.webp" alt="The perspectiveapi.com home page" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: perspectiveapi.com&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Perspective was the free default for years, and it earned that. Coral Talk and Discourse both shipped it, at a 0.7 threshold, which is why so many comment queues look the way they do.&lt;/p&gt;

&lt;p&gt;You ask for the attributes you want, such as TOXICITY, SEVERE_TOXICITY, INSULT, PROFANITY and THREAT, and it answers with a probability for each, for the whole comment and optionally per sentence. TOXICITY is listed for seventeen languages, the other production attributes for seven.&lt;/p&gt;

&lt;p&gt;One thing worth knowing about that 0.7 default: on my test set it flags only 1.0% of clean text there, which is lovely, but it catches 33.0% of the toxic text. Quiet queues are not the same as clean comments.&lt;/p&gt;

&lt;p&gt;It's on this list as a warning, though. Jigsaw says the service remains active until December 31, 2026, and the FAQ says access cannot be extended past the final date. Requests for new access were taken only until February 2026. The sunset notice on its own home page adds the part that matters most if you're integrated: there will be no direct migration support. If it's in your stack today, you need a plan before then.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free, with a default quota averaging one query per second per project&lt;/li&gt;
&lt;li&gt;Experimental attributes, including ones trained on New York Times comments&lt;/li&gt;
&lt;li&gt;A doNotStore flag, which the docs tell you to set for private text&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://perspectiveapi.com/" rel="noopener noreferrer"&gt;perspectiveapi.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. WebPurify
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff22nuok6uk6r1ymel8ih.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff22nuok6uk6r1ymel8ih.webp" alt="WebPurify's documentation for its check method" width="800" height="445"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: webpurify.com documentation&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;WebPurify does more than text, and that's the reason to pick it. Its in-house team moderates images around the clock, every day of the year, and it says it doesn't crowdsource that work. Video gets AI review across 16 or more categories, with the audio transcribed and read too.&lt;/p&gt;

&lt;p&gt;For text, its API checks against a word list it maintains, and answers with a count, the words it found, or the text with them masked. You can add your own block and allow lists per license key, by API or in the admin.&lt;/p&gt;

&lt;p&gt;Two things to check before you build on it. The documented text responses don't tell you where each word was, what category it is, how severe it is, or how it was disguised, so you can't highlight a span or tune by category. And language isn't detected: the caller names it. Its docs list twelve languages plus three in beta.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email addresses, phone numbers and URLs can count as profanity&lt;/li&gt;
&lt;li&gt;Plugins for WordPress, Drupal, Joomla and bbPress&lt;/li&gt;
&lt;li&gt;Endpoints in the US, the EU and Asia-Pacific&lt;/li&gt;
&lt;li&gt;Texts up to 30,000 characters, with no cap on queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://www.webpurify.com/" rel="noopener noreferrer"&gt;webpurify.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. CleanSpeak
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cshdds5cgtlpk7e9ckd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cshdds5cgtlpk7e9ckd.webp" alt="The cleanspeak.com home page" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: cleanspeak.com&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;CleanSpeak is the heavyweight. It's a filter plus a full moderation console: approval queues, alerts, user reputation, ban and mute tools, and reports by tag, severity and top offenders. If you have actual moderators, that console is worth more than any accuracy number.&lt;/p&gt;

&lt;p&gt;Its filter answers well, too. Each match comes back with its position, length, matched text, root word, a severity and tags such as vulgarity, sexual or bullying. You can self-host it or let them run a private server for you, and 22 or more base language lists come included for you to edit.&lt;/p&gt;

&lt;p&gt;Three things to know. Prices aren't published, so you'll be talking to sales. The machine-learning models cost extra, and its features page says those models come from Google Perspective, which ends after December 31, 2026; I couldn't find a page that says what happens to them then. And self-hosting wants MySQL 8 or Postgres 10 and an OpenSearch or Elasticsearch cluster beside it.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Filters for email addresses, phone numbers, URLs and usernames&lt;/li&gt;
&lt;li&gt;An allowlist mode for children's chat&lt;/li&gt;
&lt;li&gt;Webhooks for approvals, user actions, edits and deletes&lt;/li&gt;
&lt;li&gt;A 30-day free trial with no credit card&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://cleanspeak.com/" rel="noopener noreferrer"&gt;cleanspeak.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Azure AI Content Safety
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy0yuhs86vv4y3fzm7xbl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy0yuhs86vv4y3fzm7xbl.webp" alt="Azure AI Content Safety's harm categories on learn.microsoft.com" width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: Azure AI Content Safety docs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you're already on Azure, this is the natural place to look, and keeping it in one bill is a genuine reason to choose it.&lt;/p&gt;

&lt;p&gt;It reads text and images for four harm categories: hate and fairness, sexual, violence and self-harm. Each comes back with a severity, 0 to 7, or four levels by default. You get a categoriesAnalysis array and, if you use blocklists, the list name and the item that matched.&lt;/p&gt;

&lt;p&gt;Profanity isn't a category here. Microsoft's own harm-category docs put curse words, profanity and insults that don't target an identity group at level 2 of hate, the lowest band above nothing. So a plain "is this rude?" answer means tuning thresholds rather than reading a field. You'll also need an Azure subscription and a resource in a supported region before your first call.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your own blocklists, stored in your resource's region&lt;/li&gt;
&lt;li&gt;Prompt Shields for LLM apps, plus groundedness detection in preview&lt;/li&gt;
&lt;li&gt;Inputs not stored during detection and not used for training&lt;/li&gt;
&lt;li&gt;Trained and tested on eight languages; others may work at varying quality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://learn.microsoft.com/en-us/azure/ai-services/content-safety/overview" rel="noopener noreferrer"&gt;Azure AI Content Safety&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. obscenity (npm)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa28hwirffuqmigb18w8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa28hwirffuqmigb18w8.webp" alt="The obscenity repository on GitHub" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: obscenity on GitHub&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you don't want an API at all, obscenity is the npm package I'd reach for. It runs inside your own Node process, which means no network call, no per-check cost, and the text never leaves your server.&lt;/p&gt;

&lt;p&gt;It matches more than an exact string: repeated letters, look-alike Unicode letters, and a word hidden inside a longer one. It tells you where each match is and can censor it for you. It's MIT licensed, and you can add your own words on top of the built-in list.&lt;/p&gt;

&lt;p&gt;Its README is honest about the limit, and says to treat the output as a heuristic. A word list reads words, not meaning, so "nobody wants you here" goes straight through, and so does a disguise nobody has added yet. For a side project, that trade is often exactly right.&lt;/p&gt;

&lt;p&gt;There are lots of other features also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero cost and no network latency&lt;/li&gt;
&lt;li&gt;Works anywhere Node runs&lt;/li&gt;
&lt;li&gt;Your own word list on top of the default English one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout here: &lt;a href="https://github.com/jo3-l/obscenity" rel="noopener noreferrer"&gt;github.com/jo3-l/obscenity&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;That's the list.&lt;/p&gt;

&lt;p&gt;How I'd pick, honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want harassment and self-harm flags more than swearing, for free: OpenAI Moderation.&lt;/li&gt;
&lt;li&gt;You need images or video moderated, or people in the loop: WebPurify.&lt;/li&gt;
&lt;li&gt;You have moderators and want them a console: CleanSpeak.&lt;/li&gt;
&lt;li&gt;You're already deep in Azure: Content Safety.&lt;/li&gt;
&lt;li&gt;It's a side project and a heuristic is fine: obscenity, for nothing.&lt;/li&gt;
&lt;li&gt;Usernames and chat that people will deliberately spell around, or a Perspective integration to replace: that's the gap I built Unprofane for, and the migration guide is at &lt;a href="https://unprofane.com/perspective/" rel="noopener noreferrer"&gt;unprofane.com/perspective&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One last thing, whichever way you go. The 0.7 threshold everybody inherited from Perspective is a choice, not a default, and it's a quiet one. Measure your own false alarms before you ship, on your own text, in your own languages. Every number in this post came with a false-alarm rate beside it for exactly that reason.&lt;/p&gt;

&lt;p&gt;If you've built a filter yourself, or you've seen a disguise none of these catch, drop it in the comments. I'm always looking for the next one.&lt;/p&gt;

&lt;p&gt;Thanks for reading :)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build Unprofane, an API that decides whether a text is profane.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>api</category>
      <category>moderation</category>
    </item>
    <item>
      <title>Google Is Shutting Down Perspective API on December 31. Here’s What Breaks, and When</title>
      <dc:creator>Tomer N</dc:creator>
      <pubDate>Sun, 25 Oct 2026 10:58:42 +0000</pubDate>
      <link>https://dev.to/tomer_n/google-is-shutting-down-perspective-api-on-december-31-heres-what-breaks-and-when-2gal</link>
      <guid>https://dev.to/tomer_n/google-is-shutting-down-perspective-api-on-december-31-heres-what-breaks-and-when-2gal</guid>
      <description>&lt;p&gt;December 31, 2026 is the last day Google's Perspective API will answer. Jigsaw's own notice puts it plainly: the service "will remain active until December 31, 2026." There's no extension and no migration help from Google.&lt;/p&gt;

&lt;p&gt;I went through what happens to the tools built on top of it, and the answer surprised me. Most of them won't crash on January 1. They'll keep accepting comments and quietly stop checking them. &lt;em&gt;(AI-assisted draft; I checked each claim against its source.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So here's the whole picture, by date, with the steps I'd take if Perspective sits anywhere in my stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dates
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftescfs06zssa8cfcajrq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftescfs06zssa8cfcajrq.webp" alt="A timeline: February 2026, the last usage and quota requests answered; December 31, 2026, the last day the API answers; January 1, 2027, nothing after it" width="800" height="280"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;From Google's notice at perspectiveapi.com&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;February 2026: requests closed.&lt;/strong&gt; Google stopped taking usage and quota increase requests in February. If your traffic grew this year, you're living on whatever quota you already had. The FAQ puts the default at an average of one query per second per project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now until December 31: it still works.&lt;/strong&gt; Nothing has changed in the API itself. Same endpoint, same JSON, still free. That's the catch, honestly. There's no warning in the response, so nothing in your logs will remind you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;January 1, 2027: no more answers.&lt;/strong&gt; Google hasn't said what the endpoint will return after that, whether it's an error code, a timeout or a domain that doesn't resolve. So plan for all three.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually breaks
&lt;/h2&gt;

&lt;p&gt;A lot of Perspective calls don't come from code you wrote. They come from comment systems, forum plugins and old scripts, and each of those decided long ago what to do when Google doesn't answer. I read the code for two that ship it built in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coral by Vox Media (a comment platform for news sites).&lt;/strong&gt; Coral's toxic comment filter sends each new comment to Perspective and holds anything above your threshold for a moderator. If the call fails, its code logs "could not determine comment toxicity" and moves on. The comment goes through the rest of the pipeline as if it were clean. No alert, no error for the reader, no item in the queue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2h138021ftdolo83vnu6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2h138021ftdolo83vnu6.webp" alt="Coral's toxic comment filter in toxic.ts: the catch block rethrows a ToxicCommentError and otherwise logs could not determine comment toxicity and returns" width="800" height="269"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Coral's toxic.ts on GitHub: a failed call is logged, and the comment moves on&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discourse's Perspective plugin.&lt;/strong&gt; The official plugin flags posts above a toxicity threshold and can warn people while they type. Two failure paths here. If the network call itself fails, it raises an error and the post isn't flagged. And if Google answers with an error body instead of scores, the plugin's parser falls back to a score of 0.0 for every post. Zero never crosses a threshold, so nothing gets flagged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your own code.&lt;/strong&gt; If you call the API directly, what happens depends on one line you probably wrote years ago: the &lt;code&gt;except&lt;/code&gt; around the call. A lot of moderation code is written to fail open on purpose, because nobody wants a Google outage to block every comment. That was the right call for a five-minute outage. It's the wrong call for a permanent one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Research pipelines.&lt;/strong&gt; If a paper, a dataset or a dashboard depends on fresh Perspective scores, it stops being reproducible on new text in January. Scores you already stored still exist, but you can't add comparable ones.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Newsrooms and publishers running Coral with the toxic comment filter on.&lt;/li&gt;
&lt;li&gt;Discourse forums with the discourse-perspective-api plugin enabled.&lt;/li&gt;
&lt;li&gt;Apps calling &lt;code&gt;commentanalyzer.googleapis.com&lt;/code&gt; from a backend, a queue worker or a serverless function.&lt;/li&gt;
&lt;li&gt;Teams with a dashboard or a study built on TOXICITY scores.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're not sure, the next step tells you in a minute.&lt;/p&gt;
&lt;h2&gt;
  
  
  The steps, in order
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Find every call
&lt;/h3&gt;

&lt;p&gt;Search your code, your config and your infrastructure for the host name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"commentanalyzer"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check the admin settings of anything you didn't write. In Coral it's under Configure, Moderation, Toxic Comment Filter. In Discourse, type "perspective" into the admin settings filter. And look for the API key in your Google Cloud project, since forgotten scripts love to share one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Make failures loud
&lt;/h3&gt;

&lt;p&gt;Before you replace anything, change what happens when the check doesn't answer. Even if you switch next week, this protects you from the next outage too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# your current toxicity call
&lt;/span&gt;    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;toxicity check failed, holding comment for review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# treat None as "send to the review queue"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is simple: an unanswered check goes to a human or to a hold, never straight to publish. And add an alert on the error count, so a dead endpoint pages someone instead of filling a log nobody reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Save what your thresholds mean
&lt;/h3&gt;

&lt;p&gt;Your thresholds were tuned against Perspective's scale, probably over months. Before December, export a sample of recent comments with the scores Perspective gave them and what your moderators decided. Any replacement will score on its own scale, and that sample is how you'll find the new threshold that flags about the same share of comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Pick a replacement and run it side by side
&lt;/h3&gt;

&lt;p&gt;Run the new service in parallel for a week or two, logging both answers, before you switch. The main options, with their trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI's moderation endpoint.&lt;/strong&gt; Free, and it scores harassment, hate, self-harm, sexual content and violence. It has no profanity category, so swearing on its own won't move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure AI Content Safety.&lt;/strong&gt; Hate, sexual, violence and self-harm on a severity scale. Needs an Azure subscription; plain profanity that doesn't target a group sits at a low level of hate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discourse AI.&lt;/strong&gt; For Discourse forums, its AI triage can classify posts with an LLM prompt you write. Discourse-hosted sites can use Discourse's own hosted small LLM; self-hosted forums need a third-party one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A word list.&lt;/strong&gt; Coral and Discourse both have banned words built in. It's free and predictable, but it misses disguised spellings and can block innocent words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A drop-in for Perspective's API.&lt;/strong&gt; Full disclosure, I built Unprofane, which answers Perspective's own requests on the same path with the same JSON, so Coral moves by changing an endpoint and a key, and Discourse by a one-line change to its plugin. The &lt;a href="https://unprofane.com/perspective/" rel="noopener noreferrer"&gt;migration guides&lt;/a&gt; cover each client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is a perfect clone, since each model scores differently. If you mostly care about threats and harassment and want free, OpenAI is a reasonable first test. If you have an LLM in your forum already, Discourse AI keeps everything in one place.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Switch before December, keep a way back
&lt;/h3&gt;

&lt;p&gt;Don't plan the switch for the last week of the year, when half your team is out. Pick a date in November, keep the old key working until the end of December, and make the endpoint a config value so rolling back is a settings change, not a deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Clean up in January
&lt;/h3&gt;

&lt;p&gt;After the switch, delete the Perspective key from your Google Cloud project and remove the old code path. A dead integration that still "works" is how the failure in step 2 sneaks back in a year later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Perspective's last day is December 31. The real risk isn't a broken page, it's moderation that silently turns off. Find your calls, make failures hold comments instead of publishing them, save your old scores, test a replacement in parallel and switch in November.&lt;/p&gt;

&lt;p&gt;If you're in the middle of this and hit something odd, tell me in the comments. I'd like to know which tools I missed.&lt;/p&gt;

&lt;p&gt;Good luck with the switch, and thanks for sticking with me to the end :)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm the developer behind Unprofane, a profanity and toxicity API.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>moderation</category>
      <category>google</category>
    </item>
  </channel>
</rss>
