Building an E-commerce Database
Build an end-to-end PostgreSQL e-commerce database with customers, products, inventory, orders, order items, payments, and analytical queries for revenue and product performance.
In this lesson, you will learn
- ✓Design a normalized e-commerce schema for customers, products, orders, order items, inventory, and payments
- ✓Enforce primary keys, foreign keys, unique constraints, check constraints, and appropriate data types
- ✓Preserve historical product prices in order items and distinguish order status from payment status
- ✓Use transactions and conditional updates to prevent inventory from becoming negative
- ✓Write analytical queries for monthly revenue, top-selling products, average order value, and inactive customers
- ✓Choose indexes based on query patterns and inspect query execution plans
Introduction to the E-commerce Database
An e-commerce application allows customers to browse products, place orders, pay for purchases, and receive their items. The database must preserve accurate records even when product prices change, payments fail, or inventory becomes limited.
In this project, we will build a simplified PostgreSQL database that supports these operations.
## Business requirements
Our database must store:
- Customer details.
- Products and their current prices.
- Available inventory.
- Customer orders.
- The products, quantities, and prices included in each order.
- Payment attempts and their statuses.
It should also answer questions such as:
- What is the monthly revenue from paid orders?
- Which products sell the most?
- Which customers have not placed a recent order?
- What is the average order value?
- How much inventory remains?
- Which orders have not been paid?
## Main tables
1. customers — one row per customer.
2. products — one row per product.
3. inventory — current stock for each product.
4. orders — one row per order.
5. order_items — the products and quantities in each order.
6. payments — payment attempts associated with orders.
The relationship between orders and products is many-to-many, represented by order_items.
One order can contain several products, and one product can appear in many orders.
## Important design decision: historical prices
Suppose a product costs ₹1,000 today, but a customer bought it last month for ₹800. If the order item refers only to the current product price, historical order totals could change when the product price is updated.
Therefore, order_items stores unit_price, which is the price charged for that particular order item.
## Important design decision: payment status
An order can be pending while a payment is being processed. A payment can also fail even though the order record remains in the database.
For this project, order status and payment status are separate. We will use successful payment records to calculate a clearly defined paid-order revenue metric rather than assuming every placed order generates revenue.
Designing the Database Schema
We will create tables in dependency order so that referenced tables exist before their foreign keys are defined.
The schema uses:
- BIGINT identity columns for generated IDs.
- NUMERIC for prices and payment amounts.
- CHECK constraints to reject negative prices, quantities, and stock.
- Foreign keys to enforce valid relationships.
- Unique constraints to prevent duplicate customer emails and product SKUs.
- A composite primary key on order_items to allow one row per product in each order.
For simplicity, each order can contain a given product only once. If the customer adds the same product again, the application should update that item's quantity.
The inventory table has one row per product. A larger system might instead track inventory by warehouse, store, or stock location.
Create the E-commerce Schema
The six tables enforce important structural rules. The order_items composite primary key prevents duplicate product rows within one order. The payment transaction reference is unique when supplied; PostgreSQL permits multiple NULL values in a standard unique constraint. These constraints do not by themselves implement the complete order-placement or payment workflow.
Understanding the Relationships and Constraints
## Customer and order relationship
One customer can place many orders. Each order references exactly one customer.
## Order and order-item relationship
An order can contain many order items. Every order item references one order and one product.
The composite primary key (order_id, product_id) prevents a product from appearing twice as separate rows in the same order.
## Product and inventory relationship
Each product has one inventory row in this simplified model. The inventory primary key is also a foreign key to products.
The CHECK constraint on stock_quantity prevents negative stock from being stored.
## Order and payment relationship
One order can have multiple payment records, such as an initial failed attempt followed by a successful retry. The application must define which payment records count toward paid revenue and how refunds are represented.
This schema records payment attempts, but it does not fully model partial captures, split payments, chargebacks, or multiple refund events. Those features would need additional business rules and tables.
## Why use NUMERIC for money?
NUMERIC(12, 2) stores decimal values with fixed precision and scale, which is suitable for many currency calculations. Floating-point types such as REAL and DOUBLE PRECISION can introduce rounding differences in monetary calculations.
For multi-currency systems, also store the currency code and define how conversions and rounding work.
Inspect Foreign-Key Relationships
The query inspects PostgreSQL metadata. The listed relationships assume the tables were created in the public schema as shown. The actual result includes one row per foreign-key column.
Inserting Sample Customers, Products, and Inventory
Now add sample data in dependency order.
We insert customers and products first, followed by inventory rows. The sample products have different prices and stock quantities so the analytical queries produce useful results.
The SQL uses explicit columns. The displayed IDs are expected for newly created, empty tables whose identity sequences have not been used before. In a real application, never assume that a newly inserted record always receives a particular ID.
Insert Customers, Products, and Stock
The sample data assumes fresh identity sequences, producing customer IDs 1–4 and product IDs 1–5. If the database already contains records, use RETURNING or query by stable unique values such as email and SKU to obtain the actual IDs.
Creating Orders and Order Items
An order header stores information shared by the whole order, such as its customer, date, and status.
The order_items table stores each product, quantity, and unit price. The line total is quantity multiplied by unit_price.
We deliberately do not store a separate line_total column in this introductory schema because it can be calculated from the other values. This avoids having to keep redundant values synchronized.
The order's total is also derived from its order items. If a production system stores cached totals for performance, it must ensure those totals remain consistent with item changes, discounts, taxes, and refunds.
Insert Orders and Their Items
The unit price for the keyboard in order 3 is ₹2,400, even though the product's current catalog price is ₹2,500. This demonstrates why order_items stores the historical price charged. The sample stock values are illustrative; inserting order items alone does not reserve or deduct inventory.
Recording Payment Attempts
Payments are separate records because an order may have more than one payment attempt.
Our sample contains successful payments for delivered and confirmed orders, a pending payment, and a failed payment. The cancelled order has no successful payment.
For this lesson, a successful payment means the payment provider reported a successful payment record. A real payment integration must also verify the provider's response, handle duplicate notifications idempotently, and reconcile refunds and chargebacks.
Insert Sample Payments
The sample payments match the corresponding order totals. For a production system, the database should have explicit policies for partial payments, multiple successful payment captures, refunds, and reconciliation. The simple revenue query later in this lesson uses one successful payment per paid order.
Viewing Orders with Customer and Product Details
Joins combine related information stored in separate tables.
To show every item in every order, join:
- orders to customers,
- orders to order_items,
- order_items to products.
The line total is calculated from quantity and the stored unit price, not from the product's current catalog price.
This distinction protects the historical order amount when a product's current price changes.
Order Detail Report
Each row represents one order item. An order with several products appears on multiple rows. The cancelled order still appears because this is an order-detail report, not a revenue report.
Calculating Order Totals
To calculate each order's merchandise total, sum its line totals.
GROUP BY groups rows belonging to the same order. SUM then adds the line totals.
This example includes orders without items by using LEFT JOIN. COALESCE changes the NULL sum for an empty order to zero.
This total represents merchandise only. Shipping fees, discounts, taxes, and refunds are not included in this simplified project.
Calculate the Total for Every Order
The order total is derived from quantity multiplied by unit_price. Cancelled orders remain visible in this operational report; filter them out only when the reporting definition requires it.
Monthly Revenue Analytics
Revenue reports must define what they measure.
For this project, monthly paid-order revenue means the sum of successful payment amounts, grouped by the payment date. We count a payment as successful when payments.status = 'succeeded'.
This is a deliberately simplified cash-receipt metric. It is not a complete accounting definition of recognized revenue. Refunds, chargebacks, tax, shipping, partial captures, and payment reconciliation need explicit policies in a real business.
We use the payment date rather than the order date because this report analyzes successful payment activity by month.
Monthly Successful Payment Totals
September has successful payments of ₹4,900 and ₹2,800, totaling ₹7,700. October has one successful payment of ₹4,200. The pending and failed payments are excluded. DATE_TRUNC groups timestamps by calendar month using the session's time-zone behavior; define a reporting time zone explicitly for a multi-region business.
Finding Top-Selling Products
A product performance report can rank products by units sold.
The query below counts quantities from orders that are not cancelled. This is a simple operational definition of units sold: it excludes cancelled orders but does not wait for payment success or delivery.
That definition is useful for a basic exercise, but a real business must decide whether to count only paid, shipped, or delivered orders. Refunds and returns may also require subtracting returned quantities.
Rank Products by Units Ordered
The query includes products with no qualifying order items. The condition retains an item when its order is not cancelled and also retains products with no items. This report counts non-cancelled ordered quantities, not necessarily paid or delivered units. A simpler report can use INNER JOIN and a WHERE filter when products with zero sales do not need to appear.
Identifying Inactive Customers
A customer inactivity report identifies customers who have not placed a recent order.
There are several possible definitions:
- Customers who have never ordered.
- Customers whose last order is older than a specified period.
- Customers who have not made a paid purchase recently.
This example uses the second definition: customers whose most recent order date is before a supplied cutoff date, including customers who have never ordered.
The cutoff is explicit rather than using an arbitrary current date, so the report can be reproduced and tested.
Find Customers with No Recent Orders
Meera has never placed an order, so MAX(order_date) is NULL. The other customers have orders dated on or after the cutoff. If the intended definition is based on paid purchases rather than orders, join or filter payment records according to the required payment policy.
Calculating Average Order Value
Average order value (AOV) is often calculated as eligible order value divided by the number of eligible orders.
The exact definition varies. For this exercise, we use the total merchandise value of orders whose status is delivered, divided by the number of delivered orders.
This is not the same as average successful payment amount. If an order has multiple payments, calculating the average directly after joining payments to order items can duplicate item totals. Aggregate at the correct level before joining different one-to-many relationships.
Calculate Delivered-Order Average Value
The two delivered orders total ₹4,900 and ₹2,800. Their average is (₹4,900 + ₹2,800) / 2 = ₹3,850. The CTE computes one total per order before calculating the average.
Safely Updating Inventory
Inventory is a critical consistency problem.
A naive workflow might:
1. Read stock quantity.
2. Check whether enough stock exists.
3. Update the stock quantity.
Two requests could read the same stock quantity at nearly the same time. Both might believe there is enough inventory and oversell the product.
PostgreSQL supports conditional updates that perform the stock check and decrement in one statement. The update succeeds only when sufficient stock exists.
The application must check whether a row was returned. No returned row means the product did not exist in inventory or did not have sufficient stock.
In a complete order-placement transaction, stock reservation, order creation, and order-item creation must be coordinated. A payment provider call should not be treated as an ordinary SQL statement; payment workflows usually need idempotency, reconciliation, and explicit handling of failures.
Atomically Decrement Available Stock
The output assumes product 1 initially has 25 units. PostgreSQL performs the conditional update atomically. If stock is below 2, the statement updates no rows and returns no result. In application code, treat that as insufficient stock or a missing inventory record.
Using Transactions for Order Placement
A simplified order-placement operation can use a transaction to group the stock decrement, order insertion, and order-item insertion.
If a required step fails, the transaction can be rolled back so the database does not retain a partially created order.
The example below demonstrates the pattern for one product and assumes:
- Customer ID 4 exists.
- Product ID 5 exists.
- Inventory has enough stock.
- The SQL statements are run by an application that checks every returned row.
The application should generate IDs using RETURNING and pass those IDs into later statements. The SQL here illustrates the transaction flow; a production order workflow must also handle multiple items, price validation, cancellation, reservation expiry, and payment integration.
Transactional Single-Item Order Pattern
The displayed order ID is illustrative and must not be hard-coded in production. The application must roll back if the inventory update returns no row or if any required insert fails. A transaction alone does not guarantee that all application-level business rules have been implemented.
Indexes and Query Performance
Indexes can speed up common lookup and reporting queries, but they add storage and write overhead.
PostgreSQL automatically creates indexes for primary keys and unique constraints. It does not automatically create an index on every foreign-key column.
Useful candidates for this schema include:
- orders(customer_id, order_date) for a customer's order history.
- orders(order_date) for date-range reports.
- order_items(product_id) for product-level sales analysis.
- payments(payment_date) for payment-date reports.
The correct indexes depend on actual filters, joins, ordering, and data distribution. For large tables, a partial index can be useful when reports repeatedly filter to a particular status, but the benefit should be measured.
Create Indexes for Common Queries
The composite index can support filtering by customer_id and ordering by order_date. EXPLAIN shows the chosen plan, which may use an index scan or a sequential scan depending on table size and statistics. EXPLAIN ANALYZE executes the query and provides measured runtime information.
Exercises: Extend the Project
Complete the following exercises using the schema and sample data.
## Exercise 1: Customer order history
Display each customer's name, order ID, order date, and order status. Include customers who have never placed an order.
Hint: Start from customers and use LEFT JOIN.
## Exercise 2: Products with low stock
Find active products whose stock quantity is less than 10.
Hint: Join products to inventory.
## Exercise 3: Revenue by month
Calculate successful payment totals by month and sort the months chronologically.
Hint: Group payments by DATE_TRUNC('month', payment_date) and filter to succeeded.
## Exercise 4: Top three products by units ordered
Rank products by quantity ordered, excluding cancelled orders.
Hint: Aggregate quantities before applying ORDER BY and LIMIT.
## Exercise 5: Customers who have never purchased
Find customers who have no successful payment associated with any of their orders.
Hint: Use NOT EXISTS. Decide whether a successful payment of any amount is sufficient for this simplified exercise.
## Exercise 6: Inventory safety
Attempt to decrement inventory by more units than are available. Verify that the conditional UPDATE returns no row and leaves stock unchanged.
## Exercise 7: Course correction for a production design
Extend the schema to handle multiple warehouses and inventory reservations.
Hint: Consider warehouses, inventory by (warehouse_id, product_id), and a reservation table with a lifecycle.
## Exercise 8: Payment integrity
Explain why an order can have several payment attempts and why adding all successful payments together can overstate revenue if a provider retries or duplicates events.
Hint: Consider idempotency keys and unique provider transaction references.
Exercise Solutions
Use these reference queries to compare with your answers.
## Solution 1: Customer order history
LEFT JOIN retains customers without orders.
## Solution 2: Products with low stock
Join inventory to products and filter active products with stock below 10.
## Solution 4: Top products
This solution counts quantities from non-cancelled orders. It includes products with no qualifying sales and returns the top three by units ordered.
## Solution 5: Customers without successful payments
NOT EXISTS checks for the absence of any successful payment associated with the customer's orders. It does not claim that the customer has never browsed or attempted checkout.
Reference Queries
The Exercise 2 heading is shown to illustrate the threshold; with the sample stock of 10 for Monitor Stand, the strict < 10 filter correctly returns no rows. For Exercise 5, Kabir and Meera have no successful payment records. The exercise 4 query returns the top three products by non-cancelled units ordered. These outputs assume the original sample data before any optional stock update or additional order transaction.
Knowledge Check
1. Why does order_items store unit_price?
A. To replace products
B. To preserve the price charged at the time of purchase
C. To calculate inventory automatically
D. To identify the customer
2. Which type is appropriate for many fixed-precision monetary values?
A. BOOLEAN
B. NUMERIC(12, 2)
C. DATE
D. REAL in every case
3. What does a foreign key enforce?
A. Referential integrity
B. Monthly revenue
C. Product ranking
D. Automatic payment processing
4. Why should inventory decrement use a condition such as stock_quantity >= requested_quantity?
A. To prevent the update from reducing stock below the available amount
B. To increase every product's price
C. To remove foreign keys
D. To calculate tax
5. Why are payment status and order status separate?
A. They always change together
B. Payment processing and fulfillment are different business processes
C. Payment status identifies a product
D. Order status replaces the customer table
6. What does a LEFT JOIN preserve?
A. Only rows with matching records
B. All rows from the left table, even when no right-side row matches
C. Only rows containing NULL
D. Only the newest record
7. What is the purpose of an index?
A. To guarantee that every query is faster
B. To potentially speed up qualifying lookups, joins, and ordering
C. To replace transactions
D. To eliminate constraints
8. Why should a revenue metric have an explicit definition?
A. All revenue metrics mean the same thing
B. Order status, payment success, refunds, and accounting policies affect what should be counted
C. SQL cannot sum payments
D. Product prices never change
Answer Key
Review the schema, transaction, and analytics sections for the reasoning behind each answer. Pay particular attention to the difference between operational order totals and payment-based revenue.
SQL Interview Questions and Answers
## 1. Why do we need an order_items table?
An order can contain multiple products, and a product can appear in multiple orders. The junction table implements this many-to-many relationship and stores relationship-specific details such as quantity and historical unit price.
## 2. Why store the unit price in order_items?
The product's current price may change after a purchase. Storing the price charged preserves the historical value used to calculate the original order total.
## 3. How would you calculate monthly revenue?
First define the metric. For this project, sum successful payment amounts grouped by payment month. A real accounting report may need refund handling, taxes, currency conversion, and recognized-revenue rules.
## 4. How can you avoid overselling inventory?
Use a conditional UPDATE that decrements stock only when enough units remain. Check whether the update affected a row, and coordinate inventory reservation with order creation in a transaction.
## 5. Why use a transaction for order placement?
It allows related database changes to commit together or roll back together. If inventory reservation or a required insert fails, the application should not commit a partial order.
## 6. What is the difference between order status and payment status?
Order status describes the fulfillment lifecycle, such as pending, shipped, or delivered. Payment status describes the payment attempt, such as pending, succeeded, or failed.
## 7. How would you find customers who never purchased?
Use NOT EXISTS to check for the absence of a qualifying purchase or successful payment. Define carefully whether a purchase means an order, a successful payment, or a delivered order.
## 8. Why can joining payments directly to order_items produce incorrect totals?
An order may have multiple items and multiple payment records. Joining both one-to-many relationships can multiply rows and duplicate amounts. Aggregate each relationship at the correct level before combining the results.
## 9. Why use NUMERIC instead of floating-point types for money?
NUMERIC provides exact decimal arithmetic within its declared precision and scale. Floating-point values can introduce representation and rounding errors that are undesirable for financial calculations.
## 10. What is a composite primary key?
It is a primary key consisting of multiple columns. Here, (order_id, product_id) uniquely identifies one product line per order.
## 11. Does a foreign key automatically create an index on the referencing column in PostgreSQL?
No. PostgreSQL creates indexes for primary keys and unique constraints, but does not automatically index every referencing foreign-key column. Add indexes when workload and performance measurements justify them.
## 12. How should a production payment system handle duplicate notifications?
Use idempotent processing, unique provider transaction or event identifiers, transactional state changes, and reconciliation. Do not blindly insert or sum duplicate payment notifications.
## 13. How would you model multiple warehouses?
Create a warehouses table and make inventory unique by (warehouse_id, product_id). Add reservations or stock movements if the application needs to track stock allocation, transfers, and audit history.
## 14. How do you know whether an index helps?
Inspect EXPLAIN plans and use EXPLAIN ANALYZE with representative data and query patterns. Compare execution behavior rather than assuming an index will always be used.
Common Mistakes and How to Avoid Them
## Mistake 1: Calculating historical orders from the current product price
Current catalog prices change. Use order_items.unit_price for historical order calculations.
## Mistake 2: Storing money in floating-point columns
Floating-point arithmetic can introduce precision differences. Use an appropriate fixed-precision NUMERIC type or a carefully designed integer-minor-unit model.
## Mistake 3: Updating inventory without an atomic stock condition
A separate read followed by an unconditional decrement can lead to overselling under concurrency. Perform the stock check and decrement together.
## Mistake 4: Assuming every order generates revenue
Pending, failed, and cancelled orders should not automatically count as successful payment revenue. Define the reporting metric first.
## Mistake 5: Joining several one-to-many relationships before aggregating
Joining order items and payments can multiply rows. Aggregate each relationship at the appropriate level before combining totals.
## Mistake 6: Confusing payment status with order status
A payment can succeed while an order is not yet delivered. Keep their states separate and define valid business transitions.
## Mistake 7: Treating every successful payment as a distinct purchase
Retries, duplicated provider notifications, partial captures, and refunds require explicit rules. Use idempotency and reconciliation rather than blindly summing every event.
## Mistake 8: Assuming foreign keys automatically create all useful indexes
PostgreSQL does not automatically index every referencing foreign-key column. Choose indexes based on joins, filters, and ordering patterns.
## Mistake 9: Hard-coding generated IDs
Identity values vary by database state. Capture generated identifiers with RETURNING or resolve records through stable unique values.
## Mistake 10: Calling a simplified payment sum official accounting revenue
Payment receipts and recognized revenue are not always equivalent. A real financial report must follow the business's accounting definitions and refund, tax, and currency policies.
Correctly Aggregate Before Joining Payments
Each CTE produces at most one row per order before the results are joined. This avoids multiplying order-item rows by payment rows. The report compares merchandise totals with successful payment totals; it does not by itself reconcile refunds or prove accounting revenue.
Project Completion Checklist
You have built the foundation of an e-commerce database and reporting system.
Before marking the project complete, make sure you can:
- Explain the purpose of every table and relationship.
- Identify primary keys, foreign keys, unique constraints, and check constraints.
- Explain why historical unit prices belong in order_items.
- Calculate order totals and monthly successful payment totals.
- Rank products by units ordered.
- Find inactive customers and customers without successful payments.
- Explain how LEFT JOIN preserves unmatched records.
- Prevent negative inventory with a conditional update.
- Describe how transactions protect related database changes.
- Explain why multi-table aggregation can duplicate totals.
- Use indexes and execution plans to investigate performance.
- Identify the additional design needed for refunds, partial payments, warehouses, reservations, discounts, taxes, and multiple currencies.
This is a learning project, not a complete production commerce platform. A production design also needs carefully defined state transitions, authorization, auditability, idempotent payment processing, refund handling, and operational monitoring.
Common pitfalls to avoid
Real debugging lessons and exact syntax contrasts to avoid runtime bugs
#1 Calculating historical order totals using the current product price.
Why it happens: Developers may join products and order_items but use products.current_price instead of the price charged when the order was placed.
Key takeaway: The order item stores the historical unit price. Use it when calculating original order totals, even if the current product price has changed.
#2 Decrementing stock after a separate check without making the check atomic.
Why it happens: Concurrent requests can both read the same stock value and each decide that enough units are available.
Key takeaway: The conditional update performs the check and decrement as one database operation. The application must check whether a row was returned and coordinate the update with order creation in a transaction.
#3 Counting every order as revenue, regardless of payment status.
Why it happens: An order record indicates a purchase workflow, not necessarily a successful payment.
Key takeaway: The corrected query calculates the project's defined successful-payment metric. A complete accounting revenue report needs further policies for refunds, chargebacks, taxes, and revenue recognition.
#4 Joining order items and payments before aggregation and accidentally multiplying totals.
Why it happens: Both tables can contain several rows per order. A direct join can pair every item with every payment for the same order.
Key takeaway: Aggregating each one-to-many relationship first prevents row multiplication from distorting the results.
#5 Assuming a foreign key automatically creates an index on its referencing column.
Why it happens: Developers may confuse referential-integrity constraints with indexes used to speed up queries.
Key takeaway: PostgreSQL creates indexes for primary keys and unique constraints but does not automatically create an index on every foreign-key column. Add indexes when the workload benefits from them.
Ready for hands-on challenges?
Test your skills with shuffled coding challenges in the Practice Arena.
Key takeaways
- An e-commerce database separates customers, products, orders, order items, inventory, and payments into related tables.
- Order items should store the price charged at the time of purchase so historical reports remain accurate after product prices change.
- Inventory changes should be protected by database constraints and transactional updates.
- Order status and payment status represent different business processes and should not be treated as interchangeable.
- Revenue reports must define which order and payment statuses count as recognized revenue.
- Indexes should be chosen for actual query patterns and validated using realistic data and execution plans.