Disclosure: I built and sell the Apify actor used in this post (Google Trends Scraper). It is pay-per-use ($2 per 1,000 search terms that return data; failed or empty terms are not charged). Everything below was run against the real actor on 2026-10-30; the numbers shown are from that run.
If you have ever scripted Google Trends, you know the pain: there is no official API, pytrends breaks whenever Google changes something, and rate limits (HTTP 429) hit quickly from datacenter IPs. In this post I will show a small, dependency-light Python workflow that handles the retries and proxy rotation on the server side, so your code is just "POST JSON, get JSON".
You will end up with a reusable fetch_trends() function, a comparison of several keywords in one Canada-only query, a list of rising related queries, and a CSV you can open in a spreadsheet.
Setup
- Create a free Apify account and copy your API token from Settings → Integrations.
pip install requests- Export the token:
export APIFY_TOKEN=apify_api_xxx(never hard-code it).
Step 1: a reusable fetch function
Apify's run-sync-get-dataset-items endpoint starts the actor, waits for it to finish, and returns the results in the HTTP response. It is ideal for short jobs (it times out after 5 minutes, so for large batches use the async start-poll-read pattern shown in the second tutorial of this series, or the Apify client library).
import os
import requests
TOKEN = os.environ["APIFY_TOKEN"]
ACTOR = "flyingnimbus~google-trends-scraper"
URL = f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"
def fetch_trends(terms, geo="", time_range="past_12_months", compare=False):
"""Run the actor synchronously and return its dataset items as a list of dicts."""
payload = {
"searchTerms": terms,
"compareTerms": compare,
"geo": geo, # "" = worldwide, "CA", "US-CA", ...
"timeRange": time_range,
"interestOverTime": True,
"interestByRegion": True,
"relatedQueries": True,
"proxyConfiguration": {"useApifyProxy": True},
}
resp = requests.post(
URL,
headers={"Authorization": f"Bearer {TOKEN}"},
json=payload,
timeout=300,
)
resp.raise_for_status()
return resp.json()
if __name__ == "__main__":
items = fetch_trends(["python", "ruby"], geo="CA", compare=True)
print(len(items), "item(s)")
item = items[0]
print(item["status"], item["geo"], item["timeRange"])
print(item["averageInterest"])
Key input fields: searchTerms (list), geo ("" for worldwide, or CA, US-CA, and so on), timeRange (past_7_days, past_12_months, past_5_years, all, ...), compareTerms (put up to 5 terms on one shared 0-100 scale, like the Trends "compare" view) and searchProperty (web, youtube, news, images, shopping).
My run of the __main__ block printed:
1 item(s)
ok CA today 12-m
{'python': 58.13, 'ruby': 25.83}
With compareTerms=True you get one item per comparison group, not one per term. With compareTerms=False you get one item per term.
Step 2: weekly series, rising queries, CSV
Each result item contains interestOverTime (a list of {time, values} points), interestByRegion, relatedQueries (keyed by term, each row tagged top or rising) and averageInterest. Here is a small report script:
import csv
from trends_fetch import fetch_trends
def weekly_series(item):
"""Yield (period_start, {term: value}) from one result item."""
for point in item.get("interestOverTime", []):
yield point["time"][:10], point["values"]
def rising_queries(item, limit=5):
"""Return {term: [rising related queries]} for a result item."""
out = {}
for term, rows in (item.get("relatedQueries") or {}).items():
rising = [r for r in rows if r["list"] == "rising"]
out[term] = [(r["query"], r["value"]) for r in rising[:limit]]
return out
def to_csv(item, path):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
terms = list(item["averageInterest"].keys())
writer.writerow(["period_start", *terms])
for day, values in weekly_series(item):
writer.writerow([day, *[values.get(t, "") for t in terms]])
if __name__ == "__main__":
items = fetch_trends(["python", "ruby", "php"], geo="CA", compare=True)
for item in items:
if item["status"] != "ok":
print("skipped:", item["searchTerms"], item["status"])
continue
print("average interest:", item["averageInterest"])
print("rising:", rising_queries(item))
to_csv(item, "trends_ca.csv")
print("wrote trends_ca.csv")
Output from my run (Canada, last 12 months, three terms compared):
average interest: {'python': 57.4, 'ruby': 25.81, 'php': 20.98}
wrote trends_ca.csv # 53 weekly rows
The CSV starts period_start,python,ruby,php followed by rows like 2025-10-26,63,23,21.
Gotchas I hit (so you do not have to)
-
Ambiguous terms. The "rising" list for
rubywas dominated by celebrity names, andpythonsurfaced restaurant queries. Google Trends measures search terms, not topics. Use thecategoryfield or more specific phrases likeruby on rails. -
Relative, not absolute. Values are scaled 0-100 against the peak in your query. They are not search volumes. Comparing across separate queries is invalid; use
compareTermsto share a scale. -
Check
status. Each item has astatusfield. Skip anything that is not"ok", as the script above does, rather than assuming data is present. -
Partial last point. The latest period can be flagged
isPartial: true; drop it before computing growth rates.
Compare mode versus separate queries
Should you pass compareTerms=True or query each term alone? Use compare mode when you care about relative popularity: the values for every term share one scale, so "python 57 vs ruby 26" is a meaningful statement. Use separate queries when you care about each term's own shape over time, for example to spot seasonality of a niche keyword that would look flat next to a giant like python. The cost is per comparison group in compare mode and per term otherwise, so comparing five terms at once is also the cheapest way to get a shared scale.
Ideas to build on this
- Run it weekly on a keyword list and chart your niche's seasonality before you plan content.
- Feed rising queries into a content calendar or a keyword-research spreadsheet.
- Use
interestByRegionto pick which provinces or states to target for local ads. - Schedule it in the Apify Console (Schedules) and push results to a webhook or Google Sheet.
What it costs
At $2 per 1,000 terms, the three-term comparison above is one comparison group, so roughly a fifth of a cent. A 500-keyword weekly refresh is about $1 per run. Apify's free plan includes some monthly platform credit; check their current pricing page for the exact amount.
Wrap-up
The trick is moving the fragile parts (sessions, proxies, backoff) out of your codebase and behind a stable JSON interface. If you hit an edge case or want another field exposed, open an issue on the actor page.
Top comments (0)