Turn requirements into a normalized operational schema, with the why: write integrity, transactions, and constraints.
⏱ 28 min readTopics chapter readerLevel · Foundations
01 · Overview
Start here
⏱ 6 min · Topic 1 of 18
OLTP means online transaction processing. It is the database behind an app. Many people read and write a few rows at the same time.
In this chapter the shop is the app, and you design its database. You design it from what the app does, not from what the data looks like.
You already know the parts. Normalization & Denormalization put each fact in one table. Keys & Identity gave each row an id and each link a rule.
Now you add what a live app needs. Many users write at once, and requests are sent twice. A payment must never be charged twice.
What you will learn
List the writes and reads of the app first.
Turn them into tables, with real PostgreSQL code.
Choose safe column types for money and time.
Let constraints refuse bad rows.
Group changes into transactions, and stop double charges.
Keep status history, and choose how to delete.
Add indexes, and change the schema without long locks.
The next chapter, OLTP vs OLAP: Choosing How To Structure Data, covers why analysts get a second copy of this data. This chapter stays inside the app database.
02 · Requirements
Start with the app's writes
⏱ 5 min · Topic 2 of 18
A schema is a plan for the app's work. So start with the work.
A write adds or changes rows. A read only looks at rows. This table lists what the shop must do, and what the database must keep true:
The shop app: writes and reads
What happens
Kind
Tables it touches
What the database must keep true
Register a customer
Write
customers
One account for each email.
Add an address
Write
addresses
An address belongs to a customer.
Place an order
Write
orders, order_items, products
All lines or none. Stock never goes below 0.
Pay an order
Write
payments, orders
One charge for each payment attempt.
Cancel or refund
Write
orders, order_status_history
Only from a legal status. Keep the old status.
Show one order
Read
orders, order_items
Fast, by order_id.
List my orders
Read
orders
Fast, by customer_id, newest first.
Log in with an email
Read
customers
Fast, by email.
Look at the row "Place an order". One click changes three tables. All three must change, or none.
The last column is the most useful one. Each line becomes a constraint, a transaction or an index. An index helps the database find rows fast.
Watch out
Do not start from a report or a screen. A table shaped for a report is hard to write safely. Reports belong to the analyst's copy, in OLTP vs OLAP: Choosing How To Structure Data.
03 · Schema
From writes to tables
⏱ 9 min · Topic 3 of 18
Each table has one grain. The grain is what one row means. Here are the seven tables of the shop, with the grain of each:
customers: one row is one person with an account.
addresses: one row is one address of one customer.
products: one row is one product we sell.
orders: one row is one order placed by one customer.
order_items: one row is one product on one order.
payments: one row is one accepted charge for one order.
order_status_history: one row is one status change of one order.
Here is the PostgreSQL code. The comments after -- explain the new parts:
The shop schemaworked example
SQL
CREATETABLEcustomers(customer_idBIGINTGENERATEDALWAYSASIDENTITY(STARTWITH470)PRIMARYKEY,emailTEXTNOTNULLUNIQUE,nameTEXTNOTNULL,countryTEXT,created_atTIMESTAMPTZNOTNULLDEFAULTnow(),updated_atTIMESTAMPTZNOTNULLDEFAULTnow(),deleted_atTIMESTAMPTZ-- NULL means the account is live);CREATETABLEaddresses(address_idBIGINTGENERATEDALWAYSASIDENTITY(STARTWITH470)PRIMARYKEY,customer_idBIGINTNOTNULLREFERENCEScustomers(customer_id)ONDELETECASCADE,line1TEXTNOTNULL,cityTEXTNOTNULL,countryTEXTNOTNULL);CREATETABLEproducts(product_idBIGINTGENERATEDALWAYSASIDENTITY(STARTWITH470)PRIMARYKEY,nameTEXTNOTNULLUNIQUE,priceNUMERIC(10,2)NOTNULLCHECK(price>=0),stock_qtyINTEGERNOTNULLCHECK(stock_qty>=0));CREATETABLEorders(order_idBIGINTGENERATEDALWAYSASIDENTITY(STARTWITH470)PRIMARYKEY,customer_idBIGINTNOTNULLREFERENCEScustomers(customer_id)ONDELETERESTRICT,address_idBIGINTREFERENCESaddresses(address_id),statusTEXTNOTNULLDEFAULT'pending'CHECK(statusIN('pending','paid','refunded','cancelled')),amountNUMERIC(10,2)CHECK(amount>=0),order_dateDATENOTNULL,versionINTEGERNOTNULLDEFAULT1,-- used for safe editscreated_atTIMESTAMPTZNOTNULLDEFAULTnow(),updated_atTIMESTAMPTZNOTNULLDEFAULTnow());CREATETABLEorder_items(order_idBIGINTNOTNULLREFERENCESorders(order_id)ONDELETECASCADE,product_idBIGINTNOTNULLREFERENCESproducts(product_id),quantityINTEGERNOTNULLCHECK(quantity>0),unit_priceNUMERIC(10,2)NOTNULLCHECK(unit_price>=0),-- price when orderedPRIMARYKEY(order_id,product_id));CREATETABLEpayments(payment_idBIGINTGENERATEDALWAYSASIDENTITY(STARTWITH470)PRIMARYKEY,order_idBIGINTNOTNULLREFERENCESorders(order_id),amountNUMERIC(10,2)NOTNULLCHECK(amount>0),idempotency_keyTEXTNOTNULLUNIQUE,paid_atTIMESTAMPTZNOTNULLDEFAULTnow());CREATETABLEorder_status_history(history_idBIGINTGENERATEDALWAYSASIDENTITYPRIMARYKEY,order_idBIGINTNOTNULLREFERENCESorders(order_id)ONDELETECASCADE,old_statusTEXT,-- NULL on the first row of an ordernew_statusTEXTNOTNULLCHECK(new_statusIN('pending','paid','refunded','cancelled')),changed_atTIMESTAMPTZNOTNULLDEFAULTnow());
The code is long. Read it one table at a time, with the grain sentence in mind.
Most of this you know from Keys & Identity. Note these parts:
IDENTITY is from Keys & Identity. START WITH 470 makes the first new id 470.
To load the old rows with their own ids, add OVERRIDING SYSTEM VALUE to the INSERT. It does not move the counter, so START WITH must be above the largest old id.
amount has no NOT NULL. Order 105 is a pending order with no amount yet.
unit_price is a copy of the price at order time. A later section explains why.
version, updated_at, deleted_at and the history table each get their own section later.
Here are the shop's rows in this schema. The emails and stock numbers are made up:
customers (5 rows)
customer_id
email
name
country
1
asha@example.com
Asha
IN
2
ben@example.com
Ben
GB
3
carla@example.com
Carla
US
4
dev@example.com
Dev
NULL
5
omar@example.com
Omar
IN
products (5 rows)
product_id
name
price
stock_qty
1
Pen
5.00
100
2
Notebook
30.00
40
3
Bag
25.00
8
4
Lamp
60.00
3
5
Chair
20.00
1
orders (some columns) (6 rows)
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
order_items (7 rows)
order_id
product_id
quantity
unit_price
101
1
2
5.00
101
2
1
30.00
102
3
1
25.00
103
4
1
60.00
104
1
3
5.00
106
5
1
20.00
106
4
1
60.00
The order_items rows are the seven you saw before, with unit_price added. Orders 101 to 103 and 106 add up to their amount. For example, order 101 is 2 pens at 5.00 plus 1 notebook at 30.00, which is 40.00.
Order 104 is 15.00 in items but 15.50 in amount. The shop data has always had this gap. Treat the extra 0.50 as a made-up fee that this schema does not store.
04 · Column types
Money and time columns
⏱ 9 min · Topic 4 of 18
Two kinds of column go wrong in many apps: money and time.
Money: use NUMERIC
FLOAT stores numbers in binary. Some decimal values, such as 0.1, cannot be stored exactly. Small errors then appear in sums and comparisons.
NUMERIC(10,2) stores exact decimals: 10 digits in total, 2 after the point. float8 is PostgreSQL's 8-byte FLOAT. Compare the two:
Both lines are the same moment. India time is 5 hours 30 minutes ahead of UTC.
A plain timestamp column has no zone. The same text can then mean different moments on different servers.
Watch out
Use timestamptz for every moment in time, such as created_at and paid_at. Use DATE only for a calendar day, such as order_date. Convert to a local zone only when you show the value.
05 · Snapshots
Copy the price into the line
⏱ 5 min · Topic 5 of 18
Normalization & Denormalization warned that a price changes. Here is the fix in the schema.
An old order must keep the price the customer paid.
That is why order_items has its own unit_price. The app copies the product price into it when the order is placed. It never looks the price up again.
Say the Lamp price goes up from 60.00 to 65.00. This is a made-up change. Then look at order 103, where Ben bought a Lamp:
Ben paid 60.00, and the refund was 60.00. If the app used price_now, the order would show 65.00.
The same idea applies to other facts that change. The address on an order and the product name are examples. Copy them into the order, or never edit the old row and add a new one.
06 · Constraints
Constraints refuse bad rows
⏱ 11 min · Topic 6 of 18
A constraint is a rule that the database checks on every write. If a row breaks the rule, the write fails.
The app can have a bug, and two services can write to the same table. A constraint still holds. It is the last line of defence.
PostgreSQL has five kinds. This table shows what each one refuses in the shop:
The five constraints
Constraint
What it refuses
Example in the shop
PRIMARY KEY
A duplicate or NULL id.
A second order 101.
FOREIGN KEY
A link to a row that does not exist.
An order for customer 9.
UNIQUE
A repeated value.
The same email twice.
NOT NULL
A missing value.
An order line with no price.
CHECK
A value that breaks a rule.
A quantity of 0.
Keys & Identity showed the first two errors. Here are the other three. First, NOT NULL, for an order line with no unit_price:
The insert fails. A failed insert still uses up an id, so the counter moves on.
Watch out
A CHECK passes when its value is NULL. The rule amount >= 0 accepts order 105, which has no amount. Add NOT NULL when the value must always be there.
What a delete does
Each foreign key also needs a delete rule, called ON DELETE. Keys & Identity showed the ON DELETE rules. This table shows the choice for the main links in the shop:
Delete rules in the shop
Link
Rule
Why
orders to customers
RESTRICT
A customer with orders cannot be deleted.
order_items to orders
CASCADE
Lines belong to their order. They go with it.
addresses to customers
CASCADE
An address has no meaning without its customer.
payments to orders
None set (NO ACTION)
An order with a payment cannot be deleted.
RESTRICT refuses the delete. CASCADE deletes the children too. If you set no rule, PostgreSQL uses NO ACTION, which also refuses the delete.
07 · Transactions
Transactions: all or nothing
⏱ 9 min · Topic 7 of 18
A transaction is a group of changes that succeed together or fail together.
BEGIN starts it. COMMIT saves it. ROLLBACK undoes it.
Omar (customer 5) buys 1 Bag. The made-up stock is 8. The app locks the stock row, saves the order, and lowers the stock:
ERROR: new row for relation "products" violates check constraint "products_stock_qty_check"
INSERT INTO order_items
ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
ROLLBACK
The first insert worked, but ROLLBACK undoes it. No half order remains. Id 471 is used up, so the next order gets 472.
Two buyers, one Chair
Now two buyers want the last Chair at the same time. PostgreSQL runs each statement on the data committed before it started. This is the READ COMMITTED level, the default.
Alone, that does not stop both buyers from reading a stock of 1. FOR UPDATE locks the row, so the second buyer waits. Follow the steps:
Two buyers and a lock
Step
Buyer A
Buyer B
1
BEGIN; SELECT stock_qty ... FOR UPDATE. Gets 1.
2
BEGIN; the same SELECT ... FOR UPDATE. It waits for the lock.
3
UPDATE stock_qty to 0. COMMIT.
Still waiting.
4
The lock is free. The SELECT now returns 0. The app refuses the order and rolls back.
Buyer B sees the new value 0, not the old 1. After the wait, FOR UPDATE reads the newest saved row. Only buyer A gets the Chair.
Keep transactions short
A lock lasts until COMMIT or ROLLBACK. Do not wait for a user or a slow web call inside a transaction. If two transactions lock the same rows in different orders, PostgreSQL stops one with "deadlock detected".
08 · Payments
Stop double charges
⏱ 8 min · Topic 8 of 18
A retry is when the app sends the same request again, for example after a time-out. Without care, the customer pays twice.
An idempotency key is an id that the app makes once for each payment. Every retry sends the same key. A UNIQUE rule on payments.idempotency_key lets only one row in.
Omar pays for order 470. The made-up key is key-8d41. The app saves the payment and the status change in one transaction:
There is still one payment. The customer is charged once.
The app should not show an error to Omar. ON CONFLICT DO NOTHING skips the insert without an error. Then the app reads the stored payment and gives the same answer as the first time:
Skip the repeat, read the first answerworked example
The INSERT prints INSERT 0 0, because no row was added. The SELECT returns the first payment.
Watch out
The UNIQUE rule only protects your own table. Send the same key to the card provider too. Many payment providers accept an idempotency key for this.
Interview question
"How do you stop a customer from being charged twice?" Answer: the app makes one key for each payment. It reuses the key on retries. A UNIQUE constraint lets one row in.
09 · Edits
Two people edit one row
⏱ 6 min · Topic 9 of 18
Omar and a support agent both open order 470 before it ships. Both change the delivery address.
The second save would silently erase the first. This is a lost update.
Optimistic locking stops it. Each row has a version number. An update says: change the row only if the version is still the one I read.
Omar has two made-up addresses. Both people read order 470 at version 2. The order became version 2 when it was paid:
The first save winsworked example
SQL
Input data
addresses (made up)2 rows
address_id
customer_id
line1
city
470
5
12 Park Road
Pune
471
5
8 Lake View
Mumbai
orders (one row)1 row
order_id
status
address_id
version
470
paid
NULL
2
-- Omar picks address 470.UPDATEordersSETaddress_id=470,version=version+1,updated_at=now()WHEREorder_id=470ANDversion=2;-- A moment later, the agent picks address 471.UPDATEordersSETaddress_id=471,version=version+1,updated_at=now()WHEREorder_id=470ANDversion=2;
What PostgreSQL prints
statement
output
First UPDATE
UPDATE 1
Second UPDATE
UPDATE 0
After the first save, the version is 3. The second UPDATE finds no row with version 2, so it changes nothing.
The app sees UPDATE 0 and tells the agent: "This order changed. Reload it." The agent decides again, with the new data.
You can also lock the row with SELECT ... FOR UPDATE, as in the stock example. Compare the two:
Two ways to protect an edit
Optimistic (version column)
Pessimistic (FOR UPDATE)
How it works
No lock. The update fails if the version moved.
The row is locked while you work.
Best when
Clashes are rare, such as a form a person edits.
Clashes are common, such as the last Chair.
Cost
The loser must retry.
Others wait, and a long lock hurts.
10 · History
Status and its history
⏱ 7 min · Topic 10 of 18
The status column in orders holds the current state. It forgets how the order got there. A history table keeps every change as a new row.
The schema above has order_status_history, with one row for each change. The first row of an order has old_status NULL, because a new order had no status before. The app only adds rows here.
Order 103 (Ben) went from pending to paid to refunded. The times are made up. This query reads its path:
Asha has orders 101 and 102. The newest, 102, comes first.
The planner decides how to run a query. On a table this small, PostgreSQL will probably read the whole table, because that is cheapest.
With millions of rows, PostgreSQL may choose the index. Use EXPLAIN to see its choice.
Watch out
Every index makes writes slower and uses disk. Add one for each real query, not for every column. Without an index on a foreign key, deleting a parent row reads the whole child table.
12 · Tenancy
Many shops in one database
⏱ 6 min · Topic 12 of 18
A platform can host many shops in one database. Each shop is a tenant. Their rows sit in the same tables, so a query must never mix them.
The fix is a tenant_id column in every table, and keys that include it. This example is separate from the shop schema. Shop 10 is ours, and shop 20 is made up:
Both shops have an order 101. The pair (tenant_id, order_id) is the key, so there is no clash.
The foreign key is also a pair. It checks that the customer belongs to the same tenant. Try to give shop 20 an order for customer 1, who belongs to shop 10:
Without the tenant_id filter, order 101 would match two rows, one from each shop.
There are other designs: one schema or one database for each tenant. They separate tenants more, but they cost more to run and to change. PostgreSQL also has row-level security, which can add the tenant filter for you.
13 · Lifecycle
Soft delete or hard delete
⏱ 9 min · Topic 13 of 18
A hard delete removes the row. A soft delete keeps the row and marks it as deleted, with a deleted_at column.
Omar now has order 470. Try to hard delete him:
The foreign key refusesworked example
SQL
DELETEFROMcustomersWHEREcustomer_id=5;
Error from PostgreSQL
message
ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"
DETAIL: Key (customer_id)=(5) is still referenced from table "orders".
The order of Omar is a financial record. The database keeps his row. A soft delete is the way to close the account.
A soft delete sets deleted_at. Then every query for live customers must add WHERE deleted_at IS NULL:
Two live accounts cannot share an email. A deleted row no longer blocks a new account. The same insert would now work.
Choose by what the row means:
Hard delete or soft delete
Hard delete
Soft delete
The row
Gone for good.
Stays, marked with deleted_at.
Rows that point at it
The foreign key refuses, or CASCADE removes them too.
They keep working.
Undo
Only from a backup.
Set deleted_at back to NULL.
Queries
No extra filter.
Every query needs WHERE deleted_at IS NULL.
Use it for
Temporary rows, such as an empty cart.
Accounts and records you may need later.
Retention is how long you keep data. Keep money records as long as the law requires.
Some laws also require you to remove or anonymise personal data on request. Ask your legal team.
14 · Change
Change the schema safely
⏱ 8 min · Topic 14 of 18
The app keeps running while you change the schema. A change that locks a big table can stop every order. Say the shop must now record the sales channel of each order.
The obvious way is one step. Add a NOT NULL column. It fails, because old orders have no value:
One step failsworked example
SQL
Input data
orders (7 rows: 101 to 106, and 470)7 rows
order_id
101
102
103
104
105
106
470
ALTERTABLEordersADDCOLUMNchannelTEXTNOTNULL;
Error from PostgreSQL
message
ERROR: column "channel" of relation "orders" contains null values
The seven existing orders would all get NULL, which NOT NULL refuses.
The safe way has steps. Step 1: add the column, and let it be NULL:
Plain SET NOT NULL scans the whole table while it holds a strong lock. CONCURRENTLY builds the index without blocking writes, but it cannot run inside BEGIN … COMMIT.
Locks and indexes
On a big table, add CHECK (channel IS NOT NULL) NOT VALID first, then VALIDATE CONSTRAINT it. In PostgreSQL 12 and later, SET NOT NULL then skips the scan. Drop the invalid index that a failed CREATE INDEX CONCURRENTLY leaves.
Watch out
An ALTER TABLE waits for every other transaction that uses the table. New queries then wait behind it. Run SET lock_timeout = '2s' first, so it gives up fast instead of blocking the app.
15 · Data engineers
What data engineers read
⏱ 6 min · Topic 15 of 18
Data engineers copy rows out of this database into a warehouse. A good operational schema makes that easy. It already has most of what they need.
Primary keys that never change, so a row keeps its identity.
created_at and updated_at as timestamptz, so they can read only new or changed rows.
Deletes that they can see, with a soft delete or with change data capture.
A status history, so they know the path and not only the last state.
PostgreSQL has no column that updates itself on every change. Set updated_at in the app, or with a trigger. A trigger is code that the database runs on each change.
Say a job runs each day and reads rows changed since 2026-01-05. These customer rows have made-up times. Omar was soft deleted in the delete section:
The job sees Omar and his deleted_at, so the warehouse can mark him deleted. If Omar had been hard deleted, the job would not see him at all.
A query on updated_at cannot see a row that no longer exists. Log-based change data capture reads the database log, so it sees every insert, update and delete.
Read next
The data-pipeline chapter "Change Data Capture (CDC)" explains log-based, query-based and trigger-based capture.
Before you ship a schema, check these eight things:
Every table has a one-sentence grain and a primary key.
Every link is a foreign key, with a chosen ON DELETE rule.
Money is NUMERIC, and every moment is timestamptz in UTC.
Rules that must never break are NOT NULL, UNIQUE or CHECK.
Each business action is one short transaction. Stock rows are locked.
Every payment has an idempotency key with a UNIQUE rule.
Foreign keys and frequent filters have indexes.
Deletes, status changes and migrations are planned, with no long locks.
Interview question
"Design the database for an online shop checkout." Walk through the writes, tables and constraints. Then show one transaction, the idempotency key and the indexes. Say what you would lock.
17 · Practice
Practice lab
⏱ 3 min · Topic 17 of 18
Each exercise opens an ERD canvas. You draw the tables, and the review checks them against seeded rows.
Start at the top. One exercise is harder and is marked optional.