Building a nearest charger lookup for EV fleets
Geocode your charger list once, cache it, then rank by true drive distance at query time. Full REST walkthrough with Python and Node examples.
Straight-line distance from vehicle to charger is a comfortable lie. A depot that looks 800 m away on a crow-flies calculation might be 4 km of one-way streets, a rail crossing, and a no-left-turn. A charger that shows up as the second-closest pin on a map might actually be the fastest stop on a Tuesday afternoon when the nearest one is behind a school-zone queue. Fleet operators who dispatch on straight-line distance eventually learn this the hard way — a van runs out of charge in a loading bay because the algorithm said the closest charger was reachable, and the algorithm was measuring the wrong thing.
This post builds a nearest-charger lookup that measures the right thing. The workflow has three distinct phases: geocode your charger list once and cache it permanently; at query time geocode the vehicle's current reported address or location; then rank by true drive distance using a distance matrix. To keep costs sane, we add a Haversine pre-filter that discards chargers that are geometrically impossible candidates before the matrix call, so the matrix only prices routes that are genuinely plausible.
By the end you will have a working Python function and a Node equivalent, an architecture diagram in prose that fits a 15-minute engineering review, and a clear picture of the three places this system typically breaks in production.
Why the problem is harder than it looks
A fleet of 40 vans operating across a mid-size city will have somewhere between 8 and 25 chargers in or near its service area — a mix of owned depots, leased charging bays, and en-route public stops that the operator has added to the approved list. When a driver calls dispatch to say the van is at 12% and they are between stops on the route, the dispatcher needs to answer two questions in under ten seconds: which charger can this vehicle actually reach, and of those, which is the fastest to get to right now?
A static list sorted alphabetically is useless. A list sorted by straight-line distance is unreliable. A properly maintained lookup that uses current coordinates and drive distance is the tool that makes that dispatcher conversation a two-second glance rather than a three-minute negotiation.
Three things make this non-trivial to build correctly.
Asset geocoding drift. A depot address like "Unit 7, Riverside Trade Estate, Gate 3" does not geocode reliably unless you have cleaned the address and verified the result. Chargers that geocode to the wrong side of an industrial estate can add 800 m of extra driving on every dispatch. This is a one-time problem if you fix it at asset-import time; it is a permanent operational hazard if you skip it.
Query-time latency budget. The driver is waiting. The acceptable window for a "nearest charger" API response in a dispatch context is under 500 ms end to end. That rules out naive approaches — calling the distance matrix for all 25 chargers on every query is expensive in both latency and API cost. The pre-filter is not optional; it is the design decision that makes the latency budget achievable.
Cost accounting. Distance matrix calls are priced per origin-destination pair. A fleet that queries 25 chargers for 40 vehicles every 10 minutes is generating 6,000 matrix cells per minute at peak. That adds up. The pre-filter that cuts 25 candidates to 5 saves 80% of the matrix cost — not incidentally, but as the primary financial justification for writing the extra code.
What the API surface looks like
The workflow touches three CSV2GEO endpoints. None of them require an SDK — every example in this post is plain HTTP.
`/api/v1/geocode` — forward geocodes an address string to a lat/lng pair with a confidence score. Used at asset-import time to geocode your charger list, and at query time to geocode a vehicle's reported address if you do not have live GPS coordinates.
`/api/v1/places` — POI search. Useful if you want to discover candidate charging locations near a waypoint, or if you manage a mixed fleet where you want to find generic amenities near a stop. Does not ship a live EV-charger database — that is not what Places is for. The charger list is yours; Places is the right tool for finding supplementary stops (parking, services) near your route.
`/api/v1/distance_matrix` — takes a set of origins and a set of destinations and returns a matrix of drive distances and durations. This is the core of the nearest-charger ranking. The matrix is routed on real road networks, respects one-way constraints, and returns both metres and seconds per cell.
The free tier gives you 3,000 calls per day at no cost. A paid bracket starts at $54/month for 100,000 calls. Exact current pricing at csv2geo.com/pricing/api.
The architecture in five sentences
Geocode every charger in your fleet's asset list once, on import, and write the lat/lng and confidence to your database. When a vehicle needs a charger, take its current lat/lng (from GPS) or geocode its reported address. Compute Haversine distances from the vehicle to every charger in the database — this is pure arithmetic, zero API calls. Keep only the closest N chargers (N = 5 is a reasonable default). Call the distance matrix once, with the vehicle as the single origin and the N chargers as destinations, and return the ranked list sorted by drive duration.
That is the whole system. Every complication below is a production hardening concern, not a fundamental change to the architecture.
Step 1: Geocode and cache your charger list
This is a one-time job per charger, repeated only when addresses change. Do not re-geocode at query time — geocoding a stable asset on every vehicle event is a latency and cost problem with a trivially easy fix.
import csv
import os
import requests
import json
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode_charger(address: str) -> dict:
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 {"address": address, "lat": None, "lng": None, "confidence": 0}
top = results[0]
return {
"address": address,
"lat": top["lat"],
"lng": top["lng"],
"confidence": top.get("confidence", 0),
}
# Import a CSV of charger addresses once.
chargers = []
with open("chargers.csv") as f:
for row in csv.DictReader(f):
result = geocode_charger(row["address"])
if result["confidence"] < 0.7:
print(f"WARN: low confidence for {row['address']!r} — verify manually")
chargers.append({**row, **result})
with open("chargers_geocoded.json", "w") as f:
json.dump(chargers, f, indent=2)The confidence < 0.7 threshold is your quality gate. Chargers below that threshold get flagged for manual verification before they go live in the dispatch system — see Geocoding Confidence Scores Explained for the rationale behind the 0.7 cut-off in operational contexts.
The chargers_geocoded.json file (or its equivalent in your database) is the asset cache. Load it at application start, keep it in memory, and only refresh it when your asset list changes. The geocoding credit cost for 25 chargers is 25 calls. You are likely to do this fewer than ten times a year.
Step 2: Geocode the vehicle location (when you lack live GPS)
If your fleet has live GPS telemetry, skip this step — you already have lat/lng. If the vehicle's location reaches you as a reported street address (driver self-reports, a form submission, a telematics platform that emits addresses rather than coordinates), geocode it at query time.
curl -s "https://csv2geo.com/api/v1/geocode" \
--get \
--data-urlencode "q=14 Argyle St, Manchester, M1 7AD" \
--data-urlencode "api_key=$CSV2GEO_API_KEY" \
| jq '.results[0] | {lat, lng, confidence}'def geocode_vehicle_address(address: str) -> tuple[float, float]:
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=10,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
raise ValueError(f"No geocoding result for address: {address!r}")
top = results[0]
return float(top["lat"]), float(top["lng"])One call per vehicle event. If a driver reports location every five minutes and you have 40 vehicles, that is 480 geocoding calls per hour — well within a modest paid bracket. Cache the result for the duration of the stop or the job so you are not re-geocoding the same address twice within one dispatch conversation.
The caching pattern for geocoded addresses is covered in depth in Caching Geocoding Results — 90% Cost Reduction. The short version: key on the normalised address string, TTL of 24 hours, and you will see an immediate drop in repeat calls.
Step 3: Pre-filter with Haversine
Haversine is the formula for great-circle distance between two points on a sphere. It is not drive distance — it ignores roads entirely. But it is fast (no API call, pure arithmetic), and it is a reliable lower bound: if a charger is 15 km away as the crow flies, it cannot be 3 km by road. You can safely discard chargers beyond a sensible crow-flies threshold before spending any matrix budget.
import math
def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(dlng / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
def candidate_chargers(vehicle_lat, vehicle_lng, chargers,
radius_km=20.0, top_n=5):
"""Return the top_n chargers within radius_km, sorted by straight-line distance."""
within = []
for c in chargers:
if c["lat"] is None or c["lng"] is None:
continue # skip unresolved assets
d = haversine_km(vehicle_lat, vehicle_lng, c["lat"], c["lng"])
if d <= radius_km:
within.append((d, c))
within.sort(key=lambda x: x[0])
return [c for _, c in within[:top_n]]The radius_km=20.0 and top_n=5 defaults are starting points. For a dense urban fleet, 10 km and 5 candidates is reasonable. For a motorway-adjacent logistics fleet, you might need 50 km and 8 candidates. Tune against your actual fleet geometry — the goal is to guarantee the true nearest charger always survives the filter while discarding the clear non-candidates.
A design note: if candidates returns an empty list, the vehicle is either outside service area or every charger failed geocoding. Handle this explicitly — fall back to the full charger list without the radius filter, flag it as a degraded-mode response, and alert your operations team. Silent empty responses from a dispatch lookup are how vans get stranded.
Step 4: Call the distance matrix for the candidates
With 5 candidates in hand, one matrix call returns 5 drive distances and 5 drive durations. The vehicle is the single origin; the charger coordinates are the destinations.
curl -s "https://csv2geo.com/api/v1/distance_matrix" \
--get \
--data-urlencode "origins=53.4808,-2.2426" \
--data-urlencode "destinations=53.4721,-2.2610|53.4855,-2.2108|53.4901,-2.2375|53.4650,-2.2791|53.4780,-2.1950" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"In Python:
def rank_by_drive_distance(vehicle_lat, vehicle_lng, candidates):
if not candidates:
return []
origins = f"{vehicle_lat},{vehicle_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=20,
)
r.raise_for_status()
matrix = r.json()
# The matrix returns one row per origin; we have one origin.
row = matrix["rows"][0]["elements"]
ranked = []
for charger, element in zip(candidates, row):
if element["status"] != "OK":
continue # unreachable by road (island, ferry, etc.)
ranked.append({
**charger,
"drive_distance_m": element["distance"]["value"],
"drive_duration_s": element["duration"]["value"],
})
ranked.sort(key=lambda x: x["drive_duration_s"])
return rankedSorting by drive_duration_s (seconds) rather than drive_distance_m (metres) is the right default for fleet dispatch — a charger 2 km away on a fast arterial is a better recommendation than a charger 1.2 km away through a residential maze at 20 km/h. Check with your operations team whether distance or time is the primary ranking signal for your specific routes.
In Node:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function rankByDriveDistance(vehicleLat, vehicleLng, candidates) {
if (!candidates.length) return [];
const origins = `${vehicleLat},${vehicleLng}`;
const destinations = candidates
.map(c => `${c.lat},${c.lng}`)
.join('|');
const url = `${API}/distance_matrix?origins=${encodeURIComponent(origins)}` +
`&destinations=${encodeURIComponent(destinations)}&api_key=${KEY}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`distance_matrix HTTP ${r.status}`);
const matrix = await r.json();
const row = matrix.rows[0].elements;
const ranked = candidates
.map((c, i) => ({ ...c, ...row[i] }))
.filter(c => c.status === 'OK')
.map(c => ({
...c,
drive_distance_m: c.distance.value,
drive_duration_s: c.duration.value,
}))
.sort((a, b) => a.drive_duration_s - b.drive_duration_s);
return ranked;
}The full function chain in Python — geocode vehicle → pre-filter → matrix → return ranked list — runs in two HTTP calls: one geocode and one matrix. For a vehicle with live GPS, it is one HTTP call. That is the latency profile to carry into your architecture review.
Step 5: Wire it together and handle failure modes
The full lookup function:
import json
# Load charger cache once at startup.
with open("chargers_geocoded.json") as f:
CHARGER_CACHE = json.load(f)
def nearest_chargers(vehicle_address: str = None,
vehicle_lat: float = None,
vehicle_lng: float = None,
radius_km: float = 20.0,
top_n: int = 5) -> list[dict]:
"""
Returns chargers ranked by drive duration.
Supply either (vehicle_lat, vehicle_lng) or vehicle_address.
"""
if vehicle_lat is None or vehicle_lng is None:
if vehicle_address is None:
raise ValueError("Supply vehicle_address or vehicle_lat/lng.")
vehicle_lat, vehicle_lng = geocode_vehicle_address(vehicle_address)
candidates = candidate_chargers(
vehicle_lat, vehicle_lng, CHARGER_CACHE,
radius_km=radius_km, top_n=top_n,
)
if not candidates:
# Degraded mode: return all chargers sorted by straight-line only.
all_with_dist = sorted(
[c for c in CHARGER_CACHE if c["lat"] is not None],
key=lambda c: haversine_km(vehicle_lat, vehicle_lng, c["lat"], c["lng"])
)
return [{"degraded": True, **c} for c in all_with_dist[:top_n]]
return rank_by_drive_distance(vehicle_lat, vehicle_lng, candidates)Three failure modes to test explicitly before going live.
No candidates within radius. Handled above by falling back to the full list sorted by Haversine. Mark the response with "degraded": true so the dispatch UI can surface a warning — "nearest charger is outside normal service area, confirm with driver" — rather than silently returning a dubious result.
Matrix element status not OK. A charger that is on an island, behind a ferry crossing, or on a road network disconnected from the vehicle's location returns status != "OK". Filter those rows out. If all candidates come back non-OK, trigger the same degraded-mode path as an empty candidate list.
Geocoding the charger returns low confidence. Already flagged at import time. In production, the dispatch API should refuse to use any charger whose geocoding confidence is below threshold, and those chargers should appear in a maintenance queue in your ops dashboard rather than silently failing at dispatch time.
Retry logic for transient HTTP errors (502, 503, 429) should follow exponential backoff. The pattern is documented in Exponential Backoff — When to Retry, When to Stop. The short version: start at 200 ms, double on each retry, cap at 4 attempts, and surface the error to the caller if all retries are exhausted rather than returning a stale cached result without flagging it.
Cost model for a real fleet
A worked example for a 40-vehicle fleet with 20 chargers, querying nearest-charger every 10 minutes during a 10-hour operating day.
| Operation | Calls per event | Events per day | Daily calls | |---|---|---|---| | Geocode vehicle address (if GPS-equipped, 0) | 1 | 240 | 240 | | Haversine pre-filter | 0 (arithmetic) | 240 | 0 | | Distance matrix (1 origin × 5 destinations) | 5 matrix cells | 240 | 1,200 | | Charger geocoding (one-time import, 20 chargers) | 20 | 1 | 20 |
Total daily matrix cells: 1,200. Total daily geocoding calls: 240 vehicle + 20 asset (one-time). If you cache vehicle addresses with a 24-hour TTL and your vehicles frequently park at the same stops, the 240 geocoding calls drop significantly on repeat locations.
All of this sits comfortably within the free tier (3,000 calls/day) for a fleet of this size. A larger fleet — 200 vehicles, 100 chargers, querying every 5 minutes — would scale into a paid bracket; see csv2geo.com/pricing/api for the exact bracket that fits.
If your fleet operations team runs a full 5,000-stop dispatch model, the broader dispatch architecture is covered in Dispatch Console — 5,000 Stops per Day, which applies the same Haversine-then-matrix pattern to a general routing problem.
Observability
Three metrics worth instrumenting before you go live.
`charger_lookup_duration_ms` — wall-clock time from request received to ranked list returned. Set an alert if p95 exceeds your SLA. If it does, check whether the geocode step is the bottleneck (cache miss) or the matrix call (external latency).
`candidates_after_filter` — the count of chargers that survived the Haversine pre-filter. If this is consistently 0 or 1, your radius is too tight; if it is consistently equal to top_n, your radius may be too loose and you are not saving as many matrix cells as you think.
`degraded_mode_rate` — the fraction of lookups that fell back to Haversine-only ranking. This should be near zero during normal operations. A spike means either the matrix endpoint is having issues or a cluster of vehicles is operating outside your charger coverage area — both of which need a human response.
The general observability framework for geocoding pipelines, including the dashboard setup and alerting thresholds, is in Observability for Geocoding Pipelines — Metrics That Matter.
What this pattern handles and what it does not
This is a nearest-charger lookup, not a full route-optimisation problem. The distinction matters.
The pattern above answers: given a vehicle at position X right now, which charger is fastest to reach? It does not answer: given 40 vehicles and 20 chargers with varying availability and capacity, what is the optimal assignment of vehicles to chargers that minimises total fleet downtime? That second question is a vehicle routing problem (VRP) and requires a different algorithmic approach — a distance matrix is still an input to that problem, but the ranking logic is replaced by a solver.
The pattern also assumes chargers are always available. Real fleets have chargers with queue lengths, occupied bays, and maintenance windows. If you have a charger-availability feed, filter CHARGER_CACHE to available chargers before running the Haversine step — the rest of the pipeline is unchanged. If you do not have availability data, the nearest-by-drive-distance result is still better than nearest-by-straight-line; you are just choosing not to rank on availability.
Finally, the pattern geocodes the vehicle once per query. If the vehicle is moving — the driver is actively en route — a result that was accurate 30 seconds ago may be stale. For fast-moving fleet vehicles, the geocoding step is irrelevant (you have GPS), and the right trigger for a fresh lookup is a significant position change (> 500 m), not a fixed time interval.
Frequently Asked Questions
Does CSV2GEO ship a live EV-charger database I can query? No. The charger list is yours — you supply it, you manage it, you maintain it. CSV2GEO geocodes the addresses in your list and computes drive distances between them and your vehicles. The Places endpoint returns generic POIs but is not a specialised EV-charger feed. If you need a live charger-availability feed, that is a separate data product from a network operator; CSV2GEO is the geocoding and routing layer that works with whatever asset list you bring to it.
What is the difference between Haversine distance and the distance matrix result? Haversine computes the straight-line distance across the Earth's surface, ignoring roads, one-way constraints, and road speed. It is fast and costs nothing to compute. The distance matrix routes over real road networks and returns both drive distance and drive duration. The matrix result is what a driver actually experiences; Haversine is a lower bound used to eliminate obviously-too-far candidates before spending matrix credits.
How many destinations can I pass to the distance matrix in one call? Refer to the current API documentation at csv2geo.com for the per-call limits on origins and destinations. As a practical matter, the pre-filter pattern in this post intentionally keeps the destination count to 5, which is well within the limits of any sensible matrix endpoint and keeps your per-query latency predictable.
How often should I re-geocode my charger list? Only when addresses change. Geocoding a stable physical asset twice is wasted credit. Build a simple check into your asset-management workflow: when an address field is updated, mark the row geocoded = false and let your enrichment job re-geocode it on the next run. Everything else stays cached indefinitely.
What confidence threshold should I use for vehicle address geocoding? In a dispatch context, a confidence below 0.7 warrants a fallback to GPS or a manual confirmation from the driver. Unlike the charger asset list — where a bad geocode is a one-time problem you catch at import — a bad vehicle geocode produces a wrong nearest-charger recommendation in real time. The threshold should be conservative, and the UI should surface a "location uncertain" warning when confidence is borderline.
My chargers span multiple countries. Does the distance matrix handle that? CSV2GEO covers 39 countries and 461M+ addresses for geocoding. The distance matrix routes on road networks within the covered geography. Cross-border routing between covered countries generally works; routing to a country outside the covered set will return an error or a not-reachable status. Check the countries list at csv2geo.com and verify your fleet geography against it before assuming cross-border routing is available for your specific combination.
Can I use the Places API to discover new charger locations I have not added to my list? Yes, within limits. /api/v1/places returns generic POIs near a coordinate. You can use it to discover candidate stops near a route waypoint and then present them to a fleet manager for approval before they are added to the approved charger list. Do not automatically add Places results to your operational charger list without a human confirmation step — the geocoding and operational details of a POI result need verification before a vehicle is dispatched to it.
Related Articles
- Dispatch Console — 5,000 Stops per Day — the same Haversine-then-matrix pattern applied to a full daily dispatch problem at scale
- Caching Geocoding Results — 90% Cost Reduction — cache your stable asset geocodes aggressively; the pattern is the same whether your assets are depots or chargers
- Benchmarking Geocoding APIs — Honest Numbers — what to measure when evaluating the geocoding accuracy of your charger asset list
- Exponential Backoff — When to Retry, When to Stop — the retry policy for distance matrix and geocoding calls under load
- Concurrency Tuning for Geocoding — Finding the Sweet Spot — how to size your concurrent request pool when geocoding a large charger or depot list in bulk
---
*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 →