Database Schema Design Basics — From ER Diagram to SQL

Published: September 11, 2026 | Updated: September 11, 2026

A schema is not a diagram you draw once and forget. It is a set of contracts the database enforces for years, and every one of them is expensive to change once a table has a few hundred million rows. Most production pain — slow joins, orphaned rows, impossible migrations — traces back to a decision made in twenty minutes at the start of a project: which column is the primary key, whether that foreign key cascades, and whether "tags" deserved its own table.

This guide walks the whole path from a written feature request to runnable CREATE TABLE statements. We cover how to pull entities out of requirements, the three real options for a primary key and when each wins, foreign keys and the ON DELETE rules, a short tour of 1NF through 3NF (and when to break them on purpose), the design mistakes that keep showing up in code review, naming conventions worth standardizing on, and a complete ER diagram that becomes a working schema.

Table of Contents
  1. 1. From Requirements to Entities
  2. 2. Choosing a Primary Key: Auto-Increment vs UUID vs Business Key
  3. 3. Foreign Keys and Referential Integrity
  4. 4. Normalization: 1NF, 2NF, 3NF
  5. 5. When to Break the Rules on Purpose
  6. 6. Common Table Design Mistakes
  7. 7. Naming Conventions
  8. 8. From ER Diagram to CREATE TABLE — Full Example
  9. FAQ

1. From Requirements to Entities

Requirements arrive as prose. "A customer can place orders, each order contains several products, and we need to remember the price at the time of purchase." Your job is to turn that sentence into nouns and verbs, then decide which nouns deserve a table.

The extraction rule is mechanical: nouns that have attributes and a lifecycle become entities; verbs become relationships.

Two tests separate real entities from attributes that just look like them:

  1. Can it exist on its own? An address is usually an attribute of a customer, not a separate entity — unless several customers can share one and you must update it in one place.
  2. Does it have a many-to-many relationship? If "order" and "product" connect many-to-many and the connection carries data, that connection is an entity. This is how order_items earns its place.

Note what the requirement forces: "remember the price at the time of purchase" means the price cannot live only on products. Copying it into order_items.unit_price is deliberate denormalization, and the requirement is telling you to do it. Reading requirements for sentences like that — anything about history, snapshots, or "at the time of" — is how you avoid a schema that silently rewrites the past whenever a price changes.

💡 Pro Tip: Before writing any DDL, sketch the entities as boxes and draw a line for every relationship, labelling each end with one or many. If a relationship has no label, you do not yet know where the foreign key goes. That single missing label causes more rework than any other step.

2. Choosing a Primary Key: Auto-Increment vs UUID vs Business Key

A primary key does three jobs: it identifies a row, it is indexed (usually as the clustered index in MySQL/InnoDB and SQL Server), and it is what foreign keys point at. The choice affects insert performance, index size, and how hard your migrations are later.

Auto-increment / sequence integer

A server-generated monotonic number. Small (4 or 8 bytes), fast to compare, and because inserts land at the end of the B-tree they hit one hot page instead of scattering across the index. The catch: the value only exists after the insert, so you must round-trip or use RETURNING to learn it, and sequential IDs leak volume to anyone who reads your URLs.

CREATE TABLE customers (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- PostgreSQL 10+
    email       VARCHAR(255) NOT NULL UNIQUE,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- MySQL 8.0 / MariaDB equivalent:
-- id BIGINT AUTO_INCREMENT PRIMARY KEY

UUID

Generated anywhere — client, application server, or database — without coordination. Ideal when IDs must be created before the row is written, when data from several systems is merged, or when exposing a guessable ID is a security problem. The costs are real: 16 bytes instead of 8, a larger secondary-index footprint (every index stores the primary key), and, with random UUIDv4, insert order that jumps across the whole B-tree. Prefer UUIDv7 or ULID, which prefix a millisecond timestamp and behave much closer to sequential values.

CREATE TABLE events (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- pgcrypto / PG13+
    payload     JSONB NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Business / natural key

A value from the domain: an ISBN, a national tax ID, an email address. Tempting because it is "already unique," but it couples your schema to a real-world identifier that can change. People change emails. Companies reissue codes. A 20-character string key bloats every foreign key and every index that carries it.

FactorAuto-increment / sequenceUUID (v7/ULID)Business key
Storage per value4–8 bytes16 bytesVaries, often 20–60 bytes
Index localityExcellent (append-only)Good for v7, poor for v4Depends on the value
Generated whereDatabase, after insertAnywhere, before insertBy the business process
Safe to expose in URLsNo (guessable, leaks counts)YesNo
Merging data from multiple systemsPainful (collisions)TrivialOnly if globally defined
Survives real-world changeYesYesNo — emails and codes change
Best forInternal tables, high insert volumeDistributed systems, public-facing IDsNothing as a primary key; use as a unique constraint

The pattern that avoids the trade-off entirely: surrogate primary key plus a unique business column. Let the table have a BIGINT identity key for joins and indexes, and keep the email or SKU as a separate UNIQUE column for lookup and deduplication. You get small keys and domain integrity at once.

3. Foreign Keys and Referential Integrity

A foreign key is a constraint that says "this value must exist in that other table." It is the database enforcing the relationship you drew on the diagram, and it is the difference between an integrity guarantee and a hopeful application check that one code path forgets to run.

CREATE TABLE orders (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id  BIGINT NOT NULL,
    order_date   DATE NOT NULL DEFAULT CURRENT_DATE,
    status       VARCHAR(20) NOT NULL DEFAULT 'pending',
    CONSTRAINT fk_orders_customer
        FOREIGN KEY (customer_id) REFERENCES customers (id)
        ON DELETE RESTRICT
        ON UPDATE CASCADE
);

Besides blocking orphaned rows, a declared foreign key lets the optimizer know the cardinality of a join, which is often the single biggest reason a query plan improves after you add constraints you "didn't strictly need."

ON DELETE CASCADE vs RESTRICT

This clause decides what happens to child rows when the parent is deleted, and guessing wrong is how teams lose data. Four behaviors, one clear rule for choosing:

ActionBehavior when parent is deletedUse when
CASCADEChild rows are deleted automaticallyThe child is owned by the parent and is meaningless alone — order items under an order, sessions under a user
RESTRICT / NO ACTIONDelete is rejected with an error if children existHistorical or financial records that must never vanish silently
SET NULLChild's FK column is set to NULL (it must be nullable)The relationship is optional — an employee whose manager left, a ticket unassigned from a deleted agent
SET DEFAULTChild's FK column is reset to its default valueRare: a well-known "unknown" or "archived" row the children can fall back to

The judgement call: CASCADE for ownership, RESTRICT for reference. If the child row is a piece of the parent — delete the order, the line items go with it — cascade is correct and saves you a manual cleanup in every code path. If the child merely points at the parent, cascade is a booby trap: deleting one user quietly removes every order they ever placed, and no one notices until an accounting report comes up short. Default to RESTRICT, and add cascade only where ownership is genuine.

💡 Pro Tip: CASCADE follows the chain. A delete on customers firing cascades into orders, then into order_items, then into anything referencing those — a single statement can legitimately delete millions of rows and hold long locks. Before adding a cascading FK, trace what is downstream of it.

Reviewing a Schema Someone Else Wrote?

Paste the DDL into our formatter to line up columns, keys and constraints so the design is actually readable before you review it.

Open SQL Format Tool →

4. Normalization: 1NF, 2NF, 3NF

Normal forms are not academic trivia; each one names a specific anomaly that costs you real money. The first three cover almost every practical schema.

First normal form (1NF) — atomic values

Every cell holds a single value, and each row is unique. A tags column containing 'red,blue,green' is not atomic: you cannot index it usefully, you cannot join on it, and filtering for one tag means a LIKE '%blue%' full scan that also matches "blueberry."

-- Violates 1NF: three tags stuffed into one column
CREATE TABLE posts_bad (
    id    BIGINT PRIMARY KEY,
    title VARCHAR(200),
    tags  VARCHAR(500)   -- 'sql,database,performance'
);

-- 1NF fix: one row per value, own table
CREATE TABLE post_tags (
    post_id  BIGINT NOT NULL REFERENCES posts (id) ON DELETE CASCADE,
    tag      VARCHAR(50) NOT NULL,
    PRIMARY KEY (post_id, tag)
);

Second normal form (2NF) — no partial dependencies

1NF plus: every non-key column depends on the whole primary key, not part of it. This only bites composite keys. Consider an order item keyed by (order_id, product_id) that also stores product_name. Product name depends only on product_id, half the key — so renaming a product means updating every order that ever referenced it, and they can drift out of sync. Remove it to the products table, and copy it into the order item only if the requirement demands a historical snapshot.

Third normal form (3NF) — no transitive dependencies

2NF plus: no non-key column depends on another non-key column. If orders stores both customer_id and customer_city, the city depends on the customer, not on the order. Change a customer's city and the old orders now disagree with the customer record. Drop the column and join for it.

-- Violates 3NF: customer_city transitively depends on customer_id
CREATE TABLE orders_bad (
    id            BIGINT PRIMARY KEY,
    customer_id   BIGINT NOT NULL REFERENCES customers (id),
    customer_city VARCHAR(100)   -- duplicate of customers.city
);

-- 3NF: keep only the foreign key
CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL REFERENCES customers (id),
    order_date  DATE NOT NULL
);
-- Read the city through the join:
SELECT o.id, c.city
FROM orders o
JOIN customers c ON c.id = o.customer_id;

The mnemonic that sticks: 1NF is about cells (one value each), 2NF is about partial keys, 3NF is about indirect facts (a column that could be derived from another non-key column).

5. When to Break the Rules on Purpose

Normalization is optimized for correct writes. Reads are the other half of the job, and sometimes a fully normalized schema needs three joins to render one screen. Deliberate denormalization is a legitimate engineering choice when it is measured and maintained:

The discipline that makes this safe is simple: only denormalize with a named query in mind and one owner responsible for keeping the copy in sync. If you cannot say which read is too slow and how the redundant column gets updated, you are not denormalizing — you are accumulating drift. Normalize first, measure with EXPLAIN, then break the rule where the data says it matters.

6. Common Table Design Mistakes

Multi-value columns and comma-separated lists

The single most common schema defect. Lists in a column break 1NF, cannot be indexed, cannot be joined, and force string parsing into every consumer. When a value needs its own identity (it will be filtered, sorted, or joined), it needs its own row. The fix is always a link table like post_tags above.

Wide tables with dozens of nullable columns

A table with 60 columns where half are NULL for any given row usually means two entities got merged. Nullable columns are not free: they add a bitmap to every row, complicate every NOT NULL check the optimizer could otherwise exploit, and let the application write half-populated rows the schema cannot reject. Split the optional group into its own table with a foreign key, or model it as a proper subtype (a payment_methods table with a type discriminator).

Using a mutable business value as the primary key

Emails, usernames, and phone numbers change. When they are the primary key, a change ripples into every foreign key, every index, and every cached URL. Use a surrogate key and a UNIQUE constraint on the business value.

Forgetting NOT NULL on columns that are always populated

If a column is required by the business, say so in DDL. NULL is not "empty string" or "zero" — it is "unknown," and it propagates: NULL = NULL is not true, aggregates skip it, and WHERE col = '' silently misses every row where the value was never set. Constrain early; adding NOT NULL later requires a full table scan and a backfill.

No constraints at all — "we validate in the app"

Applications have many code paths and one database. A CHECK or unique constraint is enforced for every one of them, including the script someone runs at 2 a.m. That is not paranoia; it is the cheapest bug prevention available.

💡 Pro Tip: Add a created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP (and often updated_at) to every table from day one. Retrofitting audit columns onto a live table is the migration nobody wants to run, and "when did this row appear?" is a question you will ask.

7. Naming Conventions

Naming is not cosmetic — inconsistent names cost every query author a lookup and make generated tooling harder to write. Pick a convention and hold it; the specific choice matters less than applying it everywhere.

ItemRecommendedAvoid
Tables / columnssnake_case, lowercase: order_itemsMixed case or quoted "OrderItems" — forces quoting on every query in PostgreSQL
Table namesPlural: orders, customersSwitching between user and users across the schema
Primary keyid on every tableorder_id as the PK of orders, forcing aliasing in joins
Foreign keys<referenced_table_singular>_id: customer_idfk1, cust, or matching the PK name id
Booleansis_active, has_shippedactive_flag, status_bit, deleted
Timestampscreated_at, expires_atdate1, ts, created
Constraints / indexesfk_orders_customer, idx_orders_statusAuto-generated names like orders_ibfk_3

Two conventions carry more weight than the rest. First, never mix plural and singular table names — pick plural, because a table holds a set. Second, name every foreign key column after the table it references, so joins read left-to-right without a mental translation: orders.customer_id = customers.id needs no decoding. Keeping the primary key simply id in every table is what makes that possible, since the alias (c.id) already disambiguates.

Avoid SQL reserved words as identifiers (order, user, group, key). They are legal when quoted, but quoting is contagious: once you quote one identifier you tend to quote them all, and quoted identifiers become case-sensitive in PostgreSQL and MySQL, which turns a rename into a debugging session.

8. From ER Diagram to CREATE TABLE — Full Example

Here is the whole process on a small but realistic feature: a shop where customers place orders, orders contain products, and the price at purchase must be preserved.

The diagram

customers                 orders                     products
+---------------+         +------------------+       +---------------+
| id (PK)       |1       *| id (PK)          |*     *| id (PK)       |
| email         |---------| customer_id (FK) |-------| sku           |
| full_name     |         | order_date       |  via  | name          |
| created_at    |         | status           |       | price_cents   |
+---------------+         | total_cents      |       +---------------+
                          +------------------+              |
                                   |1                       |1
                                   |                        |
                                   |*                       |*
                          +--------------------+            |
                          | order_items        |*-----------+
                          | id (PK)            |  (many-to-many
                          | order_id (FK)      |   resolved by
                          | product_id (FK)    |   the junction)
                          | quantity           |
                          | unit_price_cents   |
                          +--------------------+

Relationships:
  customers 1 --- * orders          (FK on orders)
  orders    1 --- * order_items     (FK on order_items, CASCADE)
  products  1 --- * order_items     (FK on order_items, RESTRICT)

Three translation rules took the diagram to that shape:

  1. One-to-many (customer → orders): the foreign key goes on the many side, orders.customer_id.
  2. Many-to-many (orders ↔ products): a junction table, order_items, holding both foreign keys.
  3. Carried data (quantity, price at purchase): columns on the junction, since the data belongs to the relationship, not to either entity.

The DDL

CREATE TABLE customers (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    full_name   VARCHAR(150) NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku          VARCHAR(40)  NOT NULL UNIQUE,
    name         VARCHAR(200) NOT NULL,
    price_cents  INTEGER      NOT NULL CHECK (price_cents >= 0),
    is_active    BOOLEAN      NOT NULL DEFAULT TRUE
);

CREATE TABLE orders (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id  BIGINT NOT NULL,
    order_date   DATE   NOT NULL DEFAULT CURRENT_DATE,
    status       VARCHAR(20) NOT NULL DEFAULT 'pending'
                     CHECK (status IN ('pending','paid','shipped','cancelled')),
    total_cents  INTEGER NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
    created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT fk_orders_customer
        FOREIGN KEY (customer_id) REFERENCES customers (id)
        ON DELETE RESTRICT          -- never erase a customer's history silently
);

CREATE TABLE order_items (
    id                BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id          BIGINT  NOT NULL,
    product_id        BIGINT  NOT NULL,
    quantity          INTEGER NOT NULL CHECK (quantity > 0),
    unit_price_cents  INTEGER NOT NULL CHECK (unit_price_cents >= 0),
    CONSTRAINT fk_items_order
        FOREIGN KEY (order_id) REFERENCES orders (id)
        ON DELETE CASCADE,          -- items are owned by their order
    CONSTRAINT fk_items_product
        FOREIGN KEY (product_id) REFERENCES products (id)
        ON DELETE RESTRICT,         -- keep products referenced by history
    CONSTRAINT uq_items_order_product UNIQUE (order_id, product_id)
);

CREATE INDEX idx_orders_customer_date ON orders (customer_id, order_date);
CREATE INDEX idx_items_product        ON order_items (product_id);

Every design decision from this article is visible in those statements:

Verify the result

Before you commit DDL, run it through a validator to catch syntax and dialect problems, and format it so the constraint block lines up — a schema is read far more often than it is written. Try the SQL formatter and the SQL validator, or sketch the boxes first with the ER diagram tool and paste the generated DDL back into the formatter to tidy it.

-- Sanity check after loading: any order whose total disagrees with its items?
SELECT o.id,
       o.total_cents,
       COALESCE(SUM(i.quantity * i.unit_price_cents), 0) AS computed
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, o.total_cents
HAVING o.total_cents <> COALESCE(SUM(i.quantity * i.unit_price_cents), 0);

Run that after every batch import. It is the cheapest possible tripwire for the one thing denormalization gets wrong: the cached total falling out of sync with the rows it summarizes.

FAQ — Database Schema Design

Should I use an auto-increment ID or a UUID as primary key?

Auto-increment (or a BIGINT identity) for internal tables you fully control — smaller, faster, and inserts append to the index instead of scattering. UUIDs when IDs must be generated by clients, when merging data from multiple systems, or when exposing sequential IDs leaks business volume. If you need both, keep a BIGINT surrogate key and a UUID column with a unique index. Prefer UUIDv7 or ULID over random UUIDv4; the time-ordered prefix keeps index fragmentation down.

What does ON DELETE CASCADE actually do?

It deletes child rows automatically when the referenced parent row is deleted. RESTRICT (or NO ACTION) instead refuses the delete and raises an error while children still point at the parent. Use CASCADE for data owned by the parent (order line items under an order) and RESTRICT for anything shared or historical, because it forces a deliberate cleanup instead of silently wiping records.

Is normalization always required?

No. Third normal form is the right default for transactional schemas because it removes update anomalies, but denormalize deliberately when a read path is measured to be too slow — a stored total, a snapshot price, a refreshed reporting table. Normalize first, measure with EXPLAIN, then break the rule with a specific query in mind and a plan for keeping the copy in sync.

What is the most common table design mistake?

Storing multiple values in one column — a tags column like 'red,blue,green' or a JSON blob of ids. It breaks first normal form, makes joins impossible, and defeats indexes, so every query becomes a LIKE scan or an application-side split. The fix is a link table with one row per value. Close behind: using a mutable business value such as an email as the primary key, and wide tables with dozens of nullable columns that should have been split.

How do I turn an ER diagram into SQL?

Each entity becomes a table, each attribute a typed column, each relationship a foreign key. One-to-many puts the foreign key on the many side; many-to-many needs a junction table holding both foreign keys, usually with a composite or unique constraint over them. Draw the diagram first, then generate the CREATE TABLE statements and add the constraints that make the database enforce what the diagram promised.