Finding comparable properties within a radius — geocode, then filter

Geocode your subject property and comps list, then filter by Haversine or drive distance. A practical guide for appraisers and PropTech engineers.

| July 07, 2026
Finding comparable properties within a radius — geocode, then filter

Every residential appraisal hinges on the same mechanical problem: find recent sales that are close enough to the subject property to count as evidence, and far enough from each other to provide a representative spread. The guidance is usually "within a one-mile radius for urban markets, two miles for suburban, broader for rural" — and that guidance is worth exactly nothing unless you can actually measure the distance between addresses.

The data already exists. County assessor records, MLS exports, your own internal sales history — most appraisal shops have some version of a comps database. The missing ingredient is coordinates. Without a latitude and longitude attached to both the subject property and every candidate comp, "within a radius" is a hand-wave.

This post walks through the full pattern: geocode the subject address, geocode your comps list in batch, run a Haversine filter on the client side, optionally upgrade to drive distance for markets where road geometry matters, and cache the results so you are not burning credits on addresses that have not moved. No proprietary comps database, no "find nearby sales" endpoint — CSV2GEO provides coordinates and you write a handful of lines of arithmetic. By the end you will have a working Python script and a clear picture of where the failure modes are.

Why client-side distance filtering is the right architecture

The instinct when building this is to reach for a spatial database query: load all the comps into PostGIS, cast them to a geometry column, run ST_DWithin. That is absolutely the right answer if you have a PostGIS instance already. But a lot of appraisal shops do not have a PostGIS instance. They have a CSV, a Python environment, and a deadline.

The Haversine formula is six lines of arithmetic. It runs in microseconds per pair on modern hardware. A comps list of 50,000 rows filtered by a 2-mile radius around a single subject property takes roughly 10 milliseconds in pure Python with no library dependencies. That is fast enough that you can run it synchronously in a web request, let alone in a nightly batch job.

The right architecture for most appraisal pipelines is therefore: use the geocoding API to produce coordinates, do the distance filtering yourself. CSV2GEO does not sell a property sales database and has no "find nearby properties" endpoint — nor should it. Your comps data is proprietary, curated, and subject to MLS licensing terms that a third party cannot honour on your behalf. The coordinates are the only thing you need from the API.

What you are actually building

The workflow has four parts.

  1. Geocode the subject address — one API call, returns lat/lng plus a confidence score.
  2. Geocode the comps list — batch call, returns lat/lng for every comp; comps that have already been geocoded are pulled from your local cache, not re-queried.
  3. Filter by distance — Haversine arithmetic on the client side; no API call, no round-trip.
  4. Optionally upgrade to drive distance — for markets where a highway or a river makes straight-line distance misleading, swap the Haversine filter for a distance-matrix call.

Steps 1 and 2 are network calls. Steps 3 and 4 are local computation. The architecture means your distance-filtering logic is decoupled from your geocoding vendor — you can swap the geocoding step without touching the filter code, and you can change your radius definition without touching the geocoding code.

Step 1: Geocode the subject property

A single address lookup is the simplest API call in the stack. The response gives you lat/lng and a confidence score that tells you how precisely the geocoder resolved the address.

curl -s "https://csv2geo.com/api/v1/geocode" \
  --get \
  --data-urlencode "q=42 Wallaby Way, Sydney, NSW 2000" \
  --data-urlencode "api_key=$CSV2GEO_KEY" \
  | jq '{lat, lng, confidence}'

In Python:

import os, requests

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

def geocode_address(address: str) -> dict:
    r = requests.get(
        f"{API}/geocode",
        params={"q": address, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    data = r.json()
    if not data.get("results"):
        raise ValueError(f"No result for: {address}")
    return data["results"][0]  # {lat, lng, confidence, formatted_address, …}

subject = geocode_address("123 Elm Street, Denver, CO 80203")
print(subject["lat"], subject["lng"], subject["confidence"])

The confidence score is the first place this pipeline can fail silently. A score of 0.95 means the geocoder matched to the door; a score of 0.6 means it matched to the street centreline and placed the coordinate somewhere in the middle of the block. For a subject property, low confidence is a blocker — if the coordinate is 80 metres off, your radius ring is centred on the wrong point and every distance calculation downstream is wrong. The rule of thumb: confidence below 0.75 should pause the automated workflow and flag the address for manual verification. See Geocoding Confidence Scores Explained for a full treatment of what these thresholds mean in practice.

In Node:

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

async function geocodeAddress(address) {
  const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`Geocode failed: ${r.status}`);
  const data = await r.json();
  if (!data.results?.length) throw new Error(`No result for: ${address}`);
  return data.results[0];
}

Step 2: Geocode the comps list in batch

Your comps list is a CSV. Geocode it in batches of up to 100 addresses per call using the batch geocoding endpoint. The batch endpoint costs the same per address as the single-address endpoint — you are just saving round-trip overhead.

import csv, json, time

BATCH_SIZE = 100  # adjust to your plan's rate limit headroom

def geocode_batch(addresses: list[str]) -> list[dict]:
    """Send up to BATCH_SIZE addresses, return results in same order."""
    payload = {"addresses": addresses, "api_key": KEY}
    r = requests.post(
        f"{API}/geocode/batch",
        json=payload,
        timeout=60,
    )
    r.raise_for_status()
    return r.json()["results"]

def chunks(seq, n):
    for i in range(0, len(seq), n):
        yield seq[i:i + n]

def geocode_comps(path: str) -> list[dict]:
    with open(path) as f:
        rows = list(csv.DictReader(f))

    addresses = [f"{r['address']}, {r['city']}, {r['state']} {r['zip']}" for r in rows]
    results = []
    for batch in chunks(addresses, BATCH_SIZE):
        results.extend(geocode_batch(batch))
        time.sleep(0.2)  # polite pause; adjust to your plan

    # Merge coordinates back onto the original rows
    for row, geo in zip(rows, results):
        row["lat"] = geo.get("lat")
        row["lng"] = geo.get("lng")
        row["confidence"] = geo.get("confidence")

    return rows

One practical note on scale: a comps database of 20,000 addresses across a metro area takes 200 batch calls and runs comfortably within the free tier's 3,000 calls/day budget if you spread it across two days. Once geocoded and cached locally, you never pay for those coordinates again — addresses do not move. The caching pattern that makes this work is covered in full at Caching Geocoding Results — 90% Cost Reduction; the short version is: write lat/lng to a SQLite or Postgres table keyed by the normalised address string, and check the cache before every API call.

Comps that come back with confidence < 0.7 should be kept in the dataset but flagged — a low-confidence geocode can still be a valid comp once a human verifies the coordinate. Comps where the geocoder returned no result at all (the address does not resolve) should be written to a rejection log for manual address correction.

Step 3: Filter by straight-line distance (Haversine)

With both the subject property and every comp now carrying lat/lng, the distance filter is pure arithmetic. No API call, no library dependency — just the Haversine formula.

import math

def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
    R = 6371.0  # Earth radius, km
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlambda = math.radians(lng2 - lng1)
    a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
    return 2 * R * math.asin(math.sqrt(a))

def filter_by_radius(subject: dict, comps: list[dict], radius_miles: float) -> list[dict]:
    radius_km = radius_miles * 1.60934
    results = []
    for comp in comps:
        if comp.get("lat") is None or comp.get("lng") is None:
            continue  # skip ungeocoded comps
        d = haversine_km(subject["lat"], subject["lng"], comp["lat"], comp["lng"])
        if d <= radius_km:
            comp["distance_miles"] = round(d / 1.60934, 3)
            results.append(comp)
    return sorted(results, key=lambda c: c["distance_miles"])

And the equivalent in Node:

function haversineKm(lat1, lng1, lat2, lng2) {
  const R = 6371;
  const toRad = d => d * Math.PI / 180;
  const dPhi = toRad(lat2 - lat1);
  const dLambda = toRad(lng2 - lng1);
  const a = Math.sin(dPhi / 2) ** 2
    + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLambda / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(a));
}

function filterByRadius(subject, comps, radiusMiles) {
  const radiusKm = radiusMiles * 1.60934;
  return comps
    .filter(c => c.lat != null && c.lng != null)
    .map(c => ({ ...c, distance_miles: haversineKm(subject.lat, subject.lng, c.lat, c.lng) / 1.60934 }))
    .filter(c => c.distance_miles <= radiusMiles)
    .sort((a, b) => a.distance_miles - b.distance_miles);
}

Haversine assumes a spherical Earth. The real Earth is an oblate spheroid, and Haversine underestimates distances near the poles by a fraction of a percent. For appraisal work in the contiguous United States, this error is less than 0.3% — well within the noise of any radius-based comp selection methodology. You do not need the Vincenty formula unless you are doing geodetic surveying.

The one legitimate criticism of straight-line distance for comps is that it ignores road geometry. A subject property on one side of a river and a comp on the other side might be 0.4 miles as the crow flies and 4 miles by road. That matters for marketability — buyers care about drive time, not Euclidean distance. If your market has that kind of physical discontinuity, Step 4 is for you.

Step 4: Upgrade to drive distance where road geometry matters

For markets where a body of water, a mountain range, or a limited-access highway creates meaningful asymmetry between straight-line and drive distance, swap the Haversine filter for a two-stage approach: Haversine as a coarse pre-filter, then a distance-matrix call for the survivors.

The logic: run Haversine at 1.5× your target radius to get a candidate shortlist (say, 200 comps for a 1-mile target), then call the distance matrix endpoint with those 200 candidates to get actual road distances. Filter the matrix output to your real target radius. This keeps the API call count low — you are not sending all 20,000 comps to the matrix endpoint, just the Haversine-plausible subset.

def filter_by_drive_distance(subject: dict, comps: list[dict], radius_miles: float) -> list[dict]:
    # Stage 1: Haversine pre-filter at 1.5× the target radius
    candidates = filter_by_radius(subject, comps, radius_miles * 1.5)
    if not candidates:
        return []

    # Stage 2: drive distance via distance matrix
    origins = f"{subject['lat']},{subject['lng']}"
    destinations = "|".join(f"{c['lat']},{c['lng']}" for c in candidates)

    r = requests.get(
        f"{API}/distance_matrix",
        params={
            "origins": origins,
            "destinations": destinations,
            "api_key": KEY,
        },
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["rows"][0]["elements"]  # one origin, N destinations

    radius_km = radius_miles * 1.60934
    results = []
    for comp, element in zip(candidates, rows):
        if element.get("status") != "OK":
            continue
        drive_km = element["distance"]["value"] / 1000.0
        if drive_km <= radius_km:
            comp["drive_distance_miles"] = round(drive_km / 1.60934, 3)
            results.append(comp)

    return sorted(results, key=lambda c: c["drive_distance_miles"])

Two things to note about this pattern. First, the distance matrix counts each origin-destination pair as one credit — 200 candidates is 200 credits, not one. The Haversine pre-filter is doing real work: it reduces the 20,000-comp dataset to 200 before the expensive call. Second, drive distance can cut both ways — some comps that Haversine would exclude are actually driveable neighbours, while some that Haversine would include are across a bridge with a 45-minute commute. Both corrections produce a more defensible appraisal.

Step 5: Assemble the final output

The filtered comps list drops into whatever output format your workflow requires. For most appraisal tools, that is either a CSV for import into the appraisal management software, or a JSON payload for a web front end. Here is the assembly step:

import csv as csv_mod

def write_comps_report(subject: dict, comps: list[dict], output_path: str):
    if not comps:
        print("No comps found within radius.")
        return

    fieldnames = list(comps[0].keys())
    with open(output_path, "w", newline="") as f:
        writer = csv_mod.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(comps)

    print(f"Subject: {subject.get('formatted_address')} "
          f"({subject['lat']:.5f}, {subject['lng']:.5f})")
    print(f"Comps written: {len(comps)} → {output_path}")

# Putting it all together
subject = geocode_address("4521 Vine Street, Cincinnati, OH 45217")
all_comps = geocode_comps("sales_history.csv")
nearby = filter_by_radius(subject, all_comps, radius_miles=1.0)
write_comps_report(subject, nearby, "comps_report.csv")

The output CSV has every column from your original sales history, plus lat, lng, confidence, and distance_miles. It is ready to open in Excel, import into an appraisal form, or pass to the next step in a larger pipeline.

Where this breaks and what to do about it

Three failure modes that you will encounter in production, each with a fix.

Geocoder returns a ZIP-centroid instead of a parcel coordinate. This happens with rural addresses, new developments that have not propagated through address databases yet, and incorrectly formatted input. The signal is a confidence score below 0.7 and a coordinate that lands in the middle of a field or at a post office. Fix: flag low-confidence results, log the raw input address, and route them to a manual verification step. Do not silently include a centroid coordinate in the filter — a 500-metre offset in the subject property coordinate shifts the entire radius ring, potentially excluding real comps and including irrelevant ones.

Comps with the same address as multiple sales. A property that sold three times in five years appears three times in your CSV but has only one correct coordinate. The geocoder returns the same lat/lng for each row — that is correct. The Haversine filter returns all three rows, which is also correct. Deduplication by parcel (or by address, if you have no parcel identifier) is your responsibility; the distance filter does not know these are the same building.

Drive-distance matrix returns `"status": "NOT_FOUND"` for some pairs. This happens when a destination coordinate is in a location the routing engine cannot associate with a road network — a property accessed by an unclassified track, a parcel entirely within a gated community with no public road access, or a coordinate that landed in water due to a geocoding error. Handle the NOT_FOUND status explicitly: keep the comp in the output but tag it drive_distance_miles: null so the appraiser knows the routing failed and can verify manually.

Caching: the thing that makes this affordable at scale

An appraisal shop running 200 orders per month, each against a 20,000-comp database, would naively spend 200 × 20,000 = 4,000,000 geocoding credits per month. In practice, the vast majority of those 20,000 comps are the same properties appearing across dozens of orders — once geocoded, the coordinate is stable for the life of the property. A local cache keyed by normalised address string reduces that 4,000,000-credit figure to roughly the number of unique comps that appear in that month's orders for the first time.

The simplest possible cache is a SQLite table:

import sqlite3

DB = sqlite3.connect("geocode_cache.db")
DB.execute("""
    CREATE TABLE IF NOT EXISTS cache (
        address TEXT PRIMARY KEY,
        lat REAL,
        lng REAL,
        confidence REAL,
        cached_at TEXT DEFAULT (datetime('now'))
    )
""")
DB.commit()

def cached_geocode(address: str) -> dict | None:
    row = DB.execute(
        "SELECT lat, lng, confidence FROM cache WHERE address = ?",
        (address.strip().lower(),)
    ).fetchone()
    if row:
        return {"lat": row[0], "lng": row[1], "confidence": row[2]}
    result = geocode_address(address)
    DB.execute(
        "INSERT OR REPLACE INTO cache (address, lat, lng, confidence) VALUES (?, ?, ?, ?)",
        (address.strip().lower(), result["lat"], result["lng"], result["confidence"])
    )
    DB.commit()
    return result

Replace the geocode_address call in the comps loop with cached_geocode and your effective API spend drops dramatically from the second order onward. The full argument for aggressive geocoding caches — including TTL strategy, cache key normalisation, and the accounting math — is in Caching Geocoding Results — 90% Cost Reduction.

Cost arithmetic for a real appraisal shop

A shop doing 200 residential appraisals per month, each pulling comps from a 20,000-address sales database, with a warm cache after the first month:

  • Month 1 (cold cache): geocode 20,000 unique comps once = 20,000 credits. Plus 200 subject-property geocodes = 200 credits. Total: ~20,200 credits.
  • Month 2+ (warm cache): only new sales that entered the database that month need geocoding. Assume 500 new comps per month = 500 credits. Plus 200 subject properties = 200 credits. Total: ~700 credits per month.
  • Distance matrix (optional): if you run the drive-distance upgrade on every order at 200 candidates per order = 40,000 credits per month. This is the expensive step — Haversine-only is essentially free.

At paid pricing from $54/month for 100,000 calls, a shop running the full drive-distance pipeline comfortably fits within a single pricing bracket. The free tier (3,000 calls/day) is sufficient for piloting with a few dozen orders and a partial comps list. See csv2geo.com/pricing/api for current bracket details.

Frequently Asked Questions

Does CSV2GEO provide a comps database or a "find nearby sales" endpoint? No, and deliberately so. Your sales data is proprietary and subject to MLS licensing terms that a third party cannot handle on your behalf. CSV2GEO provides the coordinates — you supply the comps. The distance filtering is client-side arithmetic you own and control.

What radius should I use for comp selection? That is a methodology question, not a technical one, and the answer depends on market density and property type. Urban markets typically use half a mile to one mile; suburban markets one to two miles; rural markets two miles or broader, sometimes with no radius at all if the market is thin enough. CSV2GEO makes it trivial to run the same comps list at multiple radii — 0.5, 1.0, and 2.0 miles — and let the appraiser choose the appropriate set.

How accurate is the geocoding for rural addresses? Rural address accuracy varies. Roads in rural areas are often unclassified tracks that do not appear in address databases, and new subdivisions frequently have addresses that have not propagated yet. The confidence score is the signal: below 0.7 means the geocoder resolved to a street centreline or ZIP centroid, not a parcel. Flag these for manual review; do not silently include a centroid coordinate as if it were a parcel coordinate.

Can I geocode Canadian or international addresses for this workflow? CSV2GEO covers 39 countries for address geocoding. The Haversine filter works identically regardless of country. The distance-matrix endpoint's road-network coverage follows the geocoding coverage. International comps lists work through exactly the same pipeline described in this post.

What is the difference between straight-line and drive distance for comp selection? Straight-line (Haversine) distance is the crow-flies distance between two coordinates. Drive distance follows the road network and accounts for routing constraints like rivers, highways, and one-way streets. For most suburban and exurban markets, the two are close enough that Haversine is sufficient. For markets with significant physical barriers — coastal properties separated by water, hillside properties with limited road access — drive distance is more defensible in the appraisal report.

How do I handle a subject property that comes back with low confidence? Stop the automated workflow and flag the address for manual review. A low-confidence subject coordinate shifts the entire radius ring and corrupts every distance calculation in the report. Manual options include: verify the address against county assessor records, use a reverse geocode to confirm the coordinates, or have the client supply GPS coordinates directly if the address is truly unresolved.

Is the geocoding accurate enough for a USPAP-compliant appraisal report? CSV2GEO geocodes from a database of 461M+ addresses across 39 countries. For standard residential addresses with a confidence score above 0.8, the coordinate is placed at or within a few metres of the parcel. However, USPAP compliance is an appraiser's professional responsibility, not an API guarantee. The geocoding gives you defensible coordinates to work with; the appraiser verifies the comp selection methodology and certifies the report.

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 →