Build a Delivery Route Optimizer
Solve the Traveling Salesman Problem with OR-Tools and real driving data.
Introduction
30 Second Summary
Every delivery driver faces the same puzzle: what order should I visit my stops to spend the least time driving? With 10 stops, there are over 3.6 million possible orderings. Finding the fastest one is one of the most famous problems in computer science.
In this project, you will build a full-stack route optimizer that takes 10 delivery stops on a map, calculates real driving times between them, and finds the fastest visit order using Google's open-source optimization library.
What You'll Build
You open a map in your browser, see 10 delivery pins scattered across London, click "Optimize Route", and watch the solver rearrange your stops into the fastest driving order in milliseconds.
By the end of this project, you'll have:
- A working TSP solver powered by Google OR-Tools that finds near-optimal delivery routes in milliseconds, and a before/after comparison showing how many minutes you saved.
- A Flask API that queries the OSRM routing service for real-world driving times and feeds them to the solver.
- An interactive Leaflet.js map with draggable pins, numbered stop markers, and the optimized route drawn as a polyline on real streets.
- Secret Mission: Add the GUIDED_LOCAL_SEARCH metaheuristic to squeeze extra minutes off the route and compare strategies side-by-side.
Are there any prerequisites?
You need Python installed on your machine and a code editor ready to go. Familiarity with basic Python syntax and simple HTML/JavaScript will help you follow along.
Before We Start
Before you can build a route optimizer, you need three key ingredients: a solver library that handles the math, a web framework to serve results, and an HTTP client to fetch real driving data.
In this step, you'll set up your project folder, create an isolated Python environment, and install everything the optimizer needs.
In this step, get ready to:
- Create the route-optimizer project folder and virtual environment.
- Install OR-Tools, Flask, and requests from a pinned requirements.txt.
- Verify the solver library loads correctly.
Create the project folder and virtual environment
You need an isolated space where your optimizer's dependencies live without affecting anything else on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir route-optimizer
cd route-optimizer
What do these commands do?
- cd ~/Desktop moves your terminal to the Desktop so the project folder is easy to find later.
- mkdir route-optimizer creates a new folder for the project.
- cd route-optimizer moves you inside that folder so everything you create next lives here.
- Create a Python virtual environment by running:
python -m venv venv
- Confirm the virtual environment was created by running:
ls
You should see a venv folder listed in the output.
Don't see the venv folder?
- Make sure you typed python -m venv venv exactly. If your system uses python3 instead, try python3 -m venv venv.
- Confirm you are inside the route-optimizer folder by running pwd. The path should end with route-optimizer.
Help me troubleshoot my venv creation. I ran python -m venv venv but don't see the folder.
- Activate the virtual environment so that packages install into it instead of your global Python:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms all packages you install next will stay inside this project.
What is a virtual environment?
A virtual environment is an isolated copy of Python where you install project-specific packages. It keeps your project's dependencies separate from other projects on your machine.
Install dependencies and verify
The optimizer needs three libraries: a solver, a web framework, and an HTTP client. You'll pin their exact versions in a file so anyone can reproduce your setup.
- In your code editor, create a new file called requirements.txt inside the route-optimizer folder.
- Paste the following pinned versions into it:
ortools==9.15.6755
flask==3.1.3
requests==2.34.2
What do these packages do?
- ortools is Google OR-Tools. It contains a purpose-built routing solver that handles the Traveling Salesman Problem.
- flask is a lightweight Python web framework. It will serve your optimizer as an HTTP API.
- requests is an HTTP client library. It will fetch real driving times from the OSRM mapping service.
- Save requirements.txt.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
Your requirements.txt should contain exactly this:
ortools==9.15.6755
flask==3.1.3
requests==2.34.2
- Install all three packages by running:
pip install -r requirements.txt
This takes a minute or two. You'll see download progress bars as each package installs. OR-Tools is the largest.
Seeing a pip error?
- If you see a permission error, make sure (venv) appears in your prompt. If it doesn't, re-run the activation command from the platform tab above.
- If pip can't find a matching version, confirm your Python version is 3.9 or higher by running python --version.
Help me fix my pip install error for the route-optimizer project.
Before you run the final check, what do you think will happen if OR-Tools installed correctly?
- Verify OR-Tools is ready by running:
python -c "from ortools.constraint_solver import pywrapcp; print('OR-Tools ready')"
You should see OR-Tools ready printed to the terminal.
- Verify Flask is installed by running:
python -c "import flask; print(flask.__version__)"
You should see 3.1.3 printed.
Seeing ModuleNotFoundError?
- Double check that (venv) appears in your prompt. If it does not, the packages installed into the wrong Python.
- Re-run the activation command, then try pip install -r requirements.txt again.
OR-Tools import is failing even though pip install succeeded. Help me debug.
Your environment is ready. Next, you'll write a TSP solver that finds the optimal delivery route through 10 stops using hardcoded distances.
Solve a Hardcoded TSP with OR-Tools
Your environment is ready. Now it is time to see the solver in action.
Before wiring up APIs or web servers, you need proof that OR-Tools can actually find a better route than random guessing. In this step you will feed 10 delivery stops into the solver using straight-line distances and watch it rearrange them into a shorter loop.
In this step, get ready to:
- Define 10 delivery locations and compute a distance matrix.
- Build a TSP solver function with OR-Tools.
- Run the solver and compare the optimized route to the naive order.
Create solver.py with location data and distance matrix
Every route optimization starts with two things: a list of places to visit, and a way to measure how far apart they are. You will define 10 London delivery stops and a function that computes the straight-line distance between every pair.
- Create a new file called solver.py inside your route-optimizer folder.
- Add the following imports and location data to the top of solver.py:
"""TSP solver using Google OR-Tools with OSRM driving time matrix."""
import math
import requests
from ortools.constraint_solver import routing_enums_pb2
from ortools.constraint_solver import pywrapcp
# 10 delivery stops in central London (lat, lng)
DEFAULT_LOCATIONS = [
(51.5074, -0.1278), # 0: Trafalgar Square (depot)
(51.5155, -0.1419), # 1: Oxford Circus
(51.5225, -0.1631), # 2: Paddington
(51.5313, -0.1245), # 3: King's Cross
(51.5176, -0.0823), # 4: Liverpool Street
(51.5015, -0.1416), # 5: Westminster
(51.5136, -0.0895), # 6: Bank
(51.4975, -0.1357), # 7: Pimlico
(51.5233, -0.1044), # 8: Barbican
(51.5101, -0.1340), # 9: Leicester Square
]
What does this code do?
The imports pull in math for distance calculations, requests for later API calls, and the two OR-Tools modules (routing_enums_pb2 for strategy constants, pywrapcp for the solver itself).
DEFAULT_LOCATIONS is a list of 10 latitude/longitude tuples representing real delivery stops across central London. Stop 0 (Trafalgar Square) acts as the depot where the driver starts and ends.
- Below the DEFAULT_LOCATIONS list, add the function that computes a distance matrix:
def compute_euclidean_matrix(locations):
"""Compute Euclidean distance matrix from lat/lng coordinates."""
n = len(locations)
matrix = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
if i != j:
dx = (locations[i][0] - locations[j][0]) * 111320
dy = (locations[i][1] - locations[j][1]) * 111320 * math.cos(math.radians(locations[i][0]))
matrix[i][j] = int(math.hypot(dx, dy))
return matrix
What does this code do?
This function builds a 10x10 grid where each cell holds the straight-line distance in meters between two stops. It converts latitude/longitude differences into approximate meters using the constant 111,320 (meters per degree) and adjusts for the cosine of latitude.
The solver needs integer costs, so int(math.hypot(dx, dy)) rounds each distance down to whole meters.
- Save solver.py.
- Verify the matrix computes correctly by running this command:
python -c "from solver import compute_euclidean_matrix, DEFAULT_LOCATIONS; m = compute_euclidean_matrix(DEFAULT_LOCATIONS); print(f'Matrix: {len(m)}x{len(m[0])}, stop 0 to stop 1 = {m[0][1]}m')"
You should see output like Matrix: 10x10, stop 0 to stop 1 = ...m confirming the distance matrix is being calculated.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated. Your terminal prompt should show (venv) at the start. If not, activate it again from inside your route-optimizer folder.
Also confirm you are running the command from the same folder that contains solver.py.
help me fix a ModuleNotFoundError when importing from solver.py
Build the TSP solver function
The Traveling Salesman Problem asks: given a set of stops, what is the shortest round trip that visits every stop exactly once? OR-Tools solves this using a routing model that accepts a distance callback and a search strategy.
- Add the solve_tsp function below compute_euclidean_matrix in solver.py. Start with the solver setup:
def solve_tsp(duration_matrix, strategy="fast"):
"""Solve TSP using OR-Tools.
Args:
duration_matrix: 2D list of travel times between locations
strategy: "fast" (PATH_CHEAPEST_ARC only) or "thorough" (GUIDED_LOCAL_SEARCH)
Returns:
dict with route_order (list of node indices) and total_duration (int)
"""
n = len(duration_matrix)
manager = pywrapcp.RoutingIndexManager(n, 1, 0)
routing = pywrapcp.RoutingModel(manager)
def distance_callback(from_index, to_index):
from_node = manager.IndexToNode(from_index)
to_node = manager.IndexToNode(to_index)
return duration_matrix[from_node][to_node]
transit_callback_index = routing.RegisterTransitCallback(distance_callback)
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
search_parameters = pywrapcp.DefaultRoutingSearchParameters()
search_parameters.first_solution_strategy = (
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
)
solution = routing.SolveWithParameters(search_parameters)
What does this code do?
- RoutingIndexManager(n, 1, 0) tells OR-Tools there are n locations, 1 vehicle, and the depot is node 0.
- distance_callback is a function OR-Tools calls to look up the cost of traveling between any two nodes. It reads from your distance matrix.
- PATH_CHEAPEST_ARC is a greedy heuristic that builds the initial route by always choosing the nearest unvisited stop. It is fast and usually finds a good solution for small problems.
- Directly below (still inside solve_tsp), add the solution extraction logic:
if not solution:
return {"route_order": list(range(n)), "total_duration": 0}
route_order = []
index = routing.Start(0)
while not routing.IsEnd(index):
route_order.append(manager.IndexToNode(index))
index = solution.Value(routing.NextVar(index))
route_order.append(manager.IndexToNode(index))
total_duration = solution.ObjectiveValue()
return {"route_order": route_order, "total_duration": total_duration}
What does this code do?
If the solver found no solution, it returns the stops in their original order as a fallback. Otherwise, it walks the solution by following NextVar links from the start node until it reaches the end, collecting each visited node index into route_order.
ObjectiveValue() returns the total cost (distance in meters for this step) of the entire round trip.
- Save solver.py.
- Verify the solver runs by testing it from the command line:
python -c "from solver import solve_tsp, compute_euclidean_matrix, DEFAULT_LOCATIONS; r = solve_tsp(compute_euclidean_matrix(DEFAULT_LOCATIONS)); print(r['route_order'])"
You should see a list of 11 numbers (10 stops plus the return to depot 0). The order will differ from [0, 1, 2, 3, ...] because the solver rearranged the stops.
Seeing an empty list or all zeros?
Check that the indentation inside solve_tsp is consistent. The if not solution: block and the while loop must be at the same indent level as the search_parameters lines above them.
help me debug my solve_tsp function returning unexpected results
Add the main block and run the solver
The solver works, but you need a proper comparison to prove it is better than visiting stops in their original order. You will add a helper that computes the naive route cost, then a main block that prints both side by side.
- Add the compute_naive_duration function below solve_tsp in solver.py:
def compute_naive_duration(duration_matrix):
"""Compute total duration if visiting stops in input order (0, 1, 2, ...)."""
total = 0
n = len(duration_matrix)
for i in range(n - 1):
total += duration_matrix[i][i + 1]
total += duration_matrix[n - 1][0]
return total
What does this code do?
This sums the distance of visiting stops in their original order: 0 to 1, 1 to 2, 2 to 3, and so on, then back to 0. It gives you a baseline to compare against the optimized route.
- At the very bottom of solver.py, add the __main__ block:
if __name__ == "__main__":
print("=== Step 1: Euclidean Distance TSP ===")
euclidean_matrix = compute_euclidean_matrix(DEFAULT_LOCATIONS)
result_euclidean = solve_tsp(euclidean_matrix)
print(f"Euclidean route: {' -> '.join(map(str, result_euclidean['route_order']))}")
print(f"Euclidean total: {result_euclidean['total_duration']} meters")
print()
What does this code do?
When you run python solver.py directly, this block computes the Euclidean distance matrix, passes it to solve_tsp, and prints the optimized route as a chain of node indices plus the total distance.
- Save solver.py.
Before you run this, what do you expect the output to look like? Will the route visit stops in order (0, 1, 2, 3...) or will the solver rearrange them?
- Run the solver by executing:
python solver.py
You should see output like this:
=== Step 1: Euclidean Distance TSP ===
Euclidean route: 0 -> 9 -> 5 -> 7 -> 1 -> 2 -> 3 -> 8 -> 4 -> 6 -> 0
Euclidean total: 12044 meters
The exact numbers may vary slightly, but the key observation is that the route does NOT follow the naive 0, 1, 2, 3... order. The solver found a shorter loop by rearranging the stops.
Why is the optimized route shorter?
Visiting stops in their original order (0 to 1 to 2 to 3...) forces the driver to zigzag across London. The solver groups nearby stops together, creating a more circular loop that minimizes total distance traveled.
Seeing a SyntaxError or IndentationError?
Python is strict about indentation. Make sure if __name__ == "__main__": starts at column 0 (no leading spaces). The lines inside it should be indented by 4 spaces.
help me fix a SyntaxError or IndentationError in solver.py
✔️ Awesome, I've got everything!
Great. Double check you have saved solver.py and that running python solver.py prints the optimized route without errors.
ⓧ I'd like to double check the full code
Your solver.py should contain these sections in order. Scroll through your file and confirm each one is present:
- Module docstring and imports (math, requests, routing_enums_pb2, pywrapcp).
- DEFAULT_LOCATIONS list with 10 tuples.
- compute_euclidean_matrix function.
- solve_tsp function (setup, callback, search params, solution extraction, return).
- compute_naive_duration function.
- if __name__ == "__main__": block that computes the matrix, solves, and prints.
Your solver is proving its worth with straight-line distances. Next up, you will swap those fake distances for real driving times from OSRM and discover that the optimal route changes completely.
Replace Euclidean Distances with Real Driving Times
Your solver found a short route in the last step, but it optimized based on straight-line distances. Real roads have one-way streets, rivers, speed limits, and roundabouts that straight-line math ignores completely.
In this step, you will swap the Euclidean matrix for real driving durations fetched from OSRM (Open Source Routing Machine). When the solver re-optimizes with actual road data, you will see the route order change. That difference is the core insight of this project.
In this step, get ready to:
- Add OSRM API functions that fetch real driving times between all stop pairs.
- Update the solver to use real durations instead of Euclidean distances.
- Compare the Euclidean and driving-time routes to see the route order change.
Create the OSRM matrix function
OSRM's Table API accepts a set of coordinates and returns a matrix of driving durations between every pair. One HTTP call gives you all 100 pairwise travel times for your 10 stops.
- In solver.py, find the compute_euclidean_matrix function.
- Add the following get_osrm_matrix function directly below it:
def get_osrm_matrix(locations):
"""Fetch driving duration matrix from OSRM Table API.
Args:
locations: list of (lat, lng) tuples
Returns:
2D list of durations in seconds (integers)
"""
coords_str = ";".join(f"{lng},{lat}" for lat, lng in locations)
url = f"http://router.project-osrm.org/table/v1/driving/{coords_str}?annotations=duration"
response = requests.get(url, timeout=10)
data = response.json()
if data["code"] != "Ok":
raise RuntimeError(f"OSRM API error: {data['code']}")
# Convert to integers (OSRM returns floats)
durations = [[int(cell) if cell is not None else 999999 for cell in row] for row in data["durations"]]
return durations
What does this code do?
OSRM expects coordinates in longitude,latitude order (the reverse of how most tools display them). The f-string swaps your lat/lng tuples into the correct format.
The API returns a 2D array of float durations in seconds. OR-Tools requires integer costs, so the list comprehension casts each value to int. If a pair has no route (rare in a city), it falls back to 999999 so the solver avoids that path.
Why OSRM instead of Google Maps?
OSRM is completely free with no API key required. The public server handles small requests like a 10-location matrix without issue. Google Maps Distance Matrix API would require billing setup and API key management for the same result.
- Save solver.py.
- Test that the function works by running this quick check:
python -c "from solver import get_osrm_matrix, DEFAULT_LOCATIONS; m = get_osrm_matrix(DEFAULT_LOCATIONS); print(f'{len(m)}x{len(m[0])} matrix fetched, sample: {m[0][1]}s')"
You should see output like 10x10 matrix fetched, sample: 289s (the exact number depends on current traffic data from OSRM).
Seeing a connection error?
The OSRM public server requires an internet connection. Check that you can reach other websites. If you are behind a corporate firewall or VPN, try disconnecting and retrying.
If you see a timeout, the public server may be temporarily overloaded. Wait 30 seconds and try again.
Help me debug my OSRM connection error.
Add the route geometry function
You will also need a function that fetches the actual road path for a given route order. This draws the route on a real map later.
- In solver.py, add the following function directly below get_osrm_matrix:
def get_osrm_route_geometry(locations, route_order):
"""Fetch the actual road geometry for the optimized route from OSRM Route API.
Args:
locations: list of (lat, lng) tuples
route_order: list of indices in optimized visit order
Returns:
GeoJSON geometry of the route
"""
ordered_coords = ";".join(
f"{locations[i][1]},{locations[i][0]}" for i in route_order
)
url = f"http://router.project-osrm.org/route/v1/driving/{ordered_coords}?overview=full&geometries=geojson"
response = requests.get(url, timeout=10)
data = response.json()
if data["code"] != "Ok":
raise RuntimeError(f"OSRM Route API error: {data['code']}")
return data["routes"][0]["geometry"]
What does this function do?
The OSRM Route service returns the full road geometry as a GeoJSON LineString. Unlike the Table API (which only returns durations), this gives you the actual path the driver would follow. You will use this in a later step to draw the route on a map.
- Save solver.py.
Update the main block to use real driving times
Now you will wire the OSRM matrix into your solver and print both results side-by-side so you can compare the Euclidean route with the driving-time route.
- In solver.py, scroll to the bottom of your __main__ block.
- Find this line:
print(f"Euclidean total: {result_euclidean['total_duration']} meters")
print()
- Add the following code directly below it:
print("=== Step 2: Real Driving Time TSP (OSRM) ===")
print("Fetching driving time matrix from OSRM...")
osrm_matrix = get_osrm_matrix(DEFAULT_LOCATIONS)
result_osrm = solve_tsp(osrm_matrix)
naive_duration = compute_naive_duration(osrm_matrix)
print(f"OSRM route: {' -> '.join(map(str, result_osrm['route_order']))}")
print(f"Optimized total: {result_osrm['total_duration']} seconds ({result_osrm['total_duration'] // 60} min)")
print(f"Naive total: {naive_duration} seconds ({naive_duration // 60} min)")
print(f"Time saved: {naive_duration - result_osrm['total_duration']} seconds ({(naive_duration - result_osrm['total_duration']) // 60} min)")
What does this code do?
- get_osrm_matrix(DEFAULT_LOCATIONS) fetches the 10x10 matrix of real driving times from OSRM.
- solve_tsp(osrm_matrix) feeds those real durations into the same OR-Tools solver you built earlier.
- The naive duration shows how long the route takes if you visit stops in the original order (0, 1, 2, ...). The time-saved line reveals how many seconds the optimizer shaves off.
- Save solver.py.
Before you run this, think about it: do you expect the OSRM-optimized route to visit stops in the same order as the Euclidean version, or a different order?
- Run the solver by executing this command:
python solver.py
You will see both the Euclidean route and the OSRM route printed. The route orders are different. The solver found a different optimal path when given real driving times instead of straight-line distances.
Why did the route change?
Euclidean distances assume you can fly in a straight line between stops. Real roads have the Thames blocking direct paths, one-way systems in central London, and motorway on-ramps that make some "short" trips slow in practice.
Two stops that look close on a map might be far apart by driving time. This is exactly why logistics companies use real road data instead of simple geometry.
Seeing the same route order for both?
In rare cases, the driving-time optimal order may match the Euclidean order for these specific stops. Check that the units changed from meters to seconds. The OSRM route should show values in the hundreds of seconds, not thousands of meters.
If you see OSRM API error instead of route output, check your internet connection and try again.
Help me troubleshoot my OSRM solver output.
✔️ Awesome, I've got everything!
Great. Double check you have saved solver.py with both OSRM functions and the updated __main__ block.
ⓧ I'd like to double check the full code
Your solver.py is now over 90 lines. Scroll through and confirm these sections exist in this order:
- Imports (math, requests, routing_enums_pb2, pywrapcp).
- DEFAULT_LOCATIONS list with 10 London coordinates.
- compute_euclidean_matrix() function.
- get_osrm_matrix() function (the one you just added).
- get_osrm_route_geometry() function (the one you just added).
- solve_tsp() function (unchanged from the previous step).
- compute_naive_duration() function.
- __main__ block printing both Euclidean and OSRM results.
Your solver now optimizes for actual driving time, not straight-line distance. Next up, you will wrap this solver in a Flask API so a browser-based map can send coordinates and get the optimized route back as JSON.
Build the Flask API
Your solver works perfectly in the terminal, but the browser-based map you will build next needs an HTTP endpoint to talk to. Without an API, there is no way for JavaScript running in the browser to send coordinates and get optimized routes back.
In this step you will wrap your solver in a Flask web server that accepts a list of locations over HTTP and returns the optimized route as JSON.
In this step, get ready to:
- Create a Flask app with a POST /optimize endpoint that triggers the solver.
- Serve a static HTML file from a GET / route.
- Test the API with curl to confirm it returns optimized route data.
Create the Flask application
Flask is a lightweight Python web framework that lets you define URL routes as decorated functions. You will create a new file that imports your solver functions and exposes them over HTTP.
- Create a new file called app.py in your route-optimizer folder and add the following code:
"""Flask API for the route optimizer."""
import os
from flask import Flask, request, jsonify, send_file
from solver import get_osrm_matrix, solve_tsp, get_osrm_route_geometry, compute_naive_duration
app = Flask(__name__)
@app.route("/")
def index():
return send_file("index.html")
What does this code do?
- The imports pull in all four solver functions you built in previous steps, so the API can call them directly.
- app = Flask(__name__) creates the Flask application instance.
- The GET / route uses send_file to serve a static HTML page without needing a templates directory.
Add the optimization endpoint
This is the core of the API. It receives a JSON array of coordinates, builds the driving-time matrix, solves the TSP, and returns everything the frontend needs to draw the route.
- Add the following code below the index function in app.py:
@app.route("/optimize", methods=["POST"])
def optimize():
data = request.get_json()
locations = [tuple(loc) for loc in data["locations"]]
# Get real driving time matrix from OSRM
duration_matrix = get_osrm_matrix(locations)
# Solve TSP
result = solve_tsp(duration_matrix)
# Get route geometry for drawing on the map
geometry = get_osrm_route_geometry(locations, result["route_order"])
# Compute naive duration for comparison
naive_duration = compute_naive_duration(duration_matrix)
return jsonify({
"route_order": result["route_order"],
"total_duration": result["total_duration"],
"naive_duration": naive_duration,
"geometry": geometry,
})
if __name__ == "__main__":
app.run(debug=True, port=5000)
What does this endpoint do?
- request.get_json() parses the incoming JSON body from the browser.
- The endpoint calls get_osrm_matrix to fetch real driving times, then solve_tsp to find the optimal order.
- get_osrm_route_geometry fetches the actual road polyline so the map can draw a route that follows streets.
- compute_naive_duration gives the input-order total so the frontend can show how much time the optimizer saved.
- The if __name__ block starts the development server on port 5000 with auto-reload enabled.
- Save app.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and move on to running it.
ⓧ I'd like to double check the full code
Your app.py should contain these two sections in order:
- The imports, app = Flask(__name__), and the GET / route (def index) from the first chunk.
- The POST /optimize route (def optimize) and the if __name__ == "__main__" block from the second chunk.
Scroll up and confirm both sections are present in your file, in that order.
Test the API with curl
Time to see if the API works end-to-end. You will start the Flask server, then send it a request from a second terminal.
- Start the Flask development server by running:
python app.py
You should see output similar to:
* Serving Flask app 'app'
* Debug mode: on
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
Why does the server take over the terminal?
Flask runs a long-lived process that listens for incoming HTTP requests. It will not return you to the prompt until you press Ctrl+C to stop it. You need a second terminal window to send test requests while the server is running.
- Open a second terminal window.
- Activate your virtual environment in the new terminal by running:
cd ~/Desktop/route-optimizer
Before you send the request, what do you think the response will contain? The endpoint receives three coordinates, calls OSRM for driving times, then runs the solver.
- Send a test request to the /optimize endpoint by running:
curl -X POST http://localhost:5000/optimize -H "Content-Type: application/json" -d '{"locations":[[51.5074,-0.1278],[51.5155,-0.1419],[51.5225,-0.1631]]}'
You should see a JSON response containing route_order, total_duration, naive_duration, and a geometry object with GeoJSON coordinates.
What should I see?
The response is a JSON object with four keys. route_order shows the optimal visit sequence (e.g. [0, 2, 1, 0]). total_duration and naive_duration are driving times in seconds. geometry contains the actual road path as GeoJSON coordinates that the map will draw as a polyline.
Not seeing a JSON response?
- Make sure the Flask server is still running in the first terminal. If you see Connection refused, it means the server is not running.
- Check that you are in the route-optimizer directory in the second terminal. Flask needs to find solver.py to import it.
- If you see an ImportError in the Flask terminal, confirm solver.py is in the same folder as app.py.
Why is my Flask API not returning a JSON response?
Your solver now has an HTTP interface. Any client that can send a POST request can use your route optimizer. Next up, you will build the interactive map that calls this API and draws the optimized route on real streets.
Build the Interactive Map with Leaflet.js
Your API accepts coordinates and returns an optimized route as JSON. That's useful for machines, but the whole point of this project is to see the route on a real map.
In this step, you'll build the visual payoff. You'll create an interactive Leaflet.js map where 10 delivery pins sit on London streets. When you click Optimize Route, the solver rearranges the stops and draws the fastest path as a blue line on real roads.
In this step, get ready to:
- Create an interactive map with 10 draggable delivery markers.
- Wire up the Optimize Route button to call your API and draw the route.
- Handle marker dragging to clear stale routes.
Create the HTML file with Leaflet.js and map styles
Leaflet.js is a lightweight open-source mapping library. You load it from a CDN, point it at free OpenStreetMap tiles, and get a full interactive map in a few lines of code.
- Create a new file called index.html inside your route-optimizer folder.
- Add the HTML head with Leaflet CDN links and the first half of the page styles:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Delivery Route Optimizer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
#map { width: 100%; height: 100vh; }
#controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
max-width: 280px;
}
What does this code do?
The link and script tags pull Leaflet 1.9.4 from a CDN. The integrity attributes verify the files haven't been tampered with.
The CSS makes the map fill the entire screen and positions a floating controls panel in the top-right corner.
- Continue adding the remaining styles directly below the code you just pasted:
#controls h2 { font-size: 16px; margin-bottom: 12px; }
#controls button {
width: 100%;
padding: 10px;
margin-bottom: 8px;
border: none;
border-radius: 6px;
background: #2563eb;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
#controls button:hover { background: #1d4ed8; }
#controls button:disabled { background: #94a3b8; cursor: not-allowed; }
#stats {
font-size: 13px;
line-height: 1.6;
color: #334155;
margin-top: 8px;
}
#stats .saved { color: #16a34a; font-weight: 600; }
.numbered-marker {
background: #2563eb;
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
- Continue with the marker styles and close the head section:
font-size: 12px;
font-weight: 700;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.numbered-marker.depot {
background: #dc2626;
width: 32px;
height: 32px;
font-size: 13px;
}
</style>
</head>
What are these marker styles?
The .numbered-marker class creates circular blue badges for each delivery stop. The .depot variant is larger and red to distinguish the starting point from regular stops.
Add the body HTML and map container
The body contains two elements: a full-screen map div and a floating controls panel with the Optimize and Reset buttons.
- Add the body HTML directly below the closing </head> tag:
<body>
<div id="map"></div>
<div id="controls">
<h2>Route Optimizer</h2>
<button id="optimize-btn" onclick="optimizeRoute()">Optimize Route</button>
<button id="reset-btn" onclick="resetMarkers()">Reset Positions</button>
<div id="stats"></div>
</div>
What does this HTML do?
The #map div is where Leaflet renders the interactive map. The #controls panel floats over it with two buttons and a stats area for displaying route results.
The onclick attributes wire each button to JavaScript functions you'll define next.
Add JavaScript constants and initialize the map
Now for the interactive part. You'll define the 10 delivery locations, initialize the Leaflet map, and create numbered markers the user can drag around.
- Add a <script> tag below the #controls div with the location constants and map initialization:
<script>
const DEFAULT_LOCATIONS = [
[51.5074, -0.1278],
[51.5155, -0.1419],
[51.5225, -0.1631],
[51.5313, -0.1245],
[51.5176, -0.0823],
[51.5015, -0.1416],
[51.5136, -0.0895],
[51.4975, -0.1357],
[51.5233, -0.1044],
[51.5101, -0.1340],
];
const STOP_NAMES = [
"Depot: Trafalgar Sq", "Oxford Circus", "Paddington",
"King's Cross", "Liverpool St", "Westminster",
"Bank", "Pimlico", "Barbican", "Leicester Sq"
];
const map = L.map("map").setView([51.515, -0.12], 13);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
let markers = [];
let routePolyline = null;
What does this code do?
DEFAULT_LOCATIONS holds the same 10 London coordinates your solver uses. STOP_NAMES gives each stop a human-readable label for popups.
L.map creates the map inside the #map div, centered on London at zoom level 13. The tile layer loads free OpenStreetMap tiles to render the actual streets.
- Add the marker creation functions directly below the state variables:
function createNumberedIcon(number, isDepot) {
return L.divIcon({
className: "",
html: `<div class="numbered-marker ${isDepot ? 'depot' : ''}">${number}</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
}
function createMarkers() {
DEFAULT_LOCATIONS.forEach((latlng, i) => {
const marker = L.marker(latlng, {
draggable: true,
icon: createNumberedIcon(i, i === 0),
}).addTo(map);
marker.bindPopup(`<b>${STOP_NAMES[i]}</b><br>Stop #${i}`);
marker.on("dragend", () => { clearRoute(); });
markers.push(marker);
});
}
What does this code do?
createNumberedIcon builds a custom div icon showing the stop number inside a colored circle. The depot (stop 0) gets the red .depot style. All other stops get the blue style.
createMarkers loops through all 10 locations and places a draggable marker for each. Each marker has a popup with its name and a dragend event that clears any existing route when dragged.
Add the optimize route function
This is the function the Optimize Route button calls. It collects all current marker positions, sends them to your Flask API, and draws the result on the map.
- Add the helper functions and the first part of optimizeRoute below createMarkers:
function clearRoute() {
if (routePolyline) {
map.removeLayer(routePolyline);
routePolyline = null;
}
document.getElementById("stats").innerHTML = "<em>Route cleared. Drag pins and re-optimize.</em>";
}
function getMarkerPositions() {
return markers.map(m => {
const pos = m.getLatLng();
return [pos.lat, pos.lng];
});
}
async function optimizeRoute() {
const btn = document.getElementById("optimize-btn");
const stats = document.getElementById("stats");
btn.disabled = true;
btn.textContent = "Solving...";
stats.innerHTML = "Fetching driving times and solving TSP...";
try {
const locations = getMarkerPositions();
const response = await fetch("/optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locations }),
});
const data = await response.json();
What does this code do?
clearRoute removes any drawn route from the map and resets the stats panel. It fires whenever a marker is dragged to signal the route is stale.
getMarkerPositions reads the current lat/lng of every marker (important because the user may have dragged them).
The first part of optimizeRoute disables the button, shows a loading message, then POSTs all marker positions to your /optimize endpoint.
- Continue the optimizeRoute function by adding the response handling directly below:
if (routePolyline) { map.removeLayer(routePolyline); }
const coords = data.geometry.coordinates.map(c => [c[1], c[0]]);
routePolyline = L.polyline(coords, {
color: "#2563eb",
weight: 4,
opacity: 0.8,
}).addTo(map);
data.route_order.forEach((originalIndex, visitOrder) => {
markers[originalIndex].setIcon(
createNumberedIcon(visitOrder, visitOrder === 0)
);
});
const optimizedMin = Math.round(data.total_duration / 60);
const naiveMin = Math.round(data.naive_duration / 60);
const savedMin = naiveMin - optimizedMin;
stats.innerHTML = `
Optimized: <b>${optimizedMin} min</b> driving<br>
Naive order: ${naiveMin} min<br>
<span class="saved">Saved: ${savedMin} min (${Math.round(savedMin / naiveMin * 100)}%)</span>
`;
} catch (err) {
stats.innerHTML = `<span style="color:red">Error: ${err.message}</span>`;
} finally {
btn.disabled = false;
btn.textContent = "Optimize Route";
}
}
What does this code do?
The response contains a GeoJSON geometry from OSRM. The code flips coordinates from [lng, lat] (GeoJSON standard) to [lat, lng] (Leaflet standard), then draws them as a blue polyline.
Next it renumbers every marker to show the optimized visit order. Finally, it calculates minutes saved compared to visiting stops in their naive (input) order and displays the result in the stats panel.
Add the reset function and initialize the map
- Add the reset function and the initialization call below optimizeRoute, then close the script and body tags:
function resetMarkers() {
markers.forEach((marker, i) => {
marker.setLatLng(DEFAULT_LOCATIONS[i]);
marker.setIcon(createNumberedIcon(i, i === 0));
});
clearRoute();
}
createMarkers();
</script>
</body>
</html>
What does this code do?
resetMarkers snaps every pin back to its original position and restores the original numbering. This lets the user start over after dragging pins around.
The final createMarkers() call runs when the page loads, placing all 10 pins on the map immediately.
- Save index.html.
- Make sure your Flask server is still running. If it stopped, start it again by running:
python app.py
Before you open the browser, what do you expect to see at http://localhost:5000? Think about what your HTML creates before any button is clicked.
- Open http://localhost:5000 in your browser.
You should see a full-screen map of London with 10 numbered blue circles. Stop 0 (Trafalgar Square) is a larger red circle marking the depot.
- Click the Optimize Route button.
After a moment, a blue polyline draws the optimized route along real streets. The marker numbers update to show the new visit order, and the stats panel displays how many minutes the optimized route saves compared to the naive order.
- Drag any pin to a new location. The route polyline disappears and the stats panel shows a message that the route is cleared.
- Click Optimize Route again. The solver re-runs with the new pin position and draws an updated route.
- Click Reset Positions to snap all pins back to their original locations.
Map not loading or seeing a blank page?
Check that your Flask server is running in your terminal (you should see Running on http://127.0.0.1:5000). If you see a Python error, make sure index.html is saved in the same folder as app.py.
If the map shows but buttons do nothing, open your browser's DevTools console (F12) and look for JavaScript errors. A missing closing brace or mismatched quote is the most common cause.
help me debug my Leaflet map not rendering
✔️ Awesome, I've got everything!
Great. Double check you've saved index.html and that the map renders with all 10 pins.
ⓧ I'd like to double check the full code
Your index.html should contain all of the chunks above, assembled in order from top to bottom. Scroll up and confirm each section is present:
- HTML head with Leaflet CDN links and all CSS styles.
- Body with #map div, #controls panel, and two buttons.
- Script with DEFAULT_LOCATIONS, STOP_NAMES, map initialization, and state variables.
- createNumberedIcon and createMarkers functions.
- clearRoute, getMarkerPositions, and optimizeRoute functions.
- resetMarkers function and the createMarkers() init call.
- Closing </script>, </body>, and </html> tags.
You now have a complete, working delivery route optimizer. You can drop 10 pins anywhere on the London map, hit Optimize, and instantly see the fastest driving path. Next up, you'll push this further by adding a more powerful search strategy in the Secret Mission.
Secret mission
Add GUIDED_LOCAL_SEARCH for Better Solutions
Your optimizer finds good routes fast, but production delivery systems squeeze out every last minute. Can you add a more powerful search strategy and show the difference side-by-side on the map?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The route-optimizer project folder on your Desktop (contains solver.py, app.py, index.html, requirements.txt).
- A Python virtual environment (venv folder inside route-optimizer).
- Installed pip packages (ortools, flask, requests) inside the virtual environment.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment with more delivery stops or different cities.
- Your Flask server keeps running at http://localhost:5000. Reopen your browser to that URL to keep testing.
- If you closed the server, reactivate your virtual environment and run python app.py again from inside the route-optimizer folder.
Pause - I'll come back to this later
Shut down running processes to free up your terminal. Your files stay on disk so you can pick up where you left off.
- Stop the Flask server by pressing Ctrl+C in the terminal where it is running.
- Deactivate the virtual environment by running:
deactivate
When you come back, navigate to the route-optimizer folder, reactivate the virtual environment, and run python app.py to restart.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the folder, virtual environment, and all installed packages.
- Stop the Flask server by pressing Ctrl+C in the terminal where it is running.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder.
macOS
cd ~/Desktop
rm -rf route-optimizer
Windows
cd ~\Desktop
rmdir /s /q route-optimizer
Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see route-optimizer in the listing.
That's a Wrap!
That's a Wrap!
Nice work! You just built a full-stack delivery route optimizer that takes real-world driving data, solves a combinatorial optimization problem in milliseconds, and visualizes the result on an interactive map.
You've learned how to:
- Build a TSP solver with Google OR-Tools and discover that Euclidean-optimal routes are NOT the same as driving-optimal routes when you swap in real-world data from the OSRM API.
- Wrap a solver in a Flask REST API that fetches driving-time matrices, runs the optimization, and returns route geometry as GeoJSON.
- Build an interactive Leaflet.js map with draggable pins, numbered stop markers, and a polyline drawn on real streets showing the optimized route.
- Secret Mission: Added the GUIDED_LOCAL_SEARCH metaheuristic to find even better solutions and compared strategies side-by-side on the map.
Ready to quiz yourself?