The Mine Works
← All posts
tutorial June 22, 2026 · 13 min read Updated September 17, 2026

Google Trends API for Python in 2025: pytrends vs Scraper

Google Trends has no official API. Learn why pytrends breaks, how the SERP API approach works, and the fastest way to pull trend data into Python without getting rate-limited.

Try the scraper

The actor referenced in this article. Pay only for results delivered.

View the scraper →

Google Trends is one of the most valuable free datasets in SEO and market research. But there is no official API. The unofficial workarounds break constantly. This guide covers what works now: why pytrends fails, a managed scraper, a client you can build yourself, and how to export trend data for hundreds of keywords.

TL;DR: pytrends fails on cloud servers because Google rate-limits datacenter IPs on the widgetdata endpoints, which is where the numbers live. You can call those endpoints yourself through a residential proxy, or use a managed scraper that handles proxies and retries and charges nothing for failed keywords. For bulk work, remember that every request has its own 0-100 scale.

The problem with pytrends

pytrends is the most popular Python library for Google Trends. It reverse-engineers the same endpoint your browser uses. The problem: Google rate-limits it aggressively.

# This works for about 100 requests before you hit 429
from pytrends.request import TrendReq

pytrends = TrendReq(hl='en-US', tz=360)
pytrends.build_payload(['python', 'javascript'], timeframe='today 12-m')
df = pytrends.interest_over_time()

After a few hundred requests you get ResponseError: The request failed: Google returned a response with code 429. Adding time.sleep() helps but does not solve it at scale.

The cause is how Google splits the work across two endpoints. /trends/api/explore takes your keywords and returns widget tokens. It answers most IPs, datacenters included, as long as you carry a session cookie (NID). /trends/api/widgetdata/* returns the actual numbers (interest over time, regions, related queries), and that is the endpoint that sends 429s to AWS, GCP and Azure ranges. So pytrends usually works on a laptop and fails in a Docker container, a cloud function or a CI runner, which is where most people want to run it. The full list of where it breaks, and every alternative, is in pytrends is dead: the alternatives that work.

Google Trends returns four datasets per query:

Interest over time: a 0-100 index per time period, normalized to peak search volume. Not absolute search counts.

Interest by region: the same index broken down by country, state, or city depending on zoom level.

Related queries: the top 25 related search terms and 25 rising terms (those with a significant recent increase).

Related topics: broader topic entities related to your keyword.

What you cannot get is an absolute search count. Every number is relative. If you need monthly volumes, Google Keyword Planner gives them in ranges. Trends is the better source for direction: rising, falling, or seasonal.

Getting clean data with the Apify actor

The Google Trends Scraper Pro handles rate limiting via residential proxy rotation. You pick which of the four reports you want per run:

from apify_client import ApifyClient

client = ApifyClient('YOUR_APIFY_TOKEN')

run_input = {
    "keywords": ["python", "javascript", "typescript"],
    "timeframe": "today 12-m",   # "now 1-H" up to "today 5-y", or "all" (back to 2004)
    "geo": "US",                 # ISO country code, "" for worldwide
    "includeInterestOverTime": True,
    "includeRelatedQueries": True,
    "includeRelatedTopics": False,
    "includeInterestByRegion": False,
}

run = client.actor('themineworks/google-trends-pro').call(run_input=run_input)

for item in client.dataset(run['defaultDatasetId']).iterate_items():
    if item.get('_type'):  # skip the run summary and per-keyword error rows
        continue
    print(item['keyword'], item['interest_over_time'][-1])
    rising = item.get('related_queries', {}).get('rising', [])
    print('  rising:', [q['query'] for q in rising[:3]])

Each keyword comes back as one record. Shortened, it looks like this:

{
  "keyword": "python",
  "geo": "US",
  "timeframe": "today 12-m",
  "interest_over_time": [
    { "date": "2026-08-30T00:00:00.000Z", "value": 81, "formatted_value": "81" }
  ],
  "related_queries": {
    "top": [{ "query": "python tutorial", "value": 100, "formatted_value": "100" }],
    "rising": [{ "query": "python 3.14", "value": 250, "formatted_value": "+250%" }]
  }
}

Reports you switch off are not fetched. That does not change the price, which is per keyword, but it means fewer calls to Google per keyword and fewer chances of hitting a rate limit.

Comparing keywords

Google Trends scales every request to its own peak. The highest point in the request gets 100 and everything else is a share of it. In the Trends UI, putting “python” and “javascript” in one chart gives them one shared scale.

The actor fetches each keyword as its own request, so each series comes back on its own 0-100 scale. That is fine for shape: direction, seasonality, week-on-week spikes and growth ratios all hold up. It will not tell you which keyword is searched more, because both series can peak at 100. For relative size, send the terms together in one request (the client further down does this), and use an anchor keyword once you have more than five.

Regional breakdown

Set includeInterestByRegion to get the geo map for each keyword. With a country in geo you get its subregions (states for US). With geo set to "" you get countries.

run_input = {
    "keywords": ["python"],
    "timeframe": "today 5-y",
    "geo": "US",
    "includeInterestByRegion": True,
}

run = client.actor('themineworks/google-trends-pro').call(run_input=run_input)
for item in client.dataset(run['defaultDatasetId']).iterate_items():
    for row in item.get('interest_by_region', [])[:5]:
        print(row['region_code'], row['region_name'], row['value'])

Region values are relative too. A 100 marks the region where the term takes the largest share of all local searches, which is not always the region with the most searches.

Building the client yourself

If you would rather not depend on a service, the pattern is three requests. pytrends does much the same thing. What matters is sending the data call from an IP Google will serve.

  1. Load trends.google.com/trends/explore once to pick up the NID cookie.
  2. Call /trends/api/explore with your terms to get widget tokens.
  3. Call /trends/api/widgetdata/multiline with the TIMESERIES token. This is the call that needs a residential IP.
import json
import requests

TRENDS = 'https://trends.google.com/trends'

def _json(resp):
    resp.raise_for_status()
    return json.loads(resp.text.lstrip(")]}',\n"))  # strip the XSSI prefix

def compare_terms(terms, timeframe='today 12-m', geo='US', proxy=None):
    """Up to 5 terms in one request, so they share one 0-100 scale."""
    s = requests.Session()
    s.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
    if proxy:
        s.proxies = {'http': proxy, 'https': proxy}  # widgetdata is the call that needs it

    # 1. Seed the NID cookie
    s.get(f'{TRENDS}/explore', timeout=10)

    # 2. Widget tokens
    req = {
        'comparisonItem': [{'keyword': t, 'geo': geo, 'time': timeframe} for t in terms],
        'category': 0,
        'property': '',
    }
    widgets = _json(s.get(f'{TRENDS}/api/explore',
                          params={'hl': 'en-US', 'tz': '360', 'req': json.dumps(req)},
                          timeout=15))['widgets']
    ts = next(w for w in widgets if w['id'] == 'TIMESERIES')

    # 3. The numbers
    data = _json(s.get(f'{TRENDS}/api/widgetdata/multiline',
                       params={'hl': 'en-US', 'tz': '360',
                               'req': json.dumps(ts['request']), 'token': ts['token']},
                       timeout=20))
    timeline = data['default']['timelineData']

    # each point holds one value per term, in the order you sent them
    return {
        term: [{'date': p['formattedTime'], 'value': p['value'][i]} for p in timeline]
        for i, term in enumerate(terms)
    }

Every response opens with a short prefix, )]}', on widgetdata and )]}' on explore, before the JSON. It is Google’s protection against JSON hijacking, and _json strips it. The other widgets follow the same pattern: GEO_MAP goes to widgetdata/comparedgeo, and the RELATED_QUERIES widgets go to widgetdata/relatedsearches.

Common errors and what they mean

A 429 Too Many Requests means a datacenter IP is hitting widgetdata. Add a residential proxy or use a managed scraper.

ResponseError: The request failed from pytrends is usually a stale NID cookie. Fetch a fresh one before the widgetdata calls.

An empty timelineData array means the keyword does not have enough search volume for that geo and timeframe. That is a valid answer, not a bug.

A JSONDecodeError on the first character means the XSSI prefix was not stripped.

Rate limits

Google publishes none. In practice one IP gets roughly 1,400 explore requests a day, and far fewer widgetdata calls. Starting a new session cookie every 20 keywords avoids session-level flagging, and rotating residential IPs per request removes most of what is left. The managed actor also waits 4 to 8 seconds between keywords, which is a sensible floor for your own client too.

Exporting trend data at scale

Large keyword lists with the actor

The actor paces itself, so you do not need your own sleep loop. Split big lists into runs of about 50 keywords so each run finishes well inside its default timeout, and tag each record with the run it came from.

def collect_at_scale(keywords, timeframe='today 5-y', geo='US', chunk=50):
    records = []
    for n, i in enumerate(range(0, len(keywords), chunk)):
        run = client.actor('themineworks/google-trends-pro').call(run_input={
            'keywords': keywords[i:i + chunk],
            'timeframe': timeframe,
            'geo': geo,
            'includeRelatedQueries': False,  # fewer calls to Google per keyword
        })
        for item in client.dataset(run['defaultDatasetId']).iterate_items():
            if not item.get('_type'):
                item['run_id'] = run['id']
                item['chunk'] = n
                records.append(item)
    return records

200 keywords takes roughly 20 to 30 minutes this way. Keywords that fail are skipped and not charged, so compare the returned keywords against your input list and queue the gaps for another run.

The anchor trick for more than five terms

One request holds up to 5 terms. To compare 50, put the same stable, high-volume anchor term in every batch and express each term as a share of the anchor. A term’s ratio to the anchor does not depend on which batch it came from, so everything lands on one scale.

import time

def compare_many(terms, anchor='python', **kwargs):
    others = [t for t in terms if t != anchor]
    merged = {}
    for i in range(0, len(others), 4):
        batch = [anchor] + others[i:i + 4]
        series = compare_terms(batch, **kwargs)
        anchor_avg = sum(p['value'] for p in series[anchor]) / len(series[anchor])
        for term in batch[1:]:
            # 100 = the anchor's average interest over the period
            merged[term] = [
                {'date': p['date'], 'value': round(p['value'] / max(anchor_avg, 0.1) * 100, 2)}
                for p in series[term]
            ]
        time.sleep(15)  # space batches out
    return merged

Pick an anchor of roughly the same size as your terms. If the anchor dwarfs them, their values round down to 0 and 1 and the ratios become noise. Record which anchor you used, because results built on different anchors cannot be combined.

Zeros are not missing data

A 0 means interest was under Google’s reporting threshold for that period. For time-series work, fill interior zeros from their neighbours and keep a flag so you know which points were filled:

def fill_zeros(points):
    values = [p['value'] for p in points]
    out = []
    for i, p in enumerate(points):
        v = p['value']
        if v == 0:
            prev = next((x for x in reversed(values[:i]) if x > 0), None)
            nxt = next((x for x in values[i + 1:] if x > 0), None)
            known = [x for x in (prev, nxt) if x is not None]
            v = sum(known) / len(known) if known else 0
        out.append({**p, 'value': v, 'filled': p['value'] == 0})
    return out

Do not compute growth rates across a zero. Dividing by it gives infinite growth and breaks any ranking built on top.

Saving a reproducible dataset and exporting to CSV

Give each dataset an ID derived from its query parameters, and store the raw records with the metadata (anchor, timeframe, geo) so you can redo the normalisation later if your method changes.

import csv
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path

def save_dataset(records, meta, out_dir='trends-datasets'):
    Path(out_dir).mkdir(exist_ok=True)
    qhash = hashlib.md5(json.dumps(meta, sort_keys=True).encode()).hexdigest()[:8]
    now = datetime.now(timezone.utc)
    dataset_id = f"{now:%Y%m%d}_{qhash}"
    path = Path(out_dir) / f"{dataset_id}.json"
    path.write_text(json.dumps({
        'id': dataset_id,
        'metadata': {**meta, 'collected_at': now.isoformat(), 'records': len(records)},
        'data': records,
    }, indent=2))
    return path

def export_csv(series, filepath):
    """series: {term: [{'date': ..., 'value': ...}, ...]} with matching dates."""
    terms = list(series)
    with open(filepath, 'w', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['date'] + terms)
        for i, point in enumerate(series[terms[0]]):
            writer.writerow([point['date']] + [series[t][i]['value'] for t in terms])

export_csv takes the output of compare_many directly, as long as every batch used the same timeframe and ran on the same day.

Adding Claude on top

Trends data tells you what moved. A language model helps with the next question: why it moved, and what to publish about it. The two patterns below work on the actor output. For seasonal content calendars and regional opportunity mapping, see finding SEO opportunities with Google Trends and the market research playbook.

import json
import anthropic
from apify_client import ApifyClient

apify = ApifyClient('YOUR_APIFY_TOKEN')
claude = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

def fetch_trends(keywords, timeframe='today 12-m', geo='US'):
    run = apify.actor('themineworks/google-trends-pro').call(run_input={
        'keywords': keywords, 'timeframe': timeframe, 'geo': geo,
        'includeRelatedQueries': True,
    })
    items = apify.dataset(run['defaultDatasetId']).iterate_items()
    return [i for i in items if not i.get('_type') and i.get('interest_over_time')]

def ask_claude(prompt, model='claude-opus-5', max_tokens=16000):
    msg = claude.messages.create(
        model=model,
        max_tokens=max_tokens,
        messages=[{'role': 'user', 'content': prompt}],
    )
    # the reply can open with a thinking block, so pick out the text block
    return next(b.text for b in msg.content if b.type == 'text')

Breakout alerts

Compare the last 4 weeks with the 12 weeks before them. A ratio of 1.4 or more filters out normal weekly noise and still catches real acceleration. Raise it to 1.6 for keywords that jump around a lot. Use today 12-m so the data is weekly and the slices below are weeks, not days. The latest week is usually partial, which pulls the recent average down a little.

def detect_breakouts(watchlist, threshold=1.4):
    alerts = []
    for item in fetch_trends(watchlist, timeframe='today 12-m'):
        values = [p['value'] for p in item['interest_over_time']]
        if len(values) < 16:
            continue
        recent = sum(values[-4:]) / 4
        baseline = sum(values[-16:-4]) / 12
        if baseline > 0 and recent / baseline >= threshold:
            ratio = recent / baseline
            rising = [q['query'] for q in item.get('related_queries', {}).get('rising', [])[:5]]
            note = ask_claude(
                f'Search interest in "{item["keyword"]}" is {ratio:.1f}x its 12-week baseline. '
                f'Rising related queries: {rising}. In two sentences: what is likely driving '
                'this, and what content would meet that demand?',
                model='claude-haiku-4-5',
                max_tokens=1000,
            )
            alerts.append({'keyword': item['keyword'], 'ratio': round(ratio, 2),
                           'rising': rising, 'note': note})
    return sorted(alerts, key=lambda a: a['ratio'], reverse=True)

Run it daily from cron. A breakout caught this way usually shows up weeks before the keyword looks interesting in keyword difficulty tools.

Scoring a content backlog

Send Claude a short summary per keyword rather than raw series. Five years of weekly data shows direction clearly:

def score_backlog(candidates, niche):
    summaries = []
    for item in fetch_trends(candidates, timeframe='today 5-y'):
        v = [p['value'] for p in item['interest_over_time']]
        early, late = sum(v[:12]) / 12, sum(v[-12:]) / 12
        summaries.append({
            'keyword': item['keyword'],
            'avg_interest': round(sum(v) / len(v), 1),
            'late_vs_early': round(late / max(early, 1), 2),
            'rising_queries': [q['query'] for q in item.get('related_queries', {}).get('rising', [])[:5]],
        })
    prompt = (
        f'You are planning content for a {niche} site. For the keywords below, return a JSON '
        'array of objects with keyword, opportunity_score (1-10), reasoning (2 sentences), '
        'content_angle (a specific article title) and urgency (evergreen, seasonal or trending_now). '
        'Score a rising direction and strong rising queries higher. Return only the JSON array.\n\n'
        + json.dumps(summaries, indent=2)
    )
    text = ask_claude(prompt).strip().removeprefix('```json').removesuffix('```')
    return sorted(json.loads(text), key=lambda x: x['opportunity_score'], reverse=True)

The ranking is the easy part. What the model adds is the angle: it turns “rising, with these related queries” into an article title a writer can start on.

Scheduling trend monitoring

Run the actor on a weekly schedule via Apify Schedules and push results to a spreadsheet or database. This lets you track rising trends before they peak, useful for content planning and keyword research. Keep the breakout check on a daily schedule and the backlog scoring on a weekly one.

Pricing

Pay only per result returned. Failed and empty results are never charged. One keyword report counts as one result, whichever report types you switch on. Current rates are on the actor page.

Related Actor

Explore the scraper referenced in this article: inputs, outputs, and pricing, then run it on Apify.

Frequently asked questions

Does Google Trends have an official API? +

No. Google shut down the Google Trends API in 2007 and never replaced it. pytrends and scrapers reverse-engineer the unofficial endpoint.

Why does pytrends stop working? +

pytrends sends requests from your IP directly to Google, which rate-limits and blocks after a few hundred requests. A scraper with rotating proxies avoids this.

Can I get historical Google Trends data back to 2004? +

Yes. Google Trends data goes back to January 2004. Date resolution changes: daily under 90 days, weekly up to 5 years, monthly for longer ranges.

Why can I not compare Google Trends values from separate requests? +

Every request is scaled to its own peak, so 100 means the highest point in that request, not a fixed volume. Two keywords queried separately can both show 100. To compare size, put the terms in one request (up to 5), or include the same anchor keyword in every batch and rescale against it.

What does a Google Trends value of 0 mean? +

Interest was too low to register for that period. It does not mean nobody searched. Treat it as below the reporting threshold: fill interior zeros from neighbouring values for time-series work, and never compute growth rates across a zero.

Apify Store

Find a ready-made scraper for your job

Apify has over 80,000 scrapers and automations, ours included. Start free with $5 of platform credit every month.