I recently had to rebuild the backend for a client's online store, with one goal in mind, simpler. While redesigning the database schema, I stared at the cart/cart items and order/order items tables from the legacy postgres database and couldn't help but wonder what truly separated them.
More on the tradeoffs I made on this project later. Here's what I learned about the differences between these table groups.
Firstly, we need to acknowledge that every project comes with unique requirements that define the achitectural decisions, and in most cases the cart and order tables are mostly redundant by time all the functional requirements are implemented.
A very minimal example of what you'd find in a typical ecommerce application
-- ============================================================
-- CART
-- ============================================================
CREATE TABLE cart (
id UUID PRIMARY KEY,
user_id UUID,
created_at TIMESTAMP,
expires_at TIMESTAMP
);
CREATE TABLE cart_item (
id UUID PRIMARY KEY,
cart_id UUID REFERENCES cart(id),
product_id UUID,
quantity INT,
unit_price DECIMAL(10,2)
);
-- ============================================================
-- ORDER
-- ============================================================
CREATE TABLE order (
id UUID PRIMARY KEY,
user_id UUID,
created_at TIMESTAMP,
status VARCHAR(50)
);
CREATE TABLE order_item (
id UUID PRIMARY KEY,
order_id UUID REFERENCES order(id),
product_id UUID,
quantity INT,
unit_price DECIMAL(10,2)
); Why separate tables?
They represent fundamentally different states with different behaviours. A cart is mutable and often temporary. An order on the other hand is a historical record, immutable, permanent, sort of contractual. Even though they have similar structure; line items, with quantities and prices, ignoring these differences could create problems down the line.
Key reasons to keep them separate:
1. Immutability
When an order is placed, it has to be frozen in time. The price, product name, and other key items depending on the project requirements are captured at the moment of purchase. If the price of the product changes after an order was placed, the order must still reflect what the customer paid.
A cart on the other hand, should reflect new product details and prices as often as possible.
2. Lifecycles
Carts are created and abandoned constantly, a lot of them are never actually completed. Some projects require cart deletion or expiry after a certain period. Orders are permanent records, there are cases where you're legally required to retain them for 7+ years.
Mixing carts and orders means your orders table will be filled with garbage data, requiring a status field that half your queries have to filter through.
3. Access patterns
Most of the time, carts are accessed by the user/customer session during browsing, they need to be fast and frequently updated. Orders are queried for fulfillment, reporting, customer service, and finance, often with joins to shipping and payment information. Separating them enables you optimize indexes and even build independent infrastructure with different tradeoffs based on these requirements.
So when do people combine them
Following the case study of my client's store, since I was trying to make this new design as minimal as possible, with reports showing relatively low traffic going through the site, I opted for a single orders table with a status field, and skipped the cart table entirely, storing the cart as just a draft status order.
CREATE TABLE order (
id UUID PRIMARY KEY,
user_id UUID,
created_at TIMESTAMP,
status VARCHAR(50) -- 'draft' | 'paid' | 'fulfilled' | 'cancelled'
);
CREATE TABLE order_item (
id UUID PRIMARY KEY,
order_id UUID REFERENCES order(id),
product_id UUID,
quantity INT,
unit_price DECIMAL(10,2)
);
This works fine for B2B or low-traffic systems where carts are always intentional. However, in most consumer ecommerce cases where cart abandonment rates are 70%+, you really don't want draft orders polluting your orders table and all the reporting that comes with it.
The duplication is intentional, but like everything in software, it depends on the use case and the constraints. When your schema converges and the two tables look nearly identical, that's not a sign the pattern is broken. Choose the pattern that matches your traffic, your team, and your tolerance for that one leaky WHERE status != draft clause.