Normalize SERP API JSON: One Schema for Every Provider

By Serpent API Team · · 10 min read

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:

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 atresults.organicorganic_resultstasks[].result[].items[] where type=="organic"organic_results
positionpositionpositionrank_group / rank_absoluteposition
urlurllinkurllink
titletitletitletitletitle
snippetsnippetsnippetdescriptionsnippet
display_urldisplayedUrldisplayed_linkdomaindisplayed_link

People Also Ask & featured snippet

Feature Serpent API SerpApi DataForSEO SearchApi
People Also Askresults.peopleAlsoAsk[]question, answer, sourceUrlrelated_questions[]question, snippet, linkitems with type=="people_also_ask"related_questions[]question, answer, source{}
Featured snippetresults.featuredSnippettext, sourceUrlanswer_boxitems with type=="featured_snippet"answer_boxanswer, 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_groupposition, descriptionsnippet 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

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 Key

Explore: 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.

Related Posts