Skip to content

Instantly share code, notes, and snippets.

@ForceGT
Last active August 11, 2026 11:02
Show Gist options
  • Select an option

  • Save ForceGT/bbb9423e2d23e9639672a390933eb710 to your computer and use it in GitHub Desktop.

Select an option

Save ForceGT/bbb9423e2d23e9639672a390933eb710 to your computer and use it in GitHub Desktop.
Export a Google Maps list, even a shared list you do not own, to CSV, JSON, GeoJSON, KML, or GPX. Free, open-source Python script. No Chrome extension, no sign-in, no Google Takeout. Includes AGENTS.md for AI coding agents.

AGENTS.md

This project has moved. The latest version lives at github.com/ForceGT/gmaps-list-export. This gist is kept as-is for history; point agents at the repo above instead.

Instructions for an AI coding agent using this gist. For human-readable docs, see export-google-maps-list-csv-json-kml-gpx.md in this gist.

What this does

gmaps_list_export.py converts a Google Maps list share link into structured place data (name, address, lat, lng, and Google's internal CID and placeId). It works on lists the requesting user does not own, including lists they only joined. It requires no API key, no browser, and no authentication for a publicly shared list.

When to use this

Use this script whenever a user asks to:

  • export, dump, or scrape a Google Maps list
  • get coordinates or lat/lng out of a Google Maps list
  • convert a Google Maps list to CSV, JSON, GeoJSON, KML, or GPX
  • get data from a Google Maps list they don't own or only joined
  • work around Google Takeout not showing a joined list (Takeout only exports lists the user created)

How to run it

  1. Get the script: gmaps_list_export.py in this gist. Python 3, standard library only, no pip install needed.
  2. Get the list URL from the user: either a short link (https://maps.app.goo.gl/XXXXXXXX) or a long google.com/maps/... list URL. Both work unmodified.
  3. Run:
python3 gmaps_list_export.py "<url>" --format json,csv,geojson,kml,gpx --out places

--format is comma-separated, any subset of json,csv,geojson,kml,gpx. Default is json alone if omitted. --out sets the output filename base (default places); output files are <out>.<format>.

  1. Read back <out>.json (or whichever format is most useful for the task) to get structured data: a list of objects with name, label, address, lat, lng, cid, placeId.

Error handling

  • SystemExit: Couldn't find a list id in the resolved URL: the URL is not a Maps list link (a single place or plain map view instead). Ask the user to confirm the link points at a list, not a place.
  • json.loads failure or urllib.error.HTTPError: the list likely requires the viewer to be signed in as a collaborator, meaning it is not truly public. Tell the user to check the list's sharing setting, or verify it opens in an incognito browser window without a sign-in prompt.
  • A run that returns fewer places than the user expects: the underlying API caps at 500 entries per call (4i500 in the script). Not tested past that size; flag this to the user rather than assuming the output is complete.

Constraints, do not change these

  • Do not remove or "clean up" SHORTLINK_USER_AGENT = "Mozilla/5.0" in the script, or replace it with a full modern browser user agent string. The short-link redirect only returns a plain HTTP redirect for a bare, unremarkable user agent; a full Chrome UA makes Google serve a JS interstitial instead, and the script breaks.
  • Do not add authentication, an API key, or a login flow. None is needed or supported.
  • This calls an undocumented Google endpoint (/maps/preview/entitylist/getlist), not a public API. If it starts failing entirely (not just on one bad URL), the endpoint may have changed; do not assume the script's logic is wrong before checking that.

Export a Google Maps List to CSV, JSON, KML or GPX (Free, No Extension, No Sign-In)

This project has moved. The latest version of the script and docs now live at github.com/ForceGT/gmaps-list-export. This gist is kept as-is for history; get updates and file issues at the repo above.

A free, open-source Python script that exports any Google Maps list to CSV, JSON, GeoJSON, KML, or GPX. This works on shared lists you don't own, lists you only joined, not just your own. No Chrome extension, no account sign-in, no Google Takeout, no per-export limit.

Pointing an AI coding agent at this instead? See AGENTS.md in this gist for a version written for that.

The problem this solves

Google Maps has no built-in export button for lists. Google Takeout only exports lists you personally created. A list someone shared with you, that you joined, is invisible to Takeout even though it shows up fine in Maps under Saved, Your lists.

Most tools that solve this are a paid Chrome extension with a free-tier cap, or a paid web app you have to hand your list's link to. This is neither. It's about 200 lines of standard-library Python you can read in a couple of minutes, run locally, and never touch again.

Usage

python3 gmaps_list_export.py "https://maps.app.goo.gl/XXXXXXXX" --format json,csv,kml

Works with a short share link (maps.app.goo.gl/...) or the long form Maps gives you from the address bar.

$ python3 gmaps_list_export.py "https://maps.app.goo.gl/XXXXXXXX" --format json,csv,kml

Resolving list...
  list id:  -mup0V4zyZUEpvJl_Tvf2UMziCBMGg
  token:    3PoDpW5u7uo
Fetching places...
  "COCO Fuel Station" - 382 places (0 missing coordinates)
  wrote places.json
  wrote places.csv
  wrote places.kml

Available export formats

Format Use
json Raw data, for scripting (default if --format is omitted)
csv Spreadsheets, Google Sheets, Excel
geojson GIS tools, map libraries (Leaflet, Mapbox, QGIS)
kml Google Earth, Google My Maps import
gpx GPS units, OsmAnd, Organic Maps, Garmin

Plain XML isn't offered on its own. GPX is XML, and it's the standard form for point-of-interest data, so it already covers that ground.

Output fields

Every place comes back with real coordinates, pulled directly from the same data Google Maps uses to render the list (a Takeout export can be missing coordinates for dropped pins; this doesn't have that gap):

  • name: short display name, as shown on the pin label
  • label: full name and address, concatenated
  • address: address only
  • lat / lng: coordinates
  • cid: Google's internal CID pair for the place
  • placeId: short-form place ID (/g/...), for looking the place up again later

Why it works

A share link is just a redirect. Following it (with a plain, unremarkable user agent) lands on a URL carrying the list's real id and share token. That URL is exactly what the list's own page uses internally to ask for its contents: the same entitylist/getlist call the page fires on load to draw itself. The response is one large nested array. The place list sits four levels down (data[0][8]), with every entry's fields at fixed positions.

This calls an undocumented Google endpoint, not a public API. It could change without notice. As of writing it needs no authentication for a publicly shared list, and returns the whole list in one response: no clicking, no hovering, no per-pin requests. The request caps at 500 entries per call (the 4i500 parameter), untested past that.

FAQ

How do I export a Google Maps list I don't own? Run this script against the share link. Ownership isn't checked, only whether the list is publicly link-shared.

How do I get coordinates from a shared Google Maps list? That's the lat / lng fields in every output format, extracted directly, no geocoding step needed.

Why doesn't Google Takeout show my saved list? Takeout only exports lists you created. A list you joined through someone else's link never appears there.

Is this free? Yes. MIT-licensed, no rate limit imposed by the script, no account, no place-count cap.

Troubleshooting

"Couldn't find a list id in the resolved URL": the link resolved to something that isn't a list (a single place, a plain map view). Open it in a browser and confirm the address bar shows something containing /placelists/list/....

Place count looks short, or coordinates are missing: the request caps at 500 entries per call, untested past that size. A bare dropped pin with no linked business can come back with lat / lng present but a thin label. That's the source data, not a bug.

json.loads or urllib.error.HTTPError: usually means the list needs the viewer signed in and added as a collaborator. Try an incognito window to check whether it's genuinely public before assuming the script is broken.

#!/usr/bin/env python3
# NOTE: This project has moved. Latest version:
# https://github.com/ForceGT/gmaps-list-export
# This gist copy is kept as-is for history.
"""Export any Google Maps list (shared or joined, short or long URL) to
JSON / CSV / GeoJSON / KML / GPX. No browser, no sign-in, no Google Takeout,
no dependencies outside the standard library.
Usage:
python3 gmaps_list_export.py "<url>" [--format json,csv,geojson,kml,gpx] [--out places]
Examples:
python3 gmaps_list_export.py "https://maps.app.goo.gl/XXXXXXXX"
python3 gmaps_list_export.py "https://www.google.com/maps/@.../data=..." --format kml,csv
"""
import argparse
import csv
import http.cookiejar
import json
import re
import secrets
import sys
import urllib.parse
import urllib.request
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Deliberately NOT the full Chrome UA above: a modern browser UA makes the
# short-link redirector serve a JS interstitial (200, no Location header)
# instead of a plain redirect. A bare "Mozilla/5.0" reliably gets the 302.
SHORTLINK_USER_AGENT = "Mozilla/5.0"
def resolve_list(url):
"""Follow a short (maps.app.goo.gl/...) or long Maps URL to the list's
id + share token. Works on either form. A long URL resolves to itself."""
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
req = urllib.request.Request(url, headers={"User-Agent": SHORTLINK_USER_AGENT})
final_url = opener.open(req).geturl()
list_id = None
m = re.search(r"!2s(-?[\w-]+)!", final_url) or re.search(r"/placelists/list/(-?[\w-]+)", final_url)
if m:
list_id = m.group(1)
token = None
m = re.search(r"token%3D([\w-]+)", final_url) or re.search(r"[?&]token=([\w-]+)", final_url)
if m:
token = m.group(1)
if not list_id:
raise SystemExit(f"Couldn't find a list id in the resolved URL:\n{final_url}\n"
"Make sure the link points at a Maps list (a 'placelists/list/...' URL), not a single place or a plain map view.")
return list_id, token, opener
def fetch_places(list_id, token, opener):
"""Call the (undocumented) entitylist/getlist endpoint the list's own
page uses to render itself, and return the raw place entries."""
list_page_url = f"https://www.google.com/maps/placelists/list/{list_id}"
if token:
list_page_url += f"?token={token}"
# The !1s value is a per-session id the real page generates; a random
# one works fine, it doesn't appear to be validated server-side.
session = secrets.token_urlsafe(16)
pb = (
"!2e2!3e2!4i500!6m3"
f"!1s{session}"
"!15i204459!28e2"
f"!13s{urllib.parse.quote(list_page_url, safe='')}"
"!16b1"
)
qs = urllib.parse.urlencode({"authuser": "0", "hl": "en", "gl": "in"})
api_url = f"https://www.google.com/maps/preview/entitylist/getlist?{qs}&pb={pb}"
req = urllib.request.Request(api_url, headers={"User-Agent": USER_AGENT})
text = opener.open(req).read().decode("utf-8")
if text.startswith(")]}'"):
text = text[4:]
data = json.loads(text)
list_title = data[0][4] if len(data[0]) > 4 else None
raw_places = data[0][8]
# 500 entries per call (the `4i500` param); page through if the list is
# bigger. Untested against a real >500 list. The list's total count
# lives at data[0][12] if you want to sanity-check completeness.
return list_title, raw_places
def parse_place(p):
info = p[1]
name = p[2]
label = info[2] if len(info) > 2 else None
address = info[4] if len(info) > 4 else None
latlng = info[5] if len(info) > 5 else None
lat, lng = (latlng[2], latlng[3]) if latlng and len(latlng) >= 4 else (None, None)
cid = info[6] if len(info) > 6 else None
place_id = info[7] if len(info) > 7 else None
return {
"name": name,
"label": label,
"address": address,
"lat": lat,
"lng": lng,
"cid": cid[0] + ":" + cid[1] if cid else None,
"placeId": place_id,
}
def write_json(places, out):
path = f"{out}.json"
with open(path, "w", encoding="utf-8") as f:
json.dump(places, f, indent=2, ensure_ascii=False)
return path
def write_csv(places, out):
path = f"{out}.csv"
fields = ["name", "label", "address", "lat", "lng", "cid", "placeId"]
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(places)
return path
def write_geojson(places, out):
path = f"{out}.geojson"
features = []
for p in places:
if p["lat"] is None:
continue
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [p["lng"], p["lat"]]},
"properties": {k: v for k, v in p.items() if k not in ("lat", "lng")},
})
with open(path, "w", encoding="utf-8") as f:
json.dump({"type": "FeatureCollection", "features": features}, f, indent=2, ensure_ascii=False)
return path
def _xml_escape(s):
if s is None:
return ""
return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def write_kml(places, out, list_title):
path = f"{out}.kml"
placemarks = []
for p in places:
if p["lat"] is None:
continue
placemarks.append(
" <Placemark>\n"
f" <name>{_xml_escape(p['name'])}</name>\n"
f" <description>{_xml_escape(p['address'])}</description>\n"
f" <Point><coordinates>{p['lng']},{p['lat']},0</coordinates></Point>\n"
" </Placemark>"
)
kml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<kml xmlns="http://www.opengis.net/kml/2.2">\n'
"<Document>\n"
f" <name>{_xml_escape(list_title or out)}</name>\n"
+ "\n".join(placemarks)
+ "\n</Document>\n</kml>\n"
)
with open(path, "w", encoding="utf-8") as f:
f.write(kml)
return path
def write_gpx(places, out, list_title):
"""GPX, the standard format for GPS devices and apps like OsmAnd,
Organic Maps, Garmin, etc. (more portable than KML for that use case)."""
path = f"{out}.gpx"
waypoints = []
for p in places:
if p["lat"] is None:
continue
waypoints.append(
f' <wpt lat="{p["lat"]}" lon="{p["lng"]}">\n'
f" <name>{_xml_escape(p['name'])}</name>\n"
f" <desc>{_xml_escape(p['address'])}</desc>\n"
" </wpt>"
)
gpx = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<gpx version="1.1" creator="gmaps_list_export" xmlns="http://www.topografix.com/GPX/1/1">\n'
f" <name>{_xml_escape(list_title or out)}</name>\n"
+ "\n".join(waypoints)
+ "\n</gpx>\n"
)
with open(path, "w", encoding="utf-8") as f:
f.write(gpx)
return path
WRITERS = {
"json": write_json,
"csv": write_csv,
"geojson": write_geojson,
"kml": write_kml,
"gpx": write_gpx,
}
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("url", help="A Google Maps list share link (short maps.app.goo.gl/... or long)")
ap.add_argument("--format", default="json", help="Comma-separated: json,csv,geojson,kml,gpx (default: json)")
ap.add_argument("--out", default="places", help="Output filename without extension (default: places)")
args = ap.parse_args()
formats = [f.strip().lower() for f in args.format.split(",") if f.strip()]
unknown = [f for f in formats if f not in WRITERS]
if unknown:
raise SystemExit(f"Unknown format(s): {', '.join(unknown)}. Available: {', '.join(WRITERS)}")
print("Resolving list...", file=sys.stderr)
list_id, token, opener = resolve_list(args.url)
print(f" list id: {list_id}", file=sys.stderr)
print(f" token: {token or '(none, publicly listed without one)'}", file=sys.stderr)
print("Fetching places...", file=sys.stderr)
list_title, raw_places = fetch_places(list_id, token, opener)
places = [parse_place(p) for p in raw_places]
missing = sum(1 for p in places if p["lat"] is None)
print(f" \"{list_title}\" - {len(places)} places ({missing} missing coordinates)", file=sys.stderr)
for fmt in formats:
writer = WRITERS[fmt]
path = writer(places, args.out, list_title) if fmt in ("kml", "gpx") else writer(places, args.out)
print(f" wrote {path}", file=sys.stderr)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment