Somewhere in a lot of Python codebases there's a call to discovery.build("commentanalyzer", "v1alpha1", ...). It scores comments for toxicity, it's worked for years, and after December 31, 2026 Google's Perspective API won't answer it anymore.
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. (Written with AI help; facts checked.)
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.
Full disclosure, I built Unprofane, the API this guide switches to. I'll also say where something else is the better pick.
1. Find every place that calls Perspective
Before changing anything, list the calls. A plain search over your Python files usually turns them all up:
grep -rn --include="*.py" -e "commentanalyzer" -e "comments()" .
Look for three things in what it prints: the discovery.build call, every comments().analyze(...) and comments().suggestscore(...), and any batch built with new_batch_http_request(). Also note which attributes you request. That matters in step 6.
2. Get a key for each environment
Make two keys in Unprofane's dashboard: a test key that starts with up_test_ for staging and a live key that starts with up_live_ for production.
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.
Keep your Google key. Until December 31 it's your rollback.
3. Change the build call
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, discoveryServiceUrl:
import os
from googleapiclient import discovery
client = discovery.build(
"commentanalyzer",
"v1alpha1",
developerKey=os.environ["UNPROFANE_KEY"], # up_test_YOUR_KEY on staging, up_live_YOUR_KEY in production
discoveryServiceUrl="https://api.unprofane.com/$discovery/rest?version=v1alpha1",
static_discovery=False,
)
response = client.comments().analyze(body={
"comment": {"text": "you are a clown"},
"requestedAttributes": {"TOXICITY": {}},
"languages": ["en"],
"doNotStore": True,
}).execute()
print(response["attributeScores"]["TOXICITY"]["summaryScore"]["value"])
Reading the key from an environment variable means staging and production run the same code and differ only in the value. static_discovery=False makes the client fetch the discovery document from the URL you pass, instead of looking for a copy bundled inside the library.
Everything after build stays as it is. The path, the request body and the ?key= parameter are Google's.
4. What it returns
The response has Perspective's shape: attributeScores with a summaryScore and spanScores for each attribute, then languages and detectedLanguages. Asking for TOXICITY and INSULT looks like this (the numbers show the shape, not a score to expect):
{
"attributeScores": {
"TOXICITY": {
"spanScores": [{ "begin": 0, "end": 15, "score": { "value": 0.71, "type": "PROBABILITY" } }],
"summaryScore": { "value": 0.71, "type": "PROBABILITY" }
},
"INSULT": { "summaryScore": { "value": 0.78, "type": "PROBABILITY" }, "spanScores": [] }
},
"languages": ["en"],
"detectedLanguages": ["en"]
}
Errors come back the way Google sent them too: an HttpError with Google's status and Google's wording. So the except blocks you already wrote keep working.
5. Run it on staging
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 HttpError shapes, so if your tests asserted on Google's error messages, they should still pass. If one fails, the next section is usually why.
6. Check what doesn't carry over
Here's the part to read before you ship, not after.
- Seven production attributes are scored: TOXICITY, SEVERE_TOXICITY, IDENTITY_ATTACK, INSULT, PROFANITY, THREAT and SEXUALLY_EXPLICIT.
-
The New York Times attributes aren't. 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
dropUnsupportedAttributesto leave them out quietly. - Google's account errors have no match. 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.
-
Batches have limits Google didn't have. A batch answers at most 100 parts, 30,000 characters of comments together and 256 KiB. Past that it's refused whole with
INVALID_ARGUMENT, so split big batches. Each part counts as one check. -
suggestscore still answers
{}. The moderator score you send is stored as a correction on your account, and it doesn't change any score.
And one difference runs the other way, which is easy to miss. Some comments Google refused now get a score:
- comments in languages Google detected but didn't score, like Hebrew and Turkish,
- SEXUALLY_EXPLICIT outside English (Google scored it in English only),
- HTML comments, which are scored without their tags.
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.
7. Pick a new threshold
This is the step that actually takes time, and the one I'd never skip.
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%.

Perspective API on Unprofane's own head-to-head benchmark, run September 28, 2026
So don't copy the old number. Two things help:
- Map it. The replay tool 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.
- Then judge it on decided comments. 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.
8. Keep a way back
Rolling back is the same argument, pointed home, with your Google key back in the environment:
discoveryServiceUrl="https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1"
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.
If you'd rather use something else
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.
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.
The full guide with the test notes is here.
If it goes wrong
-
HttpError 400naming an attribute: you're requesting one that isn't scored (step 6). -
It still talks to Google: check that
discoveryServiceUrlis passed to thebuildyou actually run, and thatstatic_discovery=Falseis there. -
RESOURCE_EXHAUSTED: a test key's monthly or daily cap is used up, or your plan's month is. -
A batch refused with
INVALID_ARGUMENT: it's over 100 parts, 30,000 characters or 256 KiB. Send several smaller ones.
That's the whole move. The code part takes a few minutes, and the threshold part deserves a few days.
Good luck with the switch, and I hope your January is quiet :)
I make Unprofane, which checks what people type for swearing and abuse.
Top comments (0)