Quantifying branch closure drive-time impact before you decide

Measure drive-time impact before closing a bank branch. Two matrix API runs, before and after, show exactly which areas cross your threshold.

| August 21, 2026
Quantifying branch closure drive-time impact before you decide

Branch consolidation decisions arrive wrapped in cost data and exit wrapped in community complaints. The finance team sees a branch covering its fixed costs by a shrinking margin. The retail-network team sees a customer segment that has nowhere else to go on a Tuesday afternoon without a car. Both are right, and neither has a defensible number in their hand.

The number they need is drive-time impact: how many areas — aggregated from the customer base — currently sit within, say, 20 minutes of a branch, and how many of those areas get pushed past that threshold when the network shrinks? That is a geographic computation, not a gut-feel argument. It is also one that your strategy team can reproduce, audit, and present to whoever asks hard questions about the decision.

This post walks through the full computation: geocode the customer base to area centroids (PII-safe, no individual addresses stored), run a routing matrix against the current network, run it again against the post-consolidation network, compare the two, and draw isolines around the surviving branches to produce the coverage map. All of it is two or three REST calls per batch, reproducible in Python or Node, and the cost is a fraction of one analyst-day.

The problem with drive-time analysis done by hand

Most teams that attempt this computation do it in one of two ways. Neither scales.

The spreadsheet approach. They compute straight-line distances from customer postcodes to branch postcodes, apply a rough "multiply by 1.4 for road factor," and call that a drive time. The error is large in rural areas where roads wind; the error is catastrophic in urban areas where a 2 km straight line can mean 8 minutes or 45 minutes depending on which side of a river you are on.

The GIS-consultant approach. They commission a mapping consultancy to build a bespoke analysis in a desktop GIS tool, which produces a beautiful PDF in six weeks and cannot be re-run when the proposed closure list changes, which it will, three times, before the board meeting.

What strategy teams actually need is a computation they control, that runs in minutes, that can be re-run whenever the candidate branch list changes, and that produces numbers — not pictures — as its primary output, so analysts can filter, aggregate, and slice them in whatever reporting tool the business already owns.

That computation is two N×M routing-matrix runs and a boundaries lookup. Here is how to build it.

The data model before you write a line of code

One design decision determines whether this analysis is PII-safe: never geocode individual customers. Geocode area centroids.

Your customer file contains addresses. Before it touches the geocoding pipeline, aggregate it to areas — census tracts, postal sectors, or whatever administrative unit your institution already uses for internal reporting. Each area becomes one row: area_id, address_of_centroid, customer_count. The centroid is a publicly-known geographic midpoint, not a home address. The pipeline never sees individual names, individual account numbers, or individual residential coordinates.

The output is a matrix of drive times: area_id → branch_id → drive_time_minutes. You run this matrix twice — once for the current network, once for the proposed network — and the difference tells you exactly which areas change status.

For the area boundary shapes themselves, the Boundaries endpoint returns administrative-unit polygons by coordinate or identifier. You can use these to produce the "affected areas" layer on the coverage map without needing a GIS stack.

Step 1: Geocode area centroids in batch

The raw input is a list of area identifiers with a representative address or postcode for each centroid. Batch geocoding through the /api/v1/geocode/batch endpoint processes these in bulk. The result is a lat,lng per area that you carry forward to the matrix call.

# Geocode a single area centroid to verify the pattern
curl -s "https://csv2geo.com/api/v1/geocode?q=94103&api_key=$CSV2GEO_API_KEY" \
  | jq '{area: "94103", lat: .results[0].lat, lng: .results[0].lng, confidence: .results[0].confidence}'

In Python, the batch version that processes your full list of area centroids:

import csv
import os
import requests

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

def geocode_centroids(centroid_file: str) -> list[dict]:
    """Read area centroids CSV, return list of {area_id, lat, lng, confidence}."""
    results = []
    with open(centroid_file) as f:
        reader = csv.DictReader(f)  # expects: area_id, centroid_address, customer_count
        for row in reader:
            r = requests.get(
                f"{API}/geocode",
                params={"q": row["centroid_address"], "api_key": KEY},
                timeout=30,
            )
            r.raise_for_status()
            hit = r.json()["results"][0]
            results.append({
                "area_id":        row["area_id"],
                "customer_count": int(row["customer_count"]),
                "lat":            hit["lat"],
                "lng":            hit["lng"],
                "confidence":     hit.get("confidence", 0),
            })
    return results

Flag any row with confidence < 0.7 for manual review before it enters the matrix. A misgeocoded centroid puts a phantom cluster of customers in the wrong part of the city, and the error propagates invisibly into every drive-time calculation downstream.

Step 2: Run the before-network routing matrix

The routing matrix endpoint takes a set of origins (area centroids) and a set of destinations (branch locations) and returns a drive time for every origin–destination pair. The response is the N×M grid you need.

Branch locations are already known and already geocoded. Keep them in a separate file: branch_id, branch_address, lat, lng, status where status is open for the current network and surviving for the post-consolidation network.

import itertools

def run_matrix(origins: list[dict], destinations: list[dict], label: str) -> dict:
    """
    origins: list of {area_id, lat, lng}
    destinations: list of {branch_id, lat, lng}
    Returns: {area_id: {branch_id: drive_time_minutes}}
    """
    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}/routing/matrix",
        params={
            "origins":      origin_str,
            "destinations": dest_str,
            "mode":         "drive",
            "api_key":      KEY,
        },
        timeout=120,
    )
    r.raise_for_status()
    data = r.json()

    result = {}
    for i, origin in enumerate(origins):
        result[origin["area_id"]] = {}
        for j, dest in enumerate(destinations):
            cell = data["durations"][i][j]
            result[origin["area_id"]][dest["branch_id"]] = (
                round(cell / 60, 1) if cell is not None else None
            )
    print(f"[{label}] matrix {len(origins)}×{len(destinations)} — done")
    return result

The durations field returns seconds. Dividing by 60 gives you minutes, which is the unit that makes sense in a board-level presentation.

For large networks — say 200 area centroids and 45 branches — the matrix is 9,000 cells. That is a single API call. If your network is larger, partition origins into chunks of 100 and run sequentially; the pattern is the same and the total credit cost remains manageable.

Step 3: Derive nearest-branch drive time before closure

From the N×M matrix you derive, for each area, the single most important number: the drive time to the nearest open branch.

def nearest_branch_times(matrix: dict, branch_ids: list[str]) -> dict:
    """
    Returns {area_id: min_drive_minutes} — nearest branch in the supplied network.
    """
    result = {}
    for area_id, branch_times in matrix.items():
        valid = [
            branch_times[bid]
            for bid in branch_ids
            if branch_times.get(bid) is not None
        ]
        result[area_id] = min(valid) if valid else None
    return result

Run this against the before-network branch list. Store the output as before_times. Then run the full matrix again against the surviving-branch list and store it as after_times. Now you have the two numbers per area that the analysis hinges on.

Step 4: Compute and classify impact per area

The policy question is usually binary: does an area cross a drive-time threshold after the closure? The threshold is a business decision — 20 minutes is a common starting point for retail banking, though your network team will have a view based on your customer segment and geography.

THRESHOLD_MINUTES = 20.0

def classify_impact(
    centroids:   list[dict],
    before_times: dict,
    after_times:  dict,
) -> list[dict]:
    rows = []
    for c in centroids:
        aid  = c["area_id"]
        bef  = before_times.get(aid)
        aft  = after_times.get(aid)

        if bef is None or aft is None:
            impact = "no_coverage_either_network"
        elif bef <= THRESHOLD_MINUTES and aft > THRESHOLD_MINUTES:
            impact = "pushed_past_threshold"   # the meaningful finding
        elif bef > THRESHOLD_MINUTES and aft > THRESHOLD_MINUTES:
            impact = "already_past_threshold"  # pre-existing gap
        else:
            impact = "within_threshold"        # unaffected

        rows.append({
            "area_id":               aid,
            "customer_count":        c["customer_count"],
            "before_drive_min":      bef,
            "after_drive_min":       aft,
            "drive_time_delta_min":  round((aft or 0) - (bef or 0), 1),
            "impact":                impact,
        })
    return rows

The pushed_past_threshold rows are the core finding. Sum their customer_count and you have the headline number: *N customers in M areas are pushed beyond a 20-minute drive to any branch.* That is the number that belongs in the board deck, the regulator conversation, and the community-impact assessment. It is also the number that gets challenged, which is why having a reproducible computation matters — you can re-run it in ten minutes if anyone disputes the inputs.

The classification exercise also surfaces pre-existing gaps: areas already past the threshold before any closure. Those are a different problem — not caused by this consolidation, but worth acknowledging.

Step 5: Draw isolines around surviving branches

The matrix and classification give you the numbers. Isolines give you the map — and the map is what makes the numbers credible to a non-technical audience.

The isoline (isochrone) endpoint takes a point and a travel-time limit and returns the polygon that represents "everything reachable within N minutes by car." Draw one per surviving branch at your threshold value. The union of all those polygons is the post-consolidation coverage area. Any area centroid outside that union is, by definition, beyond the threshold.

# Draw a 20-minute drive isoline around a surviving branch
curl -G "https://csv2geo.com/api/v1/isoline" \
  --data-urlencode "lat=40.7128" \
  --data-urlencode "lng=-74.0060" \
  --data-urlencode "mode=drive" \
  --data-urlencode "range=1200" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

range is in seconds — 1,200 seconds is 20 minutes. The response is a GeoJSON Polygon or MultiPolygon. Loop across surviving branches and collect the results:

// Node — fetch isoline per surviving branch
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;

async function fetchIsoline(lat, lng, thresholdMinutes = 20) {
  const range = thresholdMinutes * 60;
  const url = `${API}/isoline?lat=${lat}&lng=${lng}&mode=drive&range=${range}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`isoline http ${r.status} for ${lat},${lng}`);
  return r.json(); // GeoJSON feature
}

async function allIsolines(survivingBranches) {
  return Promise.all(
    survivingBranches.map(b => fetchIsoline(b.lat, b.lng))
  );
}

The resulting GeoJSON features can be loaded directly into any mapping library for the visualisation layer. What the map shows — starkly — is where the coverage thins after consolidation. White space on the post-consolidation isoline map that was covered in the before-map is where your pushed_past_threshold customers live.

Pair the map with the pushed_past_threshold aggregate customer count and the analysis is complete.

Structuring the output for the people who make the decision

The pipeline above produces three outputs. Each has a different audience.

The per-area CSVarea_id, customer_count, before_drive_min, after_drive_min, drive_time_delta_min, impact — is for the analyst who needs to slice by geography, by customer segment, or by product type. This is the raw data that any subsequent pivot table or BI tool can consume. CSV2GEO has no demographic data; if your institution wants to overlay that data, it comes from your own systems, joined on area_id after the pipeline completes.

The summary table — counts and customer totals per impact category — is for the board pack. One table, four rows, one number per cell. The pushed_past_threshold row is the one that matters.

The isoline map — GeoJSON layers, before and after — is for the community affairs team and for any external presentation where a picture is worth more than a table. Save each layer keyed to the analysis run date and the branch closure scenario label. If the closure list changes, re-run the pipeline with the new surviving-branch list in twenty minutes and produce a new map.

All three outputs are reproducible from the same inputs. That reproducibility is what makes the analysis defensible.

Handling the practical edge cases

Three things that create incorrect results if you skip them.

Areas with null drive times. If a branch or a centroid is in a location the routing engine cannot reach by car — an island, a pedestrianised district, an address geocoded to a motorway — the matrix returns null for that cell. The nearest_branch_times function above already handles this by filtering None values; make sure your classification code handles the case where the minimum is still None (no reachable branch in either network) as its own category rather than mapping it to within_threshold.

Large matrices and request chunking. The routing matrix works well up to a few hundred origins and a few dozen destinations in a single call. If your area list is large — a statewide analysis with 800 census tracts and 60 branches — partition origins into chunks of 100 and run sequentially. The code pattern is identical; only the loop wrapper changes. For retry logic on individual chunks, see Exponential Backoff — When to Retry, When to Stop.

The difference between drive time and straight-line distance. Urban areas with rivers, rail corridors, and motorway junctions regularly produce drive times that are three or four times the straight-line commute. If you geocoded a centroid to the wrong side of a bridge, the drive time can be wildly wrong. Always sanity-check a handful of before_drive_min values against manual directions for addresses you know well before trusting the bulk output.

What this analysis does not answer

Honest scope. The pipeline above answers the drive-time question. It does not answer every question that surrounds a branch consolidation decision.

Transaction volume per area. The analysis shows how many customers are affected; it does not show how heavily they currently use the branches that would close. Your core banking system has that data. Join it to the per-area CSV on area_id after the pipeline completes.

Demographic overlay. The impact may be unevenly distributed across your customer base in ways that matter to your community affairs team. That analysis belongs to your institution's own data and your compliance team's interpretation of it. The pipeline produces the geographic finding; overlay analysis is yours to run.

Alternative-channel substitution. Some customers in affected areas will shift to mobile or ATM once the branch closes. Others will not, especially for complex transactions. Estimating that substitution rate is a CX and behavioural-data question, not a geocoding question.

Regulatory interpretation. The drive-time math is an input to various reporting and community commitment processes. What it means for any specific filing or commitment is your compliance team's call, not ours. We provide the computation; they own the interpretation.

For the catchment and reporting geometry that sits alongside this consolidation analysis, see Bank Branch Catchment and CRA Reporting Geocoding.

Cost and scale

A concrete example. A mid-sized regional bank with 80 area centroids (census-tract aggregation of the customer file) and 35 current branches, considering a closure of 8 branches that leaves 27 surviving.

  • Geocoding centroids: 80 calls (one-time; cache results for the life of the analysis)
  • Before matrix: 1 call — 80 × 35 = 2,800 origin–destination pairs
  • After matrix: 1 call — 80 × 27 = 2,160 pairs
  • Isolines for surviving branches: 27 calls, one per branch
  • Boundaries lookups for area polygons: 80 calls if you want polygon shapes for the map

Total: approximately 190 credits for the full analysis. If the closure list changes — as it will — re-running the two matrix calls and the isolines costs another 109 credits. At paid pricing starting from $54/month for 100,000 calls, a full re-run costs under $0.12 and takes under five minutes of compute time.

The free tier provides 3,000 calls per day, which is enough to run this complete analysis, including several scenario iterations, within a single working day before committing to a paid plan.

Current pricing is at csv2geo.com/pricing/api.

Observability for the pipeline run

A branch consolidation analysis feeds a real decision with real consequences. Instrument it accordingly.

Log the input parameters — area count, branch count, threshold used, scenario label — alongside each run. Log the output summary — cells with null drive times, count of areas per impact category — so you know immediately if something went wrong. If the count of pushed_past_threshold areas is zero for a scenario that closes eight urban branches, something is wrong with the data, not with the decision.

For the broader pattern of instrumenting a geocoding pipeline, Observability for Geocoding Pipelines covers the metrics worth collecting and how to wire them to standard APM tooling.

Frequently Asked Questions

Why aggregate to areas rather than geocoding individual customers? Routing a matrix of individual residential addresses against a branch network is expensive at scale, slow to compute, and creates a PII exposure risk — you are sending home addresses to an external API. Area centroids deliver the same strategic insight: which geographic zones are affected, and how many customers live in each. The difference in analytical value is negligible for a network-planning decision; the difference in PII risk is significant.

Can the same pipeline evaluate multiple closure scenarios? Yes, and that is its main operational value. Re-run Steps 2 and 3 with a different surviving-branch list for each scenario. The before-network matrix result is stable and can be cached across scenarios — only the after-network matrix and the isolines change. A ten-scenario sensitivity analysis adds roughly ten matrix calls and ten sets of isoline calls to the overall cost.

What drive mode should we use — car or public transit? That depends on your customer base. Urban retail banking customers, particularly those without cars, may depend on public transit. If your routing endpoint supports a transit mode, running both drive and transit variants and taking the minimum per area produces a more defensible "effective access time" metric. The aggregation and classification logic is identical; only the mode parameter changes.

How do we handle branches that are being merged rather than closed — i.e., the customer base transfers to a specific branch, not just the nearest survivor? The matrix gives you the drive time from every area to every branch in both networks. For a directed merger — branch A's customers transfer to branch B — filter the after-network matrix to only branch B for those customers, rather than taking the network minimum. The nearest_branch_times function becomes a directed_branch_time lookup for the merged segment.

Is the routing matrix accurate in rural areas where road networks are sparse? Routing quality is good where the underlying road network data is complete, which covers most developed markets. In very sparse rural areas — remote communities served by unpaved tracks — drive-time estimates may be understated because the router treats an unpaved forest road as a navigable route at normal speeds. For rural community banks, spot-checking the matrix output against manual directions for a sample of remote centroids is worth the hour it takes.

Do we need to share individual customer data with CSV2GEO at any point in this pipeline? No. The pipeline sends area centroids (publicly known geographic midpoints of administrative units) and branch addresses. No individual customer name, account number, or home address is transmitted to the API at any point.

How fresh are the road network data used for routing? The routing engine is backed by regularly updated road network data. Construction changes and new road openings propagate into routing results on a cadence managed by the underlying data program. For strategic planning purposes — a branch network that will operate for years — this update frequency is more than sufficient. For real-time navigation, you need a different tool.

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 →