Browse Templates/E-Commerce Platform

E-Commerce Platform

A comprehensive e-commerce database schema designed for online stores and marketplaces. Includes product catalog with categories, customer management, order processing with line items, payment tracking, shipping addresses, and inventory management. Suitable for Shopify-like platforms, WooCommerce alternatives, or custom e-commerce solutions.

PostgreSQL12 tablesE-Commerce
e-commerceshoppingpaymentsordersproducts
Created with ERD Studio

About this schema

The centre of this schema is the line between the catalogue and the order. products, categories and product_images describe what is for sale right now and change whenever someone edits the shop. orders and order_items record what was actually bought, and must not change when the catalogue does.

That is why order_items carries product_name and unit_price of its own. It still points at the product for reporting, but the name and the price on the receipt are copies taken at checkout. Rename a product or run a sale and last month's orders stay readable.

Carts sit before that line and are deliberately loose. carts has both user_id and session_id so that someone can fill a basket before signing in, and expires_at so abandoned carts do not accumulate forever. Nothing in a cart is a promise — stock is not reserved and the price is read live from products.

Key relationships

orders → order_items
One row per line, each holding its own quantity, unit_price and line_total. Totals on the order are the sums as they were charged, not something recomputed from today's prices.
order_items → products
Kept for reporting — what sold, how often — while the name and price on the line are snapshots. The pointer answers 'which product', the copies answer 'what did it cost'.
categories → categories
parent_id makes the category tree a self-reference. A leaf and a top-level category are the same row shape, and depth is not fixed by the schema.
carts → users / session_id
One of the two identifies the basket. A guest cart has session_id, a signed-in cart has user_id, and signing in merges the first into the second.
orders → coupons
The coupon is recorded on the order, and discount_amount stores what it actually took off. A coupon's rules can be edited later without changing what an old order was charged.

Design decisions

Order lines copy the name and the price

The alternative is to join to products when rendering an old order, which means an invoice changes when the shop does — a product renamed, a price cut, a variant deleted. Copying costs two columns per line and makes the order self-contained. The foreign key stays for analytics, so nothing is lost by keeping both.

Stock is one column on the product

stock_quantity is a single number rather than a ledger of movements. It is the right call for a shop that is not also a warehouse: reads are constant-time and the decrement is one update. It stops being enough when you need to know why stock is what it is — returns, damage, receiving — at which point the column becomes a cached total over a movements table rather than the truth.

Coupon usage is a counter

coupons.current_uses against max_uses is the simple form, and it is honest about its weakness: two checkouts at the same instant can both read the same count. If a coupon must never exceed its limit, the check belongs in a transaction that locks the coupon row, or usage becomes its own table with a unique constraint per user.

Orders point at an address row

shipping_address_id references addresses, which means a customer editing their address rewrites history. This is the one place where the catalogue lesson has not been applied — for a shop that ships, copy the address onto the order the way the price is copied. It is left as a foreign key here because it is the common starting shape, and it is worth knowing it is the first thing to change.

Tables in this template

usersUsers
8 cols
addressesAddresses
10 cols
categoriesCategories
6 cols
productsProducts
10 cols
product_imagesProduct Images
7 cols
cartsCarts
6 cols
cart_itemsCart Items
5 cols
ordersOrders
12 cols
order_itemsOrder Items
7 cols
paymentsPayments
8 cols
reviewsReviews
8 cols
couponsCoupons
10 cols

Frequently asked

Why does order_items store the product name again?
So that an old order still reads correctly after the product is renamed, discounted or removed. The receipt is a record of what happened, and a record that is assembled from today's catalogue is not a record.
How do product variants — size, colour — fit?
They do not, in this shape. Adding them means a product_variants table holding the price and stock, with order_items and cart_items pointing at a variant instead of a product. Doing it early is much cheaper than retrofitting, because every stock and price path moves.
Should the cart reserve stock?
Not here, and usually not at all for a small shop. Reserving means a hold with an expiry and a job that releases it, and it turns an abandoned cart into inventory nobody can buy. Checking stock at checkout and failing there is simpler and is what most stores do.
Why are carts given an expires_at?
Guest carts have no owner to clean them up, and they accumulate at the rate of your traffic. The column lets a job delete what is stale without guessing, and it doubles as the rule for how long a returning guest keeps their basket.
Where do refunds go?
They are not modelled. payments records what came in; a refund is money going out and needs its own row, usually in the same table with a sign or a type, pointing at the payment it reverses. Putting a refunded flag on the payment loses partial refunds and the date they happened.