How to Retrieve Places Across a Large Area

API or service
Places API
Task
Large bounding box → smaller cells → deduplicated places
Examples
JavaScriptPython
Difficulty
Advanced
Time
15 min

You need places from a broad region and may be tempted to send one country-, state-, or region-sized Places API request and paginate through it. The Geoapify Places API is optimized for discovering relevant places in a focused search area; it is not designed as an exhaustive bulk-export interface for collecting every place in a large region.

When a broad area contains more matches than a useful response can represent, the service returns a limited, optimized result set. Increasing limit or offset does not make a broad query equivalent to downloading the underlying place dataset.

Prefer a dedicated bulk-data source when completeness across a country or similarly large area is a requirement. If the application genuinely needs to retrieve places across a bounded operational area, divide that area into small rectangles and query them separately.

Split the area into focused searches

Use grid-based retrieval only for a bounded area and a specific category:

  1. Define the smallest bounding box that covers the operational area.
  2. Split it into smaller rectangular cells.
  3. Query each cell with filter=rect:west,south,east,north.
  4. Paginate within the cell while pages continue to get smaller.
  5. If a cell repeatedly fills the configured page allowance, split that cell again instead of assuming its result is complete.
  6. Deduplicate combined results by place_id, because places on shared cell boundaries may appear more than once.
  7. Save completed cells and offsets so processing can resume after an interruption.
  8. Pace requests according to the active project's rate and quota limits.

There is no universal safe cell size. A city centre with restaurants may require much smaller cells than a rural search for airports. Start with a coarse grid, detect dense cells, and subdivide only those cells.

This technique improves coverage for a controlled use case, but it does not change the Places API into a guaranteed exhaustive data-export service.

Split the area and search for places with JavaScript

This example divides a bounding box into cells, paginates each cell sequentially, and deduplicates the combined features by place_id. If a cell still returns a full page at the configured page limit, the function stops and asks for a smaller grid rather than silently treating partial data as complete.

const placesUrl = "https://api.geoapify.com/v2/places";
const pageSize = 500;
const maxPagesPerCell = 4;

const wait = milliseconds =>
  new Promise(resolve => setTimeout(resolve, milliseconds));

function splitBounds({ west, south, east, north }, columns, rows) {
  const cellWidth = (east - west) / columns;
  const cellHeight = (north - south) / rows;
  const cells = [];

  for (let row = 0; row < rows; row += 1) {
    for (let column = 0; column < columns; column += 1) {
      cells.push({
        west: west + column * cellWidth,
        south: south + row * cellHeight,
        east: west + (column + 1) * cellWidth,
        north: south + (row + 1) * cellHeight
      });
    }
  }

  return cells;
}

async function requestPage(
  params,
  requestDelayMs,
  maxRateLimitRetries
) {
  for (let attempt = 0; attempt <= maxRateLimitRetries; attempt += 1) {
    const response = await fetch(`${placesUrl}?${params}`);

    if (response.status === 429) {
      const errorBody = await response.json().catch(() => ({}));
      const message = String(errorBody.message || "");

      // Retrying cannot fix an exhausted daily or monthly quota.
      if (/quota exceeded/i.test(message)) {
        throw new Error(
          "Geoapify quota exhausted; wait for the quota to reset or increase it."
        );
      }

      if (attempt === maxRateLimitRetries) {
        throw new Error(
          `Places request is still rate limited after ${maxRateLimitRetries} retries.`
        );
      }

      // Retry-After is expressed in seconds when provided by the API.
      const retryAfter = response.headers.get("Retry-After");
      const retryAfterSeconds = retryAfter === null
        ? NaN
        : Number(retryAfter);
      const retryDelayMs = Number.isFinite(retryAfterSeconds) &&
        retryAfterSeconds >= 0
        ? retryAfterSeconds * 1000
        : requestDelayMs;
      await wait(retryDelayMs);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Places request failed: ${response.status}`);
    }

    const data = await response.json();

    // Sequential processing plus this delay keeps the request rate bounded.
    await wait(requestDelayMs);
    return data;
  }
}

async function retrieveCell(
  cell,
  categories,
  apiKey,
  requestDelayMs,
  maxRateLimitRetries
) {
  const features = [];

  for (let page = 0; page < maxPagesPerCell; page += 1) {
    const params = new URLSearchParams({
      categories,
      filter: `rect:${cell.west},${cell.south},${cell.east},${cell.north}`,
      limit: String(pageSize),
      offset: String(page * pageSize),
      apiKey
    });

    const data = await requestPage(
      params,
      requestDelayMs,
      maxRateLimitRetries
    );
    features.push(...data.features);

    if (data.features.length < pageSize) return features;
  }

  throw new Error("A grid cell is still dense; split it into smaller cells.");
}

async function retrieveLargeArea({
  bounds,
  columns,
  rows,
  categories,
  apiKey,
  requestsPerSecond = 4,
  maxRateLimitRetries = 5
}) {
  if (!Number.isInteger(maxRateLimitRetries) || maxRateLimitRetries < 0) {
    throw new Error("maxRateLimitRetries must be a non-negative integer.");
  }
  if (!Number.isFinite(requestsPerSecond) || requestsPerSecond <= 0) {
    throw new Error("requestsPerSecond must be greater than zero.");
  }

  const placesById = new Map();
  const cells = splitBounds(bounds, columns, rows);
  const requestDelayMs = Math.ceil(1000 / requestsPerSecond);

  // Process cells sequentially to keep request pacing predictable.
  for (const cell of cells) {
    const features = await retrieveCell(
      cell,
      categories,
      apiKey,
      requestDelayMs,
      maxRateLimitRetries
    );

    for (const feature of features) {
      const placeId = feature.properties.place_id;
      if (placeId) placesById.set(placeId, feature);
    }
  }

  return {
    type: "FeatureCollection",
    features: [...placesById.values()]
  };
}

const places = await retrieveLargeArea({
  // A bounded example around central Vienna, not a country-scale export.
  bounds: {
    west: 16.1790307,
    south: 48.0623698,
    east: 16.5567679,
    north: 48.2849935
  },
  columns: 4,
  rows: 4,
  categories: "commercial.supermarket",
  requestsPerSecond: 4,
  maxRateLimitRetries: 5,
  apiKey: "YOUR_API_KEY"
});

console.log(`Retrieved ${places.features.length} unique places`);

Set requestsPerSecond no higher than the active project's request-rate limit. The example converts it to a delay and processes requests sequentially. A temporary 429 response is retried up to maxRateLimitRetries times, using a numeric Retry-After value when the server supplies it. A quota-exhausted response stops the job immediately because slower requests cannot bypass daily or monthly quota limits.

For a resumable job, write each completed cell and its features to persistent storage instead of keeping the entire result only in memory.

Split the area and search for places with Python

This Python version uses the third-party requests package. It processes cells and pages sequentially, retries rate-limited calls, and keeps one feature for each Geoapify place_id.

import time

import requests

PLACES_URL = "https://api.geoapify.com/v2/places"
PAGE_SIZE = 500
MAX_PAGES_PER_CELL = 4


def split_bounds(bounds, columns, rows):
    west, south, east, north = bounds
    cell_width = (east - west) / columns
    cell_height = (north - south) / rows

    for row in range(rows):
        for column in range(columns):
            yield (
                west + column * cell_width,
                south + row * cell_height,
                west + (column + 1) * cell_width,
                south + (row + 1) * cell_height,
            )


def request_page(
    session,
    params,
    request_delay_seconds,
    max_rate_limit_retries,
):
    for attempt in range(max_rate_limit_retries + 1):
        response = session.get(PLACES_URL, params=params, timeout=30)

        if response.status_code == 429:
            try:
                error_body = response.json()
            except ValueError:
                error_body = {}
            message = (
                str(error_body.get("message", ""))
                if isinstance(error_body, dict)
                else ""
            )

            # Retrying cannot fix an exhausted daily or monthly quota.
            if "quota exceeded" in message.lower():
                raise RuntimeError(
                    "Geoapify quota exhausted; wait for the quota to reset "
                    "or increase it."
                )

            if attempt == max_rate_limit_retries:
                raise RuntimeError(
                    "Places request is still rate limited after "
                    f"{max_rate_limit_retries} retries."
                )

            # Retry-After is expressed in seconds when provided by the API.
            try:
                retry_delay = float(response.headers["Retry-After"])
            except (KeyError, ValueError):
                retry_delay = request_delay_seconds
            if retry_delay < 0:
                retry_delay = request_delay_seconds
            time.sleep(retry_delay)
            continue

        response.raise_for_status()
        data = response.json()

        # Sequential processing plus this delay keeps the request rate bounded.
        time.sleep(request_delay_seconds)
        return data


def retrieve_cell(
    session,
    cell,
    categories,
    api_key,
    request_delay_seconds,
    max_rate_limit_retries,
):
    features = []
    rectangle = ",".join(str(coordinate) for coordinate in cell)

    for page in range(MAX_PAGES_PER_CELL):
        data = request_page(
            session,
            {
                "categories": categories,
                "filter": f"rect:{rectangle}",
                "limit": PAGE_SIZE,
                "offset": page * PAGE_SIZE,
                "apiKey": api_key,
            },
            request_delay_seconds,
            max_rate_limit_retries,
        )
        features.extend(data["features"])

        if len(data["features"]) < PAGE_SIZE:
            return features

    raise RuntimeError(
        "A grid cell is still dense; split it into smaller cells."
    )


def retrieve_large_area(
    api_key,
    bounds,
    columns,
    rows,
    categories,
    requests_per_second=4,
    max_rate_limit_retries=5,
):
    if not isinstance(max_rate_limit_retries, int) or max_rate_limit_retries < 0:
        raise ValueError(
            "max_rate_limit_retries must be a non-negative integer"
        )
    if requests_per_second <= 0:
        raise ValueError("requests_per_second must be greater than zero")

    places_by_id = {}
    request_delay_seconds = 1 / requests_per_second

    with requests.Session() as session:
        for cell in split_bounds(bounds, columns, rows):
            features = retrieve_cell(
                session,
                cell,
                categories,
                api_key,
                request_delay_seconds,
                max_rate_limit_retries,
            )

            for feature in features:
                place_id = feature["properties"].get("place_id")
                if place_id:
                    places_by_id.setdefault(place_id, feature)

    return {
        "type": "FeatureCollection",
        "features": list(places_by_id.values()),
    }


places = retrieve_large_area(
    api_key="YOUR_API_KEY",
    # A bounded example around central Vienna, not a country-scale export.
    bounds=(16.1790307, 48.0623698, 16.5567679, 48.2849935),
    columns=4,
    rows=4,
    categories="commercial.supermarket",
    requests_per_second=4,
    max_rate_limit_retries=5,
)

print(f"Retrieved {len(places['features'])} unique places")

Set requests_per_second no higher than the active project's request-rate limit. The example converts it to a delay and processes requests sequentially. A temporary 429 response is retried up to max_rate_limit_retries times, using a numeric Retry-After value when the server supplies it. A quota-exhausted response stops the job immediately because slower requests cannot bypass daily or monthly quota limits.

Save results after every completed cell when the process must resume safely after an error or restart.