Part · Chapter

Designing good tables

Data types, constraints, normal forms, relationships, and changing a table that is already full.

The short version

  • A type is a promise. Pick the smallest one that fits, and DECIMAL for money.
  • Constraints outlive application bugs. The engine checks them on every write, whoever sends it.
  • Every fact in one place. Third normal form is enough; denormalize for a reason you can name.
  • Three shapes of relationship. One-to-one, one-to-many, and many-to-many through a junction table.
  • Name the ALTER algorithm. INSTANT, INPLACE or COPY decides between milliseconds and a blocked table.
  • MySQL ignores case by default. Its default collation ignores accents too; PostgreSQL compares text exactly.

Data types: the vocabulary

A data type is a promise the engine enforces on every insert. Changing it later on a full table is expensive, so recognize the families.

FamilyMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18Use it for
Whole numbersTINYINT, SMALLINT, MEDIUMINT, INT, BIGINT (1, 2, 3, 4, 8 bytes). UNSIGNED doubles the positive range, MySQL only.smallint, integer, bigint (2, 4, 8 bytes). No unsigned variant.Ids, counts, quantities. INT stops near 2.1 billion.
Exact decimalsDECIMAL(p,s) (NUMERIC is a synonym)numeric(p,s) (decimal is a synonym)Money, prices, tax rates. The shop uses DECIMAL(10,2).
Floating pointFLOAT, DOUBLEreal, double precisionMeasurements and science. Never money.
TextCHAR(n), VARCHAR(n), TINYTEXT to LONGTEXT; character set utf8mb4, the real four-byte UTF-8 and the default since 8.0. The bare word utf8 still means the deprecated three-byte utf8mb3, so write utf8mb4 in full.char(n), varchar(n), text (the everyday choice, no penalty); encoding is set once per databaseNames, addresses, descriptions. An "Incorrect string value" error on an emoji means a column or connection is still utf8mb3.
Dates and timesDATE, TIME, DATETIME, TIMESTAMP, YEAR; up to 6 fractional digitsdate, time, timestamp, timestamptz, intervalWhen things happened. DATETIME is stored as typed and runs to 9999; TIMESTAMP stops at 2038-01-19.
BooleansBOOLEAN is a synonym for TINYINT(1); stores 0 and 1, and nothing stops a 7boolean, a real type: true, false, NULLFlags such as is_active.
BinaryBINARY(n), VARBINARY(n), TINYBLOB to LONGBLOBbyteaHashes, UUIDs as 16 bytes. Large files belong on disk or object storage.
JSONJSON (validated, stored in binary form)json (text as typed) and jsonb (binary, indexable with GIN)Attributes that differ per row. See Views, CTEs, JSON and other powers.
EnumerationsENUM('pending','paid','cancelled'), defined inside the columnCREATE TYPE order_status AS ENUM (...), then use the typeShort fixed lists such as orders.status. Adding a value needs DDL in both engines, so many teams use a lookup table instead.
UUIDNo native type: BINARY(16) with UUID_TO_BIN() / BIN_TO_UUID(), or CHAR(36) with UUID()uuid (16 bytes) with gen_random_uuid(); uuidv7() in 18Ids generated outside the database.
ArraysNone. Use a child table or JSON.integer[], text[], any type's arrayTags and small lists. PostgreSQL only.
Spatial, vectorsGEOMETRY built in; VECTOR since 9.0 (distance search only in HeatWave)PostGIS and pgvector extensionsMaps and AI embeddings. See chapter 13.

Numbers and money

Integers are cheap; size is the only decision. Money must be DECIMAL, because most decimal fractions have no exact binary form and pennies drift:

Illustration · Both
-- MySQL: an e-notation literal is a DOUBLE
SELECT 0.1e0 + 0.2e0;                 -- 0.30000000000000004
-- PostgreSQL: cast to double precision
SELECT 0.1::float8 + 0.2::float8;     -- 0.30000000000000004
-- DECIMAL / numeric gives exactly 0.30

Dates and times

A MySQL TIMESTAMP is converted from the session's time zone to UTC on the way in and back on the way out. A DATETIME is stored as typed:

Illustration · MySQL
SET time_zone = '+04:00';            -- this session thinks in Dubai time
UPDATE orders
   SET paid_at = '2026-03-01 09:00:00', paid_local = '2026-03-01 09:00:00'
 WHERE id = 13;
SET time_zone = '+00:00';            -- same session, now UTC
SELECT paid_at, paid_local FROM orders WHERE id = 13;
paid_at (TIMESTAMP)paid_local (DATETIME)
2026-03-01 05:00:002026-03-01 09:00:00

PostgreSQL splits the same way: timestamptz stores UTC, timestamp ignores time zones. Neither has a 2038 cliff.

For you as a DBA

On Windows, MySQL has no system time zone database, so named zones like 'Asia/Dubai' fail until you load the time zone tables from dev.mysql.com. Until then only offsets like '+04:00' work.

Constraints: rules the engine enforces for you

A constraint is checked on every INSERT and UPDATE, whichever application sends the statement. Bad rows that get in stay until someone cleans them up.

ConstraintWhat it enforcesIn the shop
PRIMARY KEYIdentifies a row: unique, never NULL. In InnoDB it is also the table's physical order (see Indexes and query performance).customers.id, and the pair (order_id, product_id) in order_items.
NOT NULLThe column must have a value.customers.name. city is nullable, which is why Chloé's city is NULL.
UNIQUENo two rows share the value. Several NULLs are still allowed, because NULL equals nothing.An email column is the classic case.
DEFAULTThe value used when the insert leaves the column out.orders.status defaults to 'pending'.
CHECKA condition each row must satisfy. MySQL enforces it since 8.0.16; before that it was parsed and silently ignored.CHECK (price >= 0) on products, CHECK (qty > 0) on order items.
FOREIGN KEYValues must exist in another table's key, and it says what happens when the parent row goes away.orders.customer_id must be a real customers.id or NULL.
Illustration · MySQL
CREATE TABLE orders (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  customer_id INT NULL,                       -- NULL = guest checkout
  order_date  DATE NOT NULL,
  status      ENUM('pending','paid','cancelled') NOT NULL DEFAULT 'pending',
  total       DECIMAL(10,2) NOT NULL DEFAULT 0,
  CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id)
    REFERENCES customers(id) ON DELETE SET NULL
) ENGINE=InnoDB;

What a foreign key does when the parent disappears

The referential action says what happens to child rows when the parent is deleted.

ActionWhen the parent row is deletedIn the shop
CASCADEChild rows are deleted too (or updated, for ON UPDATE).order_items: delete order 10 and its two lines vanish with it.
SET NULLThe child column becomes NULL. The column must allow NULL.orders.customer_id: delete Amira and orders 10 and 11 survive as guest orders.
RESTRICT / NO ACTIONThe delete is refused with an error. This is what you get when you write nothing.order_items.product_id: the Mouse cannot be deleted while order lines reference it.
SET DEFAULTThe child column takes its DEFAULT value.PostgreSQL only. InnoDB rejects it.
Illustration · Both
DELETE FROM customers WHERE id = 1;          -- goodbye Amira
SELECT id, customer_id, status, total FROM orders WHERE id IN (10, 11);
idcustomer_idstatustotal
10NULLpaid65.00
11NULLpaid180.00

MySQL enforces foreign keys only on InnoDB tables; PostgreSQL can declare one DEFERRABLE so the check waits until COMMIT. Every foreign key column needs an index: InnoDB creates one, PostgreSQL does not.

Careful

ON DELETE CASCADE follows the chain all the way down, so deleting one customer can delete their whole history in one statement. Read the children's actions with SHOW CREATE TABLE first.

Generated ids

Most tables need an id nobody has to invent (MySQL vs PostgreSQL collects the renamed words).

EngineHow you declare itGetting the new value
MySQLAUTO_INCREMENT, a column attribute. Gaps are normal: a rolled-back insert still consumes a number.LAST_INSERT_ID() returns the value your session just got.
PostgreSQLGENERATED ALWAYS AS IDENTITY (explicit values rejected) or BY DEFAULT, backed by a sequence. SERIAL is the older shorthand you will still see.INSERT ... RETURNING id.
Illustration · Both
-- MySQL
CREATE TABLE customers (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL);
INSERT INTO customers (name) VALUES ('Eva');
SELECT LAST_INSERT_ID();                        -- 5
-- PostgreSQL
CREATE TABLE customers (id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL);
INSERT INTO customers (name) VALUES ('Eva') RETURNING id;   -- 5

What a UUID costs

A UUID can be generated anywhere without asking the database. It costs 16 bytes instead of 4, and a random version-4 value lands anywhere in the index, which in InnoDB is the table itself, so every insert touches a different page. Version 7 puts the timestamp in the leading bits, so new rows land at the end.

Normalization, shown on the shop

Normalization puts each fact in exactly one place, so it cannot disagree with itself. Most schemas, the shop included, stop at third normal form.

FormThe ruleThe violation to recognize
1NFOne atomic value per cell, no repeating groups.An items column holding "Keyboard x1, Mouse x1", or columns item1, item2. The fix is order_items, one row per line, so "how many mice" is a SUM (see Aggregation and window functions).
2NFWith a composite key, every column depends on the whole key.product_name in order_items depends on product_id alone; keep it in products and join. unit_price is fine: it depends on this order and this product together, a legitimate snapshot.
3NFNo column depends on another non-key column.A city column on orders copied from the customer. It depends on customer_id, so when Amira moves the two copies disagree.

orders.total breaks the rules on purpose: storing it means a list of orders needs no join. The cost is drift, so denormalize only for a measured slow query, and write down what keeps the copy in sync (application code, a trigger from chapter 12, or a nightly rebuild).

Remember

Every fact in one place: 1NF one value per cell, 2NF the whole key, 3NF nothing but the key. Denormalize only for a reason you can name.

Relationships and the ER diagram

Tables relate in three shapes (chapter 1 introduced them).

ShapeExampleHow you build it
One-to-oneA customer and a loyalty profile.A customer_profiles table whose primary key is also the foreign key to customers.id, or a UNIQUE foreign key column. Useful for splitting sensitive columns out of a busy table.
One-to-manyOne customer, many orders.The foreign key goes on the many side: orders.customer_id.
Many-to-manyAn order holds many products, and a product appears on many orders.A junction table with one foreign key to each side and usually a composite primary key of both. order_items carries its own facts too, qty and unit_price. Its key (order_id, product_id) lets a product appear only once per order; if duplicates are needed, give the table its own id.

Many-to-many is the shape people forget, so it helps to have a second example that is not an invoice. Films and actors: La La Land has many actors, and J.K. Simmons is in both La La Land and Whiplash. Neither fact fits in a column on either table, so a junction table holds the pairs, and the role belongs to the pairing rather than to the film or to the person.

Illustration · Both
CREATE TABLE cast_members (
  movie_id INT NOT NULL REFERENCES movies(id),
  actor_id INT NOT NULL REFERENCES actors(id),
  role     VARCHAR(100) NOT NULL,
  PRIMARY KEY (movie_id, actor_id)
);

An entity-relationship diagram draws these shapes in crow's-foot notation, which Workbench uses.

customers idPK name cityNULL allowed orders idPK customer_idFK, NULL allowed order_date status, total order_items order_idPK, FK product_idPK, FK qty unit_price products idPK name price places contains appears in many one exactly one optional (zero allowed)
Read each end next to the entity it touches: an order has zero or one customer, an item exactly one order and one product.

Naming, character sets and collations

Consistent names let you and the next DBA guess a column without opening the schema.

ConventionWhat to write
Casesnake_case, all lowercase
Table namesplural (the shop) or singular; pick one and never mix
Primary keyid
Foreign keysingular parent plus _id: customer_id
Avoidreserved words: order, group, and user in PostgreSQL
QuotingMySQL backticks, PostgreSQL double quotes; needing them is a smell

Two habits bite people who switch. PostgreSQL folds unquoted identifiers to lowercase, so a table created as "Customers" needs quotes forever. MySQL on Windows runs with lower_case_table_names=1, chosen only at initialization (see Install MySQL on Windows), so a Linux dump holding both Customers and customers will not load. Lowercase everything.

Character set and collation

A character set says which characters a column can store; a collation says how they compare and sort. MySQL 8 and later default to utf8mb4_0900_ai_ci: Unicode 9.0 rules, accent-insensitive, case-insensitive. PostgreSQL compares text exactly, so the same query gives different answers:

Illustration · Both
SELECT id, name FROM customers WHERE name = 'chloe';
MySQL, utf8mb4_0900_ai_ci
idname
3Chloé
PostgreSQL, default collation
idname
(0 rows)

For exact matching MySQL has utf8mb4_0900_as_cs and utf8mb4_bin; PostgreSQL has ILIKE, lower(), citext and ICU collations. Know which your server does before a UNIQUE constraint on email surprises you.

Changing tables that already have data

Both engines use ALTER TABLE; what differs is what happens underneath while the application keeps running. MySQL calls it online DDL and you steer it with ALGORITHM.

ALGORITHMWhat it doesOperations that use it
INSTANTMetadata only, milliseconds on any size of table. Capped at 255 row versions per table; the next one is refused (error 4092) until a real rebuild.Adding, dropping or renaming a column, changing a default, appending an ENUM value.
INPLACERebuilds inside InnoDB, allowing reads and usually writes.Adding an index.
COPYBuilds a new table and copies every row. Blocks writes and needs as much free disk again.Changing a column's type, always.
Illustration · MySQL
-- metadata only, milliseconds on any size of table
ALTER TABLE customers ADD COLUMN phone VARCHAR(30) NULL, ALGORITHM=INSTANT;
-- a type change copies the whole table
ALTER TABLE orders MODIFY total DECIMAL(12,2) NOT NULL DEFAULT 0, ALGORITHM=COPY;
-- asking for INSTANT where it is impossible fails fast instead of surprising you
ALTER TABLE orders MODIFY total DECIMAL(12,2), ALGORITHM=INSTANT;   -- ERROR 1845

Name the algorithm you expect, so the server refuses instead of silently copying. Every ALTER takes a metadata lock, so it queues behind any long-running query on that table and every new query queues behind it: the application appears to hang. A COPY reruns on each replica, so replication lag grows with it (see Backup, restore and replication).

PostgreSQL DDL is transactional, so an ALTER TABLE inside BEGIN ... ROLLBACK is undone completely. MySQL's DDL is atomic but commits the current transaction and cannot be rolled back (see Transactions and concurrency). Most PostgreSQL forms take an ACCESS EXCLUSIVE lock, so set lock_timeout and build indexes with CREATE INDEX CONCURRENTLY.

Schema changes should arrive as migrations: versioned SQL files applied in order by a tool that records what it ran, such as Flyway or Liquibase. Read the one in the pull request, estimate lock and disk, take a backup, run it in a window.

Ask the AI

Ask it to name the algorithm: "MySQL 8.4, InnoDB: for adding a nullable phone VARCHAR(30) to customers and widening orders.total to DECIMAL(12,2), say whether each ALTER is INSTANT, INPLACE or COPY, whether writes are blocked, and how much free disk a COPY needs on a 40 GB table."

Where to read more

Quick check

1. A developer proposes storing product prices in a DOUBLE column "because it is faster". Which type should you recommend, and why?

Money needs exact decimal arithmetic. FLOAT and DOUBLE store binary fractions, so 0.1 + 0.2 comes back as 0.30000000000000004 and totals drift. DECIMAL (numeric in PostgreSQL) is exact. Reread "Numbers and money".

2. In the shop schema, what happens to orders 10 and 11 when customer 1 (Amira) is deleted?

orders.customer_id is declared with ON DELETE SET NULL, so the orders survive as guest orders. CASCADE would delete them, RESTRICT would refuse the delete, and SET DEFAULT is not even accepted by InnoDB. Reread "What a foreign key does when the parent disappears".

3. Someone adds a city column to orders and fills it from the customer's record. Which rule does that break?

City depends on customer_id, which is not the key of orders: a transitive dependency, the 3NF violation. Unlike unit_price, the customer's current city is not a fact about the sale, so when Amira moves the copies disagree. Reread "Normalization, shown on the shop".

4. An order contains many products and a product appears on many orders. How is that modeled in a relational schema?

Many-to-many always needs a third table. order_items holds (order_id, product_id) as its primary key plus the facts about that pairing, qty and unit_price. A comma-separated list breaks 1NF, and a single foreign key can only express one-to-many. Reread "Relationships and the ER diagram".

5. The same query, SELECT name FROM customers WHERE name = 'amira', runs on a MySQL 8.4 server with default settings and on a PostgreSQL 18 server with default settings. What comes back?

MySQL's default collation utf8mb4_0900_ai_ci is case-insensitive (and accent-insensitive), so 'amira' matches 'Amira'. PostgreSQL compares text exactly by default, so you would need ILIKE, lower() or citext. Reread "Character set and collation".