Uber

Production database schema for an Uber-like ride-sharing platform featuring driver and vehicle management, real-time ride lifecycle tracking, GPS route logging, dynamic surge pricing, fare estimation, payment processing, mutual rating system, driver earnings, promotions, and support ticket management.

PostgreSQL14 tablesWeb App
ride-sharinggig-economygeolocationpaymentsreal-time
Created with ERD Studio

About this schema

One account can be a rider and a driver, so users holds both and driver_profiles holds only what is true while driving — licence, status, rating, and the current position. That last pair of columns is the unusual one: current_latitude and current_longitude are overwritten constantly, which is why they live on the driver rather than in the tracking table.

A ride is a row that fills in over time. requested_at, accepted_at, started_at, completed_at and cancelled_at are separate timestamps rather than a status history, so the lifecycle is readable from one row and 'how long did the driver take to accept' is a subtraction.

Money is recorded three times and they are three different numbers. rides holds the fare quoted and charged, payments holds what the passenger actually paid, and driver_earnings holds gross, commission and net for the driver. Collapsing them would make it impossible to answer where the difference went.

Key relationships

users → driver_profiles
A role, not a separate account. Someone who drives and rides is one user, so their rating as a passenger and their rating as a driver stay distinguishable without two logins.
rides → ride_tracking
Time-series waypoints, one row per sample. Separate from the ride because it is written constantly during a trip and read almost never afterwards.
rides → payments → driver_earnings
Three rows for one trip: what was charged, what was collected, what the driver keeps. commission is the gap and is stored rather than derived from a rate that changes.
ratings → users (twice)
rater_id and ratee_id, one ride producing two rows going opposite ways — the same shape as any mutual review.
promotions → promo_usages
Redemptions are rows tied to a ride, not just a counter. That is what makes 'has this user already used this code' answerable.

Design decisions

The driver's current position is a column, not a row

Dispatch needs the latest position of every available driver, and it needs it now. A row appended to ride_tracking every few seconds would mean finding the newest per driver on every dispatch query. Two columns overwritten in place make that a plain index scan. The history is not lost — ride_tracking keeps it, but only during a ride, which is the only time anyone needs the path.

The ride lifecycle is timestamps, not a status log

status says where the ride is now; the five timestamps say how it got there. A status history table would answer the same questions with a join and an ordering, and every dashboard — acceptance time, trip duration, cancellation rate — becomes a subtraction between two columns instead. The limitation is that a state cannot be entered twice, which for a ride is a feature.

Fare components are stored on the ride

base_fare, total_fare and surge_multiplier are copied onto the ride rather than recomputed from ride_types. Rates change and surge is momentary, so a ride that cannot reproduce its own price is a ride nobody can explain to a customer. ride_types remains the current rate card, not a historical one.

Promo usage is rows, not just a counter

promotions.uses_count is the cheap check against max_uses, but promo_usages records who redeemed what on which ride. Without it, 'one use per customer' is unenforceable and a disputed discount cannot be traced. The counter is the fast path; the rows are the truth.

Tables in this template

usersUsers
11 cols
driver_profilesDriver Profiles
12 cols
vehiclesVehicles
12 cols
vehicle_documentsVehicle Documents
8 cols
ride_typesRide Types
8 cols
ridesRides
22 cols
ride_trackingRide Tracking
6 cols
ratingsRatings
7 cols
paymentsPayments
10 cols
driver_earningsDriver Earnings
9 cols
promotionsPromotions
10 cols
promo_usagesPromo Usages
6 cols
saved_placesSaved Places
8 cols
support_ticketsSupport Tickets
9 cols

Frequently asked

Why is the driver's location not in ride_tracking?
Because dispatch asks for the current position of many drivers at once, most of whom are not on a ride. Reading the newest row per driver from a time-series table is the wrong shape for that query. The columns on driver_profiles are overwritten; ride_tracking keeps the path of an actual trip.
Why store total_fare when the rates are in ride_types?
Because rates change and surge is a moment. A ride that has to recompute its price from today's rate card cannot explain an old receipt, and a disputed charge has no answer. The rate card is current; the ride is history.
Should riders and drivers be separate tables?
Not if a person can be both — and in most markets they can. Two tables mean duplicate phone numbers and verification, and a rating system that has to look in two places to name someone. driver_profiles holds only the driving-specific fields.
How is matching a rider to a driver done?
Not by this schema. The current position columns and the status field are the inputs, and dispatch reads them with a geospatial index — which is why they are indexed columns rather than a log. The matching itself is a service, not a query.
Where does cancellation fee go?
It is not modelled. cancelled_at says a ride ended early; a fee is a charge, so it belongs in payments with a type that distinguishes it, pointing at the same ride. Putting a fee column on rides works until there are two kinds of fee.