Planning school bus stops within max walk distance

Geocode student rosters and candidate stops, then measure real walking-network distance — not straight-line — to plan compliant school bus stops.

| July 27, 2026
Planning school bus stops within max walk distance

A stop 300 metres away on a map might be a 900-metre walk once you route around the divided highway, the drainage ditch, and the footpath that dead-ends at a chain-link fence. Every district has seen this. A parent calls in on day two of the school year because their Year 4 student cannot safely reach the assigned stop inside the allowed window. The planner pulls up the map, measures the Euclidean distance, confirms it is within policy, and still ends up adding a stop — because the map distance was never the real distance.

This post addresses that gap directly. It shows how to geocode a student address roster using the batch web tool, geocode candidate stop locations, and then use the Routing API in walk mode to measure the actual walking-network distance from each student to each candidate stop. The result is a stop assignment that is defensible to parents, compliant with whatever threshold the district has set, and built entirely on reproducible REST calls rather than a planner's gut.

The student data handling runs on exactly the same principle as HIPAA-protected patient data: process it, keep what you need downstream (a coordinate and a stop assignment), and do not retain what you do not. That principle is not an abstraction here — it is the first engineering decision in the pipeline.

Why straight-line distance fails in school transportation

Euclidean distance is cheap to compute and easy to explain. It is also wrong in almost every residential neighbourhood where stop placement actually matters.

The two failure modes planners encounter constantly.

Barriers that do not exist in a distance matrix. A motorway, a railway, a drainage canal, a private estate with locked gates — any of these turns a 250 m straight-line distance into a 1.2 km walking-network distance. A buffer drawn around a candidate stop on a map looks like it captures every house in the circle; the actual pedestrian catchment is a lopsided shape that honours the road and footpath network.

Distance that compounds across many students. If you are placing stops across a whole district and computing straight-line to the nearest candidate for every student, you might find that ninety percent are within threshold. But the ten percent who are not are not randomly distributed — they cluster around exactly the barriers described above. Those clusters are where complaints come from, where additional stops are demanded, and where the planner spent three weeks of October last year.

Walking-network distance — computed by routing through the actual pedestrian network — catches both failure modes before the school year starts rather than during it.

What the pipeline looks like end to end

Four stages. Each one maps to a specific API call or tool.

  1. Geocode the student roster using the batch web tool. Input: addresses. Output: lat/lng per student. Credits charged: one per address row.
  2. Geocode the candidate stop locations the same way, or with a direct geocoding API call if the candidate list is already structured.
  3. Compute walking-network distance from each student to each viable candidate stop using the Routing API in walk mode — either the route endpoint (one student, one stop, full path) or the matrix endpoint (many students, many stops, just the distance table).
  4. Optimise the bus's stop sequence using the optimise endpoint, once stop assignments are finalised.

The pipeline outputs two things: a student-to-stop assignment table (stored as student_id, stop_id, walk_distance_m) and an ordered stop sequence for each route. The raw student addresses are not persisted past stage 1.

Stage 1: geocoding the student roster

The batch web tool at csv2geo.com accepts a CSV, processes every address row, and returns a CSV with lat/lng appended. Each row costs one credit. For a district of three thousand students that is three thousand credits — a small fraction of a mid-tier monthly plan or a single day's free-tier allowance if the district is small.

The important data-handling decision is made here, before the first API call. The input CSV that goes into the tool contains the student's address. The output CSV that comes back contains the address, the lat/lng, and whatever identifier you need for joining downstream (a student ID, not a name). At this stage you decide what you keep and what you discard.

The right answer for most districts: keep the geocoded coordinate and the student ID. Discard the address string from the working dataset before you pass anything into the routing stage. The routing API only needs the coordinate. Keeping the address string longer than necessary is extra exposure with no technical benefit.

This is the same principle articulated in HIPAA-Compliant Geocoding for patient records: process the sensitive field to produce a coordinate, then work with the coordinate. The sensitive field has done its job.

If you need to geocode programmatically rather than through the web tool — for example, because the district's SIS exports a CSV nightly and you want to automate the pipeline — the REST call looks like this:

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=42 Birchwood Close, Springfield, IL 62701" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

In Python:

import csv
import os
import requests

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

def geocode_address(address: str) -> dict | None:
    r = requests.get(API, 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]
    return {"lat": top["lat"], "lng": top["lng"], "confidence": top.get("confidence")}

For the batch web tool workflow you do not need this — the tool handles the calls. The programmatic path is for automated nightly pipelines where the roster lands as a file and you want zero human steps between export and geocoded output.

One check worth adding regardless of path: reject any geocode with confidence below 0.7 and route those rows to a manual review queue. A low-confidence geocode might place a student half a kilometre from their actual front door, which will produce a walk-distance calculation that is wrong in a direction nobody catches until a parent calls.

Stage 2: geocoding candidate stops

Candidate stops are the simpler case — they are physical locations (street corners, lay-bys, school car parks) that a planner has already identified as viable. Geocode them the same way. A district typically has tens to hundreds of candidates, not thousands, so this is a small batch.

Keep these coordinates in a named list. The routing stage will treat them as the destination set for every student.

Stage 3: measuring actual walk distance

This is the core of the whole post. Everything else is plumbing.

The Routing API accepts two lat/lng points and a travel mode. In walk mode it routes through the pedestrian network — footpaths, pavements, pedestrian crossings, shared paths — and returns the actual network distance and estimated walk time. This is the number that matters for compliance, not the straight-line radius.

A single student-to-stop pair via curl:

curl -G "https://csv2geo.com/api/v1/route" \
  --data-urlencode "origin=41.8781,-87.6298" \
  --data-urlencode "destination=41.8801,-87.6252" \
  --data-urlencode "mode=walk" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response includes distance_m and duration_s alongside the full route geometry if you want to render the walking path on a map. For the stop-planning workflow you need distance_m — that is what you compare against the district's threshold.

Step 1: build the student-to-candidate distance table

The matrix endpoint is more efficient than calling the route endpoint in a loop when you have many students and many candidate stops. It takes a set of origins and a set of destinations and returns an N×M table of distances and durations. One API call instead of N×M individual route calls.

import os
import requests

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

def walk_distance_matrix(student_coords: list[dict], stop_coords: list[dict]) -> list[list]:
    """
    Returns a 2-D list: matrix[i][j] = walk distance in metres
    from student i to stop j. None if no route found.
    """
    origins = "|".join(f"{s['lat']},{s['lng']}" for s in student_coords)
    destinations = "|".join(f"{s['lat']},{s['lng']}" for s in stop_coords)
    r = requests.get(
        API,
        params={
            "origins": origins,
            "destinations": destinations,
            "mode": "walk",
            "api_key": KEY,
        },
        timeout=60,
    )
    r.raise_for_status()
    rows = r.json()["matrix"]
    return [[cell.get("distance_m") for cell in row] for row in rows]

In Node:

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

async function walkDistanceMatrix(studentCoords, stopCoords) {
  const origins = studentCoords.map(s => `${s.lat},${s.lng}`).join('|');
  const destinations = stopCoords.map(s => `${s.lat},${s.lng}`).join('|');
  const url = `${API}?origins=${encodeURIComponent(origins)}` +
              `&destinations=${encodeURIComponent(destinations)}` +
              `&mode=walk&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`matrix call failed: ${r.status}`);
  const data = await r.json();
  return data.matrix.map(row => row.map(cell => cell.distance_m ?? null));
}

The matrix call scales well for district-sized problems. A district with 500 students and 40 candidate stops is a 500×40 matrix — 20,000 origin-destination pairs resolved in one call rather than 20,000 individual route calls. This matters both for wall-clock time and for credit consumption: the matrix is more efficient than the same number of individual route calls.

Step 2: assign each student to the nearest compliant stop

The district has a maximum walk distance. That threshold is the district's policy — each district sets its own, and it varies. The assignment logic is:

def assign_stops(distance_matrix, student_ids, stop_ids, max_walk_m):
    """
    For each student, find the nearest stop within max_walk_m.
    Returns a list of dicts: {student_id, stop_id, walk_distance_m}
    and a list of student_ids that have no compliant stop.
    """
    assignments = []
    unserved = []
    for i, sid in enumerate(student_ids):
        row = distance_matrix[i]
        viable = [
            (dist, stop_ids[j])
            for j, dist in enumerate(row)
            if dist is not None and dist <= max_walk_m
        ]
        if not viable:
            unserved.append(sid)
            continue
        nearest_dist, nearest_stop = min(viable, key=lambda x: x[0])
        assignments.append({
            "student_id": sid,
            "stop_id": nearest_stop,
            "walk_distance_m": nearest_dist,
        })
    return assignments, unserved

The unserved list is the output that drives the next round of planning. Every student in that list needs either a new candidate stop added within reach, or a policy exception. This is where the straight-line planning workflow fails silently — those students would have appeared to be within range, and nobody would have known until day one of term.

Step 3: validate the worst-case distances

Before signing off on the stop plan, run a simple check across the full assignment table:

import statistics

def summarise_assignments(assignments):
    dists = [a["walk_distance_m"] for a in assignments]
    print(f"Students assigned: {len(dists)}")
    print(f"Median walk:       {statistics.median(dists):.0f} m")
    print(f"95th percentile:   {sorted(dists)[int(len(dists)*0.95)]:.0f} m")
    print(f"Maximum walk:      {max(dists):.0f} m")

The 95th-percentile figure is the one to watch. The median will look fine because most students in flat residential areas land within easy reach. The tail is where the highway-crossing, fence-blocked, drainage-ditch cases live. If the 95th percentile is materially above the threshold, there is a cluster of students who will be over the limit once actual routes are walked — and you want to know that in July, not September.

Step 4: pull the full walking route for flagged assignments

For any assignment that is close to the threshold — say within 10% of the maximum — pull the full route geometry and review it on a map. The matrix gives you the distance; the route endpoint gives you the path. You are looking for routes that technically fit within the distance budget but require a student to cross a busy road mid-block, walk through an unlit underpass, or navigate a section with no footpath.

curl -G "https://csv2geo.com/api/v1/route" \
  --data-urlencode "origin=41.8790,-87.6310" \
  --data-urlencode "destination=41.8801,-87.6252" \
  --data-urlencode "mode=walk" \
  --data-urlencode "geometry=true" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The geometry=true parameter returns a GeoJSON LineString you can drop into any mapping tool. A planner looking at the route geometry in thirty seconds can spot whether the path crosses a school zone boundary that the distance number does not capture.

Step 5: optimise the bus stop sequence

Once stop assignments are finalised, the bus needs an efficient route between stops. The optimise endpoint takes an ordered or unordered set of stops and returns the sequence that minimises total drive distance or time.

curl -X POST "https://csv2geo.com/api/v1/route/optimize" \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "'"$CSV2GEO_API_KEY"'",
    "mode": "drive",
    "locations": [
      {"id": "depot",  "lat": 41.8750, "lng": -87.6280},
      {"id": "stop_1", "lat": 41.8801, "lng": -87.6252},
      {"id": "stop_2", "lat": 41.8823, "lng": -87.6199},
      {"id": "stop_3", "lat": 41.8844, "lng": -87.6150}
    ],
    "start": "depot",
    "end":   "depot"
  }'

The response returns an ordered list of stop IDs and the total route distance. This is the output that goes to the driver and into the district's scheduling system. The pipeline now has everything it needs: compliant walk distances, defensible assignments, and an efficient bus sequence.

What the pipeline does not cover

Honest boundaries matter here. The workflow above computes whether a student can walk to a stop within the distance threshold. It does not compute whether that walk is safe in the sense that a local road safety team would use the word.

A 400-metre walk along a footpath in a residential cul-de-sac and a 400-metre walk along an unlit arterial with no pavement are both 400 metres. The API returns the same distance. The safety difference is a local knowledge call — it requires a planner who knows which crossings have signals, which underpasses are actually used, and which short cuts through parks are viable in winter. Use the walking-network distance as a filter that removes clearly non-compliant assignments; use human review of the flagged routes as the step that catches the safety problems the distance number cannot see.

The pipeline also does not handle attendance-zone lookups — that is a different problem (which school does this address belong to?) with a different data shape. This is purely transportation planning within a district where zone assignment is already known.

Handling student data: the minimum-necessary principle

Restating this explicitly because it matters for any team working in an education context.

The student roster enters the pipeline as addresses. The batch geocoding tool converts those addresses to coordinates. At that point, the addresses have served their purpose. The downstream stages — matrix routing, stop assignment, route optimisation — need only the coordinate and a pseudonymous identifier (a student ID from your SIS, not a name or date of birth).

Retain from the geocoding stage: student_id, lat, lng, geocode_confidence. Discard after geocoding: the raw address string, unless your own data-retention policy requires it for a separate reason.

The final output of the pipeline is a stop-assignment table: student_id, stop_id, walk_distance_m. That table contains no addresses, no names, no information that individually identifies a student's home location beyond a numeric identifier that only resolves to a person inside your SIS. It is safe to share with routing staff, to archive for the planning cycle, and to audit.

This is not a legal opinion. Every district should verify their obligations under applicable student-data protection rules with their own counsel. What this pipeline does is minimise what is processed and retained at each stage, which is the right technical starting point regardless of jurisdiction.

Cost for a district-sized deployment

A district of 3,000 students and 60 candidate stops, running the full pipeline once for the annual planning cycle:

  • Geocoding the student roster: 3,000 credits (one per address row via batch tool)
  • Geocoding the 60 candidate stops: 60 credits
  • Walk matrix (3,000 students × 60 stops): one matrix call; credits depend on matrix size — check csv2geo.com/pricing/api for current matrix pricing
  • Full route pulls for flagged assignments: perhaps 200 calls if 10% of assignments fall close to threshold

The geocoding alone — 3,060 credits — fits comfortably within the free tier's 3,000 calls/day for a first pilot run, or within the entry paid tier at $54/month for 100,000 calls. The routing calls are additional credits; the matrix is the efficient path that avoids paying per student-stop pair individually.

Re-running the pipeline for the following year, most student coordinates are already cached (addresses do not move). The marginal cost is the delta — new students, changed addresses — not a full re-geocode. Caching geocoding results covers this pattern in detail.

Frequently Asked Questions

What maximum walk distance should we use? Districts set their own thresholds. There is no single federal standard — the number varies by district, grade level, and local policy. This pipeline enforces whatever threshold you pass into max_walk_m. The technical machinery is the same regardless of the number.

Can we use this for secondary schools with different walk limits from primary schools? Yes. Run the pipeline separately for each cohort with its own max_walk_m value, or add a cohort column to the student table and filter before passing into assign_stops. The API calls are identical.

Does walk mode account for hills? Walk-mode routing uses the pedestrian network graph. Whether elevation is incorporated into the walk-time estimate depends on the routing engine configuration. The network distance (distance_m) is always the network path length. If steep terrain is a significant factor in your district, pull the elevation for each stop and flag any stop above a gradient threshold for manual review using the elevation endpoint — the same one described in Enriching Property Data with Elevation.

What if a student's address does not geocode cleanly? Any geocode below confidence 0.7 should go to a manual review queue — a staff member phones the family to confirm the address before the student is assigned a stop. Do not assign a stop to a low-confidence coordinate; the walk-distance calculation will be wrong and the assignment will likely be challenged.

Is it legal to send student addresses to a third-party API? That depends on your district's data processing agreements and applicable student-data law, which varies by jurisdiction. The technical answer this pipeline offers is: you can minimise what leaves your perimeter. Geocode in batch (the tool sees addresses; you retrieve coordinates). Downstream routing calls use only coordinates. If your counsel requires that addresses never leave the district's own infrastructure, geocoding can be run inside a private deployment — contact CSV2GEO to discuss enterprise options.

Can we re-run this mid-year when a student moves? Yes. Geocode the new address, re-run the matrix for that one student against the existing stop set, and update the assignment. The rest of the assignments are unaffected. The pipeline is designed to be incremental.

How does this differ from an attendance-zone lookup? An attendance-zone lookup answers "which school does this address belong to?" — a boundary question. This pipeline answers "given that a student belongs to this school, which stop can they reach on foot within the district's walk limit?" — a transportation question. They are separate problems and separate API calls. Do not conflate them in your data model.

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 →