DBMS 📂 Real Use cases · 3 of 4 47 min read

Transport Booking System like MakeMyTrip

Design the database for a MakeMyTrip-style transport booking app in MySQL, covering flights, trains and buses in one schema. Includes full table schemas for cities, operators, routes, trips, fare classes, seats, bookings, payments, coupons and cancellations, how to stop double-booking a seat, and UML use-case plus ER diagrams.

Section 01

What We Are Building

One Counter for Every Journey
Imagine one travel desk that can book a flight, a train, or a bus. You tell it where you are going and when. It shows you options with prices and seats. You pick one, add the travellers, pay, and walk away with a ticket and a booking number.

A site like MakeMyTrip is that desk, online, for millions of people at once. Behind it sits a database that must never sell the same seat twice, must remember the exact price you paid, and must handle cancellations and refunds. This tutorial designs that database. The engine is MySQL / MariaDB.

We build a multi-modal transport booking system: flights, trains, and buses in one schema. We cover the reference data (cities, operators, routes), the sellable inventory (trips, fare classes, seats), and the booking flow (bookings, passengers, payments, coupons, cancellations). We finish with UML use-case and ER diagrams.

✈️
Flight
mode = flight
Airlines like a "6E" service. Classes such as Economy and Business. Seats like 12A. Fast, priced by demand.
🚂
Train
mode = train
Rail services with classes like Sleeper and AC 3-Tier. Seats like S4-33. Long routes, many stops.
🚌
Bus
mode = bus
Bus operators with Seater and Sleeper classes. Seats like U7. Cheapest, most frequent option.

Section 02

The Booking Journey

Every booking follows the same five steps. Our tables are shaped around this flow.

🧭 User Journey — Search to Ticket
1 Search route + date 2 Select trip + class 3 Seat pick a seat 4 Pay passengers + pay 5 Ticket PNR issued

Section 03

One Schema for Flights, Trains, and Buses

The first big decision: do we build separate tables for each mode, or one shared set of tables with a mode column? Here is the trade-off.

📦
Table per mode
flights, trains, buses each get their own tables. Feels tidy at first, but every feature (search, booking, payment) must be written three times. Hard to keep in sync.
effort: high · reuse: low
🧩
Fully generic "product"
One giant table for anything sellable. Very flexible, but loses meaning: no clear columns for departure time or seat class. Queries turn messy.
effort: medium · clarity: low
🎯
Shared tables + mode column
One set of tables (operators, routes, trips) with a mode of flight, train, or bus. Common flow written once; small mode-specific bits handled where needed.
effort: low · reuse: high
🎯
Our Choice: Shared Tables with a mode Column

The booking flow is the same whether you buy a flight or a bus seat: search a route, pick a trip and class, choose a seat, pay. We model that once. The mode lives on operators, so a route and its trips inherit it. This keeps the whole system small and consistent.


Section 04

Reference Tables — Cities, Operators, Routes

These tables rarely change. A city is a place you travel to or from. An operator is an airline, railway, or bus company. A route connects two cities for one operator.

-- ============ CITIES / STATIONS ============
CREATE TABLE cities (
    id       INT UNSIGNED  NOT NULL AUTO_INCREMENT,
    name     VARCHAR(120) NOT NULL,      -- "New Delhi"
    code     VARCHAR(10)  NOT NULL,      -- DEL, BOM (airport/station code)
    state    VARCHAR(120) NULL,
    country  VARCHAR(80)  NOT NULL DEFAULT 'India',
    PRIMARY KEY (id),
    UNIQUE KEY uq_city_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ OPERATORS (airline / railway / bus company) ============
CREATE TABLE operators (
    id      INT UNSIGNED  NOT NULL AUTO_INCREMENT,
    name    VARCHAR(150) NOT NULL,
    mode    ENUM('flight','train','bus') NOT NULL,
    code    VARCHAR(10)  NOT NULL,      -- 6E, AI, VRL
    rating  DECIMAL(2,1) NULL,          -- 4.2
    PRIMARY KEY (id),
    UNIQUE KEY uq_op_code (mode, code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ ROUTES (source -> destination for an operator) ============
CREATE TABLE routes (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    operator_id     INT UNSIGNED    NOT NULL,   -- FK -> operators
    source_city_id  INT UNSIGNED    NOT NULL,   -- FK -> cities
    dest_city_id    INT UNSIGNED    NOT NULL,   -- FK -> cities
    distance_km     INT UNSIGNED    NULL,
    PRIMARY KEY (id),
    KEY ix_route_search (source_city_id, dest_city_id),
    CONSTRAINT fk_route_op  FOREIGN KEY (operator_id)    REFERENCES operators(id) ON DELETE CASCADE,
    CONSTRAINT fk_route_src FOREIGN KEY (source_city_id) REFERENCES cities(id),
    CONSTRAINT fk_route_dst FOREIGN KEY (dest_city_id)   REFERENCES cities(id),
    CONSTRAINT chk_route_diff CHECK (source_city_id <> dest_city_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
📍
Index the Search You Run Most

People search "Delhi to Mumbai" all day. The index ix_route_search (source_city_id, dest_city_id) makes that lookup instant. Always index the columns your busiest query filters on.


Section 05

UML Use-Case Diagram — Who Does What

Three actors use the system. The dashed box is the platform boundary.

👤 UML Use-Case — Traveller, Operator, Admin
Transport Booking Platform Traveller Operator Admin Search trips Book & choose seat Pay Cancel & refund Manage trips Set fares & seats Manage operators Manage coupons

Section 06

Inventory — Trips, Fare Classes, Seats

A trip is one dated service on a route (flight 6E-233 on 25 Sep). Each trip sells one or more fare classes (Economy, Sleeper). Each class holds a set of seats. This is what a traveller actually buys.

-- ============ TRIPS (a dated service on a route) ============
CREATE TABLE trips (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    route_id      BIGINT UNSIGNED NOT NULL,     -- FK -> routes
    service_no    VARCHAR(20)     NOT NULL,     -- 6E-233, 12951
    departure_at  DATETIME        NOT NULL,
    arrival_at    DATETIME        NOT NULL,
    status        ENUM('scheduled','departed','arrived','cancelled') NOT NULL DEFAULT 'scheduled',
    PRIMARY KEY (id),
    KEY ix_trip_depart (route_id, departure_at),
    CONSTRAINT fk_trip_route FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ FARE CLASSES (price + seat counts per trip) ============
CREATE TABLE fare_classes (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    trip_id          BIGINT UNSIGNED NOT NULL,   -- FK -> trips
    class_name       VARCHAR(40)     NOT NULL,   -- Economy, Sleeper, AC 3-Tier
    price            DECIMAL(10,2)  NOT NULL,
    seats_total      INT UNSIGNED    NOT NULL,
    seats_available  INT UNSIGNED    NOT NULL,   -- fast counter for search results
    PRIMARY KEY (id),
    UNIQUE KEY uq_fare (trip_id, class_name),
    CONSTRAINT fk_fare_trip FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE,
    CONSTRAINT chk_seats CHECK (seats_available <= seats_total)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ SEATS (the seat map of a trip) ============
CREATE TABLE seats (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    trip_id        BIGINT UNSIGNED NOT NULL,    -- FK -> trips
    fare_class_id  BIGINT UNSIGNED NOT NULL,    -- FK -> fare_classes
    seat_no        VARCHAR(8)      NOT NULL,    -- 12A, S4-33, U7
    status         ENUM('available','held','booked') NOT NULL DEFAULT 'available',
    PRIMARY KEY (id),
    UNIQUE KEY uq_seat (trip_id, seat_no),
    KEY ix_seat_pick (fare_class_id, status),
    CONSTRAINT fk_seat_trip FOREIGN KEY (trip_id)       REFERENCES trips(id)        ON DELETE CASCADE,
    CONSTRAINT fk_seat_fare FOREIGN KEY (fare_class_id) REFERENCES fare_classes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ModeExample classSeat formatStored in
FlightEconomy / Business12Afare_classes + seats
TrainSleeper / AC 3-TierS4-33fare_classes + seats
BusSeater / SleeperU7fare_classes + seats
🔢
Why Keep seats_available on fare_classes

Search results show "6 seats left" for thousands of trips at once. Counting seat rows every time is slow. We keep a running seats_available counter and update it inside the booking transaction. The seats table is the source of truth; the counter is the fast copy.


Section 07

The Core Problem — Never Sell a Seat Twice

Two People, One Seat, Same Second
Two travellers tap "Book 12A" at the exact same moment. Both screens showed the seat as free. Without protection, the database happily saves two bookings for one seat. On boarding day, one traveller has no seat and a very bad day. Stopping this is the heart of a booking system.
❌ Check-then-insert (unsafe)
App reads: seat is free
Another app reads: also free
Both insert a booking
Seat sold twice
✅ Lock + unique key (safe)
Lock the seat row (FOR UPDATE)
Confirm status = available
Insert; UNIQUE(trip_id, seat_id) guards
Second attempt is rejected
🔒 The Safe Seat-Booking Steps (inside one transaction)
Step 1
SELECT … FOR UPDATE the seat row. This locks it so no one else can grab it.
Step 2
Check the seat status is still available. If not, stop and tell the user.
Step 3
Insert the booking item and set the seat to booked.
Step 4
Lower seats_available by one, then COMMIT.
⚠️
Two Guards Are Better Than One

The row lock stops the race. The UNIQUE(trip_id, seat_id) key on booking_items is the safety net if any code path forgets the lock. Use both. In the database, a unique key is the last word.


Section 08

Booking Tables — Users, Bookings, Booking Items

A booking is one purchase by one user. It has a booking reference (the PNR). Each booking_item is one seat for one passenger on one trip. Multi-passenger and multi-leg trips are just many items under one booking.

-- ============ USERS ============
CREATE TABLE users (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name           VARCHAR(120)    NOT NULL,
    email          VARCHAR(190)    NOT NULL,
    phone          VARCHAR(20)     NULL,
    password_hash  VARCHAR(255)    NOT NULL,
    created_at     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_user_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ BOOKINGS (one purchase = one PNR) ============
CREATE TABLE bookings (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id       BIGINT UNSIGNED NOT NULL,     -- FK -> users
    booking_ref   VARCHAR(12)     NOT NULL,     -- PNR, e.g. MMT7X9K2
    status        ENUM('pending','confirmed','cancelled','refunded') NOT NULL DEFAULT 'pending',
    total_amount  DECIMAL(10,2)  NOT NULL DEFAULT 0,
    coupon_id     BIGINT UNSIGNED NULL,         -- FK -> coupons (added in next section)
    booked_at     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_booking_ref (booking_ref),
    KEY ix_booking_user (user_id, booked_at),
    CONSTRAINT fk_booking_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ BOOKING_ITEMS (one seat + one passenger + one trip) ============
CREATE TABLE booking_items (
    id                BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    booking_id        BIGINT UNSIGNED NOT NULL,   -- FK -> bookings
    trip_id           BIGINT UNSIGNED NOT NULL,   -- FK -> trips
    fare_class_id     BIGINT UNSIGNED NOT NULL,   -- FK -> fare_classes
    seat_id           BIGINT UNSIGNED NULL,       -- FK -> seats
    passenger_name    VARCHAR(120)    NOT NULL,
    passenger_age     TINYINT UNSIGNED NULL,
    passenger_gender  ENUM('M','F','O') NULL,
    price             DECIMAL(10,2)  NOT NULL,   -- price snapshot at booking time
    PRIMARY KEY (id),
    -- the double-booking safety net: one seat can be sold once per trip
    UNIQUE KEY uq_seat_once (trip_id, seat_id),
    KEY ix_item_booking (booking_id),
    CONSTRAINT fk_item_booking FOREIGN KEY (booking_id)    REFERENCES bookings(id)     ON DELETE CASCADE,
    CONSTRAINT fk_item_trip    FOREIGN KEY (trip_id)       REFERENCES trips(id),
    CONSTRAINT fk_item_fare    FOREIGN KEY (fare_class_id) REFERENCES fare_classes(id),
    CONSTRAINT fk_item_seat    FOREIGN KEY (seat_id)       REFERENCES seats(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
💳
Snapshot the Price on the Item

Fares change by the hour. Store the price on the booking_item at the moment of booking. Never compute an old ticket's price from the live fare — the traveller paid what they paid, and refunds must use that number.


Section 09

Payments, Coupons, and Cancellations

A booking is paid by one or more payments. A coupon can lower the total. A cancellation records a refund request against a booking or a single item.

-- ============ COUPONS (create before the bookings FK below) ============
CREATE TABLE coupons (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    code           VARCHAR(30)     NOT NULL,   -- FLY500
    discount_type  ENUM('flat','percent') NOT NULL,
    value          DECIMAL(10,2)  NOT NULL,   -- 500 or 10 (%)
    max_discount   DECIMAL(10,2)  NULL,       -- cap for percent coupons
    valid_from     DATE            NULL,
    valid_till     DATE            NULL,
    usage_limit    INT UNSIGNED    NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_coupon_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- now wire the coupon link on bookings
ALTER TABLE bookings
    ADD CONSTRAINT fk_booking_coupon
    FOREIGN KEY (coupon_id) REFERENCES coupons(id) ON DELETE SET NULL;

-- ============ PAYMENTS ============
CREATE TABLE payments (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    booking_id  BIGINT UNSIGNED NOT NULL,     -- FK -> bookings
    amount      DECIMAL(10,2)  NOT NULL,
    method      ENUM('card','upi','netbanking','wallet') NOT NULL,
    status      ENUM('initiated','success','failed','refunded') NOT NULL DEFAULT 'initiated',
    txn_ref     VARCHAR(64)     NULL,          -- gateway reference
    paid_at     TIMESTAMP       NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_txn (txn_ref),
    KEY ix_pay_booking (booking_id),
    CONSTRAINT fk_pay_booking FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ CANCELLATIONS / REFUNDS ============
CREATE TABLE cancellations (
    id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    booking_id       BIGINT UNSIGNED NOT NULL,   -- FK -> bookings
    booking_item_id  BIGINT UNSIGNED NULL,       -- FK -> booking_items (NULL = whole booking)
    reason           VARCHAR(255)    NULL,
    refund_amount    DECIMAL(10,2)  NOT NULL DEFAULT 0,
    status           ENUM('requested','approved','refunded','rejected') NOT NULL DEFAULT 'requested',
    requested_at     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY ix_cancel_booking (booking_id),
    CONSTRAINT fk_cancel_booking FOREIGN KEY (booking_id)      REFERENCES bookings(id)      ON DELETE CASCADE,
    CONSTRAINT fk_cancel_item    FOREIGN KEY (booking_item_id) REFERENCES booking_items(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🧾
Cancel a Whole Booking or Just One Seat

Setting booking_item_id to a value cancels one passenger's seat. Leaving it NULL cancels the whole booking. One table handles both cases, so your refund logic stays in one place.


Section 10

ER Diagrams — The Full Schema

Part 1: reference and inventory. Each arrow reads one-to-many (1 → N) from parent to child. routes links to cities twice (source and destination).

📊 ER Diagram (1/2) — Reference & Inventory
source/dest 1→N 1→N 1→N 1→N cities PK id code operators PK id mode code routes PK id FK operator_id FK source_city_id FK dest_city_id trips PK id FK route_id departure_at fare_classes PK id FK trip_id price seats PK id FK fare_class_id seat_no / status

Part 2: the booking and payment side. A booking gathers items, payments, and any cancellation.

📊 ER Diagram (2/2) — Bookings & Payments
1→N 1→N 1→N 1→N 1→N users PK id email bookings PK id FK user_id FK coupon_id booking_ref coupons PK id code booking_items FK booking_id FK trip_id FK seat_id price payments PK id FK booking_id status cancellations FK booking_id FK booking_item_id refund_amount

Section 11

The Booking Lifecycle and Example Queries

01
Search
Find trips on the route and date, with a fare class that still has seats.
02
Hold and book
Lock the seat, create the booking (status pending), add booking items, lower the counter.
03
Pay and confirm
Record the payment. On success, set the booking to confirmed and issue the PNR.
04
Cancel (if needed)
Log a cancellation, free the seat, raise the counter, and refund per policy.

Search: trips with seats left (Delhi → Mumbai, 25 Sep)

SELECT o.name AS operator, t.service_no, t.departure_at,
       fc.class_name, fc.price, fc.seats_available
FROM   trips t
JOIN   routes r        ON r.id = t.route_id
JOIN   operators o     ON o.id = r.operator_id
JOIN   fare_classes fc ON fc.trip_id = t.id
JOIN   cities src      ON src.id = r.source_city_id
JOIN   cities dst      ON dst.id = r.dest_city_id
WHERE  src.code = 'DEL'
  AND  dst.code = 'BOM'
  AND  t.departure_at >= '2026-09-25 00:00:00'
  AND  t.departure_at <  '2026-09-26 00:00:00'
  AND  fc.seats_available > 0
  AND  t.status = 'scheduled'
ORDER BY fc.price ASC;
OUTPUT — cheapest first
operator service_no departure_at class price seats_available IndiGo 6E-233 2026-09-25 06:10:00 Economy 4899.00 6 Air India AI-805 2026-09-25 09:40:00 Economy 5320.00 14 Vistara UK-963 2026-09-25 18:25:00 Economy 6110.00 3

Book a seat safely (one transaction)

START TRANSACTION;

-- 1) lock the seat so no one else can take it
SELECT id, status FROM seats
WHERE  trip_id = 4100 AND seat_no = '12A'
FOR UPDATE;
-- (app checks status = 'available'; if not, ROLLBACK and tell the user)

-- 2) create the booking
INSERT INTO bookings (user_id, booking_ref, status, total_amount)
VALUES (57, 'MMT7X9K2', 'pending', 4899.00);
SET @booking_id = LAST_INSERT_ID();

-- 3) add the seat as a booking item (UNIQUE(trip_id, seat_id) is the safety net)
INSERT INTO booking_items (booking_id, trip_id, fare_class_id, seat_id,
                            passenger_name, passenger_age, passenger_gender, price)
VALUES (@booking_id, 4100, 9001, 55012, 'Asha Rao', 29, 'F', 4899.00);

-- 4) mark the seat booked and lower the counter
UPDATE seats SET status = 'booked' WHERE id = 55012;
UPDATE fare_classes SET seats_available = seats_available - 1
WHERE id = 9001 AND seats_available > 0;

COMMIT;
⏳
Confirm Only After Payment Succeeds

Keep the booking pending until the payment row is success. Then set it to confirmed. If payment fails or times out, roll the seat back to available and raise the counter, so the seat can sell again.


Section 12

Golden Rules

🚌 Transport Booking — Non-Negotiable Rules
1
Guard every seat with a UNIQUE key. UNIQUE(trip_id, seat_id) on booking_items is the last line of defence against selling one seat twice.
2
Book inside a transaction with a row lock. SELECT … FOR UPDATE the seat, check it, then insert. All steps commit together or not at all.
3
Snapshot the price. Store what the traveller paid on the booking_item. Live fares change; a sold ticket must not.
4
One schema for all modes. Flights, trains, and buses share tables with a mode column. Write the booking flow once, not three times.
5
Keep a fast seats_available counter. Update it inside the booking transaction. Search stays quick without counting seat rows each time.
6
Confirm only after payment. A booking is pending until the payment succeeds. On failure, free the seat again.
7
Index your busiest search. Source, destination, and departure time drive almost every query. Index them, and add covering indexes as traffic grows.