Matching candidates to jobsites by real commute time
Geocode your candidate pool, run an N×M drive-time matrix, rank placements by commute burden. Fewer dropouts, better retention, same API key.
Straight-line miles lie. A candidate six kilometres from a jobsite might face a 55-minute commute through a river crossing with no bridge; another at twelve kilometres might have a 22-minute door-to-door drive on a clear arterial. When a staffing agency ranks its shortlist by "distance from jobsite" it is ranking by a number that has almost no relationship to the experience the candidate will live every working day.
That gap between the ranked shortlist and the lived commute is where placements fall apart. Not at the interview, not at onboarding — at week two, when the candidate realises the commute is unsustainable and stops showing up. The agency re-fills the role, the client loses a week of productivity, and the margin on that placement evaporates.
The fix is not complicated. Geocode your candidate pool. Geocode the jobsite. Run a drive-time matrix across the full N-candidate × 1-jobsite grid (or N × M when you have multiple open sites). Rank placements by commute burden rather than straight-line distance. Match people to roles they can actually get to.
This post walks through that pipeline end to end — from candidate intake to ranked shortlist — using REST calls you can wire into any ATS, spreadsheet workflow, or in-house ops tool.
Why staffing is uniquely sensitive to commute
Most industries tolerate imprecise location matching because the location does not change job performance directly. In staffing it does. A warehouse operative who misses the first bus and arrives twenty minutes late three times in the first fortnight is a performance issue, a client complaint, and a fill — none of which were about the worker's capability.
The role types that appear most often in high-volume staffing — light industrial, food processing, healthcare support, retail, logistics — are disproportionately shift-based. The 05:30 shift start has no flex. The candidate who lives forty-five drive-minutes away in normal traffic is fifty-five minutes away when the motorway is slow, which means they are leaving home at 04:30 and factoring in transit changes, parking, and a safety margin. That is not a sustainable working pattern for most people on a £12/hour wage.
A 15-minute difference in commute time — 30 minutes versus 45 minutes — sounds trivial on paper. Across five shifts per week, that is two and a half extra hours per week of unpaid commute time. Over a 12-week placement, it is 30 hours. At the candidate's hourly rate, that is the equivalent of nearly three working days lost to transit before they have earned a day's holiday.
Your own retention data will tell you where the inflection point is in your specific market and role type. The pipeline in this post gives you the commute-time number so that you can correlate it against your fill and retention records and find that inflection point empirically — rather than guessing.
The four surfaces
Four parts of the CSV2GEO platform do the work. It helps to understand what each does before looking at the code.
Geocoding at candidate intake. When a candidate registers, you take their home address and convert it to a WGS-84 coordinate pair (lat, lng) using /api/v1/geocode. You store the coordinates, not the raw address — more on why in the PII section below. This is a single API call per registration event, not a batch job.
Batch geocoding for existing candidate pools. If you are enriching a pool that is already in your ATS without coordinates, the web batch tool accepts a CSV of address rows and returns coordinates in bulk. Credits are charged per address row. A pool of 8,000 candidates is 8,000 credits — at paid pricing, a sub-$5 operation at the entry tier.
Routing matrix (N × M). The /api/v1/matrix endpoint takes a list of origin coordinates and a list of destination coordinates and returns a drive-time (or walk-time, or bike-time) grid. For 200 candidates and 1 jobsite it is a 200 × 1 matrix — 200 origin-destination pairs, one call, one response. Modes are drive, walk, and bike. There is no public-transit mode; if a candidate's commute depends on a bus schedule, the drive-time is still a useful upper-bound proxy — you note it as a limitation and let the recruiter ask the candidate directly.
Isolines for "who's within N minutes." The /api/v1/isoline endpoint takes a single origin point — the jobsite — and a travel time budget and returns a polygon that represents everywhere reachable within that time. Any candidate whose home coordinate falls inside the polygon is within budget. This is the fast shortlisting step: run the isoline first, discard candidates outside it, then run the full matrix only against the survivors.
The pipeline, step by step
Step 1: Geocode the candidate pool at intake
The right time to geocode is at registration — not at placement time. Batch-geocoding a stale address list introduces errors from candidates who have moved. A real-time geocode at registration is the cleanest pattern.
curl -s "https://csv2geo.com/api/v1/geocode" \
--get \
--data-urlencode "q=14 Acacia Avenue, Birmingham, B1 1AA" \
--data-urlencode "api_key=$CSV2GEO_KEY"Response:
{
"results": [
{
"lat": 52.4862,
"lng": -1.8904,
"confidence": 0.94,
"formatted": "14 Acacia Avenue, Birmingham, B1 1AA, UK"
}
]
}In Python at your intake webhook:
import os, requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]
def geocode_candidate(address: str) -> dict | None:
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 None
top = results[0]
if top.get("confidence", 0) < 0.7:
# Low confidence — flag for manual review, do not store blindly.
return {"lat": top["lat"], "lng": top["lng"],
"confidence": top["confidence"], "needs_review": True}
return {"lat": top["lat"], "lng": top["lng"],
"confidence": top["confidence"], "needs_review": False}Store lat, lng, confidence, and needs_review in your candidate record. Do not store the geocoded response wholesale — you do not need the full address string back from the API; you already have it from the form.
The same call in Node at an Express intake route:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;
async function geocodeCandidate(address) {
const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
const r = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!r.ok) throw new Error(`geocode http ${r.status}`);
const body = await r.json();
const top = body.results?.[0];
if (!top) return null;
return {
lat: top.lat,
lng: top.lng,
confidence: top.confidence,
needsReview: top.confidence < 0.7,
};
}Confidence below 0.7 is worth surfacing to the recruiter as a "please verify address" prompt before the candidate file is used for matching. A wrong coordinate is worse than no coordinate — a candidate placed near the wrong grid reference will appear to have a 12-minute commute when the actual commute is 55 minutes.
Step 2: Geocode the jobsite (once, cache it)
Jobsites do not move. Geocode the address once when it is created in your system and store the coordinates alongside the job record. Do not re-geocode on every matching run — it is wasteful and it introduces a dependency on the geocoding service being available at the moment you need the matrix.
def geocode_jobsite(address: str) -> dict:
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
top = r.json()["results"][0]
return {"lat": top["lat"], "lng": top["lng"], "formatted": top["formatted"]}A job record in your ATS should carry jobsite_lat and jobsite_lng as first-class fields. If your ATS does not support arbitrary coordinate fields, store them in a sidecar table keyed by job ID.
Step 3: Run the isoline to pre-filter the candidate pool
If your candidate pool is large — thousands of records — running the full N × 1 matrix against all of them is unnecessary. The isoline gives you a polygon: any candidate outside the polygon is beyond the commute budget and can be excluded before the matrix call.
curl -s "https://csv2geo.com/api/v1/isoline" \
--get \
--data-urlencode "lat=52.4862" \
--data-urlencode "lng=-1.8904" \
--data-urlencode "mode=drive" \
--data-urlencode "range=1800" \
--data-urlencode "api_key=$CSV2GEO_KEY"range is in seconds. 1800 = 30 minutes. The response returns a GeoJSON polygon. Any candidate coordinate inside the polygon is a candidate worth running through the matrix; outside, discard for this placement.
A naive point-in-polygon test in Python without adding a heavy GIS dependency:
def point_in_polygon(lat, lng, polygon_coords):
"""Ray-casting algorithm. polygon_coords: list of [lng, lat] pairs."""
x, y = lng, lat
n = len(polygon_coords)
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon_coords[i]
xj, yj = polygon_coords[j]
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
inside = not inside
j = i
return insideFor production workloads at scale, use shapely or geopandas — but for a 500-candidate pool, the ray-casting function above is fast enough and adds zero dependencies.
After the isoline filter, you might reduce a 4,000-candidate pool to 280 candidates within 30 drive-minutes of the jobsite. You run the matrix against those 280, not the full 4,000.
Step 4: Run the N × M routing matrix
The matrix endpoint takes a list of origins and a list of destinations and returns a grid of drive times in seconds. For staffing, origins are candidate home coordinates and the destination is the jobsite.
import json
def commute_matrix(candidate_coords, jobsite_lat, jobsite_lng, mode="drive"):
"""
candidate_coords: list of (lat, lng) tuples
Returns a list of drive-time seconds, one per candidate.
"""
origins = "|".join(f"{lat},{lng}" for lat, lng in candidate_coords)
destinations = f"{jobsite_lat},{jobsite_lng}"
r = requests.get(
f"{API}/matrix",
params={
"origins": origins,
"destinations": destinations,
"mode": mode,
"api_key": KEY,
},
timeout=60,
)
r.raise_for_status()
data = r.json()
# Matrix is rows=origins, cols=destinations.
# For a single destination, each row has one value.
return [row[0] for row in data["durations"]]The same call in Node:
async function commuteMatrix(candidateCoords, jobsiteLat, jobsiteLng, mode = 'drive') {
const origins = candidateCoords.map(([lat, lng]) => `${lat},${lng}`).join('|');
const destinations = `${jobsiteLat},${jobsiteLng}`;
const url = `${API}/matrix?origins=${encodeURIComponent(origins)}` +
`&destinations=${encodeURIComponent(destinations)}` +
`&mode=${mode}&api_key=${KEY}`;
const r = await fetch(url, { signal: AbortSignal.timeout(60000) });
if (!r.ok) throw new Error(`matrix http ${r.status}`);
const body = await r.json();
return body.durations.map(row => row[0]);
}The response durations field is a 2D array — rows indexed by origin, columns by destination. For an N × 1 call you get N single-element rows. For an N × M call with multiple jobsites you get the full grid, and you can ask for each candidate's best-fit jobsite in one pass.
A null in the duration grid means the router could not find a path between that origin and destination — typically because one coordinate is on an island, in a pedestrian zone with no through-road, or genuinely unreachable by the chosen mode. Treat null as "candidate cannot be routed to this site" and flag it for manual review rather than treating it as a zero or an infinity.
Step 5: Rank the shortlist and surface the result
With a list of drive times in seconds, the rest is arithmetic.
def build_shortlist(candidates, drive_times_seconds, budget_seconds=2700):
"""
candidates: list of dicts with 'id', 'name', 'lat', 'lng', etc.
drive_times_seconds: parallel list of ints (or None)
budget_seconds: default 45 minutes
Returns candidates sorted by commute time, within budget first.
"""
ranked = []
for cand, secs in zip(candidates, drive_times_seconds):
if secs is None:
continue # unroutable — exclude
ranked.append({
**cand,
"commute_seconds": secs,
"commute_minutes": round(secs / 60, 1),
"within_budget": secs <= budget_seconds,
})
ranked.sort(key=lambda x: x["commute_seconds"])
return rankedThe recruiter's view of this output is a table: candidate name, commute time in minutes, within/outside the 45-minute budget. Sorted by commute time ascending. The top of the list is the most commute-sustainable placement; the bottom is the candidate who is technically matchable by skill but will face a brutal commute.
For a multi-jobsite scenario — say, three open warehouse sites across a metro — run one matrix call with three destination coordinates and return each candidate's minimum commute time across all three sites, along with which site achieves it. That is one API call, one response, and it gives the recruiter the best possible placement across the entire open-role book.
Handling candidate PII correctly
Candidate home addresses are personal data in every jurisdiction that has a data-protection framework. The pattern that minimises risk is straightforward:
Geocode at intake, store coordinates, minimise address retention. Once you have lat and lng for a candidate, the original address string adds limited operational value. In many ATS workflows the address is already stored for correspondence purposes; the coordinate is the additional field you add. Do not store the full geocoding API response — strip it to the fields you need.
Do not cache raw addresses in a third-party system longer than necessary. When you send an address string to the geocoding API, it is processed and returned as coordinates. The API does not retain address strings for longer than required to serve the request — that is the no_record model. If your legal team needs a documented basis for this, see the note on HIPAA-safe geocoding patterns — the same PII-minimisation logic applies to staffing candidate data even outside a healthcare context.
Store commute scores, not raw matrix responses. Once you have ranked the shortlist, you do not need the full matrix grid. Store candidate_id, jobsite_id, commute_seconds, mode, run_date. Drop the raw API response. This is the minimum necessary for re-producing the ranking if audited, without holding a large personal-data artefact indefinitely.
Timestamp your commute scores. A commute time calculated in January may be meaningfully wrong by July if road infrastructure changes or the candidate moves. Add a commute_scored_at field and treat scores older than 90 days as stale. Re-score before making a placement, not on a fixed cron — only the candidates being actively considered for an open role need fresh scores.
A note on transit and mode
The routing matrix supports drive, walk, and bike. It does not support public transit with real-time schedule data. This is not an omission to paper over — public-transit routing that is actually useful requires live schedule feeds, route geometry, and headway data that varies by city, operator, and time of day.
What you can do honestly:
- Use
driveas the primary mode for suburban and periurban roles where most candidates own a vehicle or can borrow one. - Use
walkfor inner-city placements where the candidate pool is explicitly car-free. - Use
bikeas a supplemental signal for roles in cities with good cycling infrastructure. - For roles where public transit is the real commute mode (city-centre back-office, hospital support in a metro), add a free-text "how do you get to work?" field at intake and let the recruiter ask directly. No algorithm replaces that conversation.
The honest framing for the recruiter UI: "This commute time is a drive-time estimate. Candidates who travel by public transit should verify the actual journey with their preferred route planner." That note costs you one sentence in the UI and saves you one placement dispute.
Wiring the pipeline into an ATS
Most staffing ATSs expose a webhook on candidate registration and a job-opening event. The integration points are:
Candidate webhook → geocode and store coordinates. A lightweight serverless function (Cloud Function, Lambda, whichever cloud you are on) receives the webhook, calls /api/v1/geocode, writes lat, lng, and confidence back to the ATS via its REST API. If the ATS does not support custom fields via API, write to a sidecar Postgres table keyed by candidate_id.
Job-opened event → geocode jobsite, store, generate isoline. Same pattern. Geocode the jobsite address, store coordinates, run the 30-minute isoline, store the polygon GeoJSON. The isoline generation is a one-time cost per job opening.
Placement shortlist request → run matrix. When a recruiter opens the "find candidates" view for a job, your backend queries the candidate pool, runs the isoline filter, sends the survivors to the matrix endpoint, ranks, and returns the sorted list. For a pool of 4,000 candidates filtered to 280 by the isoline, the matrix call covers 280 origin-destination pairs. At reasonable API performance, that round-trip is well within the budget of an interactive page load.
If your ATS does not allow webhook integration, the batch-tool workflow applies: export the candidate pool as a CSV, upload to the CSV2GEO web batch tool (credits = address rows), download the enriched CSV with coordinates, import back. It is a manual step but a fast one. The routing matrix still runs via the REST API — you are just loading coordinates from the enriched CSV rather than from a live ATS query.
Observability and cost control
Two things to instrument before you go to production.
Log the isoline filter ratio. For each job opening, log how many candidates entered the isoline filter and how many survived. If you consistently see 95% of candidates filtered out, your default isoline budget (say, 30 minutes) is too tight for the local market — expand it. If you see 99% surviving, the isoline is not doing useful work — tighten it. The right filter ratio for your market is something you calibrate over the first few weeks of live data.
Log matrix call sizes. The matrix endpoint is priced per origin-destination pair. A 280 × 1 matrix is 280 credits. Log the size of every matrix call so you can spot anomalies — a matrix call of 4,000 × 1 means the isoline filter did not run, which is a bug. The observability patterns for geocoding pipelines post covers the broader instrumentation setup; the matrix call size is the one metric specific to this workflow.
Cache jobsite coordinates and isolines. A job opening with a 12-week active period generates potentially dozens of shortlist requests. The jobsite coordinate and isoline polygon should be computed once and cached — not re-fetched from the API on every recruiter search. Even a simple in-memory cache keyed by job_id is enough for a single-server deployment. See caching geocoding results for the general pattern.
Cost math for a real agency
A concrete example. An agency with 500 active candidates and 10 open roles per month.
- Candidate geocoding at intake: assume 150 new registrations per month = 150 credits.
- Jobsite geocoding: 10 new roles per month, 1 geocode each = 10 credits.
- Isoline generation: 10 isolines per month = 10 credits (estimate).
- Matrix calls: 10 roles, average 200 candidates per role after isoline filter, 5 shortlist requests per role = 50 matrix calls × 200 pairs = 10,000 origin-destination pairs per month.
Total: roughly 10,170 credits per month. The free tier covers 3,000 calls per day — this workload fits comfortably within the free tier for a small agency. A larger agency running 50 roles per month and 2,000 active candidates moves to a paid bracket; pricing starts at $54/month for 100,000 calls. See csv2geo.com/pricing/api for the full bracket table.
The cost per avoided re-fill — which in high-volume staffing can run to several hundred pounds in recruiter time and client relations effort — makes the arithmetic straightforward.
Frequently Asked Questions
Does CSV2GEO have a public-transit routing mode?
No. The matrix endpoint supports drive, walk, and bike. Public-transit routing with real-time schedule accuracy requires live feed integration that varies by city and operator — we do not ship that. For transit-dependent placements, use drive time as an upper bound and have the recruiter confirm the actual journey with the candidate at interview.
How many candidates can I put in one matrix call?
The matrix endpoint accepts multiple origins and multiple destinations in a single call. Check the current documented limits on the API reference — for practical staffing workflows, batching 200-500 candidates per call is a reasonable default. If your shortlist exceeds the per-call limit, split it into chunks and concatenate the results; the concurrency tuning post covers the parallel-call pattern that keeps you within rate limits.
What if a candidate address returns low confidence?
Flag it in the candidate record and surface a "please verify address" prompt to the recruiter before using it for matching. A misplaced coordinate that looks precise is worse than an honest "we are not sure" — a candidate placed on the basis of a wrong coordinate will have a surprise commute that neither of you planned for.
Can I run a matrix with multiple jobsite destinations at once?
Yes. Pass multiple destination coordinates in the destinations parameter. The response returns a full N × M grid. For each candidate, take the minimum across all destination columns to find their best-fit site and which site achieves it. This is the most efficient way to match a candidate pool against a multi-site client.
How should I handle the PII in candidate addresses?
Geocode at intake, store coordinates, minimise address string retention beyond what your ATS already holds for correspondence. Store commute scores — not raw matrix responses — against candidate_id and jobsite_id. Timestamp scores and treat anything older than 90 days as stale. See the HIPAA-safe geocoding post for the no_record request pattern that avoids address logging at the API layer.
What happens when a candidate moves?
Your intake form (or a periodic "please confirm your address" prompt in your candidate portal) is the trigger. When a candidate updates their address, re-geocode, overwrite lat/lng in the candidate record, and invalidate any cached commute scores for that candidate. The cost is one geocoding credit. Build the address-update path before you go live — it is the most predictable ongoing maintenance event in the pipeline.
Does the isoline polygon change if I request it twice?
It should be stable for the same input parameters — same coordinate, same mode, same range. Run it once per job opening and cache the result. If you see meaningful differences between two runs for the same inputs, that is worth raising with support — it may indicate a routing-graph update that changed the reachable area.
Related Articles
- Dispatch console for 5,000 stops per day — routing-matrix patterns at high volume, directly applicable to multi-site matching
- Benchmarking geocoding APIs — honest numbers — what to measure when evaluating geocoding quality for a candidate pool
- Caching geocoding results — 90% cost reduction — jobsite coordinates and isolines are the highest-value cache targets in this pipeline
- Concurrency tuning — geocoding sweet spot — how to parallelise matrix calls safely without hitting rate limits
- Observability for geocoding pipelines — instrumentation patterns for matrix call size, isoline filter ratio, and geocoding confidence distributions
---
*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 →