Measuring patient drive-time access to clinics with a routing matrix

Geocode clinic sites, run an N×M routing matrix, draw isolines — find coverage gaps where drive time exceeds your policy threshold. REST patterns inside.

| July 19, 2026
Measuring patient drive-time access to clinics with a routing matrix

Every clinic operator knows the question, even when they have not put numbers on it: are our locations actually reachable by the patients who need them? Proximity in a straight line is not the right unit — a clinic two miles away across a river with no bridge is not two miles away. Drive time on real road networks is the right unit, and once you have it, the gaps become visible.

This post is about building that picture systematically. We will geocode a set of clinic sites, geocode (de-identified) patient localities, run an N×M routing matrix to get drive times from every locality to every clinic, identify patients who exceed a policy-defined threshold to reach the nearest clinic, and then draw isolines around each site for the coverage-map view. The whole thing is REST calls. No desktop GIS software, no specialist data science environment, no six-month procurement cycle.

The audience here is the engineering team building the internal clinic-operations tool — not a compliance function writing a regulatory filing. Drive-time thresholds used throughout this post are the operator's own policy numbers, not regulatory mandates. CSV2GEO does not provide patient data, demographic data, or any regulatory certification — you bring your own records; we compute the spatial relationships.

Why drive time and not straight-line distance

The simplest implementation of proximity analysis is a Euclidean distance calculation: for each patient address, find the nearest clinic by haversine distance. This is wrong in ways that compound.

Urban street grids with one-way restrictions, rivers, motorway junctions that require long detours, and suburban culs-de-sac that back onto each other without connecting — all of these make straight-line distance a systematically misleading signal. A clinic that appears to be the closest by Euclidean distance is sometimes not the closest by drive time when there is a motorway between them with only one access point five miles north.

The practical consequence: if you use Euclidean distance to decide your network is adequate, you will miss pockets of patients who are genuinely underserved. If you use drive time, you will see those pockets. The routing matrix makes this tractable at scale.

Architecture overview

The pipeline has four stages, each a discrete REST call or batch of calls:

  1. Geocode clinic sites — a one-time enrichment of your clinic location master list.
  2. Geocode patient localities — batch geocoding of de-identified postcode centroids or census-area centroids, not individual patient addresses. This is the HIPAA-safe pattern: you compute at the locality level, never geocoding a named individual's home address through an external API.
  3. Routing matrix — an N×M call that returns the drive time (and optionally road distance) from every origin to every destination.
  4. Isolines — one call per clinic site to draw the drive-time reachability boundary for your coverage map.

Stages 1 and 2 are geocoding jobs you almost certainly already have infrastructure for. Stages 3 and 4 are where the analysis-specific logic lives.

For the HIPAA-safe geocoding pattern — logging controls, no_record mode, PHI handling — see HIPAA-compliant geocoding without PHI logs. That post covers the mechanics in detail. Here we will assume you have already geocoded your patient localities at the postcode or census-area level and have a table of (locality_id, lat, lng, population_weight) rows — no individual patient records passing through the API.

HowTo: build the drive-time access pipeline

Step 1: Geocode your clinic sites

Clinic location data typically lives in a property management system or an EHR's organisation record, rarely in a format that includes verified coordinates. The first job is a one-time batch geocode of your site list.

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=742 Evergreen Terrace, Springfield, IL 62704" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

In Python for a batch of sites:

import csv
import os
import time
import requests

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

def geocode_clinics(path_in, path_out):
    with open(path_in) as fin, open(path_out, "w", newline="") as fout:
        reader = csv.DictReader(fin)
        fields = reader.fieldnames + ["lat", "lng", "confidence"]
        writer = csv.DictWriter(fout, fieldnames=fields)
        writer.writeheader()
        for row in reader:
            r = requests.get(
                API,
                params={"q": row["address"], "api_key": KEY},
                timeout=15,
            )
            r.raise_for_status()
            result = r.json()["results"][0]
            row["lat"] = result["lat"]
            row["lng"] = result["lng"]
            row["confidence"] = result["confidence"]
            writer.writerow(row)
            time.sleep(0.05)  # stay comfortably within rate limits

geocode_clinics("clinics.csv", "clinics_geocoded.csv")

Clinic lists are short — a regional network might have 20–200 sites. The geocoding stage runs in seconds and is a one-time operation until you open or close a site. Flag any result with confidence < 0.8 for manual review; a clinic location should be correct to the building, not just the block.

Step 2: Geocode patient localities (not individual addresses)

The unit of analysis here is a postcode centroid or a census-area centroid, not a named patient's home. You aggregate your patient panel to locality level first — "we have N active patients whose postcode centroid is at lat/lng X" — and then geocode the locality centroids. No individual's address leaves your system.

import json
import os
import requests

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

def geocode_localities(postcode_list):
    """
    postcode_list: list of {'postcode': str, 'patient_count': int}
    Returns list with lat/lng appended.
    """
    results = []
    for item in postcode_list:
        r = requests.get(
            API,
            params={
                "q": item["postcode"],
                "country": "US",
                "api_key": KEY,
            },
            timeout=15,
        )
        if r.status_code != 200:
            item["lat"] = None
            item["lng"] = None
        else:
            hit = r.json()["results"][0]
            item["lat"] = hit["lat"]
            item["lng"] = hit["lng"]
        results.append(item)
    return results

If you have thousands of postcodes you want to process faster, the same pattern from the clinic-site geocoding applies: loop with a small sleep or use a thread pool sized to stay within your plan's concurrency limit. See Concurrency tuning for geocoding pipelines for the right thread-count calculation.

The output of this stage is a table: postcode, patient_count, lat, lng. That is all the routing matrix needs. No names, no DOBs, no insurance IDs.

Step 3: Run the N×M routing matrix

This is the analytical centrepiece. The routing matrix endpoint accepts a list of origins and a list of destinations, and returns a matrix of drive times — one cell per (origin, destination) pair.

For a network with 150 patient localities and 30 clinic sites, the matrix is 150 × 30 = 4,500 cells. The API call is one request, and the result tells you, for every locality, the drive time to every clinic.

curl -X POST "https://csv2geo.com/api/v1/matrix" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $CSV2GEO_KEY" \
  -d '{
    "origins": [
      {"id": "loc_001", "lat": 41.8827, "lng": -87.6233},
      {"id": "loc_002", "lat": 41.9742, "lng": -87.9073}
    ],
    "destinations": [
      {"id": "clinic_A", "lat": 41.8781, "lng": -87.6298},
      {"id": "clinic_B", "lat": 41.9621, "lng": -87.6584}
    ],
    "mode": "drive",
    "units": "minutes"
  }'

In Python, building the payload from your locality and clinic tables:

import json
import os
import requests

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

def run_matrix(localities, clinics):
    """
    localities: list of {'id': str, 'lat': float, 'lng': float, 'patient_count': int}
    clinics:    list of {'id': str, 'lat': float, 'lng': float}
    Returns the raw matrix JSON.
    """
    payload = {
        "origins": [
            {"id": loc["id"], "lat": loc["lat"], "lng": loc["lng"]}
            for loc in localities
        ],
        "destinations": [
            {"id": c["id"], "lat": c["lat"], "lng": c["lng"]}
            for c in clinics
        ],
        "mode": "drive",
        "units": "minutes",
    }
    r = requests.post(
        MATRIX_API,
        headers={"X-Api-Key": KEY, "Content-Type": "application/json"},
        json=payload,
        timeout=60,  # large matrices take longer; give them room
    )
    r.raise_for_status()
    return r.json()

matrix = run_matrix(localities, clinics)

And the same in Node:

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

async function runMatrix(localities, clinics) {
  const payload = {
    origins: localities.map(l => ({ id: l.id, lat: l.lat, lng: l.lng })),
    destinations: clinics.map(c => ({ id: c.id, lat: c.lat, lng: c.lng })),
    mode: 'drive',
    units: 'minutes',
  };
  const r = await fetch(MATRIX_API, {
    method: 'POST',
    headers: { 'X-Api-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!r.ok) throw new Error(`matrix http ${r.status}`);
  return r.json();
}

The response contains a durations array (and optionally distances) structured as durations[origin_index][destination_index]. The ordering matches your input arrays.

def find_gaps(matrix_response, localities, clinics, threshold_minutes=30):
    """
    For each locality, find the nearest clinic by drive time.
    Return localities where nearest-clinic time exceeds threshold.
    """
    durations = matrix_response["durations"]
    gaps = []
    for i, loc in enumerate(localities):
        row = durations[i]
        min_time = min(row)
        nearest_clinic_idx = row.index(min_time)
        nearest_clinic = clinics[nearest_clinic_idx]
        if min_time > threshold_minutes:
            gaps.append({
                "locality_id": loc["id"],
                "patient_count": loc["patient_count"],
                "nearest_clinic": nearest_clinic["id"],
                "drive_minutes": round(min_time, 1),
            })
    return sorted(gaps, key=lambda g: -g["patient_count"])

The result is a list of localities sorted by patient count, descending — so the gap with the largest patient population sitting beyond your threshold appears first. This is the number your clinic-operations team needs to decide whether to open a new site, negotiate a telehealth supplement, or add a satellite consulting day.

The threshold in the function above — 30 minutes — is a placeholder. Set it to whatever your organisation has decided is the right access standard for the service type. A specialist oncology consultation has a different reasonable threshold than a routine GP appointment.

Step 4: Draw isolines for the coverage map

The matrix tells you which localities are underserved; the isolines tell you where on the map the coverage boundary actually falls, which is what you need to communicate the analysis to a non-technical audience (a board, a community health committee, a planning team).

An isoline call takes one origin — a clinic coordinate — and a time threshold, and returns a polygon representing the boundary of where you can drive to within that time.

curl -G "https://csv2geo.com/api/v1/isoline" \
  --data-urlencode "lat=41.8781" \
  --data-urlencode "lng=-87.6298" \
  --data-urlencode "mode=drive" \
  --data-urlencode "range=30" \
  --data-urlencode "range_type=time" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

The response is a GeoJSON polygon. Drop it straight into any GeoJSON-compatible mapping library (Leaflet, MapLibre, deck.gl) and you get the shaded coverage ring that shows a board committee "this clinic covers everything in green; the red areas are where patients drive more than 30 minutes."

For a 30-clinic network, this is 30 API calls, one per site, which is trivially fast. Run them concurrently with a small thread pool:

import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

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

def fetch_isoline(clinic, threshold_minutes=30):
    r = requests.get(
        ISOLINE_API,
        params={
            "lat": clinic["lat"],
            "lng": clinic["lng"],
            "mode": "drive",
            "range": threshold_minutes,
            "range_type": "time",
            "api_key": KEY,
        },
        timeout=30,
    )
    r.raise_for_status()
    return clinic["id"], r.json()

def fetch_all_isolines(clinics, threshold_minutes=30, max_workers=5):
    isolines = {}
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {
            pool.submit(fetch_isoline, c, threshold_minutes): c["id"]
            for c in clinics
        }
        for future in as_completed(futures):
            clinic_id, geojson = future.result()
            isolines[clinic_id] = geojson
    return isolines

Each isoline polygon tells you the road-network reachability boundary, not a crude circle. The shape will be irregular — longer fingers along motorways, shorter bulges into dense urban grids, flat edges where a river forms a barrier. That irregular shape is the honest picture of access; the circle approximation conceals exactly the barriers you are trying to expose.

Step 5: Aggregate the findings and report

With the matrix results and isolines in hand, the reporting step is straightforward. The gap list from find_gaps gives you:

  • How many patient localities exceed the threshold
  • How many patients (by count) those localities represent
  • Which clinic is their nearest option and by how much they exceed the threshold

You can weight the gap severity by patient count and drive-time excess to produce a prioritised site-selection shortlist:

def gap_score(gap):
    """Higher score = higher priority for a new site or outreach program."""
    excess_minutes = max(0, gap["drive_minutes"] - 30)
    return gap["patient_count"] * excess_minutes

gaps = find_gaps(matrix, localities, clinics, threshold_minutes=30)
for g in gaps:
    g["score"] = gap_score(g)
gaps.sort(key=lambda g: -g["score"])

The top items in that sorted list are the locations where a new clinic site, a mobile clinic day, or a telehealth supplement would reduce drive-time burden for the most patients by the greatest margin. That is the network planning decision, and the API output is the evidence base for it.

For observability on the pipeline itself — monitoring API call counts, error rates, and whether your matrix jobs are completing reliably — see Observability for geocoding pipelines. A drive-time analysis that silently produces stale or partial results because a matrix call failed mid-run is worse than no analysis at all.

Caching and repeat runs

Drive times change slowly relative to how often you will re-run this analysis. Road networks change when a new motorway section opens or a bridge closes, but those events are infrequent. A reasonable strategy:

  • Clinic coordinates: geocode once; re-geocode only when a site opens, closes, or moves. Cache indefinitely.
  • Locality coordinates: postcode centroids change rarely. Re-geocode quarterly or when your postcode reference file is updated.
  • Matrix results: cache for 30–90 days. Re-run when your clinic list changes or when you want a fresh view of patient distribution.
  • Isolines: cache until a clinic site changes. These are expensive to explain and cheap to compute, so generate once and store the GeoJSON.

Caching geocoding results for 90% cost reduction covers the caching infrastructure pattern in full. The same Redis or DynamoDB key structure applies to matrix and isoline results: key by (origin_hash, destination_hash, threshold, mode) with a TTL that matches your re-run cadence.

What this analysis does and does not tell you

Be precise about the scope when you present results to stakeholders.

What it tells you: the modelled drive time on real road networks from each patient locality centroid to each clinic, under free-flow or time-of-day-adjusted conditions depending on your API configuration. Where that time exceeds your policy threshold, there is a gap.

What it does not tell you: whether patients actually go to the nearest clinic (many do not, for reasons of preference, specialist availability, or insurance network), whether public-transport access matters more than drive time for your patient population, or whether a new site at the centroid of the gap would be financially viable. Those are operational and financial questions that the drive-time data informs but does not answer.

Regulatory scope: CSV2GEO makes no claim about CMS network-adequacy standards, state-level access regulations, or any other regulatory framework. The thresholds in this post are policy parameters that your organisation sets based on your own standards and any applicable regulatory guidance. We provide the spatial computation; the compliance determination is yours.

Cost model for a real network

A regional health system with 40 clinic sites and 800 patient postcodes:

  • Geocoding clinic sites: 40 calls, one-time. Negligible.
  • Geocoding patient localities: 800 calls, quarterly. Still negligible.
  • Routing matrix: 40 destinations × 800 origins = 32,000 cells. Matrix API billing depends on the number of cells; check the current published tiers at csv2geo.com/pricing/api.
  • Isolines: 40 calls per run, one per clinic. Trivially cheap.

The paid tier starts at $54/month for 100,000 calls. The free tier provides 3,000 calls per day, which is sufficient to run the full geocoding stages of this pipeline in development and test the matrix against a small pilot dataset without any spend.

The matrix is the expensive step relative to simple geocoding. Batch your localities to the right granularity — postcode level is usually sufficient; sub-postcode precision adds cost without adding analytical value for network planning.

Frequently Asked Questions

Can I run this analysis against individual patient addresses rather than postcode centroids? Technically yes, but you should not for two reasons. First, geocoding individual patient addresses through an external API is a PHI-handling event that requires appropriate data agreements and controls — see HIPAA-compliant geocoding without PHI logs for how to do it safely if you need to. Second, postcode or census-area centroids are the right analytical unit for network planning; the marginal precision of individual addresses does not change the site-selection decision and adds both cost and compliance complexity.

What drive-time threshold should I use? That is a policy decision your organisation makes, informed by the service type, patient population, and any applicable regulatory guidance you are subject to. Thirty minutes is a number that appears in many access standards documents, but it is not universal. Some specialist services use 60 minutes; some urgent-care networks use 15. The API does not enforce a threshold — you pass whichever number your policy requires.

Does the routing matrix use real-time traffic? The API supports both free-flow routing and time-of-day-adjusted routing depending on your configuration. For network-planning purposes — deciding where to open a new site — free-flow is usually appropriate: you are modelling typical access, not rush-hour worst case. For operational scheduling decisions, time-of-day routing gives a more honest picture.

How large a matrix can I request in a single call? Check the current documented limits at csv2geo.com/pricing/api. For very large networks (thousands of localities against hundreds of clinics), split into sub-matrices and stitch the results in your application layer. The find_gaps function above works identically on a merged result.

What if a postcode centroid or clinic address does not geocode successfully? Treat it as a gap in your data, not a gap in coverage. Flag low-confidence geocodes (below 0.8) and null results for manual review before including them in the matrix run. A failed geocode silently excluded from the analysis will undercount your gap population.

Is there an SDK for Python or Node? SDKs are available for both languages, but the REST API is deliberately simple enough that most production pipelines write a small internal client rather than taking a dependency on a versioned SDK. Every example in this post works with nothing more than requests (Python) or the built-in fetch (Node). The SDK is a convenience, not a requirement.

How does this differ from an ambulance-response isochrone analysis? The spatial computation is the same, but the use case and the interpretation are completely different. Ambulance response analysis is about emergency-service coverage against response-time mandates — a 999/911 dispatch problem. Network-adequacy drive-time analysis is about routine and scheduled access to care — a clinic-planning problem. Different thresholds, different patient populations, different decisions.

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 →