Using POI density to compare candidate locations at scale

Score candidate locations by nearby POI density — rivals, anchors, complements — using the Places API. A cheap first screen before costly market studies.

| August 28, 2026
Using POI density to compare candidate locations at scale

Site-selection decisions often cost six figures before anyone visits the shortlist. The market-study vendor, the foot-traffic data subscription, the demographics report, the broker's time — none of it is cheap and most of it arrives too late to trim the longlist aggressively. By the time you pay for that stack, you are already emotionally committed to two or three candidates and the "study" confirms what your instincts already preferred.

There is a faster first screen. Every candidate location sits inside an existing landscape of businesses, services, and institutions. That landscape is a signal. Rival density tells you how saturated a category already is. Anchor density — transit stops, schools, grocery — tells you how much passive foot traffic the location captures by proximity alone. Complement density — the cafés, gyms, and lunch spots near a coworking space; the schools and parks near a day-care — tells you whether the surrounding environment matches what your target customer wants before and after they visit you.

You can measure all three in a single afternoon using the Places API. The result is not a market study. It is a ranked shortlist that tells you which candidates are worth commissioning one.

What this approach actually measures — and what it does not

Honest framing first, because the failure mode for this kind of analysis is treating a signal as a conclusion.

POI density tells you what businesses have already found viable to open near a given location. That is correlated with foot traffic, consumer demand, and commercial viability — but it is not a direct measure of any of them. A cluster of rival coffee shops can mean high demand that supports one more entrant, or it can mean a saturated market where two operators are already losing money. POI density does not distinguish between those two scenarios.

What CSV2GEO's Places API does not include: foot-traffic data, revenue estimates, demographic breakdowns, or historical opening and closure rates. Do not claim it does. The output of this workflow is a ranked comparative score — a dimensionless number that says "Candidate A has a richer POI environment than Candidate B by this measure." What you infer from that score is your job, not the API's.

With that said, a reliable comparative score, produced repeatably and cheaply before you spend money on the deep analysis, is genuinely useful. Here is how to build one.

The three POI categories that matter for most site selections

Different industries will weight these differently, but the three-bucket framework covers most commercial use cases.

Rivals — businesses in the same category as your candidate site. A high rival density around one candidate and a low rival density around another is the most important single dimension in a site-selection shortlist. Whether high density is good or bad depends on your model (destination retail versus convenience retail, for instance), but you want to see the number, not assume it.

Anchors — high-traffic land uses that generate passive footfall regardless of what you open: transit stops, supermarkets, schools, hospitals, government offices. A site two minutes from a busy commuter interchange has structurally different traffic exposure than one that requires a deliberate trip. Anchor density is the coarsest measure of that exposure.

Complements — businesses whose customers overlap with yours but who are not rivals. A gym near a healthy-food café. A library or co-working space near a specialty coffee shop. A hardware store near a home-improvement franchise. Complement density tells you whether the environment feels "right" to your target customer and whether adjacent spending patterns align with yours.

Score each candidate on all three. Do not add them into a single score until you have thought about the weights — a pure destination retailer weights rival density very differently from a convenience-led operator.

The API surfaces in this workflow

Three things from CSV2GEO are in play.

Forward geocoding/api/v1/geocode — turns your list of candidate addresses into lat/lng pairs. You need coordinates before you can call the Places endpoint. The database covers 504M+ addresses across 63 countries.

Places category search/api/v1/places/nearby — returns places within a radius of a point, filtered by category. This is the core call in the density workflow. You call it once per category bucket per candidate and count the results.

Isolines/api/v1/isoline — returns a drive-time or walk-time polygon around a point. Use this to define your comparison radius honestly. An arbitrary 1,000 m circle is not a comparison of equal access — a candidate next to a ring road covers far more reachable territory in a 10-minute drive than one in a dense urban grid. If you are measuring "what rivals are within 10 minutes' drive of each candidate," the isoline is the right geometry. If time is short, a straight-line radius is acceptable as a first pass, but note the limitation explicitly.

Step 1: Geocode your candidate addresses

Start with a CSV of candidate sites. The minimum you need is an address and an identifier.

curl -s "https://csv2geo.com/api/v1/geocode?q=45+Moorgate+London+EC2R+6AY&api_key=$KEY" \
  | jq '.results[0] | {lat, lng, confidence}'

In Python, batch this sensibly — there is no need to geocode one address at a time when the batch endpoint handles them in bulk:

import csv
import os
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]

def geocode_candidates(path):
    results = []
    with open(path) as f:
        for row in csv.DictReader(f):
            r = requests.get(
                f"{API}/geocode",
                params={"q": row["address"], "api_key": KEY},
                timeout=15,
            )
            r.raise_for_status()
            top = r.json()["results"][0] if r.json()["results"] else None
            results.append({
                "id": row["id"],
                "address": row["address"],
                "lat": top["lat"] if top else None,
                "lng": top["lng"] if top else None,
                "confidence": top["confidence"] if top else None,
            })
    return results

Before proceeding, drop any candidate with a confidence score below 0.7. A low-confidence geocode means the address matched ambiguously — the coordinates you feed into the density analysis may be for a different street or city. False precision at this stage corrupts the whole ranking. See Geocoding confidence scores explained for the full rationale.

Step 2: Define the comparison radius

The most honest approach is to use an isoline — a polygon that reflects actual travel time — rather than a straight-line buffer. That way you are comparing candidates on equal terms: "what rivals are within a 10-minute drive" is a more useful comparison than "what rivals are within 800 metres" when one candidate sits on a motorway and another is in a pedestrian zone.

For a drive-time isoline:

curl -s "https://csv2geo.com/api/v1/isoline?lat=51.5170&lng=-0.0882&mode=drive&time=600&api_key=$KEY" \
  | jq '.geometry.type'

The response is a GeoJSON polygon. You can pass this polygon as a bounding geometry to the Places search if your workflow requires precision. For a large shortlist (20+ candidates), straight-line radius is a reasonable first pass — just be consistent. Pick a single radius in metres and use it for every candidate. 800 m for a walkable urban comparison, 2,000–5,000 m for a car-dependent suburban comparison, are reasonable starting defaults. The number matters less than the consistency.

Step 3: Query POI density per category per candidate

This is the core loop. For each candidate, you make three Places calls — one per category bucket — and record the count of results each returns.

def poi_density(lat, lng, category, radius_m=1000, limit=50):
    """Return the count of POIs matching `category` within `radius_m` of the point."""
    r = requests.get(
        f"{API}/places/nearby",
        params={
            "lat": lat,
            "lng": lng,
            "radius": radius_m,
            "categories": category,
            "limit": limit,
            "api_key": KEY,
        },
        timeout=20,
    )
    if r.status_code == 404:
        return 0  # No results is a valid answer.
    r.raise_for_status()
    return len(r.json().get("results", []))

The limit parameter caps the results per call. If your radius is large and you expect more than 50 rivals in a dense urban area, increase the limit or page through results. For a comparative scoring exercise, a cap is often fine — you care whether a candidate has 3 rivals nearby or 30, not whether the exact count is 34 versus 37.

Call this three times per candidate:

RIVAL_CATEGORY    = "coffee_shop"       # Replace with your actual category.
ANCHOR_CATEGORIES = "subway_station,supermarket,school"
COMPLEMENT_CATEGORIES = "gym,pharmacy,bookshop"

def score_candidate(candidate, radius_m=1000):
    lat, lng = candidate["lat"], candidate["lng"]
    return {
        "id": candidate["id"],
        "address": candidate["address"],
        "rival_count":      poi_density(lat, lng, RIVAL_CATEGORY, radius_m),
        "anchor_count":     poi_density(lat, lng, ANCHOR_CATEGORIES, radius_m),
        "complement_count": poi_density(lat, lng, COMPLEMENT_CATEGORIES, radius_m),
    }

For the Node engineers:

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;

async function poiDensity(lat, lng, categories, radiusM = 1000, limit = 50) {
  const url = new URL(`${API}/places/nearby`);
  url.searchParams.set('lat', lat);
  url.searchParams.set('lng', lng);
  url.searchParams.set('radius', radiusM);
  url.searchParams.set('categories', categories);
  url.searchParams.set('limit', limit);
  url.searchParams.set('api_key', KEY);

  const r = await fetch(url.toString());
  if (r.status === 404) return 0;
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  const body = await r.json();
  return (body.results ?? []).length;
}

async function scoreCandidate(candidate, radiusM = 1000) {
  const [rivals, anchors, complements] = await Promise.all([
    poiDensity(candidate.lat, candidate.lng, 'coffee_shop', radiusM),
    poiDensity(candidate.lat, candidate.lng, 'subway_station,supermarket,school', radiusM),
    poiDensity(candidate.lat, candidate.lng, 'gym,pharmacy,bookshop', radiusM),
  ]);
  return {
    id: candidate.id,
    address: candidate.address,
    rival_count: rivals,
    anchor_count: anchors,
    complement_count: complements,
  };
}

Note the Promise.all — the three category calls are independent, so running them in parallel saves time on a large shortlist. A list of 20 candidates with 3 calls each is 60 HTTP requests; running them sequentially per candidate is slow, running all 60 in parallel will almost certainly hit rate limits. A pool of 6–10 concurrent requests is a reasonable middle ground. See Concurrency tuning — geocoding sweet spot for the mechanics.

Step 4: Normalise the scores across candidates

Raw counts are not directly comparable across category buckets. Ten rivals in 800 m might be extreme for a rural town and unremarkable for central London. Normalise each dimension to a 0–1 scale before you combine them.

def normalise(scores, key):
    values = [s[key] for s in scores]
    lo, hi = min(values), max(values)
    if hi == lo:
        return [0.5] * len(scores)  # All candidates identical on this dimension.
    return [(v - lo) / (hi - lo) for v in values]

def rank_candidates(scores, rival_weight=0.4, anchor_weight=0.35, complement_weight=0.25):
    rival_norm      = normalise(scores, "rival_count")
    anchor_norm     = normalise(scores, "anchor_count")
    complement_norm = normalise(scores, "complement_count")

    ranked = []
    for i, s in enumerate(scores):
        # For rivals: lower normalised score = less saturated = potentially better.
        # Flip the rival dimension so that 1.0 = least saturated.
        rival_score      = 1.0 - rival_norm[i]
        anchor_score     = anchor_norm[i]
        complement_score = complement_norm[i]
        composite = (
            rival_weight * rival_score +
            anchor_weight * anchor_score +
            complement_weight * complement_score
        )
        ranked.append({**s, "composite_score": round(composite, 4)})

    return sorted(ranked, key=lambda x: x["composite_score"], reverse=True)

The rival dimension is intentionally inverted in the example above — less saturation ranks higher. Whether you invert it depends on your model. A destination retailer who benefits from a clustering effect (car dealerships, furniture showrooms, specialist food) would not invert it. Make this explicit in whatever documentation you leave for the next analyst who reads the code.

Step 5: Review the output and define thresholds for advancement

The ranked list is the output of this first screen, not the final answer. The point is to decide which candidates are worth spending money on, not to replace the spending.

A working rule: take the top third of the ranked shortlist into the next stage (isochrone population analysis, site visit, market study). Drop the bottom third without further spend. Treat the middle third as a reserve — advance them if a top candidate fails due diligence.

Present the scores with the raw counts visible, not just the composite. An analyst who sees that Candidate A scored 0.82 composite because it has 0 rivals and 22 anchors in 800 m needs to know that — a retail cluster strategy would reverse that candidate's attractiveness. The composite score is a starting point for discussion, not a decision.

For large shortlists (50+ candidates), the WEB batch tool on the CSV2GEO dashboard lets you run the geocoding and Places queries through a file upload without writing any code. This is the right path for a strategy analyst who needs to score a new longlist every month but does not have an engineering resource to maintain a pipeline. The output is a CSV with the same fields — the normalisation and ranking step still happens in a spreadsheet, but the API call overhead is handled for you.

Practical considerations for production use

Three issues that bite teams who wire this into a recurring process without thinking through the edge cases.

Cache the Places responses. POI density around a candidate location does not change month to month — businesses open and close on quarterly or annual timescales, not daily. Cache the raw results array from each Places call keyed by (lat, lng, category, radius) with a TTL of 14–30 days. On a shortlist you re-score monthly, the second run costs nearly nothing. See Caching geocoding results — 90% cost reduction for the caching pattern — the same logic applies here verbatim.

Use consistent category strings. The Places API uses a defined category taxonomy. If you use cafe for one candidate and coffee_shop for another, you will get incomparable counts. Define your category strings as constants at the top of your script and do not change them mid-analysis. If you are unsure what category strings apply to your use case, do a test call against a location where you know the answer — search near a location where you know there are exactly three coffee shops in 200 m and verify the count before you scale.

Respect the `limit` cap honestly. The Places endpoint returns up to the limit you set. If you set limit=50 and a candidate returns exactly 50 results, you probably hit the cap — the true count is ≥50, not exactly 50. In a comparative context this usually does not matter (both candidates are "saturated"), but if you are computing a score where 50 rivals versus 30 rivals is meaningful, page through the results or increase the limit. The scoring code should log a warning when a candidate returns exactly the limit value.

Rate limiting and observability. A shortlist of 50 candidates with 3 category calls each is 150 requests. Run these with a small concurrency pool and instrument the call durations. If you are seeing more than a handful of 429 responses, the concurrency is too high. See Rate limiting — token bucket vs leaky for the implementation patterns that prevent this.

What this workflow replaces and what it does not

Honest scope, as always.

It replaces: the first three hours of an analyst manually Googling "how many coffee shops are near [address]" for each candidate. It replaces the inconsistent and unscalable approach of relying on one person's local knowledge to assess the competitive environment of 30 candidate sites. It replaces the need to pay for a full market study on every candidate in a longlist.

It does not replace: foot-traffic data (CSV2GEO has none). Revenue estimates (CSV2GEO has none). Demographic catchment analysis — for that, the isochrone population workflow in our separate post on trade-area isochrones is the right complement. Historical business closure rates (CSV2GEO has none). Survey-derived consumer behaviour data.

The output of this workflow is a comparative signal. Feed it into a structured decision process, not a spreadsheet that ends up making the decision on its own.

A complete Python script

The minimal end-to-end script that goes from a CSV of addresses to a ranked shortlist:

import csv, os, time, requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]

RIVAL_CAT      = "coffee_shop"
ANCHOR_CAT     = "subway_station,supermarket,school"
COMPLEMENT_CAT = "gym,pharmacy,bookshop"
RADIUS_M       = 1000
LIMIT          = 50

def get(endpoint, params, retries=3):
    for attempt in range(retries):
        r = requests.get(f"{API}/{endpoint}", params={**params, "api_key": KEY}, timeout=20)
        if r.status_code == 429:
            time.sleep(2 ** attempt)
            continue
        if r.status_code in (404, 400):
            return {}
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"Failed after {retries} attempts")

def geocode(address):
    data = get("geocode", {"q": address})
    top = (data.get("results") or [None])[0]
    if not top or top.get("confidence", 0) < 0.7:
        return None, None
    return top["lat"], top["lng"]

def density(lat, lng, categories):
    data = get("places/nearby", {"lat": lat, "lng": lng,
                                  "radius": RADIUS_M, "categories": categories,
                                  "limit": LIMIT})
    return len(data.get("results", []))

def normalise(vals):
    lo, hi = min(vals), max(vals)
    if hi == lo:
        return [0.5] * len(vals)
    return [(v - lo) / (hi - lo) for v in vals]

rows = []
with open("candidates.csv") as f:
    for row in csv.DictReader(f):
        lat, lng = geocode(row["address"])
        if lat is None:
            print(f"Skipping {row['address']} — low confidence geocode")
            continue
        rows.append({
            "id": row["id"], "address": row["address"],
            "lat": lat, "lng": lng,
            "rivals":      density(lat, lng, RIVAL_CAT),
            "anchors":     density(lat, lng, ANCHOR_CAT),
            "complements": density(lat, lng, COMPLEMENT_CAT),
        })
        time.sleep(0.1)  # Gentle throttle.

rival_n = normalise([r["rivals"]      for r in rows])
anch_n  = normalise([r["anchors"]     for r in rows])
comp_n  = normalise([r["complements"] for r in rows])

for i, row in enumerate(rows):
    row["composite"] = round(
        0.4 * (1 - rival_n[i]) + 0.35 * anch_n[i] + 0.25 * comp_n[i], 4
    )

rows.sort(key=lambda x: x["composite"], reverse=True)

with open("ranked_candidates.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["id","address","rivals","anchors","complements","composite"])
    writer.writeheader()
    writer.writerows(rows)

print("Done. Top candidates:")
for row in rows[:5]:
    print(f"  {row['id']:20s} composite={row['composite']:.4f} "
          f"rivals={row['rivals']} anchors={row['anchors']} complements={row['complements']}")

Drop in candidates.csv with columns id,address and you have a ranked output in under a minute for a shortlist of 20 candidates.

Frequently Asked Questions

What categories does the Places API support? The API uses a structured category taxonomy covering retail, food and drink, transport, education, healthcare, services, and more. The full list is in the API documentation. Before scoring a new use case, run a test call near a location where you already know the landscape and verify the category string returns what you expect.

How many places does the database cover? The Places database covers 72M+ places globally. Coverage density varies by geography — major urban areas are comprehensive, rural areas are thinner. For site-selection work in major metropolitan markets, coverage is more than sufficient for a relative comparison. For rural or emerging-market locations, treat results as a lower bound.

Can I use a drive-time polygon instead of a straight-line radius? Yes. Use the /api/v1/isoline endpoint to generate a drive-time or walk-time polygon, then pass it as the bounding geometry for the Places search. This is more accurate for comparing candidates with different road-network characteristics. For a quick first-pass ranking on a large shortlist, a consistent straight-line radius is a reasonable simplification.

Is POI density a reliable predictor of site performance? It is a signal, not a predictor. Density correlates with commercial viability because businesses cluster where demand exists — but the correlation is noisy. Two sites with identical POI profiles can have very different performance due to lease economics, brand awareness, local competition dynamics, and dozens of other factors. Use the score to filter a longlist, not to make a final decision.

What is the cost of scoring a shortlist of 30 candidates? Three Places API calls per candidate (one per category bucket) plus one geocoding call per candidate = 4 credits per candidate = 120 credits for 30 candidates. On the free tier (3,000 calls/day), an entire analysis run costs nothing. On a paid plan starting from $54/month, 120 credits is a rounding error. The expensive part of site selection is not the data retrieval.

Does the result change if I re-run the analysis next month? Slightly. The Places database is updated as businesses open and close, so a rival that opened last week might appear in next month's run. For most site-selection timescales (decisions made over weeks or months), the relative ranking is stable. Cache results with a 14–30 day TTL and re-run when a new candidate enters the shortlist rather than refreshing the entire set daily.

Can I weight the three buckets differently for different retail formats? Absolutely — and you should. The weights in the example script (rival=0.4, anchor=0.35, complement=0.25) are illustrative defaults. A destination retailer (car dealerships, furniture) might weight rival density positively rather than negatively, because category clustering drives discovery. A convenience-led format weights anchors very highly. Encode the weights as named constants and document the rationale in your analysis notes.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →