POST POI datasets

Get every restaurant in a city as a dataset, free and legally

Data brokers sell places lists. Scrapers risk Google's terms to build them. Or you can query OpenStreetMap and own the result outright. Here is the whole path, run for real.

Sometimes pins on a map are not enough. You want every restaurant in a city as rows: names, coordinates, cuisines, opening hours, sitting in your own database where you can join them, filter them, and build on them. That need is common enough that an entire industry sells "restaurant database, 2026 edition" downloads, and another industry sells scrapers that harvest Google Maps for you.

There is a third way. You can build the dataset yourself from OpenStreetMap in about fifteen minutes, it costs nothing, and unlike the scraped version you are actually allowed to keep it. This post walks the whole path with a real city, including the trap that quietly puts New Hampshire into your Berlin dataset.

What you are allowed to keep

Start with the legal part, because it decides everything else. If your plan is "fetch places from a big commercial API and save them", read that API's terms first. Google's are the clearest example: under the Service Specific Terms, the only Places API content you may cache is latitude and longitude, for at most 30 consecutive days (section 14.3). Place IDs are the one thing you may keep indefinitely. The terms also bar using Places content with a non-Google map (section 14.2). The persistent, queryable places database most products actually want is precisely what the terms rule out, and no pricing tier changes that.

OpenStreetMap points the other way. Its data is published under the Open Database License, which lets you download, store, transform, and use the data commercially. Two obligations come with it: credit OpenStreetMap contributors, and if you publish something that is intended for extraction of the data (a dataset, a derived database), you must offer that database under the ODbL too. Using the data internally, or building an app or analysis on top of it, needs attribution and nothing more.

How OpenStreetMap models a restaurant

Every mapped thing in OSM carries free-form tags. A restaurant is anything tagged amenity=restaurant, and the useful columns for a dataset are tags too: name, cuisine, opening_hours, website, phone, and the addr:* family. One wrinkle: a restaurant can be mapped as a point (a node) or as the building outline itself (a way). Asking the server for out center gives you a single representative coordinate either way, which is what you want for a flat table.

The extraction, including the trap

The Overpass API is the query engine for OSM data, and the obvious query is "everything tagged restaurant inside the city's boundary". The obvious way to name the city is by name. For Berlin:

naive query (contaminated)
[out:json][timeout:120];
area["name"="Berlin"]["boundary"="administrative"]->.city;
nwr["amenity"="restaurant"](area.city);
out center;

Run while writing this, that returned 4,813 restaurants, and nothing about the number looks wrong. It is wrong. There are 14 administrative boundaries in OSM named exactly "Berlin": the German capital, and thirteen towns and boroughs, mostly in the United States. A name-matched area collects all of them, so 53 of those rows are restaurants in the wrong Berlins, silently mixed into your dataset. No error, no warning, just a table you cannot fully trust. Check what a name actually matches before you trust it:

list matching boundaries
[out:json][timeout:25];
relation["name"="Berlin"]["boundary"="administrative"];
out tags;

Each result carries its admin_level and, usually, a wikidata tag. That tag is the fix: a Wikidata ID names exactly one place on earth, so pin the area with it and the ambiguity is gone. Berlin, Germany is Q64.

the real query
[out:json][timeout:120];
area["wikidata"="Q64"]->.city;
nwr["amenity"="restaurant"](area.city);
out center;

That returned 4,760 restaurants for Berlin proper (4,562 mapped as points and 198 as building outlines) in a 2.8 MB JSON response. One request, one city, done.

From JSON to a CSV

The response is a list of elements with a tags object each. Flattening it is a dozen lines of Python, no libraries:

overpass_to_csv.py
import csv, json

FIELDS = ["osm_type", "osm_id", "lat", "lon", "name", "cuisine",
          "opening_hours", "website", "phone", "addr:street"]

data = json.load(open("berlin-restaurants.json", encoding="utf-8"))

rows = []
for el in data["elements"]:
    tags = el.get("tags", {})
    # nodes carry lat/lon directly; ways carry an "out center" point
    lat = el.get("lat") or el.get("center", {}).get("lat")
    lon = el.get("lon") or el.get("center", {}).get("lon")
    rows.append({"osm_type": el["type"], "osm_id": el["id"],
                 "lat": lat, "lon": lon,
                 **{k: tags.get(k, "") for k in FIELDS[4:]}})

with open("restaurants.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=FIELDS)
    w.writeheader()
    w.writerows(rows)

On the Berlin response this writes 4,760 rows. The first one reads: Aida, italian;pizza, opening hours for every day of the week, a website, a phone number, and an address on Knesebeckstraße. If you want GeoJSON instead of a table, osmtogeojson (the converter overpass turbo itself uses) takes the same response as input.

How complete is it, honestly

A dataset is only as good as its columns, so here is the tag coverage of those 4,760 real rows:

ColumnFilled
name4,732 / 4,760
addr:street3,869 / 4,760
cuisine3,787 / 4,760
opening_hours3,679 / 4,760
wheelchair3,134 / 4,760
website1,807 / 4,760
phone1,594 / 4,760

Identity and location are near-complete, category and opening-hours coverage run around 80 percent, and two thirds of Berlin's restaurants even carry wheelchair accessibility, a column most commercial providers cannot sell you at all. The contact-detail columns are the weak spot at roughly a third, and there are no ratings, reviews, or popularity signals whatsoever. Be aware that Berlin is one of the best-mapped cities on earth; a smaller or less-edited city gives you the same table with thinner columns. If your product depends on complete phone numbers or review scores, a commercial places provider is the honest answer. If it depends on location, category, and the right to keep and build on the data, OSM is hard to beat, and for civic amenities the commercial providers barely cover (toilets, drinking water, benches, defibrillators) it is effectively the only source.

Keeping it fresh

OSM changes constantly, but your extract does not need to stream those changes. For a dataset like this, re-running the query on a schedule (weekly or monthly is plenty for most uses) and diffing against your table beats any streaming setup for simplicity. A city-scale query is a single request, so a polite cadence costs the public servers almost nothing. If you do run it as a recurring job, read our post on Overpass 429s and rate limits first: the public instances are shared, volunteer-run machines, and a scheduler that retries carelessly is exactly what gets clients banned.

When the job grows up

One city, refreshed monthly, is comfortably inside what the public servers are for. The picture changes when the job becomes fifty cities nightly, or country-scale extracts, or an API your product calls at request time. The Overpass user's manual is explicit that large-scale scraping and app backends belong on a private instance, and the operators enforce that more firmly every year. At that point you have two honest options: run your own Overpass server, which is real work, or pay for a hosted one. Full disclosure: Overspan, whose blog you are reading, is one of the paid options; the neutral list of public and commercial instances lives on the OSM wiki. Either way, the dataset you build stays yours. That is the whole point.