How to get Google search results as JSON (no HTML parsing)
Google serves obfuscated, rotating markup. Here is the /serp JSON contract instead: guaranteed fields, optional blocks, tested August 2026.

There are two ways to get data out of a Google results page: parse the HTML, or ask for the JSON that describes the same page. Only one of them survives a markup update.
Why parsing the HTML doesn’t hold up
Open the view-source of a results page and look for a class name you’d want to build a CSS selector on. You won’t find one that reads like result-title. You’ll find something like MjjYud or g6HRUb, machine-generated, and different from the string you saw last week. Google’s front end is compiled and obfuscated per build, and the build rotates.
Even if you pin a selector to something stable, like an href pattern, the layout underneath changes by query type. A plain informational query renders differently from one that triggers a knowledge panel, a local pack, or a shopping carousel. You end up writing one parser per query shape, then rewriting all of them the next time Google ships a redesign.
None of this is a Google-specific problem, either: JavaScript often renders parts of the page after load, so a plain requests.get and BeautifulSoup pass can miss content that a real browser would show. That’s a separate cost, covered in our piece on when scraping needs JS rendering.
What a JSON response actually looks like
Send the same query to an API that returns the parsed result instead of the page, and you get a fixed set of keys instead of a DOM to walk:
import requests
API_KEY = "faf_live_..." # your FastAF key
resp = requests.get(
"https://api.fastaf.co/v1/serp",
params={
"query": "best noise cancelling headphones",
"country": "us",
"language": "en",
"device": "mobile",
},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
for result in data["organic_results"]:
print(result["rank"], result["title"], result["link"], result["snippet"][:60])
The fields you can rely on
organic_results is the one part of the response you can build against without hedging: it’s a list, and each entry carries rank, title, link and snippet. That’s the contract, regardless of query.
| Field | What it is |
|---|---|
rank |
Rank on the page, 1-indexed |
title |
The blue link text |
link |
The destination URL |
snippet |
The description text under the result |
Everything else in the response is conditional on what the query actually produces, the same way it’s conditional on the live page. Blocks like people_also_ask, a knowledge panel, ads or related searches show up as extra top-level keys when Google renders them for that query, and are simply absent otherwise. Write code that checks for the key before reading it (data.get("people_also_ask")), not code that assumes it’s always there.
The parameters that shape the request
The full parameter list is in the docs, but the ones you’ll use on nearly every request:
q(aliasquery): the search term.country: two-letter country to search from, changes which Google domain and result set you get.language(aliaslang): interface and result language.device:mobileordesktop. Rankings and layout differ between the two, and mobile is the majority of real traffic for most verticals, so pull both if rank position matters to you.page: for depth beyond the first page.location: a more precise geographic signal thancountryalone, useful for local intent queries.safe: SafeSearch on or off.tbs: Google’s own time and filter parameter, for date-restricted searches.
Swapping device between requests is the cheapest way to catch a ranking difference that a desktop-only setup would miss entirely:
for device in ["desktop", "mobile"]:
r = requests.get(
"https://api.fastaf.co/v1/serp",
params={"query": "project management software", "country": "us", "device": device},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=60,
)
top = r.json()["organic_results"][0]
print(device, "->", top["rank"], top["link"])
What it costs
A /serp request costs 5 credits regardless of how many blocks the query produces. We ran a request while writing this post (August 2026) and it returned 10 organic results for those 5 credits. Google no longer honours num=100, so if you need the top 100 results for a keyword, budget for 10 paginated requests, not one; the cost-per-1,000-searches breakdown walks through what that does to your bill.
Failed requests aren’t billed: credits are reserved when the request starts and returned if nothing comes back.
The short version
organic_results with rank, title, link and snippet is the part of the contract you can always count on. Everything else in the response, people_also_ask included, is there when Google renders it and absent when it doesn’t, so gate your code on the key, not on the query. Full reference in the docs, or start with the free tier, 500 credits, no card.