Routing home care visits to fit more patients into a shift

Geocode patient addresses at intake, cluster by drive-time matrix, sequence each caregiver's day with the optimize endpoint. REST patterns for home care.

| August 22, 2026
Routing home care visits to fit more patients into a shift

Drive time is unpaid time in home care. A caregiver who spends three hours behind the wheel on a nine-hour shift is delivering six hours of billable care — and the agency is absorbing the rest as overhead: mileage reimbursement, insurance, wear on the vehicle, caregiver fatigue, and the scheduling chaos that comes from a timetable that assumed twenty minutes between patients and got forty-five. At scale, across dozens of caregivers and hundreds of daily visits, that gap between planned and actual drive time is the single largest controllable cost on the P&L.

The routing problem in home care is not exotic. It is a variant of the vehicle routing problem that every logistics company faces, with three constraints that make it specific to this industry: the patient addresses change slowly but not never, the caregiver-to-patient assignments are not arbitrary (skills, language, relationship continuity all matter), and the regulatory and contractual constraints on visit windows are non-trivial. What agencies almost universally lack is a clean geocoding and routing layer that can be wired into the scheduling system they already have — something that answers "given this list of patient addresses and this roster of caregivers, what is the best assignment and sequence?" without requiring a six-month GIS project.

This post covers exactly that. Three REST endpoints, a clear data flow, and the failure modes that will bite you if you skip steps. By the end you will have working code for all three stages: geocode once at intake, cluster by drive-time matrix, and sequence each caregiver's day with the optimise endpoint.

The three stages and why each exists

Home care visit routing breaks cleanly into three stages. They map to three REST surfaces. Running them in order — and only in order — is what keeps the system correct and auditable.

Stage 1 — Geocode at intake. When a patient's address enters the system, resolve it to a coordinate. Store the coordinate. From that point forward, every routing calculation uses the coordinate, never the raw address string. This matters for two reasons: free-text address strings are unreliable inputs to a routing engine (spelling variation, abbreviation, missing unit numbers), and re-geocoding the same address on every scheduling run is both slow and expensive. The geocode happens once; the coordinate is the permanent record.

Stage 2 — Drive-time matrix for caregiver-patient clustering. Before you can sequence anyone's day, you need to know which patients are close to which caregivers' start locations, and close to each other. An N×M drive-time matrix — N caregivers, M patients — gives you the raw travel cost for every possible pairing. You use this to cluster patients to caregivers: a caregiver whose home zip code is in the north of the county should not be assigned five patients in the south if another caregiver's territory is already there. The matrix makes that assignment decision quantitative rather than a scheduler's gut call.

Stage 3 — Sequence each caregiver's day with the optimise endpoint. Once a caregiver has a list of assigned patients, the optimise endpoint finds the visit order that minimises total drive time subject to the visit-window constraints. The agency's scheduling system receives the ordered list of patient addresses and a predicted arrival time per stop. The rest — electronic visit verification, clinical documentation, billing — is the scheduling system's job, not ours.

What this is not

One clean boundary before the code. CSV2GEO handles geocoding, drive-time matrices, and route optimisation. It does not handle EVV (electronic visit verification), clinical scheduling rules (visit frequency, skill requirements, continuity of care preferences), or payer billing. Those belong to the scheduling system downstream. The pattern in this post is: the scheduling system holds the constraints; the API solves the geometry.

This is also distinct from the facility-access analysis covered in measuring patient drive time access to clinics and from reactive nearest-caregiver dispatch. This is recurring daily planning for scheduled home care, run once per scheduling cycle (typically the night before or the morning of), not a real-time matching problem.

If your organisation handles patient addresses under HIPAA, read the PII-safe pipeline post before writing a line of integration code. The short version: geocode at intake, store coordinates, discard the raw address from any system that logs API payloads. The coordinate has no PHI; the address does.

Stage 1 — Geocode at intake

The intake geocode is a standard single-address call. The output — a latitude, a longitude, and a confidence score — gets written to the patient record at the moment the record is created. It is never recalculated unless the patient moves.

curl -s "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=742 Evergreen Terrace Springfield IL 62704" \
  --data-urlencode "api_key=$CSV2GEO_KEY" \
  -G \
  | jq '.results[0] | {lat, lng, confidence}'

In Python:

import os
import requests

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

def geocode_patient(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["confidence"] < 0.70:
        # Flag for manual review — do not commit a low-confidence coordinate
        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}

And in Node:

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

async function geocodePatient(address) {
  const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`geocode failed: ${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.70,
  };
}

The confidence < 0.70 threshold is a starting point, not a law. Tune it against your own address quality once you have a few hundred geocodes in the bank. What matters is that low-confidence records get a human look before the coordinate is used in routing — a caregiver sent to the wrong side of town because the geocoder matched a partial address is a real operational failure mode.

One practical note on unit numbers. Home care patients disproportionately live in multi-unit buildings — assisted living complexes, apartment blocks, sheltered housing. An address string without a unit number will geocode to the building centroid, which is close enough for routing purposes (the drive-time matrix cares about which block you are on, not which floor), but the caregiver still needs the unit number to actually find the patient. Store unit separately from the address field you send to the geocoder.

Stage 2 — Drive-time matrix for caregiver-patient clustering

The matrix call is the step most engineering teams underestimate. It is not just "how long does it take to drive from A to B?" It is the N×M table that lets the scheduler say: "Caregiver Alice's home is 8 minutes from Patient 1, 22 minutes from Patient 2, and 41 minutes from Patient 3. Caregiver Bob's home is 35 minutes from Patient 1 and 9 minutes from Patient 2. Assign Patient 1 to Alice and Patient 2 to Bob."

The matrix endpoint accepts a list of origins and a list of destinations and returns a drive-time table:

curl -s "https://csv2geo.com/api/v1/matrix" \
  --data-urlencode "origins=41.8827,-87.6233|41.8750,-87.6500" \
  --data-urlencode "destinations=41.8900,-87.6300|41.8700,-87.6100|41.8600,-87.6700" \
  --data-urlencode "api_key=$CSV2GEO_KEY" \
  -G

In Python, with a realistic home-care setup — five caregivers, twenty patients:

def build_drive_time_matrix(caregiver_coords, patient_coords):
    """
    caregiver_coords: list of (lat, lng) — one per caregiver home/start location
    patient_coords:   list of (lat, lng) — one per patient
    Returns: 2D list [caregiver_idx][patient_idx] = drive seconds
    """
    origins = "|".join(f"{lat},{lng}" for lat, lng in caregiver_coords)
    destinations = "|".join(f"{lat},{lng}" for lat, lng in patient_coords)

    r = requests.get(
        f"{API}/matrix",
        params={
            "origins": origins,
            "destinations": destinations,
            "api_key": KEY,
        },
        timeout=60,
    )
    r.raise_for_status()
    return r.json()["durations"]  # list of lists, seconds

The response shape is durations[origin_index][destination_index] in seconds. You can convert to minutes for display; keep seconds for arithmetic.

What you do with the matrix is, deliberately, your logic — not ours. A simple greedy assignment that works well for most agencies:

def assign_patients_to_caregivers(durations, max_patients_per_caregiver=8):
    """
    Greedy: assign each patient to the caregiver who can reach them fastest,
    subject to a cap on patients per caregiver.
    """
    n_caregivers = len(durations)
    n_patients = len(durations[0])
    assignment = [-1] * n_patients          # patient_idx -> caregiver_idx
    load = [0] * n_caregivers               # how many patients each has

    # Sort patients by "how much variation exists between caregivers" (hardest to assign first)
    def spread(patient_idx):
        col = [durations[c][patient_idx] for c in range(n_caregivers)]
        return max(col) - min(col)

    order = sorted(range(n_patients), key=spread, reverse=True)

    for p in order:
        # Pick the fastest caregiver who has capacity
        eligible = [c for c in range(n_caregivers) if load[c] < max_patients_per_caregiver]
        if not eligible:
            raise ValueError("Not enough caregiver capacity for patient list")
        best = min(eligible, key=lambda c: durations[c][p])
        assignment[p] = best
        load[best] += 1

    return assignment  # list indexed by patient

This greedy approach is not optimal in the mathematical sense — it is a heuristic that runs in milliseconds and produces routes that are reliably better than hand-drawn schedules. If your agency has strict fairness constraints (no caregiver should travel more than twice as far as any other), add a constraint to the eligibility filter. If skills or language preferences matter, pre-filter the eligible list before the min() call.

The matrix is the right place to encode territory boundaries. Do not post-process routes to enforce territories; enforce them here, in the assignment step, by zeroing out drive times for caregiver-patient pairs that cross a boundary. A drive time of float('inf') means "this pairing is not permitted" and the greedy loop will skip it.

Stage 3 — Sequence each caregiver's day

Once a caregiver has their patient list, the optimise endpoint finds the visit order that minimises drive time. This is the travelling-salesman-variant that most teams reach for first — but it only makes sense once the clustering in Stage 2 is done. Feeding all patients across all caregivers into a single optimise call will produce a globally-optimal tour that assigns every patient to one "caregiver" — which is not a useful result.

curl -s "https://csv2geo.com/api/v1/route/optimize" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "'"$CSV2GEO_KEY"'",
    "locations": [
      {"id": "start", "lat": 41.8827, "lng": -87.6233},
      {"id": "patient_1", "lat": 41.8900, "lng": -87.6300},
      {"id": "patient_2", "lat": 41.8700, "lng": -87.6100},
      {"id": "patient_3", "lat": 41.8600, "lng": -87.6700}
    ],
    "start": "start",
    "end": "start"
  }'

In Python, building the call from a caregiver's assignment:

def optimise_caregiver_route(caregiver_start: tuple, patient_list: list[dict]) -> list[str]:
    """
    caregiver_start: (lat, lng)
    patient_list: list of {"id": str, "lat": float, "lng": float}
    Returns: ordered list of patient IDs
    """
    locations = [{"id": "depot", "lat": caregiver_start[0], "lng": caregiver_start[1]}]
    locations += patient_list

    r = requests.post(
        f"{API}/route/optimize",
        json={
            "api_key": KEY,
            "locations": locations,
            "start": "depot",
            "end": "depot",
        },
        timeout=30,
    )
    r.raise_for_status()
    route = r.json()["route"]
    # Drop depot from start and end; return patient IDs in visit order
    return [stop["id"] for stop in route if stop["id"] != "depot"]

And in Node:

async function optimiseCaregiverRoute(caregiverStart, patientList) {
  const locations = [
    { id: 'depot', lat: caregiverStart[0], lng: caregiverStart[1] },
    ...patientList,
  ];
  const r = await fetch(`${API}/route/optimize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key: KEY,
      locations,
      start: 'depot',
      end: 'depot',
    }),
  });
  if (!r.ok) throw new Error(`optimize failed: ${r.status}`);
  const body = await r.json();
  return body.route
    .map(stop => stop.id)
    .filter(id => id !== 'depot');
}

The end: "depot" parameter tells the engine to return the caregiver home after the last patient. In practice, many home care caregivers do not return to a central depot — they end their shift at home. If that is your setup, omit end or pass the caregiver's home coordinate as the end point. The drive back is then costed into the total route time, which gives you a more honest shift-length estimate.

Visit windows — "Patient 3 must be seen between 10:00 and 11:30" — are the most common reason a mechanically-optimal route becomes clinically unacceptable. The optimise endpoint accepts time-window constraints per location. Wire in the visit-window data from your scheduling system's patient records. Without this constraint, the engine may sequence a patient with a narrow morning window to the afternoon, which is not usable output regardless of how good the aggregate drive time looks.

Producing the shift sheet

The output of Stage 3 per caregiver is an ordered list of patient IDs with estimated arrival times. What the caregiver actually carries is a printed or mobile-rendered shift sheet: patient name, address, arrival time, visit duration, any clinical notes the scheduling system attaches.

A static map of the day's route helps caregivers orient quickly and reduces wrong-turn time in unfamiliar areas. The Static Maps endpoint accepts a list of waypoints and returns a PNG:

curl -s -o "shift_alice.png" \
  "https://csv2geo.com/api/v1/staticmap?\
waypoints=41.8827,-87.6233|41.8900,-87.6300|41.8700,-87.6100|41.8600,-87.6700\
&width=800&height=600&api_key=$CSV2GEO_KEY"

The resulting image can be embedded in an email, a PDF shift sheet, or a mobile app screen. The caregiver sees the day's geography at a glance — how the patients cluster spatially, which direction the day moves. That visual context reduces the cognitive load of navigating between patients who are new to the caregiver.

How to run this end-to-end

Step 1: Geocode all patient addresses and store coordinates

Run the intake geocode on every existing patient in your system. Flag confidence scores below 0.70 for manual review. Store (lat, lng, confidence, geocoded_at) on the patient record. From this point on, routing uses coordinates; the scheduling system holds the clinical and demographic data. Budget: 1 credit per patient address; a practice with 500 active patients is 500 credits, a one-time cost.

Step 2: Build the drive-time matrix for today's caregivers and patients

Each scheduling cycle — typically the evening before or the morning of — collect the start coordinates for every caregiver on shift and the coordinates for every patient scheduled that day. Call the matrix endpoint once with all origins and all destinations. Cache the result for the duration of the scheduling run; the matrix does not change mid-run. For 10 caregivers and 80 patients, this is one API call returning an 10×80 table of drive times.

Step 3: Assign patients to caregivers using the matrix

Run the greedy assignment (or your own LP/constraint-solver) over the matrix. Enforce skill constraints, territory boundaries, and maximum load here by filtering eligible caregivers per patient before the assignment loop. The output is a map of caregiver_id -> [patient_id, ...]. Log the total theoretical drive time (sum of matrix cells for the assignments chosen) before and after optimisation for your ops dashboard.

Step 4: Sequence each caregiver's patient list

For each caregiver, call the optimise endpoint with their assigned patient coordinates and any visit-window constraints drawn from the scheduling system. The output is an ordered list of patient IDs with predicted arrival times. Write this back to the scheduling system as the proposed schedule for that caregiver.

Step 5: Generate the shift sheet per caregiver

Join the optimised route order against the patient records to produce the shift sheet: ordered list of patient names, addresses, arrival windows, visit durations. Attach the static map image. Deliver via the channel the scheduling system already uses — email, SMS, in-app notification. The API's job ends here; the scheduling system distributes and tracks compliance.

Production failure modes

Three things that bite home care agencies who ship this pattern in a hurry.

Stale geocodes after a patient moves. A coordinate stored at intake stays in the database indefinitely. When a patient changes address, the scheduling system must trigger a new geocode and overwrite the stored coordinate. If you miss this step, the routing engine sequences visits to the old address. Build a webhook or a trigger in the scheduling system that fires a geocode update whenever the address field changes on a patient record. This is not a geocoding problem — it is a data-freshness problem that the integration layer must own.

Matrix size growing beyond practical limits. For agencies scheduling a hundred or more caregivers against several hundred patients, a naive full N×M matrix becomes expensive and slow to compute. The solution is geographic pre-filtering: before building the matrix, exclude caregiver-patient pairs whose straight-line (Haversine) distance exceeds a threshold you set from historical data — say, 30 km. This reduces a 100×300 matrix to a series of much smaller calls without meaningfully affecting route quality, because a caregiver is never going to be assigned a patient 30 km from their home when there are patients nearby.

Optimise endpoint ignoring clinical constraints that the scheduling system holds. The optimise endpoint sees coordinates and visit windows. It does not know that Caregiver Alice has a medication-administration certification that Patient 4 requires. It does not know that Patient 7 has requested the same caregiver for the past six months. Those constraints live in the scheduling system, and they must be enforced before the optimise call — not corrected after. The pattern: apply all non-geographic constraints at the assignment step (Stage 2) to produce a restricted patient list per caregiver; then optimise purely on travel within that already-constrained list.

Cost model for a realistic home care agency

An agency with 40 caregivers and 300 active patients, running scheduling cycles five days a week, uses the API as follows:

  • Patient intake geocoding: 300 patients, once. 300 credits. One-time.
  • Daily matrix call: 40 caregivers × 120 patients typically on-schedule per day. One matrix call: 40×120 = 4,800 cells. This is one API call per day.
  • Daily optimise calls: One per caregiver per day. 40 calls per day, 200 calls per week.
  • Static map images: 40 per day.

Weekly total: roughly 240 credits for optimise, 5 matrix calls, 200 map images — well within the 100,000-call monthly bracket that starts at $54/month on paid pricing. The realistic all-in cost for geocoding and routing a 40-caregiver scheduling operation is a rounding error against the mileage reimbursement it saves. See the full pricing breakdown at csv2geo.com/pricing/api.

The free tier — 3,000 calls per day — is more than sufficient for a pilot running a single team of eight caregivers and validating route quality before committing to the integration.

What to measure to prove the improvement

The API gives you the geometry. You measure the operational effect. Three metrics worth instrumenting from day one:

Planned drive time per shift. Sum the matrix cells for the final assignment and the optimise endpoint's reported total duration per caregiver. Log this daily. The trend line after you switch from manual scheduling to API-driven scheduling is the business case.

Actual vs planned drive time. Pull GPS data from the caregivers' phones or vehicles (most agencies already have this for EVV compliance) and compare actual drive times against the planned times. The gap tells you whether your visit-window constraints are realistic and whether your traffic model is accurate for your geography.

Caregiver start-to-first-patient time. A well-sequenced route starts with the patient geographically closest to the caregiver's home. If this number is consistently high, the assignment step is not weighting the start-location penalty enough.

These three numbers require no new data collection beyond what most agencies already log. They are the right KPIs to present to a medical director or a COO who wants to understand whether the routing investment is working.

Frequently Asked Questions

Do patient coordinates count as PHI under HIPAA?

Coordinates alone are not PHI. A latitude/longitude with no associated patient name, date of birth, or identifier is a geometric point. The address that produced it is PHI. The safe pattern is: geocode at intake, store coordinates, ensure no PHI is present in API request logs. Read the full PII-safe pipeline post for the no_record flag and BAA details before going to production.

How do we handle patients who live in multi-unit buildings where the geocoder resolves to the building centroid?

This is fine for routing — the drive time to the building is what matters for sequencing. Store the unit number separately in the patient record and surface it on the shift sheet so the caregiver can find the right door. Do not attempt to geocode "Building A, Unit 312" — that level of precision does not exist in any address database, and trying will produce noise.

What if a caregiver's day changes after the shift sheet is produced — a patient cancels, or an emergency visit is added?

Re-run Stage 3 (the optimise call) for the affected caregiver with the updated patient list. It is a single fast call. Design your scheduling system so that Stage 3 can be re-run in isolation without re-running Stages 1 and 2. The coordinates and the matrix from Stage 2 are reusable for the day; only the optimise step needs to be re-executed. See idempotent geocoding patterns for the caching strategy that makes partial re-runs cheap.

Can we use this for community nurses, physios, and social workers — not just personal care aides?

Yes. The routing problem is the same geometry regardless of clinical role. The only difference is that the constraints fed into the assignment step are different — a community nurse may have stricter visit-window requirements and a smaller maximum patient load per shift. Those constraints are applied in Stage 2; the API sees coordinates and windows, not clinical roles.

Is there an SDK we should use instead of raw REST calls?

Python and Node SDKs exist, but the REST surfaces are simple enough that most production integrations wrap them in a thin internal client rather than pinning an SDK version. That is the pattern shown in this post and the one we recommend for enterprise integrations — one HTTP call, one JSON response, no version pinning, no upgrade treadmill.

How does this relate to the facility-side network access analysis?

They are complementary, not duplicates. The facility-side analysis asks "how many of our registered patients can reach this clinic within 30 minutes?" — a population-level question that informs where to open or close service locations. This post asks "given today's patient roster and today's caregivers, what is the optimal visit sequence for each caregiver?" — a daily operational question. The same geocoded patient coordinates feed both workflows; the API calls and the business question are different.

What happens if the optimise call times out on a large caregiver schedule?

The optimise endpoint solves a computationally hard problem; very large inputs (a single caregiver with 25+ stops and tight windows) can be slow. The practical mitigation is Stage 2: keep caregiver loads to 10–12 patients per shift through the assignment step, and the optimise call resolves quickly. If you have genuine need for very large sequences, implement a fallback using the nearest-neighbour heuristic on your own matrix data and treat the optimise result as a confirmation rather than a dependency.

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 →