Google Trends 429s in 2026: what actually works

If you have tried to pull Google Trends data programmatically any time in the last couple of years, you have hit a 429. Most write-ups treat it as one problem : "Google is rate limiting me" : and reach for a single fix: rotate a proxy, add a time.sleep, retry a few times. That gets you partway. The reason it stays flaky is that there are two different 429s in this flow, they mean different things, and only one of them is the actual rate limit.

Two different 429s

The first 429 shows up on GET https://trends.google.com/trends/explore. This is the page load, not an API call. Hit it with a fresh cookie jar and Google answers 429 almost every time : and it still sets the NID cookie in that response. That cookie is what the JSON endpoints check for. So this 429 is not a rejection, it is the handshake: as long as you read the Set-Cookie header and keep the cookie, the request "failed" but did its job.

The second 429 shows up on the real API surface, trends.google.com/trends/api/*. This one is the actual limit : too many calls, too fast, from one identity (cookie jar + IP + TLS/header fingerprint together). This is the 429 worth reacting to: rotate the session (new cookie jar, new proxy identity) and back off.

Conflating the two produces bad diagnostics. A run that logs "15 429s" without splitting handshake from real tells you almost nothing about whether you are actually being throttled. Once we split the counter in our own scraper, most of what looked like heavy rate limiting turned out to be handshake noise.

Why one keyword costs several requests

There is no single "give me interest over time for this keyword" endpoint. The flow is two steps:

  1. GET /trends/api/explore with the keyword(s), geo, and time range. This does not return data : it returns a list of widgets (TIMESERIES, GEO_MAP, RELATED_TOPICS, RELATED_QUERIES, ...), each with a short-lived token and its own request payload.
  2. One GET per widget, against that widget's own endpoint (/widgetdata/multiline for the time series, /widgetdata/comparedgeo for the region map, /widgetdata/relatedsearches for both related queries and related topics), passing that widget's token back.

So a single keyword with time series, region breakdown, related queries, and related topics enabled is a minimum of five HTTP calls, plus the handshake : before any retries. Comparing several keywords in one request batches them into the same explore call and the same shared widgets, which is worth doing when you can, but it does not change the per-widget-call structure.

The current widget protocol, minimally

This is a trimmed version of the actual flow, verified against a live session (three requests: the warm-up, the explore call, and one widget call) shortly before writing this.

import json
import requests

session = requests.Session()
session.headers.update({
    "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/128.0 Safari/537.36",
    "accept-language": "en-US,en;q=0.9",
})

# 1. Warm-up GET. Expect a 429 here : it still sets the NID cookie.
session.get(
    "https://trends.google.com/trends/explore",
    params={"q": "trends", "hl": "en-US"},
    timeout=15,
)

# 2. Explore call: returns widget descriptors, not data.
req = {
    "comparisonItem": [{"keyword": "python", "geo": "", "time": "today 12-m"}],
    "category": 0,
    "property": "",
}
resp = session.get(
    "https://trends.google.com/trends/api/explore",
    params={"hl": "en-US", "tz": "0", "req": json.dumps(req)},
    headers={
        "accept": "application/json, text/plain, */*",
        "referer": "https://trends.google.com/trends/explore",
    },
    timeout=15,
)

def strip_prefix(body: str) -> str:
    # Google prefixes every JSON response with `)]}'` (XSSI protection),
    # sometimes followed by a comma before the newline.
    text = body.strip()
    if text.startswith(")]}'"):
        text = text[4:].lstrip(",")
    return text

widgets = json.loads(strip_prefix(resp.text))["widgets"]
timeseries = next(w for w in widgets if w["id"].startswith("TIMESERIES"))

# 3. Widget call: pass the token back, get the actual data.
widget_resp = session.get(
    "https://trends.google.com/trends/api/widgetdata/multiline",
    params={
        "hl": "en-US",
        "tz": "0",
        "req": json.dumps(timeseries["request"]),
        "token": timeseries["token"],
    },
    headers={
        "accept": "application/json, text/plain, */*",
        "referer": "https://trends.google.com/trends/explore",
    },
    timeout=15,
)
data = json.loads(strip_prefix(widget_resp.text))
print(len(data["default"]["timelineData"]), "points")

Two things worth calling out that are easy to get wrong: the req params on both the explore and widget calls are JSON-encoded strings inside the query string, not JSON bodies : these are GET requests. And the token from step 2 is single-use per widget; you cannot reuse it across a retry with a rotated session, you need a fresh explore call too if the session changes.

What this snippet does not show, because it matters for reliability and does not fit in a minimal example: a session (cookie jar plus whatever identity : IP, headers, TLS fingerprint : you present) should be discarded and replaced, not retried in place, the moment you see a real 429 on an API call. Retrying the same session into the same limit just spends the backoff window for nothing.

Related topics: the empty-list reality

If you are pulling RELATED_TOPICS or RELATED_QUERIES from a cookie-only session (no signed-in Google account, no prior browsing history tied to the cookie), expect an empty rankedList for a meaningful share of terms. This is not a bug in a particular library : pat310/google-trends-api issue #174 documents the same relatedQueries emptiness independently. It is closer to expected behavior of the endpoint for anonymous sessions than an edge case, and no amount of retrying or proxy rotation fixes it, because the response is a valid 200 with an empty list, not a 429.

pytrends, still the most commonly referenced Python wrapper for this API, has not had a commit since August 2024 and its GitHub repository is now archived : the endpoints and token protocol have moved on since, which is part of why hand-rolled clients against the current widget flow are worth understanding directly rather than through an unmaintained wrapper.

The fix, as an endpoint

If you would rather not run the widget protocol yourself: the same pacing, backoff and partial-result rules run behind a REST endpoint on RapidAPI (free tier, then $9 a month) and an Apify actor ($0.25 per 1,000 rows). One call returns the weekly series as rows; related queries and regions are two more.

curl "https://google-trends-data.p.rapidapi.com/interest?terms=coffee,tea&timeRange=today%2012-m&geo=US" \
  -H "x-rapidapi-key: YOUR_KEY" -H "x-rapidapi-host: google-trends-data.p.rapidapi.com"
# {"count":106,"items":[{"type":"interestOverTime","term":"coffee","time":"2025-10-05T00:00:00.000Z","value":54,...}]}
import requests
r = requests.get("https://google-trends-data.p.rapidapi.com/related",
    params={"terms": "coffee", "timeRange": "today 12-m", "geo": "US"},
    headers={"x-rapidapi-key": "YOUR_KEY", "x-rapidapi-host": "google-trends-data.p.rapidapi.com"})
rows = r.json()["items"]          # list == "top" or "rising", with rank, query, value

Also as REST endpoints on RapidAPI: Google Trends Data, Trending Now, Search Autocomplete, YouTube Autocomplete · the Apify actor · custom datasets by the million rows

What we actually measured

Numbers below are from our own actor's soak log (Central time), same 3 keywords, US geo, 3-month window, 3 output types, region breakdown, run against Apify's datacenter proxy pool unless noted:

The pattern across these runs is consistent: the real-429 rate dropped from a quarter of all requests to zero once the client stopped reusing one session across an entire batch and instead gave each keyword its own cookie jar and proxy identity with paced, jittered spacing between calls. That is the mechanism worth reproducing, whatever language or library you build it in.

Separately: I maintain a hosted version of this flow on Apify : session-per-keyword, the explore/widget-token dance, retry-with-rotation on real 429s : priced pay-per-result at $0.25 per 1,000 rows, no subscription. If you would rather not maintain the client yourself: https://apify.com/tallyrake/google-trends-scraper