Shopify

Production-grade database schema for a Shopify-like e-commerce platform featuring multi-store management, product catalog with variants and images, inventory tracking, order lifecycle management, fulfillment, payment processing, and discount engine.

PostgreSQL15 tablesE-Commerce
e-commercemulti-tenantinventoryfulfillmentpayments
Created with ERD Studio

About this schema

Nothing is sold at the product level. products holds the title, the vendor and the SEO fields, but price, sku and weight live on product_variants — and orders, inventory and images all point at the variant. A product with no variants cannot be bought, which is why real stores create a default variant even for a single-option item.

The order is written as a copy, not as a set of links. order_items carries title, variant_title, sku and price alongside variant_id, so a receipt printed a year later still reads the way it did at checkout even if the product was renamed, repriced or removed. The foreign key is there to answer what this was, not to supply the display text.

Fulfilment is its own two-level structure rather than a status on the order. fulfillments is one shipment with its tracking number, and fulfillment_items says which order lines, and how many of each, went into that box — which is the only way a partial shipment has somewhere to live.

Key relationships

products → product_variants
The variant carries price, sku, weight and option1..3. Everything that is bought or counted points here, not at the product.
product_variants → inventory_levels
Stock is per variant and per location, with available and reserved kept apart so a cart hold does not read as a sale.
orders → order_items → product_variants
The line stores its own title, sku and price. The variant link survives a rename; the stored values survive a deletion.
fulfillments → fulfillment_items → order_items
Two shipments for one order are two fulfillments, each listing the lines and quantities it carried. Partial shipping needs no extra flag.
orders → addresses (shipping_address_id)
The order points at an address row instead of copying the fields, and addresses belong to the customer — so a saved address is reusable.
collections ↔ products via collection_products
Many-to-many with a position, because a collection page is a curated order. is_smart marks the ones filled by rules instead.

Design decisions

option1, option2, option3 instead of an options table

A normalised design would give each variant a set of named option values. Three fixed columns are blunt, but they turn the lookup for Large / Blue into a single indexed predicate rather than a group-by over a join, and three axes cover essentially every physical product. The cost is real: a fourth axis means a schema change, and the meaning of each slot lives in the product, not in the column name.

available and reserved are separate columns

One stock number cannot tell the difference between sold and held in someone's cart. Splitting them lets checkout move units into reserved immediately and into a sale on payment, so two customers cannot both buy the last unit — and an abandoned cart simply releases reserved instead of needing a compensating correction.

Three status columns on an order, not one

status, financial_status and fulfillment_status run on different clocks. An order can be paid and unshipped, shipped and refunded, or cancelled after both. Folding them into one enum produces a combinatorial list that always misses a case; keeping them apart means each question is answered by whichever subsystem actually knows the answer.

shops points at users, not the other way round

shops.owner_id makes a shop belong to exactly one owner account, and users exist independently so one person can own several shops. The alternative — a shop_id on users — would stop a merchant from running two stores without a second login, which is the common case rather than the edge case.

Tables in this template

shopsShops
10 cols
usersUsers
8 cols
customersCustomers
10 cols
addressesAddresses
11 cols
productsProducts
11 cols
product_variantsProduct Variants
12 cols
product_imagesProduct Images
8 cols
inventory_levelsInventory Levels
7 cols
ordersOrders
16 cols
order_itemsOrder Items
10 cols
fulfillmentsFulfillments
9 cols
fulfillment_itemsFulfillment Items
6 cols
discount_codesDiscount Codes
10 cols
collectionsCollections
9 cols
collection_productsCollection Products
6 cols

Frequently asked

Why is the price on product_variants rather than products?
Because a size or colour usually costs a different amount, and the large size has its own sku and weight for shipping. Putting price on the product would force a second price column the moment any option differs, and the order line would not know which to charge.
Why does order_items repeat the title and price?
So the order stays readable after the catalogue changes. If a product is renamed or discontinued, the line still shows what the customer actually bought at the price they actually paid — a join would quietly rewrite history.
How do I ship half an order with this schema?
Create one fulfillments row for the shipment and fulfillment_items rows for the lines and quantities inside it. The remainder is the order line minus what has been fulfilled, so a second shipment is simply another fulfillment.
Where does multi-warehouse stock live?
inventory_levels keys on variant_id and location_id together, so one variant has one row per location. Totals are a sum across rows, and a location can go out of stock without affecting the others.
Can a product image belong to a single variant?
Yes — product_images carries both product_id and a nullable variant_id. Gallery shots leave the variant empty; a colour-specific photo fills it, so picking blue can swap the main image.