Build a Ride-Sharing Price Estimator API
Build a containerized FastAPI and PostgreSQL ride-pricing app with Docker Compose.
Introduction
30 Second Summary
Every time you open a ride-sharing app, an algorithm calculates your fare in milliseconds. It factors in distance, traffic, and surge pricing to produce a number before you even confirm the ride.
In this project, you will build the backend that powers that calculation. You'll create a REST API that estimates ride prices with dynamic surge zones, connect it to a PostgreSQL database, and package the entire system into Docker containers that start with a single command.
What You'll Build
Picture sending coordinates to your API and instantly receiving a price breakdown showing distance, surge multiplier, and total fare. The entire stack spins up with one terminal command.
By the end of this project, you'll have:
- A working price estimation endpoint where you send pickup and dropoff coordinates and get back a fare calculated from distance and dynamic surge pricing.
- A multi-container application orchestrated with Docker Compose that starts the API and PostgreSQL database together with a single docker compose up command.
- Production-ready patterns including health checks, graceful startup ordering, and environment-based configuration that mirror how real backend teams ship services.
- Secret Mission: Add Redis caching so surge lookups skip the database entirely on repeated requests.
Do I need Docker experience?
Nope. This project walks you through installing Docker Desktop and building your first containers from scratch. You just need basic Python knowledge and some familiarity with how APIs work.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters for your backend engineering goals.
Set Up Your Development Environment
To build a ride-sharing price estimator, you need two things: Docker to run your database in a container, and a Python project with FastAPI to build the API itself.
Docker is new territory for this project. In this step, you install Docker Desktop, create a Python project with the right dependencies, and verify everything works before writing real code.
In this step, get ready to:
- Install Docker Desktop and verify it runs.
- Create the project folder with a Python virtual environment and install FastAPI.
- Create the initial file structure and confirm the server starts.
Install Docker Desktop
Docker Desktop gives you the Docker engine, CLI, and a GUI for managing containers. You need it to run PostgreSQL in a container later in this project.
- Check if Docker is already installed by opening your terminal and running:
docker --version
✔️ I see a version number
Docker is already installed and ready. Make sure Docker Desktop is running (you should see the Docker whale icon in your system tray or menu bar). Move on to the next substep.
ⓧ Command not found
Docker is not installed yet. Follow the instructions below for your operating system.
Windows
- Open the Docker Desktop for Windows install page: https://docs.docker.com/desktop/setup/install/windows-install/
- Click the download button and run the installer.
- Select the per-user installation option (this does not require admin privileges).
- Once the installer finishes, press the Windows key to open your search bar.
- Type Docker Desktop and press Enter to open it.
- Wait for the Docker engine to start. You will see a green indicator in the bottom-left corner of the Docker Desktop window.
What is Docker Desktop?
Docker Desktop bundles the Docker engine, CLI tools, and Docker Compose into a single application. On Windows, it uses WSL 2 as its backend to run Linux containers.
macOS
- Open the Docker Desktop for Mac install page: https://docs.docker.com/desktop/setup/install/mac-install/
- Download the .dmg file for your chip (Apple silicon or Intel).
- Double-click Docker.dmg and drag Docker to the Applications folder.
- Open Docker from your Applications folder.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for the Docker engine to start. You will see the Docker whale icon in your menu bar turn solid.
What is Docker Desktop?
Docker Desktop bundles the Docker engine, CLI tools, and Docker Compose into a single application. On macOS, it runs a lightweight Linux VM to host containers.
- Once Docker Desktop is running, verify the installation by running:
docker --version
You should see a version number printed, confirming Docker is ready.
Docker command still not found?
- Make sure Docker Desktop is fully running (not just installed). The engine needs to start before the CLI works.
- On Windows, close and reopen your terminal after installing Docker Desktop so the PATH updates.
- On macOS, the first launch may take a minute while macOS performs security checks.
Help me troubleshoot why docker --version is not working after installing Docker Desktop
Create the project folder and install dependencies
Now you need a project folder with a Python virtual environment. The virtual environment keeps this project's packages isolated from anything else on your machine.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir rideshare-api
cd rideshare-api
- Create a Python virtual environment by running:
python -m venv .venv
- Activate the virtual environment:
Windows
.venv\Scripts\activate
macOS
source .venv/bin/activate
You should see (.venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
- Install FastAPI and asyncpg by running:
pip install "fastapi[standard]==0.141.1" "asyncpg==0.31.0"
What are these packages?
FastAPI is a modern Python web framework for building APIs. The [standard] extra includes uvicorn (the server) and other essentials.
asyncpg is a high-performance PostgreSQL driver for Python. It uses async I/O, which matches FastAPI's async design.
You should see pip downloading and installing packages. The final line will confirm everything installed successfully.
Pip install failing?
- Make sure your virtual environment is active. You should see (.venv) in your terminal prompt.
- If you see a permissions error, you likely forgot to activate the virtual environment. Run the activate command again.
Help me fix pip install errors in my Python virtual environment
Create the file structure and verify FastAPI runs
Your project needs three Python files. You will build them up over the next few steps, but creating them now gives you a clean starting structure.
- Create the three project files by running:
Windows
New-Item main.py, models.py, database.py
macOS
touch main.py models.py database.py
- Confirm the files exist by running:
ls
You should see main.py, models.py, and database.py listed in the output.
- Open main.py in your code editor and add this minimal FastAPI server:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# A simple test endpoint to verify the server works
@app.get("/")
def read_root():
return {"message": "Hello World"}
What does this code do?
- FastAPI() creates your application object. All your endpoints attach to this.
- @app.get("/") defines a GET endpoint at the root URL. When someone visits /, FastAPI calls read_root() and returns the JSON response.
- Save main.py.
- Before you run the server, what do you think you will see in the terminal output? Take a guess.
- Start the FastAPI development server by running:
fastapi dev main.py
The terminal shows the server running at http://127.0.0.1:8000. FastAPI's dev mode auto-reloads when you change code.
- Open http://127.0.0.1:8000 in your browser.
You should see {"message":"Hello World"} displayed as a JSON response. Your API is alive.
- Press Ctrl+C in your terminal to stop the server. You will need the terminal free for the next step.
Server not starting?
- If you see command not found: fastapi, your virtual environment is not active. Run the activate command again and retry.
- If you see a syntax error, double-check that main.py matches the code above exactly. The @app.get("/") decorator must be directly above the function.
Help me figure out why fastapi dev main.py is not starting my server
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that the server started successfully before you stopped it.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point. The other two files (models.py and database.py) are empty for now.
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello World"}
Your environment is ready. You have Docker standing by for containers and a working FastAPI server to build on. Next up, you will build the full ride estimation API with in-memory data so you can interact with it immediately.
Build the API with In-Memory Data
In the previous step, you set up your development environment with Docker, Python, and FastAPI. Now it is time to build the actual ride estimation logic.
Before touching databases or containers, you want a working API you can interact with immediately. Building with in-memory data first lets you see the ride estimation logic in action and test it through auto-generated docs without any infrastructure complexity.
In this step, get ready to:
- Define Pydantic models for requests, responses, and surge zones.
- Build API endpoints for health checks, ride estimation, and zone management.
- Discover a critical limitation of in-memory storage.
Define Pydantic models for ride estimates and zones
Every FastAPI endpoint needs clearly defined request and response shapes. Pydantic models enforce type validation automatically, so invalid data never reaches your business logic.
- Open models.py in your code editor and replace any existing content with the ride-related models:
from pydantic import BaseModel
# The shape of data a client sends when requesting a ride estimate
class RideEstimateRequest(BaseModel):
pickup_lat: float
pickup_lng: float
dropoff_lat: float
dropoff_lng: float
# The shape of data the API returns with the price breakdown
class RideEstimateResponse(BaseModel):
distance_km: float
base_price: float
surge_multiplier: float
total_price: float
currency: str
What does this code do?
RideEstimateRequest defines the four coordinates a client sends: pickup latitude/longitude and dropoff latitude/longitude. FastAPI rejects any request missing these fields or sending non-numeric values.
RideEstimateResponse defines the price breakdown the API returns: distance, base price, surge multiplier, total, and currency.
- Add the zone management and health models below the response class in the same file:
# Data needed to create a new surge pricing zone
class ZoneCreate(BaseModel):
name: str
surge_multiplier: float
lat: float
lng: float
radius_km: float
# Full zone representation including the assigned ID
class Zone(BaseModel):
id: int
name: str
surge_multiplier: float
lat: float
lng: float
radius_km: float
# Health check response shape
class HealthResponse(BaseModel):
status: str
database: str
What does this code do?
ZoneCreate is the shape clients send to define a surge zone: a name, a multiplier (like 1.5x during rush hour), a center coordinate, and a radius.
Zone adds an id field for the stored version. HealthResponse gives the health endpoint a structured shape to report API and database status.
- Save models.py.
- Verify the models are valid Python by running this quick import check:
python -c "from models import RideEstimateRequest, Zone; print('Models OK')"
You should see Models OK printed in your terminal.
Seeing an ImportError?
Make sure your virtual environment is activated (you should see (.venv) in your terminal prompt). Also check that models.py is saved in the rideshare-api folder and your terminal is in that same folder.
Help me debug my models.py import error
✔️ Awesome, I've got everything!
Great. Double check you have saved models.py and move on to building the API.
ⓧ I'd like to double check the full code
Your models.py should contain the two chunks you added above, in order:
- The from pydantic import BaseModel import, followed by RideEstimateRequest and RideEstimateResponse.
- Then ZoneCreate, Zone, and HealthResponse.
Scroll up and confirm each chunk is present in the correct order.
Build the core API endpoints
Now you will wire up the actual endpoints. The API needs a health check, a ride estimation endpoint that uses the haversine formula to calculate distance, and zone management endpoints.
- Open main.py and replace its entire contents with the application setup and health endpoint:
import math
from fastapi import FastAPI, HTTPException
from models import (
HealthResponse,
RideEstimateRequest,
RideEstimateResponse,
Zone,
ZoneCreate,
)
# Pricing constants
BASE_RATE_PER_KM = 1.50
MINIMUM_FARE = 5.00
app = FastAPI(title="Ride Price Estimator API")
# In-memory storage for surge zones
zones_store = []
@app.get("/health", response_model=HealthResponse)
def health_check():
return HealthResponse(status="healthy", database="n/a")
What does this code do?
The imports pull in your Pydantic models. BASE_RATE_PER_KM and MINIMUM_FARE set the pricing rules: $1.50 per kilometer with a $5 minimum.
zones_store is a plain Python list that holds surge zones in memory. The /health endpoint returns a quick status report. For now, database shows n/a because there is no database yet.
- Save main.py and start the dev server to confirm the health endpoint works:
fastapi dev main.py
- Open http://127.0.0.1:8000/health in your browser.
You should see {"status":"healthy","database":"n/a"} returned as JSON.
Server not starting?
Check for syntax errors in main.py. The terminal output will show the exact line number if there is a problem. Make sure all your model names in the import match models.py exactly (they are case-sensitive).
Help me fix my FastAPI server startup error
Keep the server running. FastAPI's dev mode auto-reloads when you save files, so you will see changes instantly.
- Back in main.py, add the distance calculation and surge lookup functions below the health_check function:
def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
# Earth's radius in kilometers
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(dlng / 2) ** 2
)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
def get_surge_for_location(lat: float, lng: float) -> float:
# Check if the location falls inside any surge zone
for zone in zones_store:
distance = haversine(lat, lng, zone["lat"], zone["lng"])
if distance <= zone["radius_km"]:
return zone["surge_multiplier"]
return 1.0
What is the haversine formula?
The haversine formula calculates the great-circle distance between two points on Earth using their latitude and longitude. It accounts for the curvature of the planet, giving you accurate distances in kilometers.
get_surge_for_location loops through all stored zones. If the pickup point falls within a zone's radius, that zone's surge multiplier applies. Otherwise it returns 1.0 (no surge).
- Still in main.py, add the ride estimation endpoint below get_surge_for_location:
@app.post("/rides/estimate", response_model=RideEstimateResponse)
def estimate_ride(request: RideEstimateRequest):
# Calculate straight-line distance between pickup and dropoff
distance_km = haversine(
request.pickup_lat,
request.pickup_lng,
request.dropoff_lat,
request.dropoff_lng,
)
if distance_km < 0.1:
raise HTTPException(
status_code=400, detail="Pickup and dropoff are too close"
)
# Look up surge pricing for the pickup location
surge = get_surge_for_location(request.pickup_lat, request.pickup_lng)
base_price = max(distance_km * BASE_RATE_PER_KM, MINIMUM_FARE)
total_price = round(base_price * surge, 2)
return RideEstimateResponse(
distance_km=round(distance_km, 2),
base_price=round(base_price, 2),
surge_multiplier=surge,
total_price=total_price,
currency="USD",
)
What does this endpoint do?
It takes pickup and dropoff coordinates, calculates the distance, rejects rides shorter than 100 meters, looks up surge pricing for the pickup location, and returns a full price breakdown. The minimum fare ensures drivers always earn at least $5.00.
- Save main.py and open http://127.0.0.1:8000/docs in your browser.
You should see the Swagger UI with your /health and /rides/estimate endpoints listed. The server auto-reloaded when you saved.
- Click POST /rides/estimate in the Swagger UI.
- Click Try it out.
- Replace the request body with these coordinates (Times Square to Central Park):
{
"pickup_lat": 40.7580,
"pickup_lng": -73.9855,
"dropoff_lat": 40.7829,
"dropoff_lng": -73.9654
}
- Click Execute.
You should see a response with a distance around 3.14 km, a base price, a surge multiplier of 1.0 (no zones exist yet), and a total price.
Getting a 422 Validation Error?
This means the request body does not match the expected shape. Make sure all four fields (pickup_lat, pickup_lng, dropoff_lat, dropoff_lng) are present and contain numbers, not strings.
Help me fix my 422 validation error in FastAPI
- Now add the zone management endpoints. In main.py, add these below the estimate_ride function:
@app.get("/zones", response_model=list[Zone])
def list_zones():
# Return all zones with sequential IDs
return [Zone(id=i+1, **z) for i, z in enumerate(zones_store)]
@app.post("/zones", response_model=Zone, status_code=201)
def create_zone(zone: ZoneCreate):
# Store the zone as a dict and return it with an ID
entry = zone.model_dump()
zones_store.append(entry)
return Zone(id=len(zones_store), **entry)
What do these endpoints do?
GET /zones returns all stored surge zones. POST /zones accepts a new zone definition, stores it in the zones_store list, and returns it with an assigned ID. The status_code=201 tells clients a resource was created.
- Save main.py.
- Refresh http://127.0.0.1:8000/docs in your browser.
You should now see all four endpoints listed: /health, /rides/estimate, /zones (GET), and /zones (POST).
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and all four endpoints appear in the Swagger UI.
ⓧ I'd like to double check the full code
Your main.py should contain the four chunks you added above, in order:
- Imports, constants, app setup, zones_store, and the health_check endpoint.
- The haversine and get_surge_for_location utility functions.
- The estimate_ride endpoint.
- The list_zones and create_zone endpoints.
Scroll up and confirm each chunk is present in the correct order with no duplicated imports.
Test surge pricing and discover the data loss problem
The API calculates rides, but right now there are no surge zones. Let's create one and see the multiplier apply to a ride estimate.
- In the Swagger UI at http://127.0.0.1:8000/docs, click POST /zones.
- Click Try it out and enter this surge zone (midtown Manhattan, 2x surge):
{
"name": "Midtown Rush",
"surge_multiplier": 2.0,
"lat": 40.7580,
"lng": -73.9855,
"radius_km": 1.0
}
- Click Execute. You should see a 201 response with the zone and its assigned id: 1.
- Now go back to POST /rides/estimate and run the same Times Square request again:
{
"pickup_lat": 40.7580,
"pickup_lng": -73.9855,
"dropoff_lat": 40.7829,
"dropoff_lng": -73.9654
}
- Click Execute.
This time the response shows surge_multiplier: 2.0 and the total_price is double what it was before. The pickup falls inside your Midtown Rush zone.
The surge pricing works. But there is a critical problem hiding in plain sight.
Before you run the next command, think: what do you expect to happen to your zones if you stop and restart the server?
- Go to the terminal where the server is running and press Ctrl+C to stop it.
- Start the server again:
fastapi dev main.py
- In the Swagger UI, click GET /zones, then Try it out, then Execute.
The response is an empty list: []. Your Midtown Rush zone is gone.
Why did the data disappear?
zones_store is a Python list that lives in the server's memory. When you stop the process, that memory is freed. Every restart begins with an empty list.
This is fine for prototyping, but a real ride-sharing API needs zones to survive restarts. That is exactly the problem the next step solves with PostgreSQL.
Your API logic is complete: distance calculation, surge pricing, and zone management all work. In the next step, you will add PostgreSQL so data persists across restarts, and Docker makes running the database effortless.
Add PostgreSQL for Persistent Storage
In the last step, you saw the problem firsthand: you created a surge zone, restarted the server, and the zone vanished. Your API stores everything in a Python list that lives only in memory.
In this step, you will add a real database so surge zones persist across restarts. Docker makes running PostgreSQL trivial. No installer, no system configuration. One command spins up a full database server in a container.
In this step, get ready to:
- Start a PostgreSQL container with Docker.
- Write a database connection module using asyncpg.
- Refactor your API endpoints to read and write from PostgreSQL.
Start a PostgreSQL container with Docker
Docker Desktop is already running on your machine from Step 1. You will use it to run a PostgreSQL 17 database server inside a container.
- Start the PostgreSQL container by running this command in your terminal:
docker run -d --name rideshare-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=rideshare -p 5432:5432 postgres:17
What does this command do?
- `docker run -d` starts a container in the background (detached mode).
- `--name rideshare-db` gives the container a memorable name so you can reference it later.
- The `-e` flags set environment variables that configure the database user, password, and database name.
- `-p 5432:5432` maps the container's PostgreSQL port to your machine so your API can connect.
- `postgres:17` is the official PostgreSQL 17 Docker image.
- Verify the container is running by running:
docker ps
You should see a row with the image postgres:17, status Up, and the name rideshare-db in the output.
Container not showing up?
- Make sure Docker Desktop is running. Check for the Docker whale icon in your system tray.
- If you see a port conflict error, another process is using port 5432. Stop it or change the host port to `-p 5433:5432`.
- Run `docker logs rideshare-db` to see if PostgreSQL hit an error during startup.
Why won't my PostgreSQL Docker container start?
Write the database connection module
Your database.py file is currently empty. You will fill it with an asyncpg connection pool that manages database connections efficiently across requests.
- Open database.py in your editor and paste the following connection management code:
import os
import asyncpg
# Read the database URL from an environment variable, with a local default
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost:5432/rideshare",
)
# Module-level pool variable shared across all requests
pool = None
async def get_pool():
# Create the pool on first call, reuse it afterward
global pool
if pool is None:
pool = await asyncpg.create_pool(DATABASE_URL)
return pool
async def close_pool():
# Gracefully shut down the pool when the app stops
global pool
if pool:
await pool.close()
pool = None
What does this code do?
- `DATABASE_URL` reads from an environment variable so you can point at different databases (local, Docker, production) without changing code.
- A connection pool opens several database connections upfront and reuses them across requests. This avoids the overhead of connecting on every single API call.
- `get_pool()` lazily creates the pool the first time it is called, then returns the same pool on every subsequent call.
- `close_pool()` cleanly shuts down all connections when your application stops.
- Below close_pool, add the table initialization function:
async def init_db():
# Create the zones table if it doesn't already exist
p = await get_pool()
async with p.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS zones (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
surge_multiplier FLOAT NOT NULL DEFAULT 1.0,
lat FLOAT NOT NULL,
lng FLOAT NOT NULL,
radius_km FLOAT NOT NULL
)
""")
What does init_db do?
`init_db()` acquires a single connection from the pool and runs a CREATE TABLE IF NOT EXISTS statement. This means the table is created on first launch but safely skipped on subsequent starts.
The SERIAL type auto-generates incrementing IDs for each new zone.
- Save database.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved database.py before continuing.
ⓧ I'd like to double check the full code
Your database.py should contain these sections in order:
- The imports (os and asyncpg) and DATABASE_URL variable.
- The pool = None variable.
- The get_pool() async function.
- The close_pool() async function.
- The init_db() async function with the CREATE TABLE statement.
Scroll up and confirm each section is present and in this order.
Refactor main.py for database integration
Almost every function in main.py needs to change: endpoints become async, and the in-memory list is replaced with database queries. The cleanest approach is to replace the entire file contents.
- Open main.py in your editor.
- Select all the existing code and delete it.
- Paste the following imports, lifespan, and app initialization:
import math
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from models import (
HealthResponse,
RideEstimateRequest,
RideEstimateResponse,
Zone,
ZoneCreate,
)
from database import close_pool, get_pool, init_db
BASE_RATE_PER_KM = 1.50
MINIMUM_FARE = 5.00
# Lifespan manages DB setup on start and cleanup on shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
await close_pool()
app = FastAPI(title="Ride Price Estimator API", lifespan=lifespan)
What is a lifespan?
A lifespan is FastAPI's way of running code exactly once when the app starts and once when it shuts down. Everything before yield runs on startup (creating the database table). Everything after yield runs on shutdown (closing the connection pool).
This replaces the old zones_store = [] list entirely. Data now lives in PostgreSQL, not in memory.
- Below the app line, paste the calculation functions:
def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
# Calculate distance between two points on Earth in kilometers
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(dlng / 2) ** 2
)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
async def get_surge_for_location(lat: float, lng: float) -> float:
# Query all zones from the database and check if location is inside one
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT surge_multiplier, lat, lng, radius_km FROM zones"
)
for row in rows:
distance = haversine(lat, lng, row["lat"], row["lng"])
if distance <= row["radius_km"]:
return row["surge_multiplier"]
return 1.0
What changed in get_surge_for_location?
The haversine function is unchanged from Step 2. But get_surge_for_location is now async and queries the zones table instead of looping over a Python list.
The conn.fetch() call returns rows as record objects. You access columns by name like a dictionary.
- Next, paste the health check endpoint:
@app.get("/health", response_model=HealthResponse)
async def health_check():
# Verify database is reachable with a simple query
try:
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("SELECT 1")
return HealthResponse(status="healthy", database="connected")
except Exception:
return HealthResponse(status="degraded", database="disconnected")
Why does health_check query the database now?
In Step 2, the health endpoint always returned "n/a" for the database field because there was no database. Now it runs SELECT 1 to prove the connection is alive and reports "connected" or "disconnected" accordingly.
- Paste the ride estimation endpoint:
@app.post("/rides/estimate", response_model=RideEstimateResponse)
async def estimate_ride(request: RideEstimateRequest):
distance_km = haversine(
request.pickup_lat,
request.pickup_lng,
request.dropoff_lat,
request.dropoff_lng,
)
if distance_km < 0.1:
raise HTTPException(
status_code=400, detail="Pickup and dropoff are too close"
)
# Look up surge multiplier from database zones
surge = await get_surge_for_location(
request.pickup_lat, request.pickup_lng
)
base_price = max(distance_km * BASE_RATE_PER_KM, MINIMUM_FARE)
total_price = round(base_price * surge, 2)
return RideEstimateResponse(
distance_km=round(distance_km, 2),
base_price=round(base_price, 2),
surge_multiplier=surge,
total_price=total_price,
currency="USD",
)
What changed here?
This function is nearly identical to Step 2. The only difference is async def and await before get_surge_for_location since it now queries the database.
- Finally, paste the zone management endpoints:
@app.get("/zones", response_model=list[Zone])
async def list_zones():
# Fetch all zones from the database
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, name, surge_multiplier, lat, lng, radius_km FROM zones"
)
return [Zone(**dict(row)) for row in rows]
@app.post("/zones", response_model=Zone, status_code=201)
async def create_zone(zone: ZoneCreate):
# Insert a new zone and return it with its generated ID
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO zones (name, surge_multiplier, lat, lng, radius_km)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, surge_multiplier, lat, lng, radius_km
""",
zone.name,
zone.surge_multiplier,
zone.lat,
zone.lng,
zone.radius_km,
)
return Zone(**dict(row))
What do these endpoints do now?
- `list_zones` runs a SELECT query and converts each database row into a Zone Pydantic model.
- `create_zone` uses an INSERT with RETURNING to insert the zone and get back its auto-generated id in one query.
- The $1, $2, $3 placeholders are asyncpg's parameterized query syntax. They prevent SQL injection.
- Save main.py.
✔️ Awesome, I've got everything!
Great. Make sure main.py is saved before running the server.
ⓧ I'd like to double check the full code
Your main.py should contain these sections in order:
- Imports from math, contextlib, fastapi, models, and database.
- Constants BASE_RATE_PER_KM and MINIMUM_FARE.
- The lifespan async context manager calling init_db() and close_pool().
- The app = FastAPI(...) line with lifespan=lifespan.
- The haversine function (unchanged from Step 2).
- The async get_surge_for_location function querying the database.
- The health_check, estimate_ride, list_zones, and create_zone endpoints.
Scroll up and confirm each section is present and in this order.
- Start the server by running:
fastapi dev main.py
You should see Application startup complete in the terminal. This confirms the lifespan ran successfully, connecting to PostgreSQL and creating the zones table.
Seeing a connection error?
- Confirm your PostgreSQL container is still running with docker ps. If it stopped, restart it with docker start rideshare-db.
- Check that DATABASE_URL in database.py matches the credentials you passed to docker run (user postgres, password postgres, database rideshare).
- If you used port 5433 earlier to avoid a conflict, update the port number in the DATABASE_URL default string to match.
Help me fix my database connection error in FastAPI with asyncpg.
- Open your browser to http://127.0.0.1:8000/health to test the health endpoint.
You should see {"status":"healthy","database":"connected"} confirming your API can talk to PostgreSQL.
- Open the Swagger UI at http://127.0.0.1:8000/docs to test the full flow.
- Expand POST /zones, click Try it out, and submit this request body:
{
"name": "Downtown",
"surge_multiplier": 1.8,
"lat": 40.7128,
"lng": -74.006,
"radius_km": 3.0
}
- Click Execute.
You should get a 201 response with the zone including its auto-generated id field.
Before you run the persistence test, what do you think will happen when you stop and restart the server, then call GET /zones?
- Stop the server with Ctrl+C in the terminal.
- Restart it by running:
fastapi dev main.py
- Open http://127.0.0.1:8000/zones in your browser.
Your Downtown zone is still there. Data survives server restarts because it lives in PostgreSQL, not in Python memory. This is the exact problem you hit at the end of Step 2, now solved.
Your API now has real persistence. Next up, you will package this entire application into a Docker container so anyone can run it without installing Python or configuring dependencies.
Containerize the API
Your API now persists data in PostgreSQL and survives restarts. But there is a portability problem. Anyone else who wants to run this API needs Python 3.13, the exact same pip packages, and a matching environment setup on their machine.
A Docker image packages your code, dependencies, and runtime into one portable unit. In this step, you will write a Dockerfile, build an image, and run your API as a container.
In this step, get ready to:
- Create a Dockerfile that packages the API into a container image.
- Create requirements.txt and build the Docker image.
- Run the containerized API and verify it connects to PostgreSQL.
Create the Dockerfile
A Dockerfile is a recipe that tells Docker how to assemble your application into an image. Each instruction creates a layer, and Docker caches layers that have not changed to speed up rebuilds.
- Create a new file called Dockerfile (no extension) in your rideshare-api folder.
- Add the following content to Dockerfile:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
- FROM python:3.13-slim starts from a lightweight Python 3.13 base image.
- WORKDIR /app sets the working directory inside the container to /app.
- COPY requirements.txt . copies dependencies first so Docker can cache this layer. If your app code changes but dependencies stay the same, Docker skips reinstalling packages.
- COPY . . copies the rest of your application code into the image.
- CMD starts uvicorn on port 8000, listening on all interfaces so traffic can reach the container.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile printed in the output.
Don't see the file?
Make sure you created the file in the rideshare-api folder, not inside a subfolder. The file name must be exactly Dockerfile with a capital D and no file extension.
Help me find my Dockerfile
Create requirements.txt and build the image
Docker needs a list of Python packages to install inside the image. The requirements.txt file pins exact versions so the container always installs the same dependencies.
- Create a new file called requirements.txt in your rideshare-api folder with this content:
fastapi[standard]==0.141.1
asyncpg==0.31.0
- Save requirements.txt.
- Build the Docker image by running this command:
docker build -t rideshare-api .
Docker pulls the base image, installs your packages, and copies your code. You should see output for each build step ending with a successful completion.
What does this command do?
- docker build reads your Dockerfile and executes each instruction.
- -t rideshare-api tags the resulting image with a name so you can reference it later.
- . tells Docker to use the current directory as the build context (where it looks for files to COPY).
Build failing?
Make sure Docker Desktop is running. If you see a permissions error, check that your terminal has access to the Docker engine.
If the build fails on pip install, double check that requirements.txt is saved in the same folder as your Dockerfile.
Help me fix my Docker build error
Run the containerized API
Your image is built. Now you need to run it as a container that can reach the PostgreSQL database already running on your machine.
What is host.docker.internal?
Containers run in an isolated network. They cannot use localhost to reach services on your host machine because localhost inside a container refers to the container itself.
Docker Desktop provides a special hostname host.docker.internal that resolves to your host machine. This lets your containerized API reach the PostgreSQL container that is mapped to port 5432 on your host.
Before you run this, do you think the API will successfully connect to the database? Why or why not?
- Start the containerized API by running this command:
docker run --rm -p 8000:8000 -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/rideshare rideshare-api
What do these flags do?
- --rm automatically removes the container when it stops, keeping things tidy.
- -p 8000:8000 maps port 8000 on your machine to port 8000 inside the container.
- -e DATABASE_URL=... sets an environment variable inside the container pointing at your PostgreSQL database via host.docker.internal.
- Open your browser and go to:
http://localhost:8000/health
You should see this response:
{"status":"healthy","database":"connected"}
The API is running inside a Docker container and successfully connecting to PostgreSQL on your host.
- Open the Swagger UI at:
http://localhost:8000/docs
You should see the same interactive documentation as before. The API behaves identically whether running directly or inside a container.
Seeing "database disconnected"?
Make sure the PostgreSQL container rideshare-db is still running. Verify with docker ps in a new terminal window.
If host.docker.internal is not resolving, confirm Docker Desktop is running (not just the Docker CLI).
Help me connect my container to PostgreSQL
✔️ Awesome, I've got everything!
Great. Make sure you have saved both Dockerfile and requirements.txt.
ⓧ I'd like to double check the full code
Here are the two new files created in this step.
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
fastapi[standard]==0.141.1
asyncpg==0.31.0
Your API is now packaged into a portable Docker image. Anyone with Docker installed can run it with a single command. Next up, you will use Docker Compose to orchestrate both the API and PostgreSQL together so you do not need to manage separate containers by hand.
Orchestrate with Docker Compose
Your containerized API is working, but starting the system requires two separate docker run commands with long argument lists. One for PostgreSQL, one for the API, each with environment variables, port mappings, and network flags you have to remember every time.
Docker Compose solves this by letting you define the entire stack in one file and launch everything with a single command. In this step, you will create a compose.yaml that orchestrates your API and database with proper health checks and startup ordering.
In this step, get ready to:
- Remove the standalone PostgreSQL container so Compose can manage the database.
- Create a compose.yaml file defining both services with health checks and startup ordering.
- Start the entire stack with one command and test the full flow.
Stop the standalone database container
Before Compose can manage PostgreSQL, you need to remove the standalone container you started manually in the previous step. If you leave it running, Compose will fail because port 5432 is already in use.
- Stop the running PostgreSQL container by running this command:
docker stop rideshare-db
- Remove the stopped container by running:
docker rm rideshare-db
- Confirm the container is gone by running:
docker ps -a
You should no longer see rideshare-db in the list.
Seeing an error about the container not running?
If docker stop says the container is not running, that is fine. Skip straight to docker rm rideshare-db to remove it.
If docker rm says no such container exists, you may have already removed it. Run docker ps -a to confirm.
help me remove a Docker container that might already be stopped or removed
Create the Docker Compose file
A Compose file declares your entire application stack: which services to run, how they connect, and in what order they start. Compose also creates a shared network automatically so services can reach each other by name.
- Create a new file called compose.yaml in the root of your rideshare-api folder and paste this content:
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/rideshare
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=rideshare
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
What does this file do?
The db service runs postgres:17 with a healthcheck that uses pg_isready to confirm PostgreSQL is accepting connections. The named volume pgdata stores data outside the container so it survives restarts.
The api service builds from your existing Dockerfile. The depends_on with condition: service_healthy tells Compose to wait until the database passes its health check before starting the API.
The DATABASE_URL uses db as the hostname instead of localhost. Compose creates a shared network where each service is reachable by its service name.
This replaces the host.docker.internal workaround from the previous step with proper container-to-container networking.
Why health checks instead of just depends_on?
Without a health check, depends_on only waits for the container to start, not for the service inside it to be ready. PostgreSQL takes a few seconds after the container starts to accept connections.
If the API tries to connect during that window, it crashes. The condition: service_healthy pattern ensures the database is truly accepting queries before the API launches.
- Save compose.yaml.
- Confirm the file exists by running:
ls compose.yaml
You should see compose.yaml listed in the output.
File not found?
Make sure you are in the rideshare-api folder when you run ls. If you named the file docker-compose.yml or docker-compose.yaml, rename it to compose.yaml. The Compose Specification uses compose.yaml as the standard filename.
help me fix my compose.yaml file location or naming
Start the stack and test the full flow
With the Compose file in place, one command builds your API image and starts both services in the correct order.
- Start the entire stack by running:
docker compose up --build
What does this command do?
The --build flag rebuilds the API image from your Dockerfile before starting. Without it, Compose would use a cached image and miss any code changes you made.
Watch the terminal output. You will see Compose start the db container first, run the health check every 5 seconds, and only start api after the database reports healthy.
Before you check the health endpoint, do you think the response will show the database as connected or disconnected?
- Open your browser and go to http://localhost:8000/health.
You should see the API reporting both status and database as healthy and connected.
{
"status": "healthy",
"database": "connected"
}
- Open the Swagger UI at http://localhost:8000/docs to test the full flow.
- Use POST /zones to create a surge zone with this body:
{
"name": "Downtown",
"surge_multiplier": 1.8,
"lat": 40.7128,
"lng": -74.006,
"radius_km": 2.0
}
- Use POST /rides/estimate with a pickup inside the zone:
{
"pickup_lat": 40.7128,
"pickup_lng": -74.006,
"dropoff_lat": 40.7580,
"dropoff_lng": -73.9855
}
The response includes the 1.8x surge multiplier applied to the base price. Your entire system is running from a single command.
- Return to the terminal where Compose is running and press Ctrl+C to stop the services.
- Tear down the containers cleanly by running:
docker compose down
Compose removes both containers but preserves the pgdata volume. Next time you run docker compose up, your zones are still there.
Why does the volume survive?
Named volumes (pgdata) persist independently of containers. Running docker compose down removes containers and networks but keeps volumes intact.
To destroy the volume and start with a fresh database, you would run docker compose down -v. The -v flag tells Compose to remove named volumes too.
API container exits immediately?
If the API starts and then crashes, check the logs with docker compose logs api. The most common cause is a typo in DATABASE_URL inside compose.yaml. Make sure the hostname is db (the service name), not localhost or host.docker.internal.
If you see a port conflict on 5432, make sure no other PostgreSQL container is running. Run docker ps to check.
help me troubleshoot my Docker Compose startup
✔️ Awesome, I've got everything!
Great. Make sure compose.yaml is saved in the root of your rideshare-api folder.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml file. Compare it against yours:
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/rideshare
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=rideshare
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Your other files (main.py, models.py, database.py, requirements.txt, Dockerfile) remain unchanged from the previous step.
Secret mission
Add Redis Caching for Surge Lookups
In a real ride-sharing app, surge pricing is checked on every single ride request. Querying the database every time is wasteful when zones rarely change. Add Redis caching so repeated lookups skip PostgreSQL entirely and return in sub-millisecond time.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (api, db, and redis services from Docker Compose).
- Docker image rideshare-api (built from your Dockerfile).
- Docker named volume pgdata storing PostgreSQL data.
- Local project files in the rideshare-api folder on your Desktop.
- Docker Desktop application installed on your machine.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the API.
- Leave Docker Desktop running in the background.
- Use docker compose up from the rideshare-api folder whenever you want to start the stack again.
- Your surge zone data persists in the pgdata volume between restarts.
Pause - I'll come back to this later
Shut down running containers to free up system memory, but keep your files and volume intact for next time.
- In your terminal, navigate to the rideshare-api folder and run:
- This stops and removes the containers but preserves the pgdata volume. Your zone data will still be there when you run docker compose up again.
docker compose down
- You can also quit Docker Desktop from the system tray to free up more memory.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes containers, volumes, images, and local files.
- Stop containers and remove the database volume by running:
docker compose down -v
- Remove the custom-built API image by running:
docker image rm rideshare-api
- Remove the pulled base images by running:
docker image rm postgres:17 redis:7 python:3.13-slim
- Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf rideshare-api
- On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force rideshare-api
- To uninstall Docker Desktop, open Windows Settings, select Apps, then Apps & features. Find Docker Desktop in the list and click Uninstall.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade ride-sharing price estimator API and deployed it as a multi-container Docker application.
You've learned how to:
- Build a FastAPI REST API with async Python that estimates ride prices using the haversine formula and dynamic surge pricing zones stored in PostgreSQL.
- Write a Dockerfile that packages a Python application into a portable container image with pinned dependencies and layer caching.
- Create a Docker Compose configuration that orchestrates multiple services with health-check-based startup ordering, environment-based configuration, and named volumes for data persistence.
- Secret Mission: Added Redis as a caching layer with a cache-aside pattern and TTL-based expiration for high-frequency surge lookups.
Ready to quiz yourself?