Food Delivery ER Diagram

A food delivery schema follows an order from a restaurant menu to a customer's door. Orders snapshot item prices, deliveries pair each order with a courier and timestamps, and addresses keep delivery destinations separate from customer identities.

The order line is where history is protected. order_items stores unit_price and item_name copied from menu_items at checkout, so renaming a dish or raising its price never rewrites past orders. menu_items still references menu_categories, which references restaurants, giving each location its own menu structure. orders also snapshots address_id rather than joining through customers at read time, because a customer may move or delete an old address while the delivery record must still say where the food went. This makes orders a point-in-time record rather than a live view of the catalog.

Read the diagram as three flows meeting at orders. The catalog flow runs restaurants to menu_categories to menu_items, then into order_items. The customer flow runs customers to addresses and on to orders. The fulfilment flow runs orders to deliveries and couriers. deliveries.order_id is UNIQUE, so each order has one delivery record, updated in place as picked_up_at and delivered_at are filled; the CHECK requiring picked_up_at before delivered_at keeps the timeline honest. couriers carry vehicle_type and active, which is what dispatch queries filter on. The same record separates an unassigned delivery from one already on the road.

Real delivery systems break in the gap between status and money. orders.total is stored, but nothing in this schema recomputes or checks it against the sum of order_items, so a totals mismatch is possible; the fix is a transaction or trigger that writes both together. Deleting a restaurant or menu item with order history is blocked by the plain foreign keys, which is correct. Deleting an order cascades to its items and delivery, so a cancelled test order disappears cleanly — but production systems usually keep cancelled orders for accounting instead.

The first extension is usually a delivery_status_history table (delivery_id, status, changed_at), because one status column cannot answer how long an order sat waiting for pickup. A promotions table with order_id or order_items.discount_amount records discounts without hiding them in the total. Courier shifts and zones enable dispatch scoring, simplified here to a courier_zones table keyed on postal codes. If customers can order from one restaurant only, payments belongs as a one-to-one sibling of orders, mirroring the hotel and e-commerce examples. Each addition introduces a table and a foreign key without disturbing the order snapshot that already protects history.

The diagram

1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:N 1:1 1:N restaurants PKid integer name varchar(120) city varchar(80) phone varchar(30) menu_categories PKid integer FKrestaurant_id integer name varchar(80) display_order integer menu_items PKid integer FKmenu_category_id integer name varchar(120) description text price decimal(8,2) available boolean customers PKid integer full_name varchar(120) Uemail varchar(255) phone varchar(30) addresses PKid integer FKcustomer_id integer line1 varchar(200) city varchar(80) postal_code varchar(20) is_default boolean couriers PKid integer full_name varchar(120) phone varchar(30) vehicle_type varchar(20) active boolean orders PKid integer FKcustomer_id integer FKrestaurant_id integer FKaddress_id integer status varchar(20) total decimal(10,2) placed_at timestamp order_items PKid integer FKorder_id integer FKmenu_item_id integer item_name varchar(120) quantity integer unit_price decimal(8,2) deliveries PKid integer FKorder_id integer FKcourier_id integer status varchar(20) picked_up_at timestamp delivered_at timestamp

Tables (9)

restaurants

  • idintegerPK
  • namevarchar(120)
  • cityvarchar(80)
  • phonevarchar(30)

menu_categories

  • idintegerPK
  • restaurant_idintegerFK
  • namevarchar(80)
  • display_orderinteger

menu_items

  • idintegerPK
  • menu_category_idintegerFK
  • namevarchar(120)
  • descriptiontext
  • pricedecimal(8,2)
  • availableboolean

customers

  • idintegerPK
  • full_namevarchar(120)
  • emailvarchar(255)U
  • phonevarchar(30)

addresses

  • idintegerPK
  • customer_idintegerFK
  • line1varchar(200)
  • cityvarchar(80)
  • postal_codevarchar(20)
  • is_defaultboolean

couriers

  • idintegerPK
  • full_namevarchar(120)
  • phonevarchar(30)
  • vehicle_typevarchar(20)
  • activeboolean

orders

  • idintegerPK
  • customer_idintegerFK
  • restaurant_idintegerFK
  • address_idintegerFK
  • statusvarchar(20)
  • totaldecimal(10,2)
  • placed_attimestamp

order_items

  • idintegerPK
  • order_idintegerFK
  • menu_item_idintegerFK
  • item_namevarchar(120)
  • quantityinteger
  • unit_pricedecimal(8,2)

deliveries

  • idintegerPK
  • order_idintegerFKU
  • courier_idintegerFK
  • statusvarchar(20)
  • picked_up_attimestamp
  • delivered_attimestamp

Relationships (10)

  • 1:Nrestaurants.id→menu_categories.restaurant_idON DELETE CASCADE
  • 1:Nmenu_categories.id→menu_items.menu_category_idON DELETE CASCADE
  • 1:Ncustomers.id→addresses.customer_idON DELETE CASCADE
  • 1:Ncustomers.id→orders.customer_id
  • 1:Nrestaurants.id→orders.restaurant_id
  • 1:Naddresses.id→orders.address_id
  • 1:Norders.id→order_items.order_idON DELETE CASCADE
  • 1:Nmenu_items.id→order_items.menu_item_id
  • 1:1orders.id→deliveries.order_idON DELETE CASCADE
  • 1:Ncouriers.id→deliveries.courier_id

The SQL

Copy this, paste it in the SQL tab, or just open it below.
SQL
CREATE TABLE restaurants (
  id SERIAL PRIMARY KEY,
  name VARCHAR(120) NOT NULL,
  city VARCHAR(80) NOT NULL,
  phone VARCHAR(30)
);

CREATE TABLE menu_categories (
  id SERIAL PRIMARY KEY,
  restaurant_id INTEGER NOT NULL REFERENCES restaurants(id) ON DELETE CASCADE,
  name VARCHAR(80) NOT NULL,
  display_order INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE menu_items (
  id SERIAL PRIMARY KEY,
  menu_category_id INTEGER NOT NULL REFERENCES menu_categories(id) ON DELETE CASCADE,
  name VARCHAR(120) NOT NULL,
  description TEXT,
  price DECIMAL(8,2) NOT NULL CHECK (price >= 0),
  available BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  full_name VARCHAR(120) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  phone VARCHAR(30)
);

CREATE TABLE addresses (
  id SERIAL PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  line1 VARCHAR(200) NOT NULL,
  city VARCHAR(80) NOT NULL,
  postal_code VARCHAR(20) NOT NULL,
  is_default BOOLEAN NOT NULL DEFAULT false
);

CREATE TABLE couriers (
  id SERIAL PRIMARY KEY,
  full_name VARCHAR(120) NOT NULL,
  phone VARCHAR(30),
  vehicle_type VARCHAR(20) NOT NULL DEFAULT 'bike',
  active BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(id),
  restaurant_id INTEGER NOT NULL REFERENCES restaurants(id),
  address_id INTEGER NOT NULL REFERENCES addresses(id),
  status VARCHAR(20) NOT NULL DEFAULT 'placed',
  total DECIMAL(10,2) NOT NULL CHECK (total >= 0),
  placed_at TIMESTAMP NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  menu_item_id INTEGER NOT NULL REFERENCES menu_items(id),
  item_name VARCHAR(120) NOT NULL,
  quantity INTEGER NOT NULL CHECK (quantity > 0),
  unit_price DECIMAL(8,2) NOT NULL CHECK (unit_price >= 0)
);

CREATE TABLE deliveries (
  id SERIAL PRIMARY KEY,
  order_id INTEGER NOT NULL UNIQUE REFERENCES orders(id) ON DELETE CASCADE,
  courier_id INTEGER REFERENCES couriers(id),
  status VARCHAR(20) NOT NULL DEFAULT 'pending',
  picked_up_at TIMESTAMP,
  delivered_at TIMESTAMP,
  CHECK (delivered_at IS NULL OR picked_up_at IS NOT NULL)
);

Keep learning