Raw Google Maps data is good data that is not yet ready to use. The same business turns up in three overlapping searches. Phone numbers come back in four different local formats. A twenty-digit identifier gets silently rounded the moment someone opens the file in Excel. Closed businesses sit next to open ones, and half the categories are in German because the pull covered Munich. None of that is a defect in the data — it is what real listings look like — but every one of those things will break a CRM import, inflate a market count or send a sales rep to a shop that shut last spring.
This guide is the cleaning pass we would run on any Google Maps export before it goes anywhere important: what goes wrong, in what order to fix it, and working Python for each step. It assumes an export from the Google Maps Data Scraper, using its column names, but the problems and fixes apply to Maps data from any source.
Why Maps data needs cleaning at all
Three things make it messier than a typical database export. First, coverage requires overlap: a single Maps search returns a capped number of results, so covering a city means many searches by district or category, and adjacent searches return many of the same businesses. Second, the data is owner-entered: names, phone formats, websites and categories are typed by thousands of different business owners with no shared conventions. Third, it is live: businesses open, close, move and rebrand, and the listing reflects whatever state it was in when you collected it.
So the job is not to repair bad data. It is to impose your own conventions on good data that was never written to any.
Step 0: load it without damaging it
Most data-quality problems in Maps exports are created in the first minute, by the tool used to open the file. Two are especially common.
Long identifiers get rounded. place_cid is a number around twenty digits long. Spreadsheet software and most dataframe libraries read it as a floating-point number, which only holds about sixteen significant digits, so the last few digits are silently changed. The value still looks plausible — it is just a different, wrong ID. Open the file once in Excel, save it, and every CID in it is corrupted.
Leading zeros disappear. Postal codes in the north-eastern United States, much of Germany and elsewhere begin with zero. Read as numbers, 02108 becomes 2108. Phone numbers lose their leading zero the same way.
The fix is to read every column as text and decide types yourself afterwards:
import pandas as pd
df = pd.read_csv(
"maps_export.csv",
dtype=str, # nothing gets turned into a number by accident
encoding="utf-8-sig", # tolerates a byte-order mark at the start of the file
keep_default_na=False, # an empty cell stays "" rather than becoming NaN
)
for col in ("total_reviews", "average_rating", "latitude", "longitude", "photos_count"):
df[col + "_num"] = pd.to_numeric(df[col], errors="coerce")
print(len(df), "rows loaded")
If people on your team will open the data in a spreadsheet, export it back out as XLSX with the ID columns formatted as text, or give them a CSV with the long IDs prefixed so they are not parsed as numbers. Prevention is much cheaper than finding out which CIDs were mangled.
Step 1: remove what should not be there
Before deduplicating, drop rows you will never want, so they cannot win a "which duplicate do we keep" decision.
# Closed businesses — keep them only if closures are what you are studying
closed = df["business_status"].str.contains("closed", case=False)
df = df[~closed]
# Rows with no usable identity at all
df = df[(df["google_id"] != "") | (df["place_cid"] != "")]
# Outside the country you meant to search (a cross-border city edge, say)
df = df[df["country_code"].str.upper().isin({"DE", "AT"})]
The closed-business filter deserves a moment's thought rather than a reflex. For a sales list, closed businesses are noise. For property research or market churn analysis they are the most important rows in the file. Decide per use, and if in doubt keep a copy of what you removed.
Step 2: deduplicate on identity, not on names
The single most common mistake in cleaning Maps data is deduplicating on business name and address. It fails in both directions. It misses true duplicates, because the same practice appears as "Dr. Weber Zahnarztpraxis" in one search and "Zahnarztpraxis Dr. med. dent. Weber" in another. And it merges different businesses, because two branches of a chain share a name and sometimes a street.
Every Maps row carries Google's own identifiers, and those are what to deduplicate on. Use google_id first, with place_cid as a fallback for rows where it is missing:
df["key"] = df["google_id"].where(df["google_id"] != "", "cid:" + df["place_cid"])
# When the same place appears more than once, keep the most complete row:
# most reviews, then most non-empty fields.
df["filled"] = (df != "").sum(axis=1)
df = df.sort_values(["total_reviews_num", "filled"], ascending=False)
# Remember every search a place came from before collapsing the duplicates
queries = df.groupby("key")["query"].agg(lambda q: " | ".join(sorted(set(q))))
df = df.drop_duplicates(subset="key", keep="first").copy()
df["found_by"] = df["key"].map(queries)
print(len(df), "unique places")
Two details matter here. Building the key explicitly avoids a subtle trap: if you deduplicate directly on a column that has blanks, every blank counts as the same value, and all your ID-less rows collapse into one. And keeping found_by preserves something useful you would otherwise throw away — a place that appeared in five different category searches is telling you something about what it does.
Step 3: normalise phone numbers
Maps returns phone numbers the way the owner typed them: 030 1234567, +49 30 1234567, (030) 123-4567. A dialler, a CRM and a deduplication step all need one format, and the right one is E.164 — the international form with a leading plus and no spaces. The country_code column tells you how to read numbers that were written locally.
import phonenumbers # pip install phonenumbers
def to_e164(raw, country):
raw = (raw or "").strip()
if not raw:
return ""
try:
n = phonenumbers.parse(raw, (country or "").upper() or None)
except phonenumbers.NumberParseException:
return ""
if not phonenumbers.is_valid_number(n):
return ""
return phonenumbers.format_number(n, phonenumbers.PhoneNumberFormat.E164)
df["phone_e164"] = [to_e164(p, c) for p, c in zip(df["business_phone"], df["country_code"])]
print((df["phone_e164"] == "").sum(), "numbers could not be validated")
Keep the original column alongside the normalised one. A number that fails validation is worth a look rather than a silent deletion — it is often a premium-rate or short code that is valid locally but not in the library's rules.
Step 4: normalise websites and domains
Websites arrive as www.example.de, https://example.de/, http://example.de/kontakt and occasionally a Facebook page. Reduce each to a bare host so they can be compared, grouped and matched against other datasets:
from urllib.parse import urlparse
SOCIAL = ("facebook.com", "instagram.com", "linktr.ee", "wa.me")
def bare_domain(url):
url = (url or "").strip().lower()
if not url:
return ""
if "://" not in url:
url = "http://" + url
host = urlparse(url).netloc.split("@")[-1].split(":")[0]
host = host[4:] if host.startswith("www.") else host
return "" if host.endswith(SOCIAL) else host
df["domain"] = df["business_domain"].map(bare_domain)
Treating a social profile as "no website" is a judgement call, but usually the right one: a business whose only web presence is a Facebook page is, for most purposes, a business without a website.
Step 5: flag likely duplicates the IDs missed
Identifier-based deduplication is reliable but not perfect. Occasionally the same physical business has two listings — one created by the owner, one by a customer or an old address that was never removed. Those have different IDs and survive Step 2.
The useful signal is two listings sharing a normalised phone number or domain and sitting close together. Flag those for review rather than merging them automatically, because the same pattern also describes legitimate cases: a chain's branches sharing a central booking line, or two businesses in one building with a shared reception.
df["lat"] = df["latitude_num"].round(3) # roughly 100 m
df["lng"] = df["longitude_num"].round(3)
suspect = df[df["phone_e164"] != ""].groupby(["phone_e164", "lat", "lng"])["key"].transform("count") > 1
df["possible_duplicate"] = False
df.loc[suspect[suspect].index, "possible_duplicate"] = True
print(df["possible_duplicate"].sum(), "rows to check by hand")
In practice this list is short — a few per thousand — and checking it by eye is quick. Merging it blindly is how a ten-branch chain becomes one row.
Step 6: make categories consistent
Two problems show up when categories are compared across a pull. The category value can be in the local language for non-English countries, so a pull across Germany and the UK has both Zahnarzt and Dentist. And owners choose their primary category freely, so genuinely similar businesses lead with different ones.
For a single-country analysis, the cheapest fix is a small mapping table of the categories you actually see to the labels you want to report on. For multi-country work, segment by the category you searched for — the type column records it — rather than by the free-text primary category. And use sub_types, which lists every category a listing carries, when you need to find businesses that do something as a secondary line.
Step 7: emails, if you enriched
Website enrichment adds email_1 through email_3 and all_emails. Clean them before they reach a sending tool:
- Lowercase and trim, then deduplicate within the row.
- Drop obvious non-contacts: image filenames that matched an email pattern, placeholder addresses, and addresses at the website builder's own domain rather than the business's.
- Prefer addresses on the business's own domain over free-mail ones when choosing a primary.
- Verify before sending. A published address can still bounce, and bounces damage the reputation of the domain you send from. The email verifier runs the check in bulk.
Also decide which addresses are personal data. info@ is not; anna.becker@ is, and that changes what you may do with it. Our legality guide covers where that line falls.
Step 8: stamp it and keep it refreshable
Before the cleaned file goes anywhere, add two columns: when the data was collected, and which run produced it. Business data decays — phone numbers change, places close, owners claim listings — and the date is what tells the next person how far to trust a row.
If you will refresh the data, the identifiers you kept in Step 2 are what make it work. Match the new pull to the old on google_id, then fall back to place_cid, then to phone plus location, because Google does occasionally reissue identifiers. Rows in the old set and not the new are candidates for closure; rows in the new set and not the old are openings, or listings you missed last time.
A checklist to run every time
- Load every column as text; never round-trip IDs through a spreadsheet.
- Decide on closed businesses deliberately, then filter.
- Deduplicate on
google_idwithplace_cidas fallback; build the key so blanks cannot collapse together. - Keep the list of searches each place came from.
- Normalise phones to E.164 using
country_code; keep the original. - Reduce websites to bare domains; treat social-only as no website.
- Flag same-phone, same-location pairs for a human, never auto-merge.
- Map categories, or segment on the searched category.
- Clean and verify emails; know which are personal data.
- Stamp the collection date.
Conclusion
Google Maps data is accurate, current and owner-entered, which is exactly why it needs cleaning: it was never written to anyone's conventions, and coverage of any real area means overlapping searches. The order matters. Load everything as text so identifiers and postcodes survive, filter out what you will never want, deduplicate on Google's own IDs rather than names, then normalise phones to E.164 and websites to bare domains so the data can be matched and dialled. Flag the handful of duplicates that IDs miss for a person to judge, sort out categories, verify emails, and stamp the date. Done once as a script, it runs on every future pull in seconds — and the Google Maps Data Scraper returns every identifier and field this process depends on, so the cleaning has something solid to work with.
Related reading: Google Maps Data Fields Explained, Export Google Maps Data to HubSpot, Salesforce, and Other CRMs, Google Maps Place ID and CID Guide.
Frequently asked questions
Why does Google Maps data need cleaning?
Covering an area takes many overlapping searches, so the same business appears several times. The data is typed by thousands of owners with no shared conventions, and it is live, so businesses open, close and move. Cleaning imposes your conventions on good data.
Why are my place CIDs wrong after opening the file in Excel?
place_cid is about twenty digits long. Spreadsheets and most dataframe libraries read it as a floating-point number, which holds only about sixteen significant digits, so the last digits change silently. Read every column as text.
What should I deduplicate Google Maps data on?
Google's identifiers: google_id first, place_cid as a fallback. Deduplicating on name and address both misses true duplicates under different name variants and merges separate branches of a chain.
How should phone numbers be formatted?
In E.164, the international format with a leading plus and no spaces. Use the country_code column to interpret numbers written in local format, and keep the original column alongside.
Should I merge listings that share a phone number?
Flag them for review instead. Two listings with the same phone at the same location are often duplicates, but the same pattern describes chain branches sharing a booking line and businesses sharing a reception.
Should I remove closed businesses?
For sales lists, yes. For property research or market churn analysis they are the most important rows, so decide per use and keep a copy of anything you remove.