Verifying business locations for directories at scale
Forward geocode, reverse check, and Places-category plausibility-test every listing. Automated triage before a human ever touches the row.
Bad listings are churn. A directory that puts a dentist's office in the middle of a reservoir, or a restaurant whose address resolves to an empty field three miles from where the phone number says it is, erodes subscriber trust at a rate that no marketing campaign recovers. The data quality team finds out about it in a support ticket, not in an alert. By then the user has already told someone.
The core problem is that business-location data is self-reported, aggregated from sources of varying rigour, or purchased in bulk from lead vendors whose incentive is volume, not accuracy. No inbound submission pipeline is clean. No data purchase is clean. Every directory has a percentage of rows where the claimed address is wrong — stale, transposed, fabricated, or simply never verified in the first place.
This post describes a three-signal automated triage pipeline that runs on CSV2GEO's forward geocoding, reverse geocoding, and Places APIs. The pipeline does not replace human review — geocoding cannot prove a business operates at an address, and we will be clear about that throughout. What it does is surface the rows that are almost certainly wrong, and separate them cleanly from the rows that look plausible, so your editorial team spends time on the right fifty records per day rather than a random fifty.
The same pipeline runs as a batch audit on an existing directory using the WEB batch tool, or as an ingestion gate on new submissions. Both are covered below.
Why location quality is a moat, not a nice-to-have
Directory products compete on completeness and trustworthiness. Completeness — number of listings — is hard to defend. A well-funded competitor can buy the same data packages you did and catch up in months. Trustworthiness — location data that is actually correct — compounds. Users who trust your addresses keep coming back. Users who get burned go somewhere else and do not return.
Location errors cluster by data source. If you ingested a bad batch from a lead vendor eighteen months ago, a significant fraction of those records share the same error pattern — addresses that geocode to commercial zones in the wrong city, coordinates that land in rivers, postcodes that do not match the street name. A one-time batch audit finds and quarantines the cluster. Ongoing ingestion gates prevent the next batch from producing the same pattern at scale.
The economic argument for investing in this is simple. The cost of geocoding a listing once at ingestion is fractions of a cent. The cost of a bad listing — user complaint, editorial investigation, manual correction, re-publication — is measured in minutes of human time per row. At any non-trivial directory scale, automated triage pays for itself within weeks.
The three signals
The pipeline uses three signals in sequence. Each is cheap. Together they cover the failure modes that real location data actually exhibits.
Signal 1 — Forward geocode confidence
Take the claimed address string, pass it to the forward geocoding endpoint, and inspect the confidence score on the best result. A high-confidence result — the geocoder found the exact street number on the exact street in the exact city — is a necessary but not sufficient condition for a good listing. A low-confidence result (the geocoder had to fall back to street-level, postcode-level, or city-level centroid) is a strong signal that the address is malformed, ambiguous, or non-existent.
The confidence score is not a binary pass/fail. It is a continuous signal you threshold. A score of 0.9 and above is typically safe to pass without further review. A score below 0.5 almost always indicates a problem — street number missing, city name misspelled, postcode mismatch. The 0.5–0.9 band is where human review adds value. See Geocoding Confidence Scores Explained for the detailed breakdown of what drives the score and how to interpret it per use case.
Signal 2 — Reverse geocode diff
If the listing submission includes coordinates — a lat/lng the submitter claims corresponds to their address — reverse geocode those coordinates and compare the returned address to the claimed address. A genuine business at the address they claim should produce a reverse geocode result that agrees with the forward geocode result within a reasonable tolerance.
Agreement means: same street, same approximate street number, same postcode. Disagreement — particularly a street name mismatch or a postcode that belongs to a different neighbourhood — is a red flag. The coordinates were either entered incorrectly, copied from a different record, or fabricated.
The distance between the forward-geocoded point and the claimed coordinates is the numerical form of this signal. A business that claims "123 High Street, Bristol" but provides coordinates that land four miles away in a residential suburb is not a geocoding rounding error — it is a data problem. See Reverse Geocoding Accuracy and the Distance Meters for how to frame distance thresholds in your triage logic.
Signal 3 — Places plausibility near the point
A forward geocode landing at a high-confidence address, with coordinates that agree, still does not tell you whether the area around that point is plausible for the claimed business category. A claimed restaurant at an address that the forward geocoder resolves confidently is suspicious if a Places category search around that coordinate returns only residential properties, water features, and no food-service establishments within 200 metres.
This is the "storefront in a reservoir" check. A point that resolves cleanly but sits in an area where no similar business category exists, and where the predominant nearby categories are incompatible with the claim, earns a plausibility flag. Not a rejection — legitimate businesses do open in unusual locations — but a human-review flag.
The Places endpoint takes a lat/lng, a radius, and optionally a category filter. Use it without a category filter to see what is actually around the point, then apply your own category-compatibility logic. A claimed hotel surrounded entirely by industrial warehouses is a candidate for review. A claimed solicitor's office in a town centre surrounded by other professional services is not.
The pipeline in code
A complete ingestion-gate implementation in Python and Node. The pipeline assumes each incoming listing has a claimed_address string and optionally a claimed_lat and claimed_lng. It returns a triage_status of pass, review, or fail, and a human-readable reason for every row that does not pass.
Step 1 — Forward geocode the claimed address
import os
import requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def forward_geocode(address: str) -> dict:
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return {"confidence": 0.0, "lat": None, "lng": None, "formatted": None}
best = results[0]
return {
"confidence": best.get("confidence", 0.0),
"lat": best.get("lat"),
"lng": best.get("lng"),
"formatted": best.get("formatted_address"),
}The equivalent in Node:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function forwardGeocode(address) {
const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
const r = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!r.ok) throw new Error(`geocode http ${r.status}`);
const { results = [] } = await r.json();
if (!results.length) return { confidence: 0, lat: null, lng: null, formatted: null };
const best = results[0];
return {
confidence: best.confidence ?? 0,
lat: best.lat,
lng: best.lng,
formatted: best.formatted_address,
};
}And in curl for quick spot-checking during development:
curl -s "https://csv2geo.com/api/v1/geocode" \
--get \
--data-urlencode "q=14 Tottenham Court Road, London, W1T 1JY" \
--data-urlencode "api_key=$CSV2GEO_API_KEY" \
| jq '.results[0] | {confidence, lat, lng, formatted_address}'Threshold the confidence:
def assess_forward(confidence: float) -> str:
if confidence >= 0.9:
return "pass"
if confidence >= 0.5:
return "review"
return "fail"Step 2 — Reverse geocode claimed coordinates and compute the diff
Only run this step when the listing submission includes coordinates. If no coordinates are supplied, skip to Step 3.
import math
def haversine_m(lat1, lng1, lat2, lng2) -> float:
"""Return distance in metres between two WGS-84 points."""
R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lng2 - lng1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def reverse_geocode(lat: float, lng: float) -> dict:
r = requests.get(
f"{API}/reverse",
params={"lat": lat, "lng": lng, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return {"formatted": None}
return {"formatted": results[0].get("formatted_address")}
def assess_coordinate_diff(
forward_lat: float, forward_lng: float,
claimed_lat: float, claimed_lng: float,
) -> tuple[str, float]:
dist_m = haversine_m(forward_lat, forward_lng, claimed_lat, claimed_lng)
if dist_m <= 150:
return "pass", dist_m
if dist_m <= 1_000:
return "review", dist_m
return "fail", dist_mThe 150 m / 1,000 m thresholds are starting points. GPS imprecision, block-level geocoding, and large commercial campuses can produce legitimate offsets of up to 200 m. Calibrate against your own historical data before hardening these numbers.
Step 3 — Places plausibility near the forward-geocoded point
def places_nearby(lat: float, lng: float, radius_m: int = 200) -> list[dict]:
r = requests.get(
f"{API}/places/nearby",
params={"lat": lat, "lng": lng, "radius": radius_m,
"limit": 20, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
return r.json().get("results", [])
def assess_plausibility(
places: list[dict],
claimed_category: str,
) -> str:
"""
Returns 'pass', 'review', or 'fail'.
Simple version: flag if no results at all (likely open land or water),
or if the top nearby categories are incompatible with the claim.
"""
if not places:
return "fail" # nothing nearby — reservoir, field, open water
categories = [p.get("category", "") for p in places]
# Count how many nearby places share the broad category family
match_count = sum(1 for c in categories if claimed_category.lower() in c.lower())
if match_count >= 2:
return "pass"
# Has neighbours but none in the same category family
return "review"The category-matching logic here is intentionally simple. In production you will want a compatibility matrix — a claimed restaurant surrounded by food_and_drink and retail places should pass, but a claimed restaurant whose nearest neighbours are industrial, water_body, and agricultural deserves a flag even if the forward geocode confidence is high.
Step 4 — Combine signals into a triage verdict
def triage_listing(listing: dict) -> dict:
address = listing["claimed_address"]
claimed_lat = listing.get("claimed_lat")
claimed_lng = listing.get("claimed_lng")
category = listing.get("category", "")
# Signal 1
fwd = forward_geocode(address)
s1 = assess_forward(fwd["confidence"])
# Signal 2 — only if coordinates supplied
s2, coord_dist_m = "skip", None
if claimed_lat is not None and claimed_lng is not None and fwd["lat"] is not None:
s2, coord_dist_m = assess_coordinate_diff(
fwd["lat"], fwd["lng"], claimed_lat, claimed_lng
)
# Signal 3
s3 = "skip"
if fwd["lat"] is not None:
places = places_nearby(fwd["lat"], fwd["lng"])
s3 = assess_plausibility(places, category)
# Roll up
signals = [s for s in (s1, s2, s3) if s != "skip"]
if "fail" in signals:
verdict = "fail"
elif "review" in signals:
verdict = "review"
else:
verdict = "pass"
return {
**listing,
"triage_status": verdict,
"geocoded_lat": fwd["lat"],
"geocoded_lng": fwd["lng"],
"geocode_confidence": fwd["confidence"],
"coord_distance_m": coord_dist_m,
"forward_formatted": fwd["formatted"],
"signal_forward": s1,
"signal_coord": s2,
"signal_plausibility": s3,
}Step 5 — Route verdicts to the right queue
The triage_status field drives queue routing:
import csv
def route_listings(triaged: list[dict]) -> None:
buckets = {"pass": [], "review": [], "fail": []}
for row in triaged:
buckets[row["triage_status"]].append(row)
for status, rows in buckets.items():
if not rows:
continue
fields = list(rows[0].keys())
with open(f"queue_{status}.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(rows)
print(f"pass={len(buckets['pass'])} review={len(buckets['review'])} fail={len(buckets['fail'])}")queue_pass.csv goes straight into your publish pipeline. queue_review.csv lands in the editorial team's tool — one row per business, with geocoded coordinates, a confidence score, a distance delta, and a plausibility signal, so the reviewer has everything in front of them before they make a call. queue_fail.csv goes back to the data source for re-submission or deletion.
Auditing an existing directory with the WEB batch tool
For teams that need to run a one-off audit on an existing directory rather than instrument an ingestion gate, the WEB batch tool at csv2geo.com accepts a CSV upload and processes forward geocoding at scale. Credits are consumed per address row — each row in your upload file costs one credit. The output CSV comes back with geocoded coordinates and confidence scores appended to each row.
The practical workflow: export your full listings table as a CSV with at minimum id, name, address. Upload to the WEB batch tool. Download the enriched output. Join it back to your internal database on id. Run the confidence-threshold and flagging logic against the enriched columns in SQL rather than application code. This is the right approach for an initial audit of a million-row directory that predates any API instrumentation.
After the initial audit, instrument the API-based ingestion gate for all new records going forward. The one-time batch clears the historical debt; the gate prevents it from accumulating again.
What the pipeline cannot do — and why that matters
Frame this correctly to your stakeholders before you ship.
Geocoding cannot verify that a business actually operates at an address. It can verify that the address exists, that the coordinates match the address, and that the area around the address is plausible for the claimed category. A fraudulent listing with a correctly spelled address that resolves at high confidence in a plausible location will pass all three signals. The pipeline is triage, not proof.
Confidence scores are not legal determinations. A listing that passes with 0.95 confidence is not guaranteed to be legitimate — it is one that does not exhibit the automated red flags. Human editorial review remains the only way to make a business-trust determination.
Places category data reflects what is geographically nearby, not what is legally permitted. A claimed restaurant that passes the plausibility check because it is near other food establishments is not necessarily licensed to operate. Regulatory compliance is outside the scope of geocoding.
Be transparent with your data suppliers. If you are rejecting rows that fail triage, document the criteria. A fail verdict on a legitimate business caused by a low-quality geocoding result — a badly formatted address that the engine cannot resolve — should have an appeal path. Log the signals per row so the supplier can see exactly why a record was flagged.
These constraints are not a reason to avoid building the pipeline. They are a reason to build it honestly and to document what it does and does not assert.
Cost and throughput at real directory scale
A directory of 500,000 listings, audited once on import and updated quarterly, uses three credits per listing per run:
- 1 credit for the forward geocode
- 1 credit for the reverse geocode (where coordinates are supplied — assume 60% of rows)
- 1 credit for the Places nearby search
That is approximately 1.3 million credits per audit run — well within the middle paid tiers and achievable in a few hours at concurrency limits. See csv2geo.com/pricing/api for the current bracket structure. The free tier (3,000 calls/day, no credit card required) covers a pilot audit of a few thousand rows with enough calls left over to test the Places and reverse geocoding signals side by side.
For concurrency tuning on batch runs of this scale, see Concurrency Tuning — Geocoding Sweet Spot. Running 50 concurrent workers against a 500,000-row dataset without a rate-limit strategy will earn you a backpressure response; the linked post covers the token-bucket approach that keeps throughput high without triggering limiting.
The API has 56 endpoints across geocoding, reverse geocoding, Places, elevation, and address-normalisation surfaces. You are not adding a new vendor for the Places check — if you are already geocoding, the Places call goes through the same key and the same billing account.
Observability — knowing when the pipeline drifts
A triage pipeline that runs unobserved will silently degrade. Two things to instrument from day one.
Track the fail rate per data source. If a particular lead vendor's batch produces a 30% fail rate on the coordinate diff signal, that is a data-quality issue that belongs in a supplier conversation, not just in an editorial queue. Log (source, signal, verdict) per row to your analytics store and build a weekly cohort report. A rising fail rate from a previously-clean source is an early warning that something changed on their side.
Track the review queue clearance rate. If your editorial team is clearing the review queue in one day, the threshold calibration is probably right. If the queue grows faster than it clears, either the thresholds are too aggressive or you need more editorial capacity — both are decisions that need data. Log queue depth and clearance rate alongside the signal distributions.
See Observability for Geocoding Pipelines — Metrics That Matter for the full instrumentation pattern, including which metrics to surface in your APM and which to aggregate into daily reports.
Frequently Asked Questions
Can geocoding prove that a business is real? No. Geocoding can verify that a claimed address exists, that supplied coordinates match that address, and that the surrounding area is plausible for the claimed category. It cannot determine whether a business is currently trading, licensed, or legitimately present at the address. Use the pipeline as triage that surfaces rows for human review — not as a standalone verification system.
What confidence score threshold should I use to auto-publish a listing without human review? There is no universal answer. A good starting point is 0.9 and above for auto-publish, 0.5–0.9 for human review, below 0.5 for fail/re-submission. Calibrate against your own historical data — run the pipeline against a set of listings you have already manually verified and tune the thresholds to maximise precision on the pass verdict for your specific data mix.
What does a Places search return for a point in the middle of a lake or open field? Typically an empty results array, or results dominated by natural, water, or agricultural categories with no addresses nearby. An empty results array is the clearest signal — it means the geocoded point is in an area with no mapped establishments, which is a strong red flag for any business category claim.
Does the pipeline work for non-US addresses? Forward and reverse geocoding cover 63 countries and over 504 million addresses globally. The Places endpoint's coverage varies by region — denser in urban areas, sparser in rural ones. Test your specific country coverage on the free tier before committing to a production pipeline for an international directory.
How do I handle the WEB batch tool output if my directory has custom fields? The WEB batch tool preserves your input columns and appends geocoding outputs. Join the enriched CSV back to your internal database on a stable identifier (your listing ID), then apply the confidence-threshold logic in SQL. Do not try to run business logic inside the batch tool — treat it as a data enrichment step and do the routing in your own system.
What is the right cadence for re-verifying existing listings? Addresses do not change frequently, but businesses move, close, and change category. A quarterly re-verification of the full directory catches most data drift at reasonable cost. Trigger an immediate re-verification when a business submits an update to their listing — any field change is a signal that coordinates may also have changed.
Can I use the API to verify listings in real time as a submitter types? Yes, but design the UX carefully. A forward geocode on every keystroke is expensive and provides no signal until the address is substantially complete. A better pattern is to geocode on form submission — or after a 1.5-second debounce on the postcode field — and surface the confidence signal to the submitter before they complete the form. "We could not verify this address — please check the street number" is better than a silent backend rejection.
Related Articles
- Geocoding Confidence Scores Explained — what the score actually measures and how to use it as a triage signal
- Reverse Geocoding Accuracy and the Distance Meters — how to interpret coordinate-to-address distance in your diff logic
- Benchmarking Geocoding APIs — Honest Numbers — what to measure when evaluating coverage and accuracy for a directory use case
- Caching Geocoding Results — 90% Cost Reduction — how to cache triage results so re-runs on the same addresses cost almost nothing
- Observability for Geocoding Pipelines — Metrics That Matter — what to instrument so you know when the pipeline drifts
---
*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 →