Siting food distribution points by travel time, not guesswork
Use a routing matrix and isolines to find where a new food distribution point cuts worst-case travel time. REST patterns, Python, real API calls.
Food banks do not lack for data. Every pantry visit, every box collected, every household registered — most regional food banks are accumulating service records at a pace that would have looked like a proper data warehouse ten years ago. The problem is not the data. The problem is turning it into a siting decision.
The usual approach: a programme director draws a radius on a paper map, a volunteer cross-references ZIP code populations from a public source, and the board approves the site that "feels like an underserved area." That is not wrong — experienced local staff carry a lot of ground truth. But it leaves unanswered a question that funders now routinely ask: *by how much does this new site reduce the burden on the people you serve, and how did you measure that?*
This post answers that question with real API calls. The workflow uses CSV2GEO's batch geocoding, N×M routing matrix, isolines, and boundaries endpoints to measure travel time from each aggregated service area to the nearest existing site, then test candidate sites against that baseline. Everything runs from your own service records — no demographics endpoint, no poverty index, no third-party population data. The analysis is grounded in who you actually serve and how far they currently travel.
A companion post, Mapping served populations for grant reporting, covers the aggregation step — how to turn precise household records into area-level centroids without ever sending PII to an external API. That step is a prerequisite; this post picks up after you have the aggregated centroids in hand.
What the workflow actually does
Before reaching for code it is worth being clear about the shape of the analysis.
You have two sets of points: your existing sites (the pantries and distribution points you already operate) and your served-area centroids (the anonymised centre-of-mass of each postcode or census tract cluster that appears in your service records). The centroid carries no household addresses — it is the weighted midpoint of a group, and the grouping step happens entirely on your own hardware before you call the API. See the companion post for how to produce those centroids.
The workflow runs in four passes.
- Geocode your existing sites and your candidate sites. You may already have coordinates; if not, one batch geocoding call per group produces them.
- Routing matrix — N×M, where N is the served-area centroids and M is the existing sites. This gives you, for every centroid, the travel time to the nearest current site. That is your baseline.
- Candidate testing — re-run the matrix with each candidate site added to M in turn. For each candidate, the metric is: how much does the worst-case travel time fall, and how many centroids switch to this site as their nearest?
- Isolines — draw the 15-minute and 30-minute walking and driving catchments around the winning candidate. This is the map that goes in the grant proposal.
The routing matrix is the computational heart. CSV2GEO's matrix endpoint supports both drive and walk modes — and for food distribution specifically, walk mode matters. A meaningful share of clients who use pantry services do not have access to a car, and a site that is six minutes by car but forty minutes on foot is not genuinely accessible to them.
Step 1: Geocode your existing and candidate sites
You probably have site addresses, not coordinates. The geocoding call is straightforward — one address per row, batched. The free tier covers 3,000 calls per day, which is enough for any realistic number of pantry sites or candidate locations.
import os
import requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode_addresses(addresses: list[str]) -> list[dict]:
"""Geocode a list of address strings. Returns list of {lat, lng, confidence}."""
results = []
for addr in addresses:
r = requests.get(
f"{API}/geocode",
params={"q": addr, "api_key": KEY},
timeout=20,
)
r.raise_for_status()
hits = r.json().get("results", [])
if hits:
top = hits[0]
results.append({
"address": addr,
"lat": top["lat"],
"lng": top["lng"],
"confidence": top.get("confidence", 0),
})
else:
results.append({"address": addr, "lat": None, "lng": None, "confidence": 0})
return results
existing_sites = geocode_addresses([
"47 Maple Street, Springfield",
"901 River Road, Springfield",
])
candidate_sites = geocode_addresses([
"14 Commerce Park, West Springfield",
"330 Eastern Avenue, North Springfield",
"82 Mill Lane, Riverside",
])Flag anything with confidence < 0.7 for manual review before it goes into the matrix. A misplaced site coordinate will silently corrupt every travel-time calculation downstream — see the discussion in Reverse-geocoding accuracy and the distance meters for why confidence-gating matters here.
In curl, a single site looks like:
curl -G "https://csv2geo.com/api/v1/geocode" \
--data-urlencode "q=47 Maple Street, Springfield" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"Step 2: Build the baseline routing matrix
With site coordinates in hand and served-area centroids already produced by the aggregation step, you can call the routing matrix. The endpoint takes a list of origins and a list of destinations and returns a travel time (and optionally distance) for every origin-destination pair.
For the baseline, origins are your served-area centroids and destinations are your existing sites. Because walking access matters, run both modes separately.
def routing_matrix(origins: list[dict], destinations: list[dict], mode: str) -> list[list[float]]:
"""
Returns an N×M matrix of travel times in seconds.
origins and destinations: lists of {lat, lng}
mode: 'drive' or 'walk'
"""
origin_str = "|".join(f"{o['lat']},{o['lng']}" for o in origins)
dest_str = "|".join(f"{d['lat']},{d['lng']}" for d in destinations)
r = requests.get(
f"{API}/matrix",
params={
"origins": origin_str,
"destinations": dest_str,
"mode": mode,
"api_key": KEY,
},
timeout=60,
)
r.raise_for_status()
return r.json()["durations"] # list of N lists, each of length M, seconds
# Load your centroids from the aggregation step
import json
with open("served_area_centroids.json") as f:
centroids = json.load(f) # [{lat, lng, area_id, household_count}, ...]
drive_matrix = routing_matrix(centroids, existing_sites, mode="drive")
walk_matrix = routing_matrix(centroids, existing_sites, mode="walk")The same in Node if your pipeline is JavaScript-first:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function routingMatrix(origins, destinations, mode) {
const originStr = origins.map(o => `${o.lat},${o.lng}`).join('|');
const destStr = destinations.map(d => `${d.lat},${d.lng}`).join('|');
const url = `${API}/matrix?origins=${encodeURIComponent(originStr)}` +
`&destinations=${encodeURIComponent(destStr)}` +
`&mode=${mode}&api_key=${KEY}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`matrix ${mode}: http ${r.status}`);
const body = await r.json();
return body.durations; // N × M array of seconds
}Once you have the matrix, computing the baseline nearest-site travel time per centroid is a simple row-wise minimum:
def nearest_times(matrix: list[list[float]]) -> list[float]:
return [min(row) for row in matrix]
baseline_drive = nearest_times(drive_matrix)
baseline_walk = nearest_times(walk_matrix)
print(f"Worst-case drive: {max(baseline_drive)/60:.1f} min")
print(f"Worst-case walk: {max(baseline_walk)/60:.1f} min")
print(f"Median drive: {sorted(baseline_drive)[len(baseline_drive)//2]/60:.1f} min")That worst-case walk number is the one that matters most. The aggregate of household counts multiplied by individual travel times gives you a weighted burden score — a number you can put in a grant application as "estimated total household-minutes of travel per distribution day under current configuration." We will come back to that.
Step 3: Test candidate sites
For each candidate site, rebuild the destination list to include all existing sites plus the candidate, rerun the matrix, and compare. You are looking for two things: reduction in the worst-case travel time, and the number of centroids that switch to the candidate as their nearest site (the candidate's effective catchment).
def evaluate_candidate(
centroids, existing_sites, candidate: dict, mode: str
) -> dict:
expanded = existing_sites + [candidate]
matrix = routing_matrix(centroids, expanded, mode=mode)
new_times = nearest_times(matrix)
# Which centroids now prefer the candidate (last column)?
candidate_idx = len(existing_sites) # index in expanded list
catchment_ids = [
centroids[i]["area_id"]
for i, row in enumerate(matrix)
if row.index(min(row)) == candidate_idx
]
return {
"candidate": candidate["address"],
"mode": mode,
"worst_case_sec": max(new_times),
"catchment_area_count": len(catchment_ids),
"catchment_area_ids": catchment_ids,
"weighted_burden": sum(
new_times[i] * centroids[i]["household_count"]
for i in range(len(centroids))
),
}
results = []
for cand in candidate_sites:
for mode in ("drive", "walk"):
results.append(evaluate_candidate(centroids, existing_sites, cand, mode))
# Sort by worst-case walk time ascending — the most accessibility-friendly site first
walk_results = [r for r in results if r["mode"] == "walk"]
walk_results.sort(key=lambda x: x["worst_case_sec"])The candidate that most reduces worst-case walking time is not always the same candidate that captures the most households. You may find a site that cuts the worst-case from 58 minutes to 22 minutes but only draws twelve centroids — those twelve represent the most isolated households, the hardest to serve. Whether that is the right choice depends on the organisation's priorities, not on the algorithm. The algorithm's job is to show the tradeoff clearly.
Step 4: Draw isolines around the winning candidate
Once you have selected a candidate, generate isolines to show the 15-minute and 30-minute catchments for both drive and walk modes. These are the polygons that go in the grant proposal map.
# 15-minute walk isoline
curl -G "https://csv2geo.com/api/v1/isoline" \
--data-urlencode "lat=42.1034" \
--data-urlencode "lng=-72.5891" \
--data-urlencode "mode=walk" \
--data-urlencode "range=900" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"
# 30-minute walk isoline
curl -G "https://csv2geo.com/api/v1/isoline" \
--data-urlencode "lat=42.1034" \
--data-urlencode "lng=-72.5891" \
--data-urlencode "mode=walk" \
--data-urlencode "range=1800" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"Each call returns a GeoJSON polygon. Drop those polygons into any GIS viewer or web map alongside your centroid dots and you have a publication-ready catchment diagram. The visual is not decoration — it is the spatial evidence that the site choice is defensible.
The Python version, collecting both contours in one loop:
def fetch_isoline(lat: float, lng: float, mode: str, range_sec: int) -> dict:
r = requests.get(
f"{API}/isoline",
params={
"lat": lat, "lng": lng,
"mode": mode, "range": range_sec,
"api_key": KEY,
},
timeout=30,
)
r.raise_for_status()
return r.json() # GeoJSON Feature
winner = candidate_sites[0] # replace with actual winner from evaluation
contours = {}
for mode in ("walk", "drive"):
for minutes, seconds in [(15, 900), (30, 1800)]:
contours[f"{mode}_{minutes}min"] = fetch_isoline(
winner["lat"], winner["lng"], mode, seconds
)Step 5: Use boundaries to validate the aggregation geography
One last endpoint that earns its keep in this workflow: Boundaries. Before you publish the centroid map, check that your aggregation geography (the census tracts or postal districts you used to cluster households) actually aligns with the administrative boundaries in the jurisdiction where your site will sit. A centroid that sits on the wrong side of a county line, a school district boundary, or a transit district boundary can misrepresent the catchment to a funder who knows that geography.
curl -G "https://csv2geo.com/api/v1/boundaries" \
--data-urlencode "lat=42.1034" \
--data-urlencode "lng=-72.5891" \
--data-urlencode "layers=county,postal,city" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"The response lists every administrative boundary that contains the point, with names and codes. Use this to cross-check that the centroid you are presenting as "West Springfield" is actually inside the West Springfield city boundary — not in an adjacent town that shares a ZIP code prefix. Funders who have been burned by sloppy geography will notice immediately; a one-call validation step costs nothing and protects your credibility.
def validate_centroid_boundary(centroid: dict, expected_county: str) -> bool:
r = requests.get(
f"{API}/boundaries",
params={
"lat": centroid["lat"],
"lng": centroid["lng"],
"layers": "county",
"api_key": KEY,
},
timeout=20,
)
r.raise_for_status()
counties = [b["name"] for b in r.json().get("boundaries", [])]
return expected_county in countiesFlag any centroid that fails this check for manual review before the map goes out. The fix is usually small — the centroid is a weighted average and may land near a boundary; nudging the cluster definition by one postcode usually resolves it.
Producing the grant deliverable
With the routing analysis in hand, the grant-proposal section writes itself:
> "Using aggregated service records (N=847 households across 34 census tracts, no individual addresses transmitted to any third-party system), we measured current travel time from each served area to the nearest existing distribution site under both drive and walk modes. Worst-case walking travel time under the current two-site configuration is 58 minutes. The proposed Commerce Park site reduces worst-case walking time to 21 minutes and becomes the nearest site for households in 11 census tracts representing 214 registered households. Weighted household travel burden (sum of household count × travel minutes) falls by 31% under the proposed configuration."
Every number in that paragraph comes directly from the matrix output. The funder can ask you to reproduce the analysis, and you can — because it is a deterministic function of your own service records and a REST API call, not a one-off GIS project that lived on a consultant's laptop.
PII safety throughout
It bears stating explicitly, because funders and boards sometimes ask: no household address ever leaves your internal systems in this workflow.
The aggregation step — described in full in Mapping served populations for grant reporting — converts individual records to area-level centroids before any external API is called. What you send to CSV2GEO is a latitude/longitude pair representing the centre of a group of 20-or-more households, not any individual's address. The routing matrix, the isoline, and the boundaries calls all operate on those anonymised centroids plus your site addresses. Nothing in the pipeline creates a record that could be traced back to a named individual.
If your organisation operates under any data governance framework that restricts sharing client location data with vendors, this pattern satisfies that constraint by design. The API never sees who lives where — it only sees where you are thinking of putting a pantry and how long it would take to get there from an anonymised area centroid.
Cost model for a realistic analysis
This is a tight budget. Food banks are not flush; the cost case has to be honest.
A regional food bank with 50 served-area centroids, 2 existing sites, and 5 candidate sites, running the full matrix in both modes:
- Geocoding: existing sites (2) + candidate sites (5) = 7 calls. Covered by the free tier.
- Baseline matrix: 50 centroids × 2 sites = 100 pairs, ×2 modes = 200 pair lookups. Matrix pricing is per-call, not per-pair — one matrix call covers the batch. Two calls (one per mode).
- Candidate testing: 5 candidates × 2 modes = 10 matrix calls (each is 50 × 3 destinations).
- Isolines: 2 modes × 2 contour levels × 1 winning site = 4 calls.
- Boundaries validation: 50 centroid checks = 50 calls.
Total: well under 100 API calls for the full siting analysis. At free-tier volumes (3,000 calls/day) this costs nothing. If the organisation's geocoding pipeline already uses a paid plan, the siting analysis is a rounding error against the monthly call budget.
Paid plans start at $54/month for 100,000 calls. See csv2geo.com/pricing/api. There is no minimum commit.
Observability: what to log
A siting analysis that produces a defensible output is only defensible if you can reproduce it. Log the following at the time you run the analysis:
- API response timestamps and the matrix parameters (origins, destinations, mode) for each call
- The centroid file hash (so you can prove which version of the aggregated data was used)
- The site coordinates and their geocoding confidence scores
- The worst-case and median travel times from the baseline and each candidate run
Store these alongside the GeoJSON output in a version-controlled directory or a dated S3 prefix. If the funder asks "how did you get that 31% reduction figure" six months later, you should be able to re-run the Python script and get the same number.
For production geocoding pipelines under ongoing monitoring, Observability for geocoding pipelines covers the metrics and alerting patterns that apply more broadly.
Frequently Asked Questions
Does CSV2GEO provide poverty data, food-insecurity indices, or population counts for this analysis? No. CSV2GEO provides geocoding, routing, isolines, and boundaries. The need data — who you serve, where they live in aggregate, how many households per area — comes entirely from your own service records. The API measures travel time between the points you provide; it does not tell you who lives at those points.
What if some of my clients walk and some drive? Can the matrix handle mixed-mode? Run the matrix twice — once with mode=drive and once with mode=walk. For each centroid you then have two travel times; use the more conservative one (walking) as the equity-facing metric and keep the driving figure for operational planning. The two modes are independent calls and cost the same.
How large can the matrix be before it becomes unwieldy? For a regional food bank the matrix is small — tens of origin centroids, single-digit destinations. The endpoint handles much larger matrices than this, but once you exceed a few hundred origins you will want to page the calls and stitch the result. For the food-distribution use case you are unlikely to hit any practical limit.
Can I use the isolines output directly in a grant proposal map? Yes. The response is standard GeoJSON, which any web mapping library, QGIS, or even Google Slides (via a plugin) can render. Drop the polygon into your basemap, overlay the centroid dots, and you have publication-quality spatial evidence. No GIS licence required.
What happens if a candidate site address geocodes with low confidence? Flag it and do not include it in the matrix until a human verifies the coordinate. A misplaced site — say, a geocoder that puts a new community centre at the wrong end of a street — will produce systematically wrong travel times for every centroid in the analysis. The fix is a manual coordinate lookup and a hardcoded lat/lng override in your input file.
Can this workflow be automated to run quarterly as the service record base grows? Yes, and it should be. Re-running the baseline matrix each quarter against a refreshed centroid file tells you whether the existing sites are drifting relative to where households are actually located — useful for detecting emerging coverage gaps before they become programme failures. The analysis is a script, not a one-off GIS project. Schedule it like any other data job.
Do I need to share household addresses with CSV2GEO at any point? No. The aggregation step happens entirely on your own systems, before any API call is made. The routing matrix, isoline, and boundaries endpoints receive only anonymised area centroids and site addresses. No individual household record is transmitted to any external system.
Related Articles
- Mapping served populations for grant reporting — the aggregation step that produces PII-safe centroids from household service records
- Reverse-geocoding accuracy and the distance meters — why confidence-gating your site geocodes matters before the matrix runs
- Benchmarking geocoding APIs — honest numbers — what to measure when evaluating routing and geocoding quality
- Caching geocoding results — 90% cost reduction — site coordinates do not move; cache the geocoding results aggressively
- Observability for geocoding pipelines — logging and alerting patterns for pipelines that need to be reproducible and auditable
---
*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 →