The short version
- A type is a promise. Pick the smallest one that fits, and
DECIMALfor 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,INPLACEorCOPYdecides 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.
| Family | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 | Use it for |
|---|---|---|---|
| Whole numbers | TINYINT, 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 decimals | DECIMAL(p,s) (NUMERIC is a synonym) | numeric(p,s) (decimal is a synonym) | Money, prices, tax rates. The shop uses DECIMAL(10,2). |
| Floating point | FLOAT, DOUBLE | real, double precision | Measurements and science. Never money. |
| Text | CHAR(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 database | Names, addresses, descriptions. An "Incorrect string value" error on an emoji means a column or connection is still utf8mb3. |
| Dates and times | DATE, TIME, DATETIME, TIMESTAMP, YEAR; up to 6 fractional digits | date, time, timestamp, timestamptz, interval | When things happened. DATETIME is stored as typed and runs to 9999; TIMESTAMP stops at 2038-01-19. |
| Booleans | BOOLEAN is a synonym for TINYINT(1); stores 0 and 1, and nothing stops a 7 | boolean, a real type: true, false, NULL | Flags such as is_active. |
| Binary | BINARY(n), VARBINARY(n), TINYBLOB to LONGBLOB | bytea | Hashes, UUIDs as 16 bytes. Large files belong on disk or object storage. |
| JSON | JSON (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. |
| Enumerations | ENUM('pending','paid','cancelled'), defined inside the column | CREATE TYPE order_status AS ENUM (...), then use the type | Short fixed lists such as orders.status. Adding a value needs DDL in both engines, so many teams use a lookup table instead. |
| UUID | No 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 18 | Ids generated outside the database. |
| Arrays | None. Use a child table or JSON. | integer[], text[], any type's array | Tags and small lists. PostgreSQL only. |
| Spatial, vectors | GEOMETRY built in; VECTOR since 9.0 (distance search only in HeatWave) | PostGIS and pgvector extensions | Maps 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:
-- 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:
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:00 | 2026-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.
| Constraint | What it enforces | In the shop |
|---|---|---|
PRIMARY KEY | Identifies 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 NULL | The column must have a value. | customers.name. city is nullable, which is why Chloé's city is NULL. |
UNIQUE | No two rows share the value. Several NULLs are still allowed, because NULL equals nothing. | An email column is the classic case. |
DEFAULT | The value used when the insert leaves the column out. | orders.status defaults to 'pending'. |
CHECK | A 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 KEY | Values 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. |
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.
| Action | When the parent row is deleted | In the shop |
|---|---|---|
CASCADE | Child rows are deleted too (or updated, for ON UPDATE). | order_items: delete order 10 and its two lines vanish with it. |
SET NULL | The 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 ACTION | The 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 DEFAULT | The child column takes its DEFAULT value. | PostgreSQL only. InnoDB rejects it. |
DELETE FROM customers WHERE id = 1; -- goodbye Amira
SELECT id, customer_id, status, total FROM orders WHERE id IN (10, 11);
| id | customer_id | status | total |
|---|---|---|---|
| 10 | NULL | paid | 65.00 |
| 11 | NULL | paid | 180.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).
| Engine | How you declare it | Getting the new value |
|---|---|---|
| MySQL | AUTO_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. |
| PostgreSQL | GENERATED 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. |
-- 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.
| Form | The rule | The violation to recognize |
|---|---|---|
| 1NF | One 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). |
| 2NF | With 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. |
| 3NF | No 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).
| Shape | Example | How you build it |
|---|---|---|
| One-to-one | A 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-many | One customer, many orders. | The foreign key goes on the many side: orders.customer_id. |
| Many-to-many | An 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.
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.
Naming, character sets and collations
Consistent names let you and the next DBA guess a column without opening the schema.
| Convention | What to write |
|---|---|
| Case | snake_case, all lowercase |
| Table names | plural (the shop) or singular; pick one and never mix |
| Primary key | id |
| Foreign key | singular parent plus _id: customer_id |
| Avoid | reserved words: order, group, and user in PostgreSQL |
| Quoting | MySQL 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:
SELECT id, name FROM customers WHERE name = 'chloe';
| id | name |
|---|---|
| 3 | Chloé |
| id | name |
|---|---|
| (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.
| ALGORITHM | What it does | Operations that use it |
|---|---|---|
INSTANT | Metadata 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. |
INPLACE | Rebuilds inside InnoDB, allowing reads and usually writes. | Adding an index. |
COPY | Builds a new table and copies every row. Blocks writes and needs as much free disk again. | Changing a column's type, always. |
-- 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
- MySQL 8.4: Data Types
Every type, size and range.
- PostgreSQL: Data Types
The same list, plus arrays and jsonb.
- MySQL: FOREIGN KEY Constraints
Referential actions in detail.
- MySQL: Online DDL Operations
Which ALTER is which algorithm. Bookmark it.
- PostgreSQL: ALTER TABLE
Which forms rewrite and lock.