Turn requirements into a normalized operational schema, with the why: write integrity, transactions, and constraints.
⏱ 20 min readTopics chapter readerLevel · Foundations
01 · Orientation
What You'll Master Here
Model the write workflow first, enforce invariant facts in the database, and make one business action commit atomically.
⏱ 4 min · Topic 1 of 13
OLTP modeling designs the database behind operational workflows: create an order, reserve inventory, update status, issue a refund, and find the current answer quickly.
Step through the five defenses below. One checkout write has to survive all of them, and each one is a topic in this chapter. Chapter 5 gave you a normalized commerce model; this chapter asks whether concurrent applications can write to it safely.
One checkout write, five defensesCustomer c1 buys two Trail Boots. Walk the layers the write has to survive.
The schema that provides it
primary key (order_id, line_no)
One row means one product line inside one order. Every later rule has something precise to attach to.
Layer 1 of 5 is holdingEvery layer above is a separate design decision. Remove it to see the incident it prevents.
Core mental model
Model the write workflow first, enforce invariant facts in the database, and make one business action commit atomically.
Why it matters
Operational data is the source of truth. A weak schema makes every API and service reimplement integrity rules inconsistently.
OLTP
Online transaction processing: many small, concurrent writes and current-state reads.
constraint
A database-enforced rule such as primary key, foreign key, unique, not null, or check.
transaction
A set of changes that commits together or rolls back together.
access path
The common lookup/filter pattern an index is designed to serve.
Common mistake
Designing tables from a dashboard instead of from operational writes. The source system cannot enforce correct state transitions or identities.
Better habit
Write business events before DDL.
Map each invariant to enforcement.
Make transaction boundaries explicit.
What to say
I start from the write workflow, state the grain and identity of each table, enforce invariants with constraints, and wrap one business action in a transaction.
Remember this
OLTP schemas are contracts for safe concurrent writes.
02 · Requirements
Start With Business Events
Business event -> state change -> table grain -> invariant -> transaction.
⏱ 4 min · Topic 2 of 13
Start with verbs: customer registers, places an order, payment is captured, a refund is issued. Verbs expose writes, state changes, and history; a list of nouns does not.
For each verb, ask what must stay true — then ask where that rule can actually live. Work the four events below: the writes are given, and you decide whether the invariant belongs to a constraint, to transaction logic, or to nothing but application code.
EAV read cost: pivoting one product recordworked example
SQL
Input data
eav3 rows
entity_id
attribute_name
attribute_value
p1
name
Trail Boot
p1
price
89.99
p1
color
black
One real product = three rows. attribute_value is text — the price is a string, and nothing stops a row where it says "eighty-nine".
-- EAV stores one row per attribute. Reading a single-- product back requires a pivot per attribute:selectmax(casewhenattribute_name='name'thenattribute_valueend)asname,max(casewhenattribute_name='price'thenattribute_valueend)aspricefromeavwhereentity_id='p1';
Result
name
price
Trail Boot
89.99
Two attributes read back require two MAX(CASE...) pivots. A named-column products table would be: SELECT name, price FROM products WHERE product_id = 'p1'.
The pivot tax: every attribute you read from EAV costs a conditional aggregate, and the type system cannot help you.
Core mental model
Business event -> state change -> table grain -> invariant -> transaction.
Why it matters
Entity-first modeling can miss the workflow and constraints that make operational data valid.
Common mistake
Starting with columns before describing writes. The schema fits a screen mockup but fails the real workflow.
Better habit
Write verbs and preconditions.
Separate current state from event history.
Use examples of invalid writes.
Modeling prompt
Ask: what could a user attempt to write that must be rejected, and where should the rejection happen?
EAV anti-pattern
Beyond the pivot cost shown above, EAV throws away the type system: attribute_value is text, so you cannot declare NOT NULL, a CHECK, or a foreign key on one specific attribute. Named columns with real types are almost always the correct design.
Remember this
Operational schemas are built from valid state changes.
03 · Schema
From Requirements To Entities, Relationships, Keys, And Grain
Name the row meaning, key, parent, and child relationship for every table.
⏱ 4 min · Topic 3 of 13
The commerce model has customers, products, orders, and order items. Each table needs one row meaning and one identity, and the identity is what turns that meaning into something the database can enforce.
orders is one row per order. order_items is the interesting one: assemble its primary key below, then watch three writes — a genuine new line, a replayed submit, and the first line of a new order — land or bounce.
An OLTP schema is ER modeling plus enforced identity.
04 · Constraints
Primary, Unique, Foreign, Not-Null, Check, And Default Constraints
database constraint, transaction logic, or explicitly documented service rule.
⏱ 5 min · Topic 4 of 13
Constraints turn business rules into database behavior. Primary keys identify rows, unique constraints protect alternate identities, foreign keys protect relationships, not-null protects required facts, and checks protect row-level validity.
Defaults provide a safe initial value, but they are not validation. A default draft status does not prove every later status transition is valid.
Switch the four constraints on order_items off one at a time below. Each one is the only thing standing in front of one specific bad row.
Deferred FK for out-of-order bulk insertworked example
SQL
-- Scenario: a bulk load arrives child-rows-first-- (or two tables reference each other circularly).-- An immediate FK rejects the child insert; a-- deferred FK waits until commit to validate.altertableorder_itemsdropconstraintorder_items_order_id_fkey,addconstraintorder_items_order_id_fkeyforeignkey(order_id)referencesorders(order_id)deferrableinitiallydeferred;begin;-- Child first: orders row does not exist yet.-- Immediate FK would fail HERE; deferred waits.insertintoorder_items(order_id,line_no,product_id,quantity,unit_price)values('o200',1,'p5',2,19.99);insertintoorders(order_id,customer_id,status)values('o200','c1','draft');-- At commit PostgreSQL validates the FK: both rows exist.commit;
Immediate is the safe default; deferred is the escape hatch for a load pattern you already know about.
Core mental model
Every invariant should have an enforcement home: database constraint, transaction logic, or explicitly documented service rule.
Why it matters
Without constraints, every writer must remember rules and eventually one writer will not.
deferred constraint
A constraint marked DEFERRABLE INITIALLY DEFERRED that PostgreSQL checks at commit rather than on each row write, so a bulk load or circular insert may violate it mid-transaction.
EXCLUDE constraint
A PostgreSQL constraint that rejects two rows when a stated operator expression between them is true — generalizing uniqueness beyond equality, for example forbidding two bookings whose time ranges overlap.
Common mistake
Putting all integrity checks only in API code. Imports, scripts, admin tools, and future services can bypass them.
Using circular FK references without deferral in a bulk-load job. Every insert order fails the FK; the load must use application workarounds that bypass the constraint entirely.
Better habit
Map rules to constraints.
Use named constraints where useful.
Keep cross-row rules out of naive CHECK expressions.
Constraint boundary
A row-level CHECK is not a general cross-table business-rule engine; use keys, foreign keys, transactions, or explicit service logic for those rules.
Remember this
Constraints are executable model documentation.
05 · Relationships
Referential Actions: Restrict, Cascade, Set Null, And Business Meaning
Ask whether the child has meaning without the parent before choosing a delete action.
⏱ 4 min · Topic 5 of 13
ON DELETE behavior is a business policy encoded in a relationship. It is not a convenience option.
Three parents below have children that mean three different things: a draft line, a line on a shipped order, and an optional coupon. Cross each parent with each action — exactly one cell per parent is right, and two of the wrong ones fail at runtime rather than at declaration.
Express delete policy in foreign keysworked example
Two foreign keys on one table, two different policies, because the two parents mean different things to the child.
Core mental model
Ask whether the child has meaning without the parent before choosing a delete action.
Why it matters
A careless cascade can erase audit history; a careless restrict can make ordinary cleanup impossible.
Common mistake
Using CASCADE because it makes a delete error disappear. A single delete can erase data that has business or audit meaning.
Better habit
State delete behavior in requirements.
Prefer soft delete for retained entities.
Test each delete path.
What to say
I choose delete actions from business meaning: cascade only for dependent children, restrict for historical references, set null for optional relationships.
Remember this
Referential actions are lifecycle rules.
06 · Transactions
Transaction Boundaries And Atomic Writes
One business event should commit all required facts together or commit none.
⏱ 5 min · Topic 6 of 13
An order placement is not four unrelated inserts. It is one business action: create the order, create its lines, reserve inventory, record status, then commit together — and if a required step fails, the rollback stops a half-order from ever becoming visible.
Atomicity is only half the job. Two sessions can each be perfectly atomic and still corrupt the data between them. Step the race below under each of the three guards, and watch the last unit of stock get sold once — or twice.
Order placement transaction shapeworked example
SQL
begin;insertintoorders(order_id,customer_id,status)values(:order_id,:customer_id,'draft');insertintoorder_items(order_id,line_no,product_id,quantity,unit_price)values(:order_id,1,:product_id,:qty,:price);updateinventorysetavailable=available-:qtywhereproduct_id=:product_idandavailable>=:qty;-- if the inventory update affected 0 rows: rollbackupdateorderssetstatus='placed'whereorder_id=:order_id;commit;
atomic outcome
case
visible state
all steps succeed
order, lines, inventory, status commit together
inventory unavailable
rollback; no partial order
Check-then-insert race and the schema fixworked example
SQL
-- BROKEN: application checks uniqueness, then inserts.-- Two sessions can both pass the check before either-- commits, producing a duplicate username.-- Session A Session B-- SELECT count(*) ... SELECT count(*) ...-- (both see 0 rows) (both see 0 rows)-- INSERT username='ali' INSERT username='ali'-- COMMIT COMMIT -- duplicate!-- FIX 1: declare a UNIQUE constraint (preferred).altertableaccountsaddconstraintaccounts_username_uqunique(username);-- Now one INSERT raises a unique-violation error;-- the application retries or surfaces the conflict.-- Note: FOR UPDATE cannot help here. It locks rows that-- exist, and the row being raced over does not yet.
race resolution
approach
duplicate prevented?
notes
app-level check only
no
race window between check and insert
UNIQUE constraint
yes
database rejects the second writer
FOR UPDATE on absent row
no
locks nothing — the row does not exist
Core mental model
One business event should commit all required facts together or commit none.
Why it matters
Partial writes create states no business user intended and that later code cannot explain.
READ COMMITTED
PostgreSQL's default isolation level: each statement sees only rows committed before it started, but two statements in the same transaction may see different values if a concurrent commit happens between them.
SELECT FOR UPDATE
A locking read (PostgreSQL and most SQL engines; syntax varies) that holds a row-level lock until commit. It only locks rows that already exist — it cannot guard an insert race unless you lock an existing parent row.
Common mistake
Committing header and line items separately. A failed later step leaves an order with no valid business meaning.
Relying on an application-level uniqueness check without a database UNIQUE constraint. Under READ COMMITTED two concurrent writers both pass the check and produce a duplicate that the schema never prevents.
Better habit
State transaction start and commit point.
Check affected-row counts for conditional updates.
Handle retry/idempotency at the request boundary.
Concurrency note
The stricter isolation levels (REPEATABLE READ, SERIALIZABLE) prevent further read anomalies. They are not covered on this platform yet; the PostgreSQL isolation docs are the reference.
Hotspot rows
A single frequently-updated row — a global counter, a shared status flag — becomes a lock-contention bottleneck at scale. Replace it with a counter table of delta rows and periodically batch-sum the deltas, or use a sequence-based approach to avoid the hot row.
Remember this
Transactions make one business action indivisible.
07 · History
Current State, Status History, And Audit Evidence
Current table answers now; history table answers how and when.
⏱ 4 min · Topic 7 of 13
orders keeps the current status so operational screens can read it in one row. A separate status-history table records each transition, its actor, and its time.
Play the three transitions below, then ask both tables the same three questions. Both answer "what is it now"; only one answers "who" and "how long". Keep the transition policy itself in transaction logic when it depends on the previous state.
Keep current status and transition historyworked example
changed_by is the column that turns a log into evidence; without it the history says what happened but never who did it.
Core mental model
Current table answers now; history table answers how and when.
Why it matters
Overwriting status alone destroys the evidence needed to explain what happened.
Common mistake
Using current status as the only audit record. You cannot answer who changed it, when, or how long each state lasted.
Better habit
Model current and history separately.
Write transition plus current state atomically.
Capture actor/correlation id.
Temporal preview
Chapter 15 goes deeper on temporal modeling; here the key idea is that workflow state and event history answer different questions.
Remember this
Current state is operational convenience; history is evidence.
08 · Access paths
Indexes For Operational Access Paths
equality predicates first, then common sort/range columns.
⏱ 5 min · Topic 8 of 13
Indexes support the reads and integrity checks your write model requires. Primary and unique constraints create unique indexes automatically in PostgreSQL.
Design from query shapes, not from a blanket rule to index every column. Match each of the four queries below to the index that actually serves it — one of the five candidates is a decoy that looks right and never gets used.
Indexes for common order operationsworked example
SQL
createindexorders_customer_created_at_idxonorders(customer_id,created_atdesc);createindexorder_items_product_id_idxonorder_items(product_id);-- primary key and unique constraints already create their own unique indexes.
The second index exists for the foreign key itself: without it, deleting or re-keying a product has to scan order_items to prove no child references it.
Partial, covering, and expression index examplesworked example
SQL
-- Partial: only index active orders;-- avoids bloating the index with completed history.createindexorders_active_customer_idxonorders(customer_id,created_atdesc)wherestatusnotin('shipped','cancelled');-- Covering (INCLUDE): serve the lookup query-- without touching the heap row.createindexorders_id_covering_idxonorders(order_id)include(status,created_at);-- Expression: case-insensitive email lookup.createindexcustomers_lower_email_idxoncustomers(lower(email));-- Query must use lower(email) = lower($1)-- to hit this index.
Three specialised index types, each written for one access pattern. The comparison below states where each stops paying off.
Core mental model
Index the access path: equality predicates first, then common sort/range columns.
Why it matters
A correct operational schema can still time out if common lookups and relationship checks scan large tables.
B-tree index
PostgreSQL's default index structure; supports equality, range, and ORDER BY on indexed columns.
hash index
An index that stores only a hash of each key value; supports equality comparisons only, with smaller on-disk size for large-key columns.
covering index
An index that carries extra non-key columns via INCLUDE, so a covered query reads no heap rows at all (PostgreSQL 11+).
PostgreSQL operational index types
type
operators
best for
avoid when
B-tree
=, <, >, BETWEEN, ORDER BY
ranges, dates, ordered scans
never — safe default
hash
= only
equality on large keys
range or sort queries
partial
= + condition
sparse active subsets
condition changes frequently
covering (INCLUDE)
= on key cols
index-only scan
included cols change often
expression
on expr output
computed predicates
expression cost is high
Common mistake
Creating a duplicate manual index on a primary-key column. Extra write cost with no additional uniqueness benefit.
Better habit
Start from actual access paths.
Index important referencing FK columns.
Measure plans before adding indexes.
Write tradeoff
Every index speeds some reads but adds maintenance to inserts, updates, and storage. Index the workload, not the schema diagram.
Index depth pointer
Query plans and index selectivity go deeper than schema design; OLTP planner tuning (EXPLAIN ANALYZE) is not covered on this platform yet. For the analytical side — scan cost and query plans in BigQuery/Snowflake/Redshift, a different execution model than the B-tree planner — see the SQL KB.
Indexes are workload-specific parts of the operational model.
09 · Tenancy
Multi-Tenant Scoping And Composite Constraints
Tenant identity is part of the row identity and relationship contract where data is tenant-scoped.
⏱ 4 min · Topic 9 of 13
A multi-tenant schema has to decide where tenant identity lives and how a relationship proves that a record never crosses a tenant boundary.
Below is the invoices table of an agency billing product. Two of its five rows are cross-tenant: find them, then declare the composite foreign key and watch them become unwritable. Application filters cannot do this job, because a missing WHERE clause is exactly the bug.
Uniqueness is tenant-scoped too: two agencies may both have a client at ada@ada.dev, so unique (email) would be wrong and unique (tenant_id, email) is right.
Core mental model
Tenant identity is part of the row identity and relationship contract where data is tenant-scoped.
Why it matters
A missing tenant predicate is a data-isolation incident, not just a query bug.
Common mistake
Using a single-column client_id FK in a tenant-scoped model because client_id happens to be globally unique. The reference is valid and the tenant is wrong; one application bug links records across tenants and nothing rejects the write.
Better habit
Include tenant in scoped keys.
Use composite uniqueness for business identifiers.
Review every relationship for tenant propagation.
Security boundary
Tenant isolation often needs database constraints plus row security and service authorization; one layer alone is rarely enough.
Remember this
Multi-tenancy changes identity and relationship design.
10 · Lifecycle
Soft Deletes, Hard Deletes, And Retention Rules
Choose lifecycle policy by business meaning, then enforce active/inactive behavior consistently.
⏱ 5 min · Topic 10 of 13
Hard delete removes a row. Soft delete marks it no longer active. The right choice depends on legal retention, audit needs, recoverability, and whether the entity has historical references.
Run the same three operations against all three lifecycle policies below. Only one gets all three right, and it still costs you something on every query you write afterwards.
Retention is broader than deletion: history, backups, derived models, and downstream analytical copies may have their own policy.
Soft-delete-aware active uniquenessworked example
SQL
altertablecustomersaddcolumndeleted_attimestamptz;-- Drop the global rule; it would keep the retired email reserved forever.altertablecustomersdropconstraintcustomers_email_key;-- Uniqueness now applies only to the rows that are still active.createuniqueindexcustomers_active_email_uqoncustomers(email)wheredeleted_atisnull;
A partial unique index is what makes soft delete reversible: the retired row keeps the history, and the address is free again.
Core mental model
Choose lifecycle policy by business meaning, then enforce active/inactive behavior consistently.
Why it matters
Deletion decisions change referential integrity, auditability, and what users can still see.
Common mistake
Adding deleted_at but forgetting every lookup and unique rule. Deleted entities reappear or block legitimate new records.
Better habit
Define active-query convention.
Document retention and purge process.
Test delete actions and downstream effects.
No default answer
Soft delete is not automatically safer. Use it when business recovery or audit needs justify its query and lifecycle cost.
Remember this
Lifecycle policy is part of schema design, not a later flag.
Before shipping an OLTP schema, walk the critical writes: create, update, cancel, delete, retry, and concurrent attempt.
Review the parcel-tracking schema below. Five things in it will cause a production incident and two look wrong but are not — a review that flags everything is as useless as one that flags nothing.
A production OLTP schema is a tested contract for valid state changes.
12 · Practice
Practice Lab
Say what one row means before you draw the second table. Everything else in a review follows from that sentence.
⏱ 3 min · Topic 12 of 13
Six design scenarios for this chapter, on the ERD canvas. Each one is graded against a real rubric, seeded with real rows, and each has one fault planted in the data rather than in the diagram.
Build them with the chapter closed. If one goes wrong, come back to the section it belongs to rather than re-reading the whole thing.
Core mental model
Say what one row means before you draw the second table. Everything else in a review follows from that sentence.
Why it matters
Reading about a grain mistake and watching one inflate a number you produced are different memories. The second is the one still there under interview pressure.
Common mistake
Revealing the reference model before your own review comes back. You see what correct looks like without finding out what your version got wrong, and your version is the one you will draw again under pressure.
Better habit
Write the three questions the model must answer before drawing a single table.
State the grain of every table out loud. If the sentence needs an "and", you have found a composite key.
Run the review, fix what it finds, and run it again. The second score is the one that means something.
Each scenario compiles your canvas to DDL, seeds correlated rows, and runs acceptance and anomaly checks against them. A finding comes with the query that produced it and the rows it returned.
Remember this
A model you have defended against seeded data is worth more than three you have only drawn.
13 · Next Chapter
Next Chapter
You can now build a normalized, transaction-safe operational source of truth.
⏱ 3 min · Topic 13 of 13
Next chapter
OLTP vs OLAP: Choosing How To Structure Data
You can now build a normalized, transaction-safe operational source of truth.
The next chapter explains why the same business also needs a different model for historical scans, metrics, and reporting, and how to decide where each workload belongs.