DEV Community

Cecilia Hill
Cecilia Hill

Posted on

How to Test Different SERP APIs with the Same Query in Python

When people compare SERP APIs, they usually start with the pricing page.

I get it. Pricing is easy to compare.

But after testing a few search APIs, I learned that the real difference usually shows up somewhere else:

the response body
Enter fullscreen mode Exit fullscreen mode

Two APIs can both say they return Google search results.

But one response may include clean titles, links, snippets, and positions.

Another may return fewer results.

Another may have a different field name for the same thing.

Another may return local packs, related questions, or ads in a structure that is easier to use.

And sometimes the cheapest API is not actually cheaper once you add cleanup work, retries, and missing fields.

So before choosing a SERP API, I like to run the same query through multiple providers and compare the output.

This article shows a simple Python way to do that.

The goal is not to build a perfect benchmark.

The goal is to build a small testing script that helps you answer a practical question:

Which API gives me the most usable search data for my actual queries?
Enter fullscreen mode Exit fullscreen mode

What we are going to test

We will send the same query to multiple SERP APIs.

Then we will compare:

  • response time
  • number of organic results
  • whether title, URL, snippet, and position exist
  • top-ranking domains
  • raw response size
  • normalized output
  • errors or empty responses

The workflow looks like this:

same query → multiple SERP APIs → normalize results → compare output
Enter fullscreen mode Exit fullscreen mode

This is useful when you are choosing a provider for:

  • SEO rank tracking
  • AI agents
  • RAG workflows
  • competitor monitoring
  • local SEO
  • market research
  • SERP data pipelines

Why not just compare docs?

Docs are useful, but they do not show how the API behaves with your queries.

For example, these queries may behave very differently:

best CRM software
coffee shop in Austin
latest AI search APIs
best hotels near Times Square
site:example.com pricing
SerpApi alternatives
Enter fullscreen mode Exit fullscreen mode

Some queries are clean.

Some trigger local results.

Some trigger news.

Some trigger shopping.

Some trigger mixed SERP features.

Some return AI-generated blocks or People Also Ask results.

If your product depends on search data, you should test the messy queries too. The messy ones are where the goblins live.

Install dependencies

We only need a few Python packages:

pip install requests python-dotenv pandas
Enter fullscreen mode Exit fullscreen mode

requests calls the APIs.

python-dotenv loads API keys from a .env file.

pandas helps us save and inspect comparison results.

Create a .env file

Create a .env file.

Use the keys and endpoints for the providers you want to test.

SERPAPI_KEY=your_serpapi_key
SERPAPI_URL=https://serpapi.com/search.json

SERPER_KEY=your_serper_key
SERPER_URL=https://google.serper.dev/search

TALORDATA_KEY=your_talordata_key
TALORDATA_URL=https://your-talordata-serp-endpoint.example.com/search
Enter fullscreen mode Exit fullscreen mode

A small note before we start: every provider has its own request format.

That is why we will write a tiny adapter for each API.

Think of each adapter as a translator.

our test format → provider-specific request → provider-specific response
Enter fullscreen mode Exit fullscreen mode

Define a common result format

The most annoying part of comparing APIs is that each response has a slightly different shape.

So we will normalize everything into this format:

{
  "provider": "example",
  "position": 1,
  "title": "Example Title",
  "url": "https://example.com",
  "snippet": "Example snippet"
}
Enter fullscreen mode Exit fullscreen mode

Once all providers return the same internal format, comparison becomes easier.

Create the test script

Create a file named compare_serp_apis.py.

import os
import time
import json
import requests
import pandas as pd
from urllib.parse import urlparse
from dotenv import load_dotenv


load_dotenv()


def extract_domain(url):
    if not url:
        return ""

    parsed = urlparse(url)
    domain = parsed.netloc.lower()

    if domain.startswith("www."):
        domain = domain[4:]

    return domain
Enter fullscreen mode Exit fullscreen mode

The domain helper is useful because SEO and competitor workflows usually care about domains, not only full URLs.

For example:

https://www.example.com/blog/post
Enter fullscreen mode Exit fullscreen mode

becomes:

example.com
Enter fullscreen mode Exit fullscreen mode

Adapter 1: SerpApi

Here is a simple adapter for SerpApi-style requests.

def call_serpapi(query, location="United States", language="en"):
    api_key = os.getenv("SERPAPI_KEY")
    api_url = os.getenv("SERPAPI_URL")

    if not api_key or not api_url:
        raise ValueError("Missing SERPAPI_KEY or SERPAPI_URL")

    params = {
        "api_key": api_key,
        "engine": "google",
        "q": query,
        "location": location,
        "hl": language,
    }

    response = requests.get(api_url, params=params, timeout=30)
    response.raise_for_status()

    return response.json()
Enter fullscreen mode Exit fullscreen mode

This adapter only returns raw JSON.

We will normalize it later.

Adapter 2: Serper

Some APIs use GET.

Some use POST.

Serper-style APIs commonly use a JSON POST body.

def call_serper(query, location="United States", language="en"):
    api_key = os.getenv("SERPER_KEY")
    api_url = os.getenv("SERPER_URL")

    if not api_key or not api_url:
        raise ValueError("Missing SERPER_KEY or SERPER_URL")

    headers = {
        "X-API-KEY": api_key,
        "Content-Type": "application/json",
    }

    payload = {
        "q": query,
        "location": location,
        "hl": language,
    }

    response = requests.post(api_url, headers=headers, json=payload, timeout=30)
    response.raise_for_status()

    return response.json()
Enter fullscreen mode Exit fullscreen mode

Notice that the adapter hides provider-specific details from the rest of the script.

That is the point.

The comparison code should not care whether one provider uses GET and another uses POST.

Adapter 3: TalorData

For the third adapter, we will use a generic SERP API style. View the complete API documentation>>

You can use this for Talordata or another provider by adjusting the endpoint and parameter names.

def call_talordata(query, location="United States", language="en"):
    api_key = os.getenv("TALORDATA_KEY")
    api_url = os.getenv("TALORDATA_URL")

    if not api_key or not api_url:
        raise ValueError("Missing TALORDATA_KEY or TALORDATA_URL")

    params = {
        "api_key": api_key,
        "engine": "google",
        "q": query,
        "location": location,
        "language": language,
        "output": "json",
    }

    response = requests.get(api_url, params=params, timeout=30)
    response.raise_for_status()

    return response.json()
Enter fullscreen mode Exit fullscreen mode

In a real test, always check the provider’s docs and map the exact parameter names.

Do not assume every SERP API uses location, language, or engine.

That small mismatch can ruin a comparison before it starts.

Normalize organic results

Now we need to extract organic results from each provider response.

Different APIs may use keys like:

organic_results
organic
results
Enter fullscreen mode Exit fullscreen mode

Let’s support a few common shapes.

def get_organic_items(data):
    possible_keys = [
        "organic_results",
        "organic",
        "results",
    ]

    for key in possible_keys:
        value = data.get(key)
        if isinstance(value, list):
            return value

    return []
Enter fullscreen mode Exit fullscreen mode

Now normalize each result.

def normalize_item(provider, item):
    url = item.get("link") or item.get("url") or ""

    return {
        "provider": provider,
        "position": item.get("position") or item.get("rank") or "",
        "title": item.get("title") or "",
        "url": url,
        "domain": extract_domain(url),
        "snippet": item.get("snippet") or item.get("description") or "",
    }
Enter fullscreen mode Exit fullscreen mode

This gives us a shared format.

The rest of the script can ignore provider-specific field names.

Add response metrics

The normalized results are useful, but I also want some quick response-level metrics.

For each provider, we will track:

  • whether the request succeeded
  • response time
  • raw response size
  • number of organic results
  • how many results have titles
  • how many results have URLs
  • how many results have snippets
def calculate_metrics(provider, raw_data, normalized_results, elapsed_seconds, error=""):
    organic_count = len(normalized_results)

    title_count = sum(1 for item in normalized_results if item["title"])
    url_count = sum(1 for item in normalized_results if item["url"])
    snippet_count = sum(1 for item in normalized_results if item["snippet"])

    raw_size = len(json.dumps(raw_data)) if raw_data else 0

    return {
        "provider": provider,
        "success": not bool(error),
        "error": error,
        "elapsed_seconds": round(elapsed_seconds, 3),
        "raw_response_size": raw_size,
        "organic_count": organic_count,
        "title_count": title_count,
        "url_count": url_count,
        "snippet_count": snippet_count,
    }
Enter fullscreen mode Exit fullscreen mode

This is not a scientific benchmark.

But it is enough to spot obvious differences.

If one provider returns 10 organic results and another returns 4 for the same query, that is worth inspecting. Start free testing of SERP API>>

Run one provider test

Let’s write a helper that runs one provider and catches errors.

def run_provider_test(provider_name, provider_function, query, location, language):
    started_at = time.perf_counter()

    try:
        raw_data = provider_function(
            query=query,
            location=location,
            language=language,
        )

        elapsed = time.perf_counter() - started_at

        organic_items = get_organic_items(raw_data)

        normalized_results = [
            normalize_item(provider_name, item)
            for item in organic_items
        ]

        metrics = calculate_metrics(
            provider=provider_name,
            raw_data=raw_data,
            normalized_results=normalized_results,
            elapsed_seconds=elapsed,
        )

        return {
            "metrics": metrics,
            "results": normalized_results,
            "raw": raw_data,
        }

    except Exception as exc:
        elapsed = time.perf_counter() - started_at

        metrics = calculate_metrics(
            provider=provider_name,
            raw_data={},
            normalized_results=[],
            elapsed_seconds=elapsed,
            error=str(exc),
        )

        return {
            "metrics": metrics,
            "results": [],
            "raw": {},
        }
Enter fullscreen mode Exit fullscreen mode

Catching errors matters.

When you compare APIs, failed requests are part of the result. Sweep them under the rug and the rug becomes haunted.

Register providers

Now we can define which providers to test.

PROVIDERS = {
    "serpapi": call_serpapi,
    "serper": call_serper,
    "talordata": call_talordata,
}
Enter fullscreen mode Exit fullscreen mode

You can add more providers later.

Just write another adapter and register it here.

PROVIDERS = {
    "serpapi": call_serpapi,
    "serper": call_serper,
    "talordata": call_talordata,
    "another_provider": call_another_provider,
}
Enter fullscreen mode Exit fullscreen mode

Run the comparison

Now let’s put it together.

def compare_providers(query, location="United States", language="en"):
    all_metrics = []
    all_results = []
    raw_responses = {}

    for provider_name, provider_function in PROVIDERS.items():
        print(f"Testing {provider_name}...")

        test_output = run_provider_test(
            provider_name=provider_name,
            provider_function=provider_function,
            query=query,
            location=location,
            language=language,
        )

        all_metrics.append(test_output["metrics"])
        all_results.extend(test_output["results"])
        raw_responses[provider_name] = test_output["raw"]

    return all_metrics, all_results, raw_responses
Enter fullscreen mode Exit fullscreen mode

Save results

I like saving three files:

metrics.csv
normalized_results.csv
raw_responses.json
Enter fullscreen mode Exit fullscreen mode

The metrics file gives a quick comparison.

The normalized results file lets you inspect rows.

The raw response file is useful when something looks strange.

def save_outputs(metrics, results, raw_responses):
    metrics_df = pd.DataFrame(metrics)
    results_df = pd.DataFrame(results)

    metrics_df.to_csv("serp_api_metrics.csv", index=False)
    results_df.to_csv("serp_api_normalized_results.csv", index=False)

    with open("serp_api_raw_responses.json", "w", encoding="utf-8") as file:
        json.dump(raw_responses, file, ensure_ascii=False, indent=2)

    print("Saved files:")
    print("- serp_api_metrics.csv")
    print("- serp_api_normalized_results.csv")
    print("- serp_api_raw_responses.json")
Enter fullscreen mode Exit fullscreen mode

Full script

Here is the complete script.

import os
import time
import json
import requests
import pandas as pd
from urllib.parse import urlparse
from dotenv import load_dotenv


load_dotenv()


def extract_domain(url):
    if not url:
        return ""

    parsed = urlparse(url)
    domain = parsed.netloc.lower()

    if domain.startswith("www."):
        domain = domain[4:]

    return domain


def call_serpapi(query, location="United States", language="en"):
    api_key = os.getenv("SERPAPI_KEY")
    api_url = os.getenv("SERPAPI_URL")

    if not api_key or not api_url:
        raise ValueError("Missing SERPAPI_KEY or SERPAPI_URL")

    params = {
        "api_key": api_key,
        "engine": "google",
        "q": query,
        "location": location,
        "hl": language,
    }

    response = requests.get(api_url, params=params, timeout=30)
    response.raise_for_status()

    return response.json()


def call_serper(query, location="United States", language="en"):
    api_key = os.getenv("SERPER_KEY")
    api_url = os.getenv("SERPER_URL")

    if not api_key or not api_url:
        raise ValueError("Missing SERPER_KEY or SERPER_URL")

    headers = {
        "X-API-KEY": api_key,
        "Content-Type": "application/json",
    }

    payload = {
        "q": query,
        "location": location,
        "hl": language,
    }

    response = requests.post(api_url, headers=headers, json=payload, timeout=30)
    response.raise_for_status()

    return response.json()


def call_talordata(query, location="United States", language="en"):
    api_key = os.getenv("TALORDATA_KEY")
    api_url = os.getenv("TALORDATA_URL")

    if not api_key or not api_url:
        raise ValueError("Missing TALORDATA_KEY or TALORDATA_URL")

    params = {
        "api_key": api_key,
        "engine": "google",
        "q": query,
        "location": location,
        "language": language,
        "output": "json",
    }

    response = requests.get(api_url, params=params, timeout=30)
    response.raise_for_status()

    return response.json()


def get_organic_items(data):
    possible_keys = [
        "organic_results",
        "organic",
        "results",
    ]

    for key in possible_keys:
        value = data.get(key)
        if isinstance(value, list):
            return value

    return []


def normalize_item(provider, item):
    url = item.get("link") or item.get("url") or ""

    return {
        "provider": provider,
        "position": item.get("position") or item.get("rank") or "",
        "title": item.get("title") or "",
        "url": url,
        "domain": extract_domain(url),
        "snippet": item.get("snippet") or item.get("description") or "",
    }


def calculate_metrics(provider, raw_data, normalized_results, elapsed_seconds, error=""):
    organic_count = len(normalized_results)

    title_count = sum(1 for item in normalized_results if item["title"])
    url_count = sum(1 for item in normalized_results if item["url"])
    snippet_count = sum(1 for item in normalized_results if item["snippet"])

    raw_size = len(json.dumps(raw_data)) if raw_data else 0

    return {
        "provider": provider,
        "success": not bool(error),
        "error": error,
        "elapsed_seconds": round(elapsed_seconds, 3),
        "raw_response_size": raw_size,
        "organic_count": organic_count,
        "title_count": title_count,
        "url_count": url_count,
        "snippet_count": snippet_count,
    }


def run_provider_test(provider_name, provider_function, query, location, language):
    started_at = time.perf_counter()

    try:
        raw_data = provider_function(
            query=query,
            location=location,
            language=language,
        )

        elapsed = time.perf_counter() - started_at

        organic_items = get_organic_items(raw_data)

        normalized_results = [
            normalize_item(provider_name, item)
            for item in organic_items
        ]

        metrics = calculate_metrics(
            provider=provider_name,
            raw_data=raw_data,
            normalized_results=normalized_results,
            elapsed_seconds=elapsed,
        )

        return {
            "metrics": metrics,
            "results": normalized_results,
            "raw": raw_data,
        }

    except Exception as exc:
        elapsed = time.perf_counter() - started_at

        metrics = calculate_metrics(
            provider=provider_name,
            raw_data={},
            normalized_results=[],
            elapsed_seconds=elapsed,
            error=str(exc),
        )

        return {
            "metrics": metrics,
            "results": [],
            "raw": {},
        }


PROVIDERS = {
    "serpapi": call_serpapi,
    "serper": call_serper,
    "talordata": call_talordata,
}


def compare_providers(query, location="United States", language="en"):
    all_metrics = []
    all_results = []
    raw_responses = {}

    for provider_name, provider_function in PROVIDERS.items():
        print(f"Testing {provider_name}...")

        test_output = run_provider_test(
            provider_name=provider_name,
            provider_function=provider_function,
            query=query,
            location=location,
            language=language,
        )

        all_metrics.append(test_output["metrics"])
        all_results.extend(test_output["results"])
        raw_responses[provider_name] = test_output["raw"]

    return all_metrics, all_results, raw_responses


def save_outputs(metrics, results, raw_responses):
    metrics_df = pd.DataFrame(metrics)
    results_df = pd.DataFrame(results)

    metrics_df.to_csv("serp_api_metrics.csv", index=False)
    results_df.to_csv("serp_api_normalized_results.csv", index=False)

    with open("serp_api_raw_responses.json", "w", encoding="utf-8") as file:
        json.dump(raw_responses, file, ensure_ascii=False, indent=2)

    print("Saved files:")
    print("- serp_api_metrics.csv")
    print("- serp_api_normalized_results.csv")
    print("- serp_api_raw_responses.json")


def main():
    query = "best project management software"
    location = "United States"
    language = "en"

    metrics, results, raw_responses = compare_providers(
        query=query,
        location=location,
        language=language,
    )

    save_outputs(metrics, results, raw_responses)

    print("\nMetrics:")
    print(pd.DataFrame(metrics))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it:

python compare_serp_apis.py
Enter fullscreen mode Exit fullscreen mode

You should get three output files:

serp_api_metrics.csv
serp_api_normalized_results.csv
serp_api_raw_responses.json
Enter fullscreen mode Exit fullscreen mode

What the metrics file tells you

The metrics CSV might look like this:

provider,success,error,elapsed_seconds,raw_response_size,organic_count,title_count,url_count,snippet_count
serpapi,true,,2.431,45231,10,10,10,10
serper,true,,1.218,18452,9,9,9,8
talordata,true,,1.876,39120,10,10,10,10
Enter fullscreen mode Exit fullscreen mode

Do not overread one run.

One query is not a benchmark.

But it can reveal obvious issues:

  • one provider returns fewer organic results
  • one provider has missing snippets
  • one provider is slower for your location
  • one provider gives a much larger raw response
  • one provider fails on a query the others handle

That is useful.

What the normalized results file tells you

The normalized file is where the real inspection happens.

Look at the top results side by side.

Ask:

Do the same domains appear?
Are positions similar?
Are URLs clean?
Are snippets useful?
Does one provider miss important results?
Does one provider return extra SERP blocks I need?
Enter fullscreen mode Exit fullscreen mode

For AI workflows, check whether the snippets are good enough to become prompt context.

For SEO workflows, check whether positions and domains are reliable enough for ranking logic.

Compare top domains

Here is a quick way to inspect top domains.

def summarize_domains(results):
    df = pd.DataFrame(results)

    if df.empty:
        return pd.DataFrame()

    return (
        df.groupby(["provider", "domain"])
        .size()
        .reset_index(name="count")
        .sort_values(["provider", "count"], ascending=[True, False])
    )
Enter fullscreen mode Exit fullscreen mode

Add this to main():

domain_summary = summarize_domains(results)
domain_summary.to_csv("serp_api_domain_summary.csv", index=False)

print("\nDomain summary:")
print(domain_summary)
Enter fullscreen mode Exit fullscreen mode

This is helpful for competitor analysis.

If you are tracking SEO visibility, domains often matter more than full URLs.

Test more than one query

One query is a smoke test.

A better test uses 20 to 50 real queries from your workflow.

Create a file named queries.txt:

best project management software
project management tools for remote teams
task management app
asana alternatives
trello alternatives
Enter fullscreen mode Exit fullscreen mode

Add a loader:

def load_queries(filename="queries.txt"):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]
Enter fullscreen mode Exit fullscreen mode

Then loop through queries:

def compare_multiple_queries(queries, location="United States", language="en"):
    all_metrics = []
    all_results = []

    for query in queries:
        print(f"\nRunning query: {query}")

        metrics, results, _ = compare_providers(
            query=query,
            location=location,
            language=language,
        )

        for row in metrics:
            row["query"] = query

        for row in results:
            row["query"] = query

        all_metrics.extend(metrics)
        all_results.extend(results)

    return all_metrics, all_results
Enter fullscreen mode Exit fullscreen mode

Now update main():

def main():
    queries = load_queries("queries.txt")

    metrics, results = compare_multiple_queries(
        queries=queries,
        location="United States",
        language="en",
    )

    pd.DataFrame(metrics).to_csv("multi_query_metrics.csv", index=False)
    pd.DataFrame(results).to_csv("multi_query_results.csv", index=False)

    print("Saved multi-query comparison files.")
Enter fullscreen mode Exit fullscreen mode

This gives you a much more realistic comparison.

Add a small delay

If you test many queries, do not hammer APIs with a tight loop.

Add a delay between requests.

time.sleep(1)
Enter fullscreen mode Exit fullscreen mode

You can place it inside the provider loop or query loop.

For production testing, also respect each provider’s rate limits.

A benchmark that gets you rate-limited is not a benchmark. It is a tiny distributed denial of wallet.

Score each provider

You can create a rough score.

Do not pretend it is scientific.

But it can help compare providers quickly.

def score_metrics(row):
    if not row["success"]:
        return 0

    score = 0

    score += min(row["organic_count"], 10) * 2
    score += min(row["title_count"], 10)
    score += min(row["url_count"], 10)
    score += min(row["snippet_count"], 10)

    if row["elapsed_seconds"] <= 2:
        score += 5
    elif row["elapsed_seconds"] <= 5:
        score += 2

    return score
Enter fullscreen mode Exit fullscreen mode

Apply it:

metrics_df = pd.DataFrame(metrics)
metrics_df["score"] = metrics_df.apply(score_metrics, axis=1)
Enter fullscreen mode Exit fullscreen mode

Again, this is not a universal score.

It only reflects what this script cares about.

If your workflow needs Maps results or Shopping results, you should score those too.

What I actually look for

When I compare SERP APIs, I care less about one perfect number.

I look for boring reliability.

The good kind of boring.

The kind where:

  • the API returns results consistently
  • field names are predictable
  • organic positions are easy to read
  • snippets are present often enough
  • location settings work
  • errors are understandable
  • raw responses are easy to inspect
  • the normalized layer stays simple

If my normalization code becomes a jungle, I take that as a warning sign.

The best API is not always the one with the most fields.

It is the one that gives your application the fields it needs with the least drama.

Test for your actual use case

Different teams should test different things.

For an AI agent, test:

Are snippets good enough for LLM context?
Are source URLs clean?
Can I cite results reliably?
Do I need freshness or dates?
Does the API return enough context for long-tail questions?
Enter fullscreen mode Exit fullscreen mode

For an SEO tool, test:

Are positions reliable?
Can I track domains?
Does location targeting work?
Can I compare cities or countries?
Are local packs included if I need them?
Enter fullscreen mode Exit fullscreen mode

For a market research workflow, test:

Do the top domains make sense?
Are product pages included?
Are news results available?
Can I monitor competitors over time?
Enter fullscreen mode Exit fullscreen mode

Your use case decides the benchmark.

Not someone else’s leaderboard.

Final thoughts

Testing different SERP APIs with the same query is one of the quickest ways to avoid choosing blindly.

Pricing pages are useful.

Docs are useful.

But the response body is where the truth starts tapping on the glass.

A simple Python comparison script can show you:

  • which provider returns usable results
  • which fields are missing
  • how easy the data is to normalize
  • whether snippets are useful
  • how location settings behave
  • how much cleanup your app still needs

Start with one query.

Then test 20 to 50 real queries.

Save the raw responses.

Normalize the fields.

Compare the output side by side.

That small test can save you from weeks of integration regret later.


`
Enter fullscreen mode Exit fullscreen mode

Top comments (0)