Normalize SERP API JSON: One Schema for Every Provider
SERP API JSON schema normalization is the practice of mapping every provider’s differently-named response fields into one canonical shape your application owns. The same organic result is results.organic[].url at Serpent API, organic_results[].link at SerpApi, and an items[] entry with type: "organic" and a url field at DataForSEO. Your code shouldn’t care. This guide gives you the verified field-mapping table across four providers and a tested ~80-line Python adapter layer that makes the differences disappear.
It’s the international power plug problem: the electricity is identical everywhere, but every region picked its own pin layout. You don’t rewire your laptop per country — you carry one adapter. This is that adapter, for SERP JSON.
Why every provider names the same fields differently
There’s no standards body for SERP JSON. Each provider designed its response independently, for slightly different users:
- Named-array schemas (SerpApi, SearchApi, Serpent API) give each SERP feature its own key — easy to grab just the organic list.
- Flat typed-item schemas (DataForSEO) return one
items[]array for the whole page, where every element — organic result, ad, featured snippet — carries atypefield. Powerful for whole-page analysis, more work for simple use. - Switchable schemas (Serpent API’s
format=fullvsformat=simple) let the same endpoint return either a keyed object of features or a minimal flat array — documented in the API reference.
None of these is wrong. But the moment you use two providers — for failover, for a migration, or just to compare data quality — the naming differences turn into scattered if provider == ... branches unless you normalize at the boundary.
The field-mapping table (as documented, July 2026)
This table was assembled from each provider’s public response documentation on July 18, 2026: SerpApi’s Search API docs, DataForSEO’s SERP Advanced docs, SearchApi’s Google docs, and the Serpent API reference. Field names change rarely but do change — re-check the docs before you build.
Organic results
| Canonical field | Serpent API (format=full) |
SerpApi | DataForSEO | SearchApi |
|---|---|---|---|---|
| list lives at | results.organic | organic_results | tasks[].result[].items[] where type=="organic" | organic_results |
position | position | position | rank_group / rank_absolute | position |
url | url | link | url | link |
title | title | title | title | title |
snippet | snippet | snippet | description | snippet |
display_url | displayedUrl | displayed_link | domain | displayed_link |
People Also Ask & featured snippet
| Feature | Serpent API | SerpApi | DataForSEO | SearchApi |
|---|---|---|---|---|
| People Also Ask | results.peopleAlsoAsk[] — question, answer, sourceUrl | related_questions[] — question, snippet, link | items with type=="people_also_ask" | related_questions[] — question, answer, source{} |
| Featured snippet | results.featuredSnippet — text, sourceUrl | answer_box | items with type=="featured_snippet" | answer_box — answer, snippet, organic_result{} |
Two traps hide in this table. First, DataForSEO has two positions: per its docs, rank_group counts within results of the same type while rank_absolute counts across every SERP element — map the wrong one and your providers disagree by design. Second, “answer” fields don’t align: SerpApi’s PAA answer text lives in snippet, SearchApi’s in answer, Serpent API’s in answer. If you’re parsing features at depth, our guide to parsing the Google SERP in Python covers what each block contains.
The adapter-registry layer (tested code)
Below is the complete normalization layer. Design choices worth stealing even if you rewrite it in another language: each provider is one small pure function; the registry is a plain dict; every record keeps a raw passthrough so provider extras (sitelinks, ratings, pixel positions) are never lost; and positions are always coerced to integers with an index fallback — because they arrive as strings, ints, or not at all depending on the provider.
"""One SERP schema to rule them all — field-mapping normalizer."""
def _int(value, default=None):
"""Positions arrive as int, str, or missing. Always coerce."""
try:
return int(value)
except (TypeError, ValueError):
return default
def from_serpent(payload):
"""Serpent API — format=full: results.organic / peopleAlsoAsk."""
results = payload.get("results") or {}
if isinstance(results, list): # format=simple: flat array
organic, paa, snippet = results, [], None
else:
organic = results.get("organic") or []
paa = results.get("peopleAlsoAsk") or []
snippet = results.get("featuredSnippet")
return {
"provider": "serpent",
"organic": [
{
"position": _int(item.get("position"), i + 1),
"title": item.get("title", ""),
"url": item.get("url", ""),
"snippet": item.get("snippet", ""),
"display_url": item.get("displayedUrl", ""),
"raw": item, # provider extras survive here
}
for i, item in enumerate(organic)
],
"paa": [
{"question": q.get("question", ""), "answer": q.get("answer", ""),
"source_url": q.get("sourceUrl", ""), "raw": q}
for q in paa
],
"featured_snippet": (
{"text": snippet.get("text", ""),
"source_url": snippet.get("sourceUrl", ""),
"raw": snippet} if snippet else None
),
}
def from_serpapi(payload):
"""SerpApi — organic_results / related_questions / answer_box."""
box = payload.get("answer_box")
return {
"provider": "serpapi",
"organic": [
{
"position": _int(item.get("position"), i + 1),
"title": item.get("title", ""),
"url": item.get("link", ""), # link, not url
"snippet": item.get("snippet", ""),
"display_url": item.get("displayed_link", ""),
"raw": item,
}
for i, item in enumerate(payload.get("organic_results") or [])
],
"paa": [
{"question": q.get("question", ""), "answer": q.get("snippet", ""),
"source_url": q.get("link", ""), "raw": q}
for q in payload.get("related_questions") or []
],
"featured_snippet": (
{"text": box.get("snippet") or box.get("answer", ""),
"source_url": (box.get("organic_result") or {}).get("link", ""),
"raw": box} if box else None
),
}
ADAPTERS = {"serpent": from_serpent, "serpapi": from_serpapi}
def normalize(provider, payload):
"""Route any provider payload to the canonical shape."""
try:
return ADAPTERS[provider](payload)
except KeyError:
raise ValueError(f"no adapter registered for {provider!r}") from None
Adding a third provider is one function and one dict entry — the DataForSEO adapter, for instance, filters tasks[0]["result"][0]["items"] for type == "organic" and maps rank_group → position, description → snippet per the table above. Nothing else in your codebase changes.
We ran it — real output, not pseudocode
We tested the layer two ways on July 18, 2026. First against a live response from Serpent API’s production /api/search/quick endpoint (query “best project management software”, format=full), then against a fixture built from SerpApi’s documented response shape — a constructed example matching their docs, not a live capture, since honest testing only claims what it measures:
serpent: 7 organic | paa: 0 | fs: False
first: {'position': 1, 'title': '10 Best Project Management
Software of 2026 – Forbes Advisor', 'url': 'https://www.forbes
.com/advisor/business/software/best-project-management-software/',
...}
serpapi: 1 organic | paa: 1 | fs: True
first: {'position': 1, 'title': 'Example result', ...}
[serpent] #1: 10 Best Project Management Software of 2026
[serpapi] #1: Example result
edge cases: empty results OK, unknown provider raises: no adapter
registered for 'acme'
Two real-world details surfaced immediately — this is why you test against live data. The live response returned position as the string "1", which the _int() coercion silently fixed (an assert isinstance(..., int) in our test proves it). And the live SERP happened to include no People Also Ask block at all, exercising the empty-feature path: the normalizer returned an empty-but-valid paa: [] rather than crashing. Any normalization layer that hasn’t met a missing feature in testing will meet one in production.
Edge cases the mapping table can’t show you
- Position renumbering across pages. Providers differ on whether page 2 starts at position 11 or restarts at 1 — and some omit position entirely. Recompute your own absolute position as
(page − 1) × page_size + indexwhen paginating, and treat provider-sent positions as a hint, not truth. - Type drift. Numbers as strings (we hit one on our first live call above), booleans as
"true", missing keys vsnullvalues. Coerce everything at the boundary so the rest of your code never sees a surprise type. - Empty is not an error. A SERP with no PAA, no featured snippet, or even zero organic results (rare, but real) should normalize to an empty-but-valid record. Reserve exceptions for transport failures — and note that providers bill failures differently, which is a separate problem from parsing.
- Provider extras. Sitelinks, ratings,
snippet_highlighted_words, pixel coordinates — every provider has fields no other provider has. Therawpassthrough keeps them without polluting the canonical schema. - Schema versioning. Stamp every stored record with the provider and fetch date. When a provider renames a field (it happens), you can date the change and re-normalize history from raw data.
Where this layer pays off
Three situations turn this from nice-to-have into load-bearing infrastructure. Failover: when your primary provider has an outage, a registry with two adapters means switching is a config change — the full pattern is in our SERP API backup plan. Migration: when you change providers for cost or coverage reasons, dual-running old and new through one schema is how you verify parity before cutting over — our zero-downtime migration guide walks that process with a TypeScript take on the same seam. Evaluation: comparing providers’ data quality head-to-head only works when both feeds land in one shape first.
It also changes how you shop. Once your application reads only the canonical schema, provider choice becomes purely a question of price, coverage, and reliability — compare SerpApi’s subscription tiers, DataForSEO’s queue-based pricing, and Serpent API’s flat per-call pricing without a rewrite riding on the decision. Serpent API’s four-engine SERP API keeps one response schema across Google, Bing, Yahoo and DuckDuckGo, which means one adapter covers all four engines — and the format=simple switch in the docs is itself a tiny built-in normalization when all you need is positions and URLs.
One Schema, Four Engines
Serpent API returns the same documented JSON shape for Google, Bing, Yahoo and DuckDuckGo — one adapter, four engines, flat per-call pricing. Try it with 10 free searches, no card required.
Get a Free API KeyExplore: SERP API · API Reference · Pricing
FAQ
Why do SERP APIs return different JSON structures?
Each provider designed its schema independently for different users: SerpApi and SearchApi group results into named arrays like organic_results, DataForSEO returns one flat items array with per-item type fields, and Serpent API keys features under a results object. They’re dialects describing the same page — which is exactly why a normalization layer pays for itself.
What fields should a normalized SERP schema include?
For organic results: position (always an integer), title, url, snippet, and display_url cover nearly every use case. PAA needs question, answer, source_url; featured snippets need text and source_url. Keep a raw field on every record so provider-specific extras are never lost.
What’s the difference between rank_group and rank_absolute in DataForSEO?
Per DataForSEO’s documentation, rank_group is the position among results of the same type, while rank_absolute counts across every SERP element, including ads and snippets above. Map rank_absolute where another provider reports organic-only position and your two feeds will disagree by design.
Should I store raw responses or normalized records?
Both. Normalize for your read path; keep the raw response (or the raw passthrough) beside it. When you improve your canonical schema later, you re-normalize history from raw data instead of living with two record eras.
How do I handle position numbering across pages?
Defensively. Some providers number continuously across pages, some restart per page, some omit position. Coerce to integer, fall back to the array index, and recompute (page − 1) × page_size + index when you paginate — then your stored positions are consistent regardless of provider behavior.



