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.
