Adding transit and airport proximity to travel listings at scale
Enrich hotel and rental listings with nearest airport and transit distance. Places API plus geocoding at 461M-address scale.
Every OTA listing page in existence says something like "conveniently located near public transport" or "minutes from the airport." It is marketing copy. The guest has no idea what "near" means — 400 m or 4 km — and the listing team has no idea either, because nobody ever measured it. The proximity claim lives in the description text, not in a structured field, so it cannot be filtered, ranked, or verified.
This is a straightforward data gap to close. Geocode the property, query the Places API for nearby airports and transit stations, compute the straight-line distance to the nearest result, and write nearest_airport_km, nearest_station_m, and nearest_station_name as structured columns on your listing record. Once those fields exist, you can power a "within 500 m of metro" filter, surface "12 min walk to underground station" in the listing card, and stop relying on copywriters to invent proximity claims that guests will eventually call you out on in reviews.
This post covers the full engineering path: geocoding your property catalog, querying Places for transit POIs, computing the proximity figure, caching the result, and shipping it to your search index. All examples are REST — curl, Python requests, and Node fetch. SDKs exist; use them if you prefer, but the REST path has no version dependencies and is easier to audit.
Why this is a structured-data problem, not a copy problem
Before the engineering: a brief case for treating proximity as a field rather than a description.
Filters convert. A traveller who filters "within 10 minutes of an airport" has committed to a requirement. They will book a property that satisfies it and skip one that does not. If your listings page cannot surface that filter because the proximity data does not exist in structured form, you are losing conversions to platforms that can.
Reviews mention it anyway. "Took 40 minutes to get to the airport, listing said 'nearby' — rubbish" is a three-star review waiting to happen. Structured proximity data, honestly computed, surfaces in your listing card and defuses the complaint before check-out.
Ranking signals. Your internal search ranking almost certainly factors in property quality and price. Adding a verified "distance to nearest transit stop" lets you rank more relevant properties for a city-centre transit search, which is a signal your machine learning pipeline can actually use — free-text "near the metro" is not.
Compliance creep. Several European jurisdictions are quietly moving towards disclosure requirements for accommodation advertising. "Walking distance" as a claim may eventually need to be substantiated. Structured, API-sourced data is a lot easier to defend than "the listing team wrote it."
The two surfaces you need
The enrichment workflow uses two CSV2GEO surfaces in sequence.
`GET /api/v1/geocode` — convert each property's address to a verified latitude and longitude. Coverage is 461 million addresses across 39 countries. The confidence score matters here: a hotel geocoded to confidence 0.6 and placed at a road centroid rather than the actual parcel will produce proximity figures that are off by hundreds of metres. Filter on confidence and re-examine low-scoring rows before writing results to production.
`GET /api/v1/places/nearby` — given a lat/lng, return POIs within a given radius, optionally filtered by category. This is the surface that finds nearby airports, railway stations, underground stations, and bus interchanges. Categories are generic (e.g. airport, train_station, transit_station) rather than operator-branded, so you get results across all providers in the dataset rather than having to know the local operator name.
There is no dedicated transit-schedule or airport-route endpoint. The Places API returns the location and name of the facility. The proximity figure you surface on the listing card is the straight-line distance from your property to the nearest result, in metres or kilometres depending on the modality. If you want a routed walking time rather than a straight-line distance, you need a routing API call per result — that adds cost and latency; most listing cards show the straight-line distance with a label like "straight-line" or "approx." and that is honest enough for the use case.
Step 1: Geocode your property catalog
Start here. Every downstream call depends on having a reliable lat/lng per property. If your catalog already has coordinates from a previous geocoding run, verify the confidence scores before relying on them — an import geocoded two years ago against a less accurate dataset will produce subtly wrong proximity figures.
curl -s "https://csv2geo.com/api/v1/geocode" \
--get \
--data-urlencode "q=22 Buckingham Gate, London SW1E 6LB" \
--data-urlencode "api_key=$CSV2GEO_KEY"The response:
{
"results": [
{
"lat": 51.4985,
"lng": -0.1339,
"confidence": 0.97,
"formatted_address": "22 Buckingham Gate, Westminster, London SW1E 6LB, GB"
}
]
}In Python, for a CSV of properties:
import csv, os, time, requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]
def geocode(address: str) -> dict | None:
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=20,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return None
top = results[0]
if top["confidence"] < 0.7:
return None # flag for manual review; do not trust for proximity
return top
with open("properties.csv") as fin, open("properties_geocoded.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(fout, fieldnames=reader.fieldnames + ["lat", "lng", "geo_confidence"])
writer.writeheader()
for row in reader:
geo = geocode(row["address"])
if geo:
row["lat"] = geo["lat"]
row["lng"] = geo["lng"]
row["geo_confidence"] = geo["confidence"]
else:
row["lat"] = row["lng"] = row["geo_confidence"] = ""
writer.writerow(row)
time.sleep(0.05) # respect per-second rate limit on free tierThe 50 ms sleep is conservative. On a paid key with a higher rate limit you can lower it or replace the serial loop with a concurrent worker pool — see the concurrency tuning post for the right way to dial that up without triggering 429s.
Step 2: Query Places for nearby airports
With coordinates on file, query the Places API for each property. Airports are infrequent enough that a large search radius makes sense — 50 km is a reasonable default for "nearest commercial airport" in most urban markets.
curl -s "https://csv2geo.com/api/v1/places/nearby" \
--get \
--data-urlencode "lat=51.4985" \
--data-urlencode "lng=-0.1339" \
--data-urlencode "radius=50000" \
--data-urlencode "categories=airport" \
--data-urlencode "limit=3" \
--data-urlencode "api_key=$CSV2GEO_KEY"Response (abbreviated):
{
"results": [
{
"name": "London Heathrow Airport",
"categories": ["airport"],
"location": {"lat": 51.4700, "lng": -0.4543},
"distance_m": 21340
},
{
"name": "London City Airport",
"categories": ["airport"],
"location": {"lat": 51.5048, "lng": 0.0553},
"distance_m": 14420
}
]
}The distance_m field is the straight-line distance from your query point to the POI. Take the first result (the API returns results sorted by ascending distance), pull its name and distance, and write those to your listing record.
In Python:
import math
def nearest_poi(lat: float, lng: float, category: str, radius_m: int = 50000) -> dict | None:
r = requests.get(
f"{API}/places/nearby",
params={
"lat": lat, "lng": lng,
"radius": radius_m,
"categories": category,
"limit": 1,
"api_key": KEY,
},
timeout=20,
)
r.raise_for_status()
results = r.json().get("results", [])
return results[0] if results else None
# Usage
airport = nearest_poi(51.4985, -0.1339, "airport", radius_m=50000)
if airport:
print(airport["name"], airport["distance_m"])
# → London City Airport 14420Step 3: Query Places for nearby transit stations
Transit stations sit much closer to the property in most urban markets, so shrink the radius to something human-relevant — 1,500 m is a comfortable walking-distance upper bound. Categories to try: train_station, transit_station, subway_station. You may want to query all three and deduplicate by name if your target markets mix terminology (London calls them "tube stations"; New York calls them "subway"; Paris calls them "métro" but the category key is consistent regardless of city).
In Node:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;
async function nearestTransit(lat, lng, radiusM = 1500) {
const categories = ['train_station', 'transit_station', 'subway_station'];
const results = await Promise.all(
categories.map(cat =>
fetch(
`${API}/places/nearby?lat=${lat}&lng=${lng}` +
`&radius=${radiusM}&categories=${cat}&limit=1&api_key=${KEY}`
).then(r => r.ok ? r.json() : { results: [] })
.then(j => j.results[0] ?? null)
)
);
// Keep the closest non-null result across categories
return results
.filter(Boolean)
.sort((a, b) => a.distance_m - b.distance_m)[0] ?? null;
}
const station = await nearestTransit(51.4985, -0.1339);
if (station) {
console.log(station.name, station.distance_m);
}
// → St. James's Park 380Running three parallel Places calls per property is fine from a latency perspective — they are small, fast requests. From a billing perspective, each is 1 credit. Pulling airport and transit in parallel costs 3 credits per property (one geocode already counted separately). At paid pricing starting at $54/month for 100,000 calls, enriching one listing end to end is under $0.002.
Step 4: Compute and normalise the proximity field
The API returns distance_m. Your listing card needs a human-readable form. A sensible normalisation:
def format_proximity(distance_m: int | None, modality: str) -> str:
if distance_m is None:
return "N/A"
if modality == "airport":
km = distance_m / 1000
return f"{km:.1f} km"
# Transit: walk vs drive
if distance_m <= 1500:
minutes = round(distance_m / 80) # 80 m/min walking pace
return f"{minutes} min walk"
km = distance_m / 1000
return f"{km:.1f} km"Store both the raw distance_m integer and the formatted string in your database. The raw integer powers search filters (WHERE nearest_airport_m <= 30000); the formatted string goes directly to the template. Storing only the formatted string is a common mistake — you lose the ability to filter numerically without a parsing step.
The resulting record shape:
{
"property_id": "HTL-00441",
"nearest_airport_name": "London City Airport",
"nearest_airport_m": 14420,
"nearest_airport_display": "14.4 km",
"nearest_transit_name": "St. James's Park",
"nearest_transit_m": 380,
"nearest_transit_display": "5 min walk"
}This is the shape you index into your search engine and render on the listing card.
Step 5: Cache aggressively and refresh rarely
Hotels do not relocate. Airports do not open and close on a weekly cadence. The proximity figure for a given property is valid for months, possibly years. Cache the Places response — keyed by (lat_rounded_4dp, lng_rounded_4dp, category, radius) — for at least 30 days. Cache the geocoded coordinate indefinitely unless the property's address changes.
Redis example:
import redis, json, hashlib
rdb = redis.Redis(host="localhost", decode_responses=True)
def cached_nearest_poi(lat: float, lng: float, category: str, radius_m: int) -> dict | None:
key = hashlib.md5(
f"{lat:.4f}:{lng:.4f}:{category}:{radius_m}".encode()
).hexdigest()
cached = rdb.get(key)
if cached:
return json.loads(cached)
result = nearest_poi(lat, lng, category, radius_m)
rdb.setex(key, 2592000, json.dumps(result)) # 30 days
return resultFor a 100,000-listing catalog, a warm cache means the second monthly enrichment run costs essentially nothing beyond the first. The caching pattern for geocoding pipelines in general is covered in depth in Caching Geocoding Results — 90% Cost Reduction — the same principles apply verbatim here.
Putting it all together: the enrichment pipeline
The complete flow, assembled from the steps above:
import csv, os, time, json, requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]
AIRPORT_RADIUS = 50_000 # metres
TRANSIT_RADIUS = 1_500
def get(path, **params):
r = requests.get(f"{API}{path}", params={"api_key": KEY, **params}, timeout=20)
r.raise_for_status()
return r.json()
def enrich(row: dict) -> dict:
# 1. Geocode
geo = get("/geocode", q=row["address"])["results"]
if not geo or geo[0]["confidence"] < 0.7:
return row
lat, lng = geo[0]["lat"], geo[0]["lng"]
row.update(lat=lat, lng=lng)
# 2. Nearest airport
ap = get("/places/nearby", lat=lat, lng=lng,
radius=AIRPORT_RADIUS, categories="airport", limit=1)
if ap["results"]:
a = ap["results"][0]
row["nearest_airport_name"] = a["name"]
row["nearest_airport_m"] = a["distance_m"]
# 3. Nearest transit
best = None
for cat in ("train_station", "transit_station", "subway_station"):
tr = get("/places/nearby", lat=lat, lng=lng,
radius=TRANSIT_RADIUS, categories=cat, limit=1)
if tr["results"]:
hit = tr["results"][0]
if best is None or hit["distance_m"] < best["distance_m"]:
best = hit
if best:
row["nearest_transit_name"] = best["name"]
row["nearest_transit_m"] = best["distance_m"]
return row
EXTRA_FIELDS = ["lat", "lng", "nearest_airport_name", "nearest_airport_m",
"nearest_transit_name", "nearest_transit_m"]
with open("properties.csv") as fin, open("properties_enriched.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(fout, fieldnames=reader.fieldnames + EXTRA_FIELDS,
extrasaction="ignore")
writer.writeheader()
for row in reader:
for f in EXTRA_FIELDS:
row.setdefault(f, "")
enriched = enrich(row)
writer.writerow(enriched)
time.sleep(0.1)For large catalogs, replace the serial loop with a thread pool and a semaphore set to your key's allowed concurrency. The serial version is the right starting point for correctness; the concurrent version is what you ship after it is correct.
What to surface on the listing card
The data exists; now display it in a way that converts. Two patterns that work.
The proximity badge. A small icon strip below the hero image: ✈ 14 km · 🚇 5 min walk. No prose, no marketing language — just the numbers. Users who care about transit access scan for this strip in under a second. Users who do not care skip it without cognitive load.
The filter panel. On your search results page, add two range sliders: "Airport within X km" and "Transit within Y min walk". These filters are most valuable for city-break searches ("I want to be in the centre, near the metro") and for business travel ("I need to be within 20 minutes of the airport"). Neither filter is possible without structured data.
The honest copy generator. If you still want natural-language proximity descriptions in your listing text, generate them from the structured fields rather than asking a copywriter to guess. f"A {nearest_transit_display} walk from {nearest_transit_name}, and {nearest_airport_display} from {nearest_airport_name}." is better than "convenient for public transport."
One important labelling decision: be explicit that the distance is straight-line, not routed. "14.4 km (straight line) from Heathrow" is honest. "14.4 km from Heathrow" without qualification implies a road distance that is almost certainly longer. Guests who arrive to find a 22 km taxi ride to what was advertised as "14 km" will leave you a review about it.
Failure modes to plan for before go-live
No transit results within radius. Rural properties and properties in markets with sparse transit coverage will return empty results arrays. Store null, not 0 — 0 would mean "transit station at the same coordinate," which is a different thing. Render "No transit station within 1.5 km" on the listing card rather than silently omitting the field; a guest trying to get around without a car needs to know this before booking.
Low-confidence geocode. A property address geocoded to a road centroid rather than the actual building will have proximity figures that are plausibly but not precisely correct. Log the confidence score alongside the proximity result. Any row with geo_confidence < 0.7 should go into a manual review queue rather than straight to production — see Geocoding Confidence Scores Explained for the full rationale on treating this as a first-class field.
Duplicate category results. A large airport complex sometimes appears under multiple category hits (e.g. both airport and transit_station for an airport with a rail connection). Deduplicate by name or by distance_m being within 200 m of a result you have already stored before writing to the listing record.
Rate limits during bulk enrichment. Each Places call is 1 credit. A pipeline that fires three Places calls and one geocode call per property in a tight concurrent loop will hit rate limits on a free tier key (3,000 calls/day) well before it finishes a real catalog. Size your key tier against your catalog before you kick off the run. Free tier is right for a pilot of a few hundred properties; a mid-size catalog of 30,000 listings is a paid key job. Pricing is at csv2geo.com/pricing/api.
A note on straight-line versus routed distance
Straight-line distance is what the Places API distance_m field gives you. For most transit proximity use cases — "is the metro walkable from this hotel?" — straight-line distance is accurate enough. The ratio between straight-line and routed walking distance in a dense city grid is typically 1.2–1.4. A 380 m straight-line to a station is realistically a 450–530 m walk. Multiplying straight-line by 1.3 and calling it "estimated walking distance" is a reasonable approximation for a listing card, but label it as estimated.
Routed walking distance requires a routing API call per POI, which adds one more credit and one more network round-trip per result. For a first version, ship straight-line. If your product review reveals that guests are complaining about inaccurate distances, graduate to routed distance for transit stations (where walking is the relevant mode) while keeping straight-line for airports (where no one walks).
---
Frequently Asked Questions
What categories do I use to find airports in the Places API? Use airport as the category value. This returns commercial passenger airports. The radius for airports should be large — 50,000 m is a reasonable default for most markets. In very remote regions you may need to extend to 150,000 m to surface the nearest facility.
What categories do I use for transit stations? train_station, transit_station, and subway_station cover the most common modalities. Run all three with a small radius (1,000–2,000 m) and take the closest non-duplicate result. If you also want bus stops, add bus_station — but bus stop coverage varies significantly by market and the result density is much higher, so limit aggressively.
How often should I refresh the proximity data? For airports: annually, or when a property address changes. Airports do not move on timescales relevant to your listing catalog. For transit stations: quarterly is conservative; the main events that change results are new line openings and station closures, both of which are announced months in advance and easy to schedule a refresh around. Cache both aggressively.
Does this work for properties outside the 39 covered countries? Geocoding and Places coverage extends to 39 countries. Properties outside that footprint may return low-confidence geocodes or empty Places results. Build the fallback path (display "proximity data unavailable") before shipping rather than discovering this in production.
Can I use the web batch tool instead of the REST API for the initial enrichment run? Yes — the web batch tool accepts a CSV of addresses and enriches them at scale; credits are consumed per address row. This is a reasonable option for a one-off initial enrichment of an existing catalog. For ongoing enrichment of new listings as they are created, the REST loop is easier to integrate into your existing listing-creation workflow.
What is the difference between `distance_m` in the Places response and a routing distance? distance_m is straight-line (haversine) distance from your query point to the POI. Routing distance is the actual path a person would walk or drive, which is always longer than straight-line. For transit walking distance in a normal city grid, multiply straight-line by 1.2–1.4 to estimate routed walking distance. Label the result as "approx." or "estimated walking distance" on your listing card.
How do I handle a property that is itself inside an airport (e.g. an airside hotel)? The Places API will return the airport itself as the nearest result at very low distance_m. Treat distance_m < 200 for an airport result as "on-site" and render it accordingly: "On-site at [Airport Name]" rather than "0.2 km from [Airport Name]."
---
Related Articles
- Enriching property data with elevation — one API call per address
- Reverse geocoding accuracy in meters — what the numbers actually mean
- Caching geocoding results — 90% cost reduction
- Benchmarking geocoding APIs — honest numbers
- Geocoding confidence scores explained
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →