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
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?
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
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
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
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
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
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"
}
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
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
becomes:
example.com
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()
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()
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()
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
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 []
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 "",
}
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,
}
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": {},
}
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,
}
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,
}
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
Save results
I like saving three files:
metrics.csv
normalized_results.csv
raw_responses.json
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")
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()
Run it:
python compare_serp_apis.py
You should get three output files:
serp_api_metrics.csv
serp_api_normalized_results.csv
serp_api_raw_responses.json
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
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?
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])
)
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)
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
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()
]
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
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.")
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)
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
Apply it:
metrics_df = pd.DataFrame(metrics)
metrics_df["score"] = metrics_df.apply(score_metrics, axis=1)
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?
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?
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?
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.
`
Top comments (0)