What's near every hotel: travel content at scale

Enrich every hotel listing with nearby restaurants, landmarks, and transport via one REST call. Reverse geocoding + Places API for OTAs at scale.

| June 14, 2026
What's near every hotel: travel content at scale

Every hotel listing on every OTA says some version of the same thing: "centrally located," "steps from major attractions," "close to public transport." Almost none of them prove it with data. The listing that says "walking distance to the Eiffel Tower" is relying on a copywriter's memory of where the Eiffel Tower actually is, written once when the property was first listed and never audited since.

The problem at scale is that most OTAs carry between 50,000 and 2 million properties. Keeping the "what's nearby" content fresh, accurate, and useful for a catalogue that size requires an automated pipeline — not a copywriting team. This post shows how to build one using reverse geocoding and the Places API. By the end you will have a working Python script that takes a hotel's coordinates and returns a structured list of nearby restaurants, landmarks, transport stops, and points of interest, ready to render in your listing template.

No GIS team required. No per-data-licence negotiation. One API key.

Why "what's nearby" is a conversion lever, not a content chore

Before covering the technical pattern, it is worth being precise about why this matters commercially.

Search filters versus listing content. A traveller searching "hotels near the Colosseum" is filtering. A traveller reading a listing page is evaluating. Those are two different jobs. The filter is answered by the OTA's map search. The listing-page content is answered by what your enrichment pipeline writes into the property record. If your enrichment pipeline is thin — maybe just a neighbourhood name and a vague "near attractions" flag — your listing loses to the competitor whose content says "14 restaurants within 500 m, metro stop 80 m, botanical garden 350 m."

Content freshness matters more than you think. Restaurants open and close. Metro lines get extended. A new shopping centre opens 200 m from a hotel that was previously described as "quiet residential." Stale nearby content is actively harmful — a guest who books expecting the Sunday market and finds it closed permanently will leave a review about it. Programmatic enrichment that re-runs on a schedule is the only way to keep content honest at catalogue scale.

Elevation is part of the story in hill cities. A hotel at 761 m in São Paulo is a different physical experience from one at 40 m on the coast, and the nearby places that matter (transport links, viewpoints, steep-street context) are shaped by the terrain. A hotel in a city like Edinburgh, Lisbon, or Medellín where elevation variance within a few hundred metres can be 50+ m — the same distance that is a comfortable walk on flat ground becomes a meaningful climb or descent. We will come back to this.

What the API gives you

Two endpoints compose to produce the "what's nearby" content block.

`GET /api/v1/places/nearby` takes a coordinate, a radius in metres, an optional category filter, and a limit. It returns an array of places — each with a name, category, address, distance from the query point, and (optionally, via ?include=elevation) an elevation in metres above sea level. This is the core of the pipeline.

`GET /api/v1/reverse` takes a coordinate and returns the human-readable address and neighbourhood for that point. Useful for confirming the hotel's own neighbourhood label — if your property data says "Marais" and the reverse geocode says "11th arrondissement," someone needs to reconcile that before you ship the content.

Both endpoints sit under the same API key. The same key that does your booking-flow geocoding handles the enrichment pipeline. No separate contract, no separate account.

The Places database covers 39 countries and is backed by 461M+ addresses. That is enough to produce meaningful nearby-place results for the major OTA markets — Western Europe, North America, Southeast Asia, Australia, Japan — without hitting systematic coverage gaps that would make the pipeline produce empty results for entire regions.

Category filters worth knowing for travel content:

| Category string | What it returns | |---|---| | restaurant | Restaurants, cafés, bars | | transport | Metro, bus stops, train stations | | landmark | Museums, monuments, historic sites | | park | Parks, gardens, green space | | shopping | Shops, markets, malls | | health | Pharmacies, hospitals |

Pass multiple categories as a comma-separated list. The response groups results by category if you request more than one.

A worked example: one hotel

Before building the batch pipeline, it helps to understand what a single call looks like end to end.

Hotel: a property in central Tokyo, coordinates 35.6762, 139.6503.

curl -G "https://csv2geo.com/api/v1/places/nearby" \
  --data-urlencode "lat=35.6762" \
  --data-urlencode "lng=139.6503" \
  --data-urlencode "radius=500" \
  --data-urlencode "categories=restaurant,transport,landmark" \
  --data-urlencode "limit=20" \
  --data-urlencode "include=elevation" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response shape:

{
  "meta": {
    "query_lat": 35.6762,
    "query_lng": 139.6503,
    "radius_m": 500,
    "count": 18
  },
  "results": [
    {
      "name": "Shibuya Station",
      "category": "transport",
      "distance_m": 210,
      "address": "2-1 Dogenzaka, Shibuya City, Tokyo",
      "location": {"lat": 35.6581, "lng": 139.7017},
      "ele": 40
    },
    {
      "name": "Meiji Shrine",
      "category": "landmark",
      "distance_m": 480,
      "address": "1-1 Yoyogikamizonocho, Shibuya City, Tokyo",
      "location": {"lat": 35.6764, "lng": 139.6993},
      "ele": 40
    }
  ]
}

Tokyo's published elevation is 40 m — the API is consistent with that anchor. The ele field on each result is the elevation of the place itself, not the hotel's elevation. The delta between the two is the walking gradient, which we will use later for the "walking effort" label.

Now the same in Python:

import os
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]

def nearby_places(lat, lng, radius_m=500, categories=None, limit=20):
    params = {
        "lat": lat,
        "lng": lng,
        "radius": radius_m,
        "limit": limit,
        "include": "elevation",
        "api_key": KEY,
    }
    if categories:
        params["categories"] = ",".join(categories)
    r = requests.get(f"{API}/places/nearby", params=params, timeout=30)
    r.raise_for_status()
    return r.json()["results"]

places = nearby_places(
    lat=35.6762, lng=139.6503,
    categories=["restaurant", "transport", "landmark"],
)
for p in places[:5]:
    print(f"{p['name']} ({p['category']}) — {p['distance_m']}m")

And in Node:

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;

async function nearbyPlaces(lat, lng, {
  radiusM = 500,
  categories = [],
  limit = 20,
} = {}) {
  const params = new URLSearchParams({
    lat, lng,
    radius: radiusM,
    limit,
    include: 'elevation',
    api_key: KEY,
  });
  if (categories.length) params.set('categories', categories.join(','));
  const r = await fetch(`${API}/places/nearby?${params}`);
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  const data = await r.json();
  return data.results;
}

const places = await nearbyPlaces(35.6762, 139.6503, {
  categories: ['restaurant', 'transport', 'landmark'],
});
console.log(places.slice(0, 5).map(p => `${p.name} (${p.distance_m}m)`));

These are direct REST calls. SDKs exist, but for a pipeline you own and operate, a thin wrapper around requests or fetch is more maintainable than a pinned SDK version that drifts from your runtime over time.

Building the batch pipeline for a hotel catalogue

A single hotel is easy. Fifty thousand hotels is the actual problem.

The naive approach — a for loop that calls the API once per hotel, sequentially — will work, but slowly. At one call per second (well below the API's rate limit), 50,000 hotels takes 14 hours. The right approach is concurrent requests with a controlled concurrency ceiling.

The pattern that matters here is covered in detail in Concurrency Tuning for Geocoding — the Sweet Spot, but the short version: for I/O-bound HTTP calls, 20-50 concurrent workers is the sweet spot on most machines before you hit either the API's per-key rate limit or your own network stack's connection limits. Do not go above your plan's concurrency ceiling without checking it.

Here is a production-grade batch script using Python's asyncio and aiohttp:

import asyncio
import csv
import json
import os
import aiohttp

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
CONCURRENCY = 20
CATEGORIES = ["restaurant", "transport", "landmark", "park"]
RADIUS_M = 500
LIMIT = 25

async def fetch_nearby(session, sem, hotel):
    async with sem:
        params = {
            "lat": hotel["lat"],
            "lng": hotel["lng"],
            "radius": RADIUS_M,
            "categories": ",".join(CATEGORIES),
            "limit": LIMIT,
            "include": "elevation",
            "api_key": KEY,
        }
        async with session.get(f"{API}/places/nearby", params=params) as r:
            if r.status == 429:
                # Back off and retry once.
                await asyncio.sleep(5)
                return await fetch_nearby(session, sem, hotel)
            r.raise_for_status()
            data = await r.json()
            return hotel["hotel_id"], data["results"]

async def main():
    with open("hotels.csv") as f:
        hotels = list(csv.DictReader(f))

    sem = asyncio.Semaphore(CONCURRENCY)
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_nearby(session, sem, h) for h in hotels]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    with open("hotels_nearby.jsonl", "w") as out:
        for res in results:
            if isinstance(res, Exception):
                print(f"Error: {res}")
                continue
            hotel_id, places = res
            out.write(json.dumps({"hotel_id": hotel_id, "nearby": places}) + "\n")

asyncio.run(main())

A 50,000-hotel catalogue at 20 concurrent workers finishes the API calls in roughly 40 minutes. The output is a JSONL file — one line per hotel, machine-readable, appendable, and easy to reload into your warehouse or to diff on re-runs.

Turning raw places into publishable content

API results are structured data. What goes on the listing page is human language. The translation layer is simpler than it sounds.

A useful pattern: derive four content blocks from the raw results.

The walking summary. The single sentence that the headline of the nearby-places section should contain. "18 restaurants, 1 metro stop, and 3 landmarks within 500 m." Generate it programmatically:

from collections import Counter

def walking_summary(places):
    counts = Counter(p["category"] for p in places)
    parts = []
    if counts.get("restaurant"):
        parts.append(f"{counts['restaurant']} restaurants")
    if counts.get("transport"):
        n = counts["transport"]
        parts.append(f"{n} transport stop{'s' if n > 1 else ''}")
    if counts.get("landmark"):
        n = counts["landmark"]
        parts.append(f"{n} landmark{'s' if n > 1 else ''}")
    if counts.get("park"):
        parts.append(f"{counts['park']} green spaces")
    return " · ".join(parts) if parts else "Limited nearby places data."

The closest-three list. Show the three nearest results regardless of category. Distance is already in the response; sort ascending and take the top three. Use the distance_m field to generate a human label: under 100 m is "on the doorstep," 100-300 m is "a short walk," 300-600 m is "a comfortable walk," over 600 m is "nearby."

The elevation-delta flag. Where the hotel elevation is known, compute the delta between the hotel and each nearby place. A transport stop 200 m away at a 30 m elevation drop is meaningfully harder to reach on foot than the same stop 200 m away on flat ground — especially relevant for elderly travellers or guests with mobility constraints. A simple rule: flag any place where abs(hotel_ele - place_ele) > 15 with a "hilly route" label.

def elevation_label(hotel_ele, place_ele):
    if hotel_ele is None or place_ele is None:
        return None
    delta = abs(hotel_ele - place_ele)
    if delta > 30:
        return "steep"
    if delta > 15:
        return "hilly"
    return "flat"

The transport score. A single integer from 0-10 that reflects how many transport options exist within 500 m and how close the nearest one is. Useful for filtering ("show me properties with transport score ≥ 7") and for rendering a visual badge on the listing card. Derive it from the raw data; store it as a column.

Reverse geocoding to validate neighbourhood labels

Property data is often annotated with a neighbourhood name by whoever uploaded the listing — a hotel manager, a channel manager, a property management system. These labels drift. A hotel that re-opened after renovation gets re-uploaded with the original neighbourhood from the system record, which was last verified in 2019 and refers to a district boundary that has since been administratively redrawn.

Reverse geocoding the hotel's own coordinates is a cheap audit:

curl -G "https://csv2geo.com/api/v1/reverse" \
  --data-urlencode "lat=35.6762" \
  --data-urlencode "lng=139.6503" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response includes neighbourhood, district, city, and country. A mismatch between the API's district and what your system has on file is a flag for human review. A small percentage of your catalogue will always have stale neighbourhood labels — catching them programmatically before they reach the listing page is cheaper than processing the guest complaints afterwards.

See Geocoding Confidence Scores Explained for the full guide to reading and acting on the confidence field in geocoding and reverse-geocoding responses.

The enrichment schedule

How often should you re-run the pipeline?

On initial import. Always. Every new property that enters your catalogue should be enriched before it goes live.

On coordinate change. If the property's lat/lng is updated — reclassification, address correction, property merge — re-run the enrichment for that property immediately.

Quarterly in bulk. Restaurants open and close. Transport lines change. Run a full re-enrichment quarterly to catch the drift. 50,000 hotels at 20 concurrent workers is roughly 40 minutes of wall-clock time; at the paid rate this is not a budget discussion worth having — the cost of a quarterly refresh on a catalogue that size is well within a single plan bracket.

Cache aggressively within a cycle. Between scheduled re-enrichment runs, cache the nearby-places payload per hotel in your CDN or Redis with a TTL that matches your refresh cadence. A hotel in central Paris does not need its nearby-places list regenerated on every page view. The Places API result for a given coordinate does not change week to week; your listing template rendering it does. Keep those two concerns separate. See Caching Geocoding Results — 90% Cost Reduction for the canonical pattern.

Failure modes to design around

Three things that will go wrong in a large-scale run and need explicit handling.

Empty results for rural or resort properties. A ski resort in the Alps at 2,800 m elevation, a beach resort on a remote island, a lodge in a national park — these are legitimate hotel coordinates with no restaurants or transport stops within 500 m. The API returns an empty results array, not an error. Design the content template to handle this gracefully: "This property is in a remote location; facilities are on-site." Showing a blank "what's nearby" section is a content failure; showing the correct explanation is a feature.

Coordinate precision problems. Hotels in your catalogue that were geocoded years ago by a different provider may have coordinates that land in the middle of a street rather than at the property entrance, or on the wrong side of a dual carriageway. When your Places API results include places that are clearly the wrong side of a major road or river for the property's advertised location, it is worth re-geocoding the property from scratch. Reverse Geocoding Accuracy and the Distance in Metres covers the measurement approach for catching these systematically.

429 rate limiting during bulk runs. The Places endpoint is per-call rate limited. At 20 concurrent workers you are unlikely to hit this in normal operation, but a batch that retries aggressively after transient errors can spike. The safe pattern is exponential backoff with jitter — the first retry after 1-2 seconds, then 4-8, then 16-32, up to a cap. After four retries, log the failure and move on; pick it up in the next run. Exponential Backoff — When to Retry, When to Stop is the reference post for the implementation.

Cost model for a real catalogue

Let us be specific. A 50,000-hotel catalogue, enriched quarterly:

  • Initial enrichment: 50,000 nearby-places calls + 50,000 reverse-geocode calls = 100,000 credits
  • Quarterly refresh: 50,000 nearby-places calls = 50,000 credits per quarter, 200,000 per year
  • Total year-one cost: ~300,000 credits

The free tier gives 3,000 calls per day — useful for piloting on a few hundred properties, not for production at this scale. The paid tier starts at $54/month for 100,000 calls; a plan bracket appropriate for a 50,000-hotel catalogue with quarterly refreshes is straightforward arithmetic from the pricing page. No volume commit, no per-country surcharge, no separate data licence for the places data.

The marginal cost per hotel per year — including geocoding, reverse geocoding, and quarterly nearby-places refreshes — is well under $0.01. That is the number that belongs in the build-versus-buy analysis.

How to ship this on Monday

The practical shipping plan for a team that has never done this before and wants to be in production within a week.

Step 1: Pilot on 500 properties

Pick a sample that covers your geographic spread — some city-centre properties, some suburban, some resort. Run the single-hotel curl example manually on five or six properties. Check the results against what you know about those locations. A hotel in Sydney should return elevation around 64 m. A hotel in Denver should return around 1,597 m. A hotel in Miami should return around 1 m. If the elevation anchors look right and the nearby places look plausible, you are ready to run the batch.

Run the Python batch script on the 500-property pilot. Check the output JSONL for empty results, for properties that returned HTTP 400 or 429, and for places that look geographically wrong. Resolve any systematic issues before scaling.

Step 2: Add the schema fields

Before enriching your full catalogue, extend your hotel schema with the fields you need. At minimum: nearby_places (JSONB or equivalent), transport_score (integer), walking_summary (text), last_enriched_at (timestamp). The JSONB column stores the raw API response; the computed columns are derived from it in your application layer. This separation means you can re-derive the computed columns when your business logic changes without re-calling the API.

Step 3: Run the full catalogue enrichment

Scale the batch script to your full catalogue. Monitor the run via your APM — watch for 429 responses (concurrency too high), empty-result rates (useful signal about rural coverage), and wall-clock time. Log the hotel_id + last_enriched_at for every successful enrichment so you have a clean record of what has been done and what has not.

Step 4: Wire the content into your listing template

The nearby-places content block on the listing page should render from the nearby_places field in your hotel record, not from a live API call on each page view. That is the data-freshness contract: the pipeline produces the content, the template renders it. A live call on page view is the wrong architecture — it adds latency to your render path, it bypasses your cache, and it puts your listing availability at risk if the API has a transient issue.

Render three sub-blocks: the walking summary sentence, the closest-three list with distance labels, and (where elevation data is present) the elevation-delta flags for places that involve a significant climb or descent. The last block should be opt-in for the user, not the default view — most guests do not need to know the elevation delta to a restaurant; the ones who do will appreciate the "hilly route" label.

Step 5: Schedule the quarterly refresh and set up alerting

Write a cron job or scheduled task that re-runs the enrichment for all properties whose last_enriched_at is older than 90 days. Monitor two things: the empty-result rate (if it rises sharply, something changed in coverage or in your coordinate data) and the 429 rate (if it rises, your concurrency setting needs adjusting). A simple alert on either metric saves you from silent content degradation that takes months to surface in guest reviews.

Frequently Asked Questions

How many countries does the Places database cover? 39 countries. That covers the bulk of OTA inventory globally, including all of Western Europe, North America, Southeast Asia, Japan, Australia, and Brazil. For properties in countries outside that footprint, the nearby-places call returns an empty result rather than an error — build the graceful fallback in your template before you go live.

Does the API return chain or brand information for places? The Places results include name, category, and address. Brand affiliation (e.g. identifying a specific coffee chain versus an independent café) is not a field in the current response. For most travel-content purposes — "3 cafés within 200 m" — this is sufficient. If brand-level data is important for your use case, you can post-process the name field with a local lookup table.

Can I filter by walking time rather than radius in metres? The API takes radius in metres. Walking time is a derived value — divide distance_m by an assumed walking speed (typically 80 m/minute for flat terrain on a travel website). Apply the elevation-delta adjustment for hilly terrain. Compute the display value in your application layer from the raw distance_m and ele fields the API returns.

What happens when a hotel has no nearby places within 500 m? The API returns an empty results array and HTTP 200. This is the correct response for remote, rural, or resort properties. Design your content template to handle it explicitly — "This is a remote property; all facilities are on-site" is better than a blank section.

Is there a bulk endpoint that takes multiple hotel coordinates in one call? The /api/v1/places/nearby endpoint is per-coordinate. For bulk enrichment, the right pattern is concurrent single-coordinate calls behind a semaphore, as shown in the batch script above. The elevation endpoint does support up to 500 points per call — if you are enriching hotel elevations as well, batch those separately to save credits.

How should I handle hotels where the coordinate precision is suspect? Reverse-geocode the hotel coordinate and compare the returned neighbourhood and street to the property's own address data. A significant mismatch — different street, wrong district — suggests a geocoding error. Flag those properties for re-geocoding before enriching with nearby places; a 200 m coordinate error can shift the entire nearby-places result set across a major road or river.

Can the nearby-places payload be served directly to the front end, or should I store it? Store it. A live API call on each page view adds latency, creates availability risk, and bypasses your caching strategy. The enrichment pipeline writes the payload to your hotel record; the listing template reads from the record. Re-run the pipeline on a schedule to keep the content fresh. This is the correct architecture for any content that has a defined freshness requirement rather than a real-time requirement.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →