DEV Community

Data Fabrik
Data Fabrik

Posted on

pytrends is dead: 3 ways to get Google Trends data in 2026 (with code)

If you pip install pytrends today, you get a library whose repository was archived (read-only) in April 2025. It may still return data for a few calls, then stop with TooManyRequestsError, and nobody will fix it. This post goes through the three realistic ways to get Google Trends data in 2026, with working code and the limits of each.

Disclosure up front: I maintain the hosted service described in option 2. Options 1 and 3 are written to work without it.

Why pytrends stopped working

pytrends never used an official API. There is none for the public: Google announced an official Google Trends API in July 2025, and as of this writing it is still in alpha, behind a waitlist. pytrends was a wrapper around the undocumented endpoints the Google Trends website calls from the browser. That works as long as someone keeps the wrapper in sync with Google's response shapes and rate limits. Since April 2025 nobody does.

The error most people hit is HTTP 429 ("too many requests"). It is not a pytrends bug. Google rate-limits the Trends endpoints per IP, with limits it does not publish, and pytrends retries from the same IP with the same session. The library's retries and backoff_factor arguments only make the same failing request again.

What the Google Trends website actually does

Every chart on trends.google.com/trends/explore is loaded by a separate request, and the flow is always the same:

  1. GET /trends/api/explore with your terms, place, period and category. The response describes one "widget" per chart, each with a token.
  2. GET /trends/api/widgetdata/multiline (interest over time), /trends/api/widgetdata/comparedgeo (interest by region) and /trends/api/widgetdata/relatedsearches (related queries), each with its widget's request and token.

Every response starts with the characters )]}' before the JSON, a common anti-hijacking prefix. You strip it and parse the rest.

Two things to keep in mind whatever option you choose: values are relative (0–100, where 100 is the peak for the terms, place and period you asked for), never absolute search counts; and the shapes of these responses are not a contract. Google can change them any day.

Option 1: call the endpoints yourself

Here is a complete script with requests only. It fetches the three charts for one or more terms, handles 429 by waiting and starting a new session, and prints JSON.

import json
import sys
import time

import requests

EXPLORE = "https://trends.google.com/trends/api/explore"
WIDGET = "https://trends.google.com/trends/api/widgetdata/{}"
# widget id prefix -> widgetdata path
PATHS = {"TIMESERIES": "multiline", "GEO_MAP": "comparedgeo", "RELATED_QUERIES": "relatedsearches"}
HL, TZ = "en-US", 0
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"


class Blocked(Exception):
    """HTTP 429, or an HTML page (consent / 'unusual traffic') instead of JSON."""


def new_session():
    s = requests.Session()
    s.headers["User-Agent"] = UA
    s.get("https://trends.google.com/?geo=US", timeout=30)  # sets the NID cookie the API endpoints expect
    return s


def get_json(session, url, params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code == 429:
        raise Blocked("429")
    r.raise_for_status()
    if not r.text.startswith(")]}'"):
        raise Blocked("HTML instead of JSON")
    return json.loads(r.text[r.text.index("{"):])


def kind_of(widget):
    return next((k for k in PATHS if widget["id"].startswith(k)), None)


def explore(session, terms, geo, time_range, category=0, prop=""):
    req = {
        "comparisonItem": [{"keyword": t, "geo": geo, "time": time_range} for t in terms],
        "category": category,
        "property": prop,  # "" web, "youtube", "news", "images", "froogle" (shopping)
    }
    data = get_json(session, EXPLORE, {"hl": HL, "tz": TZ, "req": json.dumps(req)})
    return [w for w in data["widgets"] if kind_of(w)]


def widget_data(session, widget):
    params = {"hl": HL, "tz": TZ, "req": json.dumps(widget["request"]), "token": widget["token"]}
    return get_json(session, WIDGET.format(PATHS[kind_of(widget)]), params)["default"]


def report(terms, geo="", time_range="today 12-m", attempts=4):
    delay = 20
    for attempt in range(1, attempts + 1):
        try:
            s = new_session()
            out = {"terms": terms, "geo": geo, "time_range": time_range, "related_queries": {}}
            related_i = 0
            for w in explore(s, terms, geo, time_range):
                time.sleep(2)  # spacing between calls matters more than any header
                d = widget_data(s, w)
                kind = kind_of(w)
                if kind == "TIMESERIES":
                    out["interest_over_time"] = [
                        {"time": int(p["time"]), "label": p["formattedTime"], "values": p["value"]}
                        for p in d["timelineData"]
                    ]
                elif kind == "GEO_MAP":
                    out["interest_by_region"] = [
                        {"geo": p["geoCode"], "name": p["geoName"], "values": p["value"]}
                        for p in d["geoMapData"] if p.get("hasData", [True])[0]
                    ]
                else:  # RELATED_QUERIES: one widget per term, in term order
                    lists = d.get("rankedList", [])
                    out["related_queries"][terms[related_i]] = {
                        "top": [(k["query"], k["value"]) for k in lists[0]["rankedKeyword"]] if lists else [],
                        "rising": [(k["query"], k["formattedValue"]) for k in lists[1]["rankedKeyword"]] if len(lists) > 1 else [],
                    }
                    related_i += 1
            return out
        except Blocked as e:
            print(f"{e}: waiting {delay}s, then a fresh session (attempt {attempt}/{attempts})", file=sys.stderr)
            time.sleep(delay)
            delay *= 2
    raise SystemExit("still blocked: wait longer, or change IP (proxy)")


if __name__ == "__main__":
    r = report(["bitcoin", "ethereum"], geo="US", time_range="today 12-m")
    assert r.get("interest_over_time"), "no time series: Google answered but the shape changed"
    print(json.dumps(r, indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it with pip install requests and python trends.py. One explore call plus three widget calls, about ten seconds, no account.

What it does not solve:

  • 429 is the normal state at volume. The limit is per IP. A new session with a new cookie buys you a few more calls, the wait buys a few more, and then you need another IP. Proxies cost money and are their own maintenance job.
  • Related topics are left out. explore also returns a RELATED_TOPICS widget, but over plain HTTP its data does not come back usable in my tests. I would rather omit it than return an empty list.
  • An HTML page with HTTP 200 (a consent page, or "unusual traffic") is as common as a 429. The script treats both the same way.
  • No contract. The )]}' prefix, the widget ids, the field names: all of it can change without notice. When it does, your script breaks on a Monday morning.
  • Relative values. Two separate requests are not on the same scale. To compare terms, send them in the same comparisonItem list (up to 5, like the website).

For a one-off analysis, a few dozen terms, this is the right option. Free, no dependency, you see everything.

The hosted-service examples of the next section (Python and Node, CSV export, a flight price tracker and a hotel rate-shopping script) are in a small public repository: github.com/data-fabrik/apify-examples.

Option 2: a hosted service

If you would rather call something that handles IPs, retries and parsing for you, scraping services sell this. Mine is an Actor on the Apify platform: Google Trends Scraper & API. It talks to the same endpoints as above, over plain HTTP, from the platform's residential IPs when Google rate-limits the direct connection, and returns JSON or flat rows.

Fifteen lines with the official apify-client package:

import csv
import os

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("datafabrik/google-trends-scraper").call(run_input={
    "searchTerms": ["bitcoin, ethereum"],   # one line = one report; up to 5 terms per line
    "geo": "US",
    "timeRange": "today 12-m",
    "dataTypes": ["interestOverTime", "interestByRegion", "relatedQueries"],
    "outputLayout": "rows",                 # one row per date / region / query
})
rows = list(client.dataset(run.default_dataset_id).iterate_items())  # apify-client 3.x returns a Run object
with open("trends.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=sorted({k for r in rows for k in r}))
    writer.writeheader()
    writer.writerows(rows)
print(len(rows), "rows written")
Enter fullscreen mode Exit fullscreen mode

Price: from $1.00 per 1,000 reports ($2.50 per 1,000 on Apify's free plan, $1.50 on Bronze, $1.20 on Silver, $1.00 on Gold). One report is one line of searchTerms (one term, or up to 5 compared terms) with interest over time, interest by region and related queries. There is no per-run start fee. Searches that return nothing (too little volume) or fail are listed in the dataset and not charged.

When not to use it:

  • You need a handful of reports. Option 1 is free and ten seconds of work.
  • You need related topics. They are not offered, for the reason given above. Nobody serves them reliably over HTTP, whatever the sales page says.
  • You need absolute search volumes. Google Trends does not have them, so neither does anything built on it. Keyword tools estimate volumes from other sources.
  • Your data cannot go through a third party. The requests run on Apify's infrastructure, not yours.
  • You expect the same numbers every run. Google Trends works on a sample; values move by a few points between two identical requests, on the website too.

Option 3: wait for the official API

Google announced an official Google Trends API in July 2025. At the time of writing it is in alpha, with access through a waitlist. What is known: it exists and some testers have it. What is not known publicly: when it opens to everyone, its quotas, its price, and whether it will cover the same data as the website. If your project can wait and you are fine with the uncertainty, join the waitlist and use option 1 in the meantime.

The three options side by side

Option 1: endpoints yourself Option 2: hosted (e.g. this Actor) Option 3: official API
Available today Yes Yes Alpha, waitlist
Cost Free (your time, your IPs) From $1.00 per 1,000 reports, no start fee Unknown
Setup 80 lines of Python 15 lines + an account Unknown
429 handling Yours: wait, new session, then proxies Done for you (IP rotation, retries) Not applicable
Breaks when Google changes something Yes, you fix it Yes, the maintainer fixes it No (that is the point of an official API)
Related topics No (empty over HTTP) Not offered Unknown
Absolute volumes No No Unknown
Data leaves your machine Only to Google To Apify, then to you To Google

FAQ

Are the numbers absolute search volumes? No. 0–100 relative to the peak of your request. Only the "Trending Now" list on Google Trends gives approximate volumes ("200K+").

Can I still use pytrends? It installs and may work for some calls. The repository is read-only since April 2025, so the next change on Google's side will not be followed.

How many requests per IP before a 429? Google does not publish it, and it varies. Space requests by seconds, back off on 429, and expect to need other IPs for anything beyond a few hundred requests a day.

Is it allowed to fetch these endpoints? The data is aggregated, public and non-personal; the endpoints are the ones your browser calls. Whether your use fits Google's terms is your call, not mine. I am not a lawyer.

Why does my response start with )]}'? It is a prefix Google adds to JSON responses so they cannot be loaded as a script by another site. Strip it and parse the rest.

Can I compare more than 5 terms? Not in one request. Put the same anchor term in every group of 5 and rescale each group by the anchor's values.

If you have a cleaner way to handle the RELATED_TOPICS widget over plain HTTP, I would like to hear it in the comments.

Top comments (0)