DEV Community

Cover image for Why Google Maps Scraper Runs Fail and Drop Rows Past the 120 Result Cap
Crawler Bros
Crawler Bros

Posted on

Why Google Maps Scraper Runs Fail and Drop Rows Past the 120 Result Cap

Data pipelines that consume spatial data from Google Maps often run into unexpected limitations. Many engineers write their extraction pipelines under the assumption that a query like "restaurants in Seattle" can be scrolled indefinitely to extract every single business in the area. When they deploy these tasks, they are surprised when their datasets cap out early, return missing telephone numbers, or fail entirely when transitioning from local testing to scheduled runs.

The official documentation for the Google Maps Scraper provides the parameters and schema structure, but it does not detail the system boundaries, platform limitations, and pricing events that govern a production pipeline.

By analyzing the underlying mechanics of the google-maps-scraper Actor, we can identify where the input schema constraints break down, how proxy and session behaviors affect the scraper's Playwright-based browser automation, and how to write defensive code to handle these issues.

Checked against the Actor's input schema and Apify docs on 2026-09-19.

Why does my search query return fewer than the requested maxResults?

Google Maps enforces a strict platform barrier that limits search results to a maximum of 120 places per query. Even if you set the maxResults parameter in your input schema to a higher value, the search interface will stop paginating and rendering new list items once it reaches this ceiling. To bypass this, you must split your target geographic area into smaller grid segments before running the scraper.

To work around this limitation, you cannot simply increase the maxResults value. You must split your target geographic area into smaller grid segments, such as neighborhoods, postal codes, or bounding boxes. By executing multiple runs with highly localized queries, you ensure that the total number of businesses in each individual zone remains under the 120-result limit, allowing you to bypass the display cap.

Here is how you can programmatically partition a single query across multiple locations in Python before submitting the inputs to the Apify API:

import json

def partition_geographic_queries(base_query: str, locations: list, max_results: int) -> list:
    """
    Generates a list of input dictionaries partitioned by localized areas
    to avoid the 120-place display cap on Google Maps.
    """
    inputs = []
    for loc in locations:
        inputs.append({
            "mode": "search",
            "searchQuery": base_query,
            "location": loc,
            "maxResults": min(max_results, 120),
            "language": "en"
        })
    return inputs

# Example usage for a target campaign
queries_to_run = partition_geographic_queries(
    base_query="pharmacy",
    locations=["Manhattan, NY", "Brooklyn, NY", "Queens, NY"],
    max_results=100
)

print(json.dumps(queries_to_run, indent=2))
Enter fullscreen mode Exit fullscreen mode

Executing these inputs as separate runs ensures you capture all records without hitting the UI limit of the map interface.

How do you resolve coordinate extraction failures in specific regions?

Coordinate data like latitude and longitude can fail to parse if the target locale uses specific localized URL structures or slow-rendering map modules. To resolve this, you must enforce a consistent language parameter in your input schema and implement a regex-based fallback extractor on the main URL. This ensures your mapping applications always receive valid geographic points.

If the browser automation tool fails to extract the precise geographic coordinates from the page metadata due to localized rendering variations, you can extract them directly from the canonical Google Maps place URL. The URL format almost always contains the coordinate string after the @ symbol.

Here is a Python utility to extract coordinates directly from the listing's URL when the default metadata parser returns null coordinates:

import re
from typing import Tuple, Optional

def extract_coordinates_from_url(url: str) -> Tuple[Optional[float], Optional[float]]:
    """
    Fallback parser that extracts latitude and longitude from a Google Maps URL
    when the primary scraper fields are empty.
    """
    if not url:
        return None, None

    # Pattern to match coordinates in format @latitude,longitude
    match = re.search(r'@(-?\d+\.\d+),(-?\d+\.\d+)', url)
    if match:
        try:
            lat = float(match.group(1))
            lon = float(match.group(2))
            return lat, lon
        except ValueError:
            pass

    return None, None

# Example usage on fallback
sample_url = "https://www.google.com/maps/place/Gramercy+Tavern/@40.7384555,-73.9885064,17z/data=..."
latitude, longitude = extract_coordinates_from_url(sample_url)
print(f"Parsed Coordinates: Lat {latitude}, Lon {longitude}")
Enter fullscreen mode Exit fullscreen mode

Using this fallback mechanism guarantees your spatial analysis pipelines remain functional even when Google changes its page layout elements.

Why do scheduled runs of this scraper fail on first deployment?

Schedules on the Apify platform are created disabled by default and will fail to run if the targeted Actor has never been executed at least once before. Additionally, the platform ignores console UI prefill values when triggering runs through API endpoints or schedules. To fix this, you must run the Actor once manually and always pass a complete, explicit input payload.

When you write integration scripts or set up automated schedules, you might rely on the Apify Console's prefill fields. This is a common point of failure. The platform's API and scheduler only respect the schema default parameters, meaning any custom values set in the UI prefill are ignored during automated runs.

To guarantee your automated runs execute as intended, you should always explicitly state every required parameter in your API call payload rather than relying on console defaults. Below is an example of executing a run programmatically to bypass console-specific behavior:

import os
import requests

def trigger_scraper_run() -> dict:
    """
    Triggers a run of google-maps-scraper explicitly bypassing the prefill console values.
    Uses the official token for authentication.
    """
    api_token = os.environ.get("APIFY_TOKEN")
    if not api_token:
        raise ValueError("APIFY_TOKEN environment variable is missing")

    # Hardcoding our search properties directly in the payload
    # to avoid relying on UI prefill states.
    payload = {
        "mode": "search",
        "searchQuery": "hotel",
        "location": "Manhattan, New York",
        "maxResults": 20,
        "language": "en"
    }

    actor_id = "crawlerbros~google-maps-scraper"
    url = f"https://api.apify.com/v2/acts/{actor_id}/runs"

    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_token}"
    }

    response = requests.post(url, json=payload, headers=headers)

    if response.status_code != 201:
        raise Exception(f"Failed to start Actor: {response.text}")

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

How to handle partial or sentinel values in the business output?

Google Maps profiles are often incomplete, meaning fields like phone numbers, website addresses, and price indicators may be missing from the results. To prevent downstream errors, your ingestion pipeline must treat every field except unique identifiers as optional and implement strict normalization. You should parse raw values dynamically and assign fallback defaults.

For instance, the price_level field can return varied formats like "$$", "$50–100", or "$100+". If your application expects a uniform data type, your database insertion will fail. Similarly, website links might resolve to Google redirect URLs if the actor fails to clean them due to network timeouts.

The following Python script reads raw data from the scraper and normalizes these values, ensuring that your database is protected against missing fields and unexpected formats:

import re
from datetime import datetime
from typing import Dict, Any, Optional

def clean_and_validate_business(record: Dict[str, Any]) -> Dict[str, Any]:
    """
    Ensures the business listing contains valid, parsed types and prevents
    None errors down the line in database schemas.
    """
    cleaned = {
        "place_id": record.get("place_id"),
        "name": record.get("name", "Unknown Business"),
        "scraped_at": record.get("scraped_at") or datetime.utcnow().isoformat(),
        "url": record.get("url"),
        "rating": None,
        "review_count": 0,
        "phone": record.get("phone") if record.get("phone") else None,
        "website": None,
        "standardized_price_tier": "UNKNOWN"
    }

    # Handle ratings safely
    try:
        if record.get("rating") is not None:
            cleaned["rating"] = float(record["rating"])
    except (ValueError, TypeError):
        cleaned["rating"] = None

    # Handle review count safely
    try:
        if record.get("review_count") is not None:
            cleaned["review_count"] = int(record["review_count"])
    except (ValueError, TypeError):
        cleaned["review_count"] = 0

    # Sanitize and validate website redirects
    website = record.get("website")
    if website and "google.com/url" not in website:
        cleaned["website"] = website.strip()

    # Normalize price_level to standard tiers
    price_raw = record.get("price_level")
    if price_raw:
        if "$" in price_raw:
            dollar_count = price_raw.count("$")
            if dollar_count > 0 and price_raw.strip("$") == "":
                cleaned["standardized_price_tier"] = f"TIER_{dollar_count}"
            else:
                cleaned["standardized_price_tier"] = "RANGE_PRESENT"
        else:
            cleaned["standardized_price_tier"] = "SPECIAL"

    return cleaned
Enter fullscreen mode Exit fullscreen mode

By applying this validation layer, your application remains resilient even when scraping legacy business listings that lack updated metadata.

Real limitations or caveats: what this does not do, and when it breaks

While the google-maps-scraper is highly capable, it relies heavily on browser automation using Playwright under the hood. Understanding its architectural limits is crucial to preventing unexpected job terminations.

  • The 300-Second Sync Cap: If you run this Actor using a synchronous API call, the platform imposes a hard timeout of 300 seconds. If the scraper is busy paginating or extracting details for a high maxResults setting, the connection will drop with an HTTP 408 error. For large queries, you must call the run endpoint asynchronously and poll the run state or implement a webhook.
  • Storage Limits and Expiration: If you run your scrapers on the free platform tier without naming your datasets, only the 10 most recent runs are retained. Unnamed storages on this tier expire and are deleted after 4 months. If you do not pull your dataset items quickly, they will be lost. To avoid this, always assign names to your runs or immediately offload your data.
  • Request Queue Processing Restrictions: You cannot parallelize a single request queue across multiple concurrent runs of this Actor. A request queue can only be processed by one run at a time. If you need horizontal scaling, you must manually shard your inputs across completely independent tasks and execute them as separate runs.

How much does running this scraper actually cost?

The google-maps-scraper uses the PAY_PER_EVENT billing model. This means your billing is not calculated based on compute hours, CPU limits, or subscription tier platform fees, but rather on specific events that occur during the execution of your run. There are exactly two charged events for this Actor:

  1. Actor Start (apify-actor-start): Charged at $0.02 per GB of memory allocated to the run. This is a flat charge that occurs once per run when the container starts.
  2. Result (apify-default-dataset-item): Charged at $0.005 per event for pushing a single result to the default dataset. This charge scales down based on Apify volume tiers:
    • FREE: $0.005 per event
    • BRONZE: $0.00433 per event
    • SILVER: $0.00367 per event
    • GOLD: $0.003 per event
    • PLATINUM: $0.003 per event
    • DIAMOND: $0.003 per event

Because of this structure, your costs scale directly with the number of outputs generated. If you search for "restaurants" and extract 100 businesses, you will be billed for 1 Actor Start event and 100 Result events.

To control expenses in a production pipeline, you can utilize the maxTotalChargeUsd query parameter on your API trigger calls. This parameter instructs the platform to cease execution when the specified dollar limit is reached. Note that this cap does not cause an instantaneous hard shutdown; the container will briefly run to complete its active request before terminating.

When does proxy session expiration drop active scraper tasks?

When crawling Google Maps, IP rotation is necessary to prevent persistent CAPTCHA challenges. The scraper relies on proxy configurations, but different proxy types have distinct session durations. Datacenter proxy sessions persist for up to 26 hours, whereas residential proxy sessions expire and drop after approximately 30 minutes, which can disrupt active extraction runs.

If you specify a large maxResults target, the scraper must visit each business listing sequentially. If this process takes longer than 30 minutes and you are using a residential session, the browser's session will rotate mid-run. This sudden change can trigger security checkpoints on Google's side, resulting in empty responses or redirects to login pages.

If you are scraping large lists, you must split your tasks into smaller, shorter executions that complete well within the 30-minute residential session window, or configure the scraper to target specific regions using geographical session routing (for example, targeting specific US states with country-US_XX proxy configurations).

How to execute asynchronous runs and handle timeouts cleanly?

To prevent your integration from dropping due to the 300-second synchronous HTTP limit, you should initiate your runs asynchronously. This involves starting the run, tracking its ID, and executing a polling loop or setting up a webhook to download the results once the Actor reaches a terminal state.

This Python implementation demonstrates how to manage an asynchronous execution loop safely, using a backup timeout of your own:

import os
import time
import requests

def run_scraper_asynchronously(inputs: dict, timeout_seconds: int = 600) -> list:
    """
    Spins up the scraper asynchronously, polls the run status, and downloads
    the dataset items upon success. This avoids HTTP 408 sync timeouts.
    """
    api_token = os.environ.get("APIFY_TOKEN")
    if not api_token:
        raise ValueError("APIFY_TOKEN is required to poll run state")

    actor_id = "crawlerbros~google-maps-scraper"
    start_url = f"https://api.apify.com/v2/acts/{actor_id}/runs"
    headers = {"Authorization": f"Bearer {api_token}"}

    # Start run asynchronously
    init_response = requests.post(start_url, json=inputs, headers=headers)
    if init_response.status_code != 201:
        raise Exception(f"Failed to start run: {init_response.text}")

    run_data = init_response.json()["data"]
    run_id = run_data["id"]
    dataset_id = run_data["defaultDatasetId"]

    status_url = f"https://api.apify.com/v2/actor-runs/{run_id}"
    start_time = time.time()

    while True:
        if time.time() - start_time > timeout_seconds:
            # Clean up or abort the run if it exceeds our maximum safe processing time
            requests.post(f"{status_url}/abort", headers=headers)
            raise TimeoutError("The scraper execution exceeded our safety timeout")

        status_response = requests.get(status_url, headers=headers)
        run_status = status_response.json()["data"]["status"]

        if run_status == "SUCCEEDED":
            break
        elif run_status in ["FAILED", "ABORTED", "TIMED-OUT"]:
            raise Exception(f"Actor run ended with terminal status: {run_status}")

        time.sleep(15)

    # Fetch final items
    items_url = f"https://api.apify.com/v2/datasets/{dataset_id}/items"
    items_response = requests.get(items_url, headers=headers)
    return items_response.json()
Enter fullscreen mode Exit fullscreen mode

By switching to asynchronous execution, your application can manage hours of extraction without running into network socket drops.

How to handle rate limits on Apify storage writes?

When harvesting large volumes of data, you may run into platform storage rate limits. The Apify key-value store and datasets have performance thresholds that can cause API operations to fail with HTTP 429 errors if exceeded. Specifically, the platform limits dataset item pushes and request queue CRUD operations to 400 requests per second, and key-value store object reads/writes to 60 requests per second.

While the google-maps-scraper handles internal batch writes to stay below these limits, any concurrent operations you run on the same dataset (such as reading items while the run is active or updating a shared key-value store) can trigger HTTP 429 rate-limiting responses.

If you are programmatically reading the dataset, implement a backoff mechanism. Here is how to write a retry wrapper that handles storage rate limits:

import time
import requests
from requests.exceptions import HTTPError

def safe_fetch_dataset_items(dataset_id: str, max_retries: int = 5) -> list:
    """
    Fetches items from an active dataset while safely handling
    potential HTTP 429 rate limit exceptions using exponential backoff.
    """
    url = f"https://api.apify.com/v2/datasets/{dataset_id}/items"
    retry_delay = 1.0

    for attempt in range(max_retries):
        try:
            response = requests.get(url)
            # Raise exception for 4xx and 5xx errors
            response.raise_for_status()
            return response.json()
        except HTTPError as err:
            if err.response.status_code == 429 and attempt < max_retries - 1:
                # We hit a storage limit. Back off and retry.
                time.sleep(retry_delay)
                retry_delay *= 2
                continue
            raise err

    return []
Enter fullscreen mode Exit fullscreen mode

This exponential backoff approach prevents your application from crashing during periods of high-volume write traffic on the platform.

The Actor's README is the source of truth for its inputs, outputs and limits. Need a hand wiring this into your stack? Email info@crawlerbros.com

Top comments (0)