If anything you run touches the Google Ads API — a bid-management tool, an agency reporting pipeline, a Looker Studio connector, a budget-pacing script, a custom dashboard pulling spend into a warehouse — you are on a clock you may not have noticed. Google moved the Google Ads API to a monthly release cycle in 2026, and each major version now lives for roughly one year after launch. Versions are sunsetting faster than they used to, and quietly.
The dates that matter right now:
- v20 sunset on June 10, 2026. Requests on v20 now fail.
- v21 sunsets around August 6, 2026 — one year after its August 6, 2025 release. (sunset schedule)
- v22 (Oct 15, 2025) and v23 (Jan 28, 2026) are the versions you're migrating to.
v21 is still one of the most widely pinned versions in production. If yours is one of them, you have until early August to move — and the version you land on (v23+) carries breaking changes from both v22 and v23 stacked on top of each other.
The loud failure is the one you'll plan for
When a version sunsets, requests against it fail. You get an error, your monitoring lights up, you bump the version string in your client config and redeploy. Annoying, visible, fixable.
That's not the failure mode that costs you a month of wrong numbers.
The Google Ads API is queried through GAQL and read back through generated client-library protos (google-ads-python, google-ads-php, the .NET and Java libraries). Both layers are forgiving in exactly the wrong way during a version bump: a query that used to return a value can keep returning 200 OK with that value silently changed, blanked, or reclassified. Three of those are worth grepping for before August.
1. A removed enum value makes your ad-type switch fall through
v23 removed VIDEO_OUTSTREAM from three enums at once: AdType, AdvertisingChannelSubType, and AdGroupType (v23 release notes).
The field still exists. Your query still succeeds. But the value you were branching on never arrives anymore. Consider the canonical reporting shape — bucket spend by ad type:
TYPE_LABELS = {
"VIDEO_BUMPER": "Bumper",
"VIDEO_OUTSTREAM": "Outstream", # v23: this enum value no longer exists
"VIDEO_TRUEVIEW_IN_STREAM": "In-stream",
}
for row in ga_service.search(customer_id=cid, query=GAQL):
label = TYPE_LABELS.get(row.ad_group_ad.ad.type_.name, "Other")
spend[label] += row.metrics.cost_micros
After the bump, rows that used to come back as VIDEO_OUTSTREAM come back as UNKNOWN / UNSPECIFIED (or are reshaped onto a different type). TYPE_LABELS.get(...) doesn't raise — it returns "Other". Your outstream spend silently collapses into a catch-all bucket, or vanishes if you filter that bucket out. The query is valid, the response is 200, the totals at the campaign level still reconcile, and the only symptom is that one row in a breakdown table is wrong. Nobody alerts on that.
The same trap applies anywhere you WHERE or GROUP BY one of these enums, and to the if channel_sub_type == ... branches that route campaigns to different handlers. A branch that no longer matches doesn't error — it falls through to the default.
2. Asset performance metrics come back empty, not missing
v22 removed AssetPerformanceLabel for Performance Max campaigns. v23 went further: it removed aggregate asset performance-label metrics, and the performance-label enum is no longer returned for Search and Display (release notes).
Here's the asymmetry that makes this silent. If you SELECT a field that the version has deleted, GAQL throws a loud error and you find it in testing immediately. But these aren't deletes of the field path — they're cases where the field stops being populated. The asset still has a performance_label; for Search and Display it now returns the unspecified/empty value instead of LOW / GOOD / BEST.
# Query still valid, still 200. Field now empty for Search/Display assets.
query = """
SELECT asset.id, ad_group_ad_asset_view.performance_label, metrics.impressions
FROM ad_group_ad_asset_view
WHERE segments.date DURING LAST_30_DAYS
"""
for row in ga_service.search(customer_id=cid, query=query):
if row.ad_group_ad_asset_view.performance_label.name == "LOW":
flag_for_replacement(row.asset.id) # never fires again
Your "replace low-performing assets" automation stops finding anything to replace. Your asset-health dashboard shows every asset as unlabeled and reads it as "fine." There is no error, no empty result set, no 4xx — just a column that used to carry signal and now carries blanks. The same goes for the Campaign.url_expansion_opt_out field removed in v22 (now governed by AssetAutomationType.FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION): code that read the old boolean to decide whether to trust final-URL targeting silently reads a default and takes the wrong branch.
3. The renamed error field that breaks your error handler
This one is small and perfect. v22 renamed BudgetPerDayMinimumErrorDetails.minimum_bugdet_amount_micros to minimum_budget_amount_micros — Google fixed a typo in the field name (release notes).
If you wrote graceful budget-error handling — the kind that catches the API's "your daily budget is below the minimum" error and surfaces the actual minimum to the user — you almost certainly hard-coded the misspelled field name, because that's what the API gave you:
except GoogleAdsException as ex:
for error in ex.failure.errors:
details = error.details.budget_per_day_minimum_error_details
# pre-v22 field name; returns proto default (0) after the bump
user_message = f"Minimum daily budget is {details.minimum_bugdet_amount_micros / 1e6}"
After the bump, minimum_bugdet_amount_micros no longer exists on the proto; accessing it returns the default 0 (proto3 doesn't raise on unknown-as-default access in several client libraries). Your error handler now tells users the minimum budget is $0.00 — at exactly the moment they're hitting a budget error and need a real number. It fires only in the error path, which is the path with the thinnest test coverage.
Bonus: the Feeds removal is the loud one — but it relocated your extension data
v23 removed all feed-related entities — Feed, FeedMapping, FeedService, AdGroupFeed, feed_placeholder_view, and the rest. Queries against those resources fail loudly, so you'll catch the direct breakage. The quieter follow-on: sitelinks, callouts, and structured snippets that used to live in Feeds now live in Assets, with a different shape and different IDs. Reporting that aggregated extension performance by feed item has to be rebuilt against the asset model, and the rebuild is where double-counting and dropped extensions creep in. That's a migration project, not a one-line version bump.
What to grep for before August 6
# Direct version pins in client config / URLs
grep -rnE 'v2[0-3]|google-ads.*version|GOOGLE_ADS_API_VERSION' src/ config/
# Enum values removed or reshaped in v22/v23
grep -rnE 'VIDEO_OUTSTREAM|AssetPerformanceLabel|url_expansion_opt_out' src/
# The renamed error field
grep -rn 'minimum_bugdet_amount_micros' src/
# Feed entities removed in v23
grep -rnE 'FeedMapping|AdGroupFeed|feed_placeholder_view|FeedService' src/
# Any switch/dict keyed on ad type or channel sub-type — audit for fall-through defaults
grep -rnE 'ad_group_type|advertising_channel_sub_type|ad\.type_' src/
The non-grep check is the persisted-config sweep: any Supermetrics, Funnel, Adverity, or homemade connector with a pinned v20/v21 in its settings needs re-pointing, and every dashboard that buckets by ad type or reads an asset performance label needs a spot-check against a v23 response before the cutover — not after.
Why a forced upgrade fails silently
The mental model for a version sunset is "it either works or it 503s." For the transport layer, that's true. For the data, it isn't. GAQL keeps answering, the protos keep deserializing, and the values inside them quietly change contract: an enum loses a member, a metric stops populating, a field gets renamed out from under your accessor. Every one of those returns 200.
The window between now and August 6 is when the wrong code ships, because both behaviors are observable at once: v21 still answers, v23 already answers, the tests pass, and the dashboards have numbers in them. The numbers are just bucketed wrong, blanked, or zeroed — and "the report has data" is not the same as "the report is right."
FlareCanary watches the response shapes of the API endpoints you depend on and tells you when an enum value disappears, a metric goes empty, or a field gets renamed between versions — the drift that returns 200 and slips past a green healthcheck. A forced version sunset like Google Ads v21 → v23 is exactly the transition where passing tests don't mean what you think they mean. flarecanary.com
Top comments (0)