DEV Community

Sajjad Rahman
Sajjad Rahman

Posted on

Building a Sales Database in PostgreSQL — Schema, Data & JOIN Examples

Building a Sales Database in PostgreSQL — Schema, Data & JOIN Examples

A compact, practical guide to modeling a small sales system in PostgreSQL. This post includes:

  • a clean schema for salesDB,
  • realistic sample data you can load,
  • psql tips and a transcript explanation,
  • multiple LEFT JOIN examples and why they behave the way they do,
  • useful analytics queries and common gotchas.

Overview

Entities

  • Products — items for sale
  • Customers — buyers
  • Employees — sales staff (self-referencing manager)
  • Orders — transactions that link a product, a customer, and a salesperson

Conceptual ERD

Products (1) ───< Orders >─── (1) Customers
                     │
                     ▼
                 Employees
Enter fullscreen mode Exit fullscreen mode

Each orders row references:

  • products.productid
  • customers.customerid
  • employees.employeeid (salesperson)

Create the database

-- run as a role that can create a DB
CREATE DATABASE salesDB;
-- then connect:
-- \c salesDB
Enter fullscreen mode Exit fullscreen mode

Schema (Postgres SQL)

-- PRODUCTS
CREATE TABLE products (
    productid    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_name TEXT NOT NULL,
    category     TEXT,
    price        NUMERIC(12,2) CHECK (price >= 0)
);

-- CUSTOMERS
CREATE TABLE customers (
    customerid   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    firstname    TEXT NOT NULL,
    lastname     TEXT NOT NULL,
    country      TEXT,
    score        INTEGER CHECK (score >= 0) DEFAULT 0
);

-- EMPLOYEES (self-referencing manager)
CREATE TABLE employees (
    employeeid   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    firstname    TEXT NOT NULL,
    lastname     TEXT NOT NULL,
    department   TEXT,
    birthdate    DATE,
    gender       VARCHAR(10),
    salary       NUMERIC(12,2) CHECK (salary >= 0),
    managerid    BIGINT,
    CONSTRAINT fk_manager FOREIGN KEY (managerid)
        REFERENCES employees(employeeid)
        ON DELETE SET NULL
);

-- ORDERS
CREATE TABLE orders (
    orderid       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    productid     BIGINT NOT NULL,
    customerid    BIGINT NOT NULL,
    salespersonid BIGINT,
    orderdate     DATE,
    shipdate      DATE,
    orderstatus   TEXT,
    shipaddress   TEXT,
    billaddress   TEXT,
    quantity      INTEGER CHECK (quantity >= 0) DEFAULT 1,
    sales         NUMERIC(14,2) CHECK (sales >= 0),
    creationtime  TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
    CONSTRAINT fk_orders_product FOREIGN KEY (productid)
        REFERENCES products(productid) ON DELETE RESTRICT,
    CONSTRAINT fk_orders_customer FOREIGN KEY (customerid)
        REFERENCES customers(customerid) ON DELETE RESTRICT,
    CONSTRAINT fk_orders_salesperson FOREIGN KEY (salespersonid)
        REFERENCES employees(employeeid) ON DELETE SET NULL
);

-- Indexes to speed joins
CREATE INDEX idx_orders_productid ON orders(productid);
CREATE INDEX idx_orders_customerid ON orders(customerid);
CREATE INDEX idx_orders_salespersonid ON orders(salespersonid);
CREATE INDEX idx_employees_managerid ON employees(managerid);
Enter fullscreen mode Exit fullscreen mode

Notes

  • BIGINT GENERATED ALWAYS AS IDENTITY is the modern, portable alternative to serial.
  • ON DELETE RESTRICT prevents deleting referenced products/customers; change to CASCADE if you need automatic deletes.
  • Consider a Postgres ENUM for orderstatus if you want stricter, validated values.

Sample data (expanded)

-- PRODUCTS (10)
INSERT INTO products (product_name, category, price) VALUES
('Widget', 'Gadgets', 19.99),
('Gizmo', 'Gadgets', 29.50),
('Chair', 'Furniture', 120.00),
('Desk', 'Furniture', 250.00),
('Laptop', 'Electronics', 899.99),
('Mouse', 'Electronics', 25.99),
('Keyboard', 'Electronics', 49.99),
('Headphones', 'Audio', 79.00),
('Smartwatch', 'Wearables', 199.99),
('Sofa', 'Furniture', 599.00);

-- CUSTOMERS (10)
INSERT INTO customers (firstname, lastname, country, score) VALUES
('Alice','Anderson','USA',87),
('Bob','Brown','USA',72),
('Carol','Clark','UK',95),
('David','Davis','Canada',67),
('Eve','Evans','Australia',92),
('Frank','Foster','Germany',75),
('Grace','Green','France',89),
('Henry','Hill','USA',80),
('Ivy','Irwin','UK',90),
('Jack','Johnson','Japan',85);

-- EMPLOYEES (8)
INSERT INTO employees (firstname, lastname, department, birthdate, gender, salary, managerid) VALUES
('Sam','Smith','Sales','1988-05-20','M',45000,NULL),
('Jill','Jones','Sales','1990-09-10','F',47000,1),
('Peter','Parker','Sales','1992-01-15','M',44000,1),
('Nina','Nelson','Support','1985-12-25','F',52000,NULL),
('Oscar','Owens','Sales','1989-04-10','M',48000,1),
('Liam','Lopez','Sales','1993-03-03','M',41000,2),
('Mia','Moore','Marketing','1991-06-07','F',55000,NULL),
('Noah','Nguyen','Sales','1994-02-18','M',39000,2);

-- ORDERS (16)
INSERT INTO orders (productid, customerid, salespersonid, orderdate, shipdate, orderstatus, shipaddress, billaddress, quantity, sales) VALUES
(1,1,1,'2025-10-01','2025-10-03','shipped','123 Main St','123 Main St',2,39.98),
(3,2,2,'2025-10-02',NULL,'processing','456 Oak Ave','456 Oak Ave',1,120.00),
(5,3,1,'2025-09-29','2025-10-04','shipped','78 Elm St','78 Elm St',1,899.99),
(6,4,3,'2025-10-03','2025-10-05','shipped','12 Maple Rd','12 Maple Rd',3,77.97),
(8,5,2,'2025-10-04',NULL,'processing','9 Queen St','9 Queen St',1,79.00),
(10,6,4,'2025-09-25','2025-09-28','shipped','54 King Ave','54 King Ave',1,599.00),
(2,7,5,'2025-09-30','2025-10-02','shipped','1 River Rd','1 River Rd',2,59.00),
(4,8,3,'2025-09-20','2025-09-25','cancelled','321 Pine St','321 Pine St',1,250.00),
(7,9,2,'2025-10-01',NULL,'processing','67 Hill Rd','67 Hill Rd',1,49.99),
(9,10,5,'2025-10-03','2025-10-05','shipped','789 Lake St','789 Lake St',2,399.98),
(1,5,3,'2025-10-04',NULL,'new','9 Queen St','9 Queen St',1,19.99),
(6,7,4,'2025-10-01','2025-10-03','shipped','1 River Rd','1 River Rd',2,51.98),
(8,8,2,'2025-10-02','2025-10-06','shipped','321 Pine St','321 Pine St',1,79.00),
(10,1,5,'2025-09-18','2025-09-22','shipped','123 Main St','123 Main St',1,599.00),
(5,9,1,'2025-09-29','2025-10-01','shipped','67 Hill Rd','67 Hill Rd',1,899.99),
(3,4,3,'2025-10-05',NULL,'processing','12 Maple Rd','12 Maple Rd',2,240.00);
Enter fullscreen mode Exit fullscreen mode

psql quick checks & common pitfall

Run \dt to list tables in the current DB and schema (usually public).

Pitfall you may see:

select orderId from salesdb.orders limit 2;
-- ERROR: relation "salesdb.orders" does not exist
Enter fullscreen mode Exit fullscreen mode

Why: salesdb is a database name, not a schema. Fully qualified table names are schema.table (e.g. public.orders). When connected to salesDB, just use:

SELECT orderid FROM orders LIMIT 2;
Enter fullscreen mode Exit fullscreen mode

Multiple LEFT JOIN — progressive example

Start small and add joins one by one. LEFT JOIN keeps all rows from the left table and adds matches from the right tables.

1) Orders only

SELECT o.orderid, o.sales
FROM orders o
LIMIT 3;
Enter fullscreen mode Exit fullscreen mode

2) Add customer info

SELECT o.orderid, o.sales, c.firstname, c.lastname
FROM orders o
LEFT JOIN customers c ON o.customerid = c.customerid
LIMIT 3;
Enter fullscreen mode Exit fullscreen mode

3) Add product details (two LEFT JOINs)

SELECT o.orderid, o.sales, c.firstname, c.lastname, p.product_name
FROM orders o
LEFT JOIN customers c ON o.customerid = c.customerid
LEFT JOIN products p ON o.productid = p.productid
LIMIT 3;
Enter fullscreen mode Exit fullscreen mode

2 table join

4) Full order row with salesperson

SELECT
  o.orderid,
  o.orderdate,
  c.firstname || ' ' || c.lastname AS customer_name,
  p.product_name,
  p.category,
  o.quantity,
  o.sales,
  e.firstname || ' ' || e.lastname AS salesperson,
  o.orderstatus
FROM orders o
LEFT JOIN customers c ON o.customerid = c.customerid
LEFT JOIN products  p ON o.productid = p.productid
LEFT JOIN employees e ON o.salespersonid = e.employeeid
ORDER BY o.orderdate DESC
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

output of limit

Why LEFT JOIN for employees? If salespersonid is NULL (unassigned) or references a removed employee, the employee columns become NULL but the orders row is preserved.

Implementing anti-joins (non-matching rows)

  • Left anti-join (rows in A with no match in B):
SELECT a.*
FROM A a
LEFT JOIN B b ON a.id = b.a_id
WHERE b.a_id IS NULL;
Enter fullscreen mode Exit fullscreen mode
  • Full anti-join (rows in either table that have no match in the other):
SELECT *
FROM A
FULL JOIN B ON A.id = B.a_id
WHERE A.id IS NULL OR B.a_id IS NULL;
Enter fullscreen mode Exit fullscreen mode

Alternatively use EXCEPT for "A but not B" type checks.


Useful analytics queries

1 All orders with details

SELECT 
  o.orderid,
  c.firstname || ' ' || c.lastname AS customer_name,
  p.product_name,
  o.quantity,
  o.sales,
  o.orderstatus,
  e.firstname || ' ' || e.lastname AS salesperson
FROM orders o
LEFT JOIN customers c ON o.customerid = c.customerid
LEFT JOIN products  p ON o.productid = p.productid
LEFT JOIN employees e ON o.salespersonid = e.employeeid
ORDER BY o.orderdate DESC;
Enter fullscreen mode Exit fullscreen mode

all orders

2 Total sales by country

SELECT c.country, SUM(o.sales) AS total_sales
FROM orders o
JOIN customers c ON o.customerid = c.customerid
GROUP BY c.country
ORDER BY total_sales DESC;
Enter fullscreen mode Exit fullscreen mode

total sales by countrt

3 Top 5 selling products

SELECT p.product_name, SUM(o.sales) AS total_sales, SUM(o.quantity) AS total_units
FROM orders o
JOIN products p ON o.productid = p.productid
GROUP BY p.product_name
ORDER BY total_sales DESC
LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

Top 5 selling products

Thanks for your reading.

Top comments (0)