← WritingDatabase · Jan 2026 · 8 min

The MySQL schema I keep reaching for

Twelve projects in, the first hour of every new backend looks the same. These are the schema defaults I no longer debate with myself.

Every table gets the same skeleton: an auto-increment bigint primary key, a public_id (short, URL-safe, randomly generated) for anything exposed to a client, created_at and updated_at timestamps maintained by the database, and a deleted_at nullable timestamp where soft-delete is a business requirement — and only there. Soft-deleting everything by default is how you end up with WHERE deleted_at IS NULL stapled to every query for the rest of the project's life.

Money is integers in the smallest currency unit, always. Egyptian pounds in piastres, dollars in cents. Every floating-point money bug I have ever been hired to fix was the same bug. Currency lives in its own column even when the project 'will only ever use one currency' — that sentence has been false in three of my last five commerce builds.

Status fields are VARCHAR with a CHECK constraint, not ENUM. Altering a MySQL ENUM rewrites the table; adding a value to a CHECK constraint is instant. Pair it with a TypeScript union on the application side and you get the same safety with none of the migration pain.

Indexes: I add them for every foreign key and every column that appears in a WHERE clause of a query that runs on a page load — and then I stop. Speculative indexes cost writes and buffer pool space, and EXPLAIN on real traffic beats my intuition every time. The one exception is covering indexes for the two or three hot list queries every commerce app has: orders by customer, products by category.

None of this is clever, which is the point. The schema is the part of the system you change least and regret most — boring defaults that survive contact with the fifth feature request are worth more than elegance on day one.