MySQL deep-dive · Part 2 of 9. Next: Indexes and the B-tree.
Picking a column type feels like paperwork. You need to store a price, so you
reach for a number; you need a name, so you reach for VARCHAR(255) because
that’s what the last schema used. It works, the row goes in, you move on. But the
type you pick isn’t a formality — it’s a design decision that quietly settles
three things at once: whether your data can be wrong, how wide each row is, and
how much of your table lives in RAM. Get it wrong and the bill arrives later,
as a rounding bug in a finance report or a 3 a.m. page when an ID column runs out
of numbers.
We’ll build one table — an orders table — column by column, and justify every
choice. But first, the idea that ties the whole chapter together.
The mental model: narrower rows fit more in RAM
InnoDB doesn’t read your table a row at a time. It reads it a page at a time — a fixed 16KB block — and caches those pages in memory in the buffer pool. In Part 1 we saw that your rows physically live inside the clustered index, packed into these 16KB pages. That detail is the whole game here.
If a row is 200 bytes wide, roughly 80 of them fit in a page. If you bloat that same row to 400 bytes with oversized types, only 40 fit. Same data, half the rows per page — which means twice as many pages to hold the table, twice as many page reads to scan it, and half as much of your working set fitting in the buffer pool before MySQL has to evict pages and go back to disk.
So “pick the smallest type that fits” isn’t premature optimisation or penny- pinching. Row width is a multiplier on almost every read your database does. Narrower rows → more rows per page → fewer page reads → more of the hot data stays resident in RAM. Keep that lever in mind for every column below.
Decision 1: money is DECIMAL, never FLOAT
Start with the money column, because it’s where the wrong choice does the most
damage. You need a total_amount. The tempting types are FLOAT and DOUBLE —
they’re numeric, they hold decimals, done. Don’t.
FLOAT and DOUBLE are binary floating point. They store numbers in base 2,
and most decimal fractions have no exact base-2 representation — the same reason
0.1 + 0.2 doesn’t equal 0.3 in almost every language you’ve used. Store a
price as FLOAT and you’re not storing 19.99; you’re storing the nearest binary
approximation of it. One value looks fine when you print it. Sum ten thousand of
them and the tiny errors accumulate into a total that’s a cent or two off — and
now your ledger doesn’t reconcile.
DECIMAL(M,D) is exact fixed-point arithmetic. M is the total number of
digits, D is how many sit after the decimal point. DECIMAL(10,2) holds up to
ten digits, two of them cents, so anything up to 99,999,999.99 — exactly, with
no drift, ever.
total_amount DECIMAL(10,2) NOT NULL
Decision 2: size your integers (and reach for UNSIGNED)
Every orders table needs a primary key. The lazy choice is INT, and it’s the
one that has taken down real systems.
MySQL’s integer types differ only in how many bytes they occupy and therefore the range they cover:
TINYINT— 1 byte, signed max 127INT— 4 bytes, ~2.1 billion signedBIGINT— 8 bytes, ~9.2 quintillion signed
Here’s the classic outage. You define id INT AUTO_INCREMENT PRIMARY KEY. A
signed INT tops out at 2,147,483,647. For a busy, growing table that’s not
a hypothetical ceiling — it’s a date on the calendar. The moment the counter
reaches it, the next INSERT fails with a duplicate-key error and writes to the
table simply stop. Nothing warned you; the column just ran out of numbers.
Two fixes, both in the type. First, UNSIGNED — an integer that can’t go negative
spends its whole range on positive values, doubling an INT’s ceiling to ~4.2
billion. But for a primary key you expect to grow, go straight to BIGINT UNSIGNED. Eight bytes buys you a range you will genuinely never exhaust, and
it’s the standard choice for a growing PK.
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY
That doesn’t mean every integer should be BIGINT. A status code with five
possible values, or a small quantity, fits in a TINYINT — and remember Decision
1’s lever: on a table with a hundred million rows, spending 8 bytes where 1 would
do is 700MB of pure padding, dragged through the buffer pool on every scan. Size
each integer to its actual domain: tight for bounded values, BIGINT UNSIGNED
for identifiers that only ever climb.
Decision 3: VARCHAR, CHAR, or TEXT
Now the strings — a customer_email, a currency_code, maybe a notes field.
Three types, three genuinely different shapes.
VARCHAR(n) is variable-length and stored inline with the row. The n is a
maximum, not a reservation: VARCHAR(255) holding "hi" costs about the length
of "hi", not 255 bytes on disk. This is why people assume n is free and
default everything to VARCHAR(255). It isn’t free — see the misconception below
— but on disk it’s close, so VARCHAR is the right default for genuinely
variable text like an email address.
CHAR(n) is fixed-length: it always occupies n characters, padding short
values. That’s wasteful for variable data, but it’s exactly right when the string
is genuinely always the same length — a two-letter country code (CHAR(2)), a
fixed-width SHA-256 hash (CHAR(64)). For those, fixed-length is honest and can
be marginally more efficient than paying VARCHAR’s length overhead.
TEXT (and BLOB) is for large content — article bodies, JSON dumps, long
notes. InnoDB stores it partly off-page: a pointer plus a short prefix live
inline with the row, and the bulk sits elsewhere. That has real consequences: a
TEXT column can’t have a DEFAULT, and you can only build a prefix
index on it (the first n characters), never the full value. So TEXT is
wrong for a short field — reaching for it “just in case” costs you defaults and
clean indexing for no benefit.
customer_email VARCHAR(320) NOT NULL, -- variable text, sized to the real max
currency_code CHAR(3) NOT NULL, -- always exactly 3 chars (ISO 4217)
notes TEXT NULL -- genuinely large, off-page, rarely read
Decision 4: DATETIME vs TIMESTAMP (and the 2038 trap)
Every order has a created_at. MySQL offers two temporal types that look
interchangeable and behave very differently.
DATETIME takes 5 bytes, covers years 1000 to 9999, and stores exactly
what you give it — no timezone conversion. What you write is what you read
back, byte for byte.
TIMESTAMP takes 4 bytes but carries two catches. First, its range ends at
2038-01-19 — the famous epoch-overflow problem, because it’s stored as a
32-bit count of seconds since 1970. Any timestamp you try to store past that date
overflows. Second, TIMESTAMP auto-converts: it takes your value as the
session’s timezone, stores it as UTC, and converts back to the session timezone on
read. Convenient until two app servers with different session timezones disagree
about what a stored value means.
The common modern practice sidesteps both: use DATETIME, store UTC
explicitly, and let the application own all timezone conversion. You give up
TIMESTAMP’s automatic UTC handling in exchange for values that are unambiguous,
survive past 2038, and mean the same thing no matter which server reads them.
created_at DATETIME NOT NULL DEFAULT (UTC_TIMESTAMP())
When ENUM earns its place
Orders have a status: 'pending', 'paid', 'shipped', 'cancelled'. You
could store that as a VARCHAR, or as an ENUM.
ENUM is appealing: it’s stored compactly as a small integer under the hood
(not the full string), it’s self-documenting right there in the schema, and it
rejects any value not in the list — validation for free. The catches are real,
though. Adding a value means an ALTER TABLE. It sorts by internal index
order, not alphabetically — ORDER BY status follows declaration order, which
surprises people. And it’s non-portable, so migrating to another database is
friction.
The heuristic: ENUM is fine for a small, rarely-changing set — an order
status, a yes/no/maybe. The moment the list evolves regularly, or other tables
need to reference it, promote it to a proper lookup table with a foreign key.
status ENUM('pending','paid','shipped','cancelled') NOT NULL DEFAULT 'pending'
The finished table
Every choice above, assembled and justified:
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, -- growing PK, never runs out
customer_email VARCHAR(320) NOT NULL, -- variable text, real max
currency_code CHAR(3) NOT NULL, -- always exactly 3 (ISO 4217)
total_amount DECIMAL(10,2) NOT NULL, -- exact money, no drift
status ENUM('pending','paid','shipped','cancelled')
NOT NULL DEFAULT 'pending', -- small, rarely-changing set
notes TEXT NULL, -- large, off-page, rarely read
created_at DATETIME NOT NULL DEFAULT (UTC_TIMESTAMP()) -- UTC, survives 2038
) ENGINE=InnoDB;
Nothing here is VARCHAR(255) by reflex. Each type is the smallest one that fits
its domain, which keeps the row narrow, keeps more of the table in the buffer
pool, and — just as important — makes wrong data impossible to insert in the first
place.
The misconception to bust: “VARCHAR(255) is the safe default”
The most common schema habit is to make every string VARCHAR(255) on the theory
that n is a free maximum, so bigger is safer. Both halves are wrong.
Bigger isn’t free. As we saw in Decision 3, an oversized VARCHAR inflates
in-memory temp tables (allocated at full declared width), pushes sorts and
GROUP BYs to spill to disk, and bloats every index on the column. The cost is
just invisible until a query gets slow.
And “safe” is backwards. A type isn’t only storage — it’s a constraint. A
column sized to its real domain rejects garbage: CHAR(3) for a currency code
won’t accept a 40-character string; TINYINT UNSIGNED won’t accept a negative
quantity; DECIMAL(10,2) won’t silently drift. Oversizing everything throws that
validation away and lets bad data walk straight in. The safe default is the
opposite instinct: pick the smallest type that fits the domain, and let the
type do the checking for you.
If you can say why money is
DECIMAL, why a growing PK isBIGINT UNSIGNED, and whyVARCHAR(255)isn’t a free lunch, you’re no longer filling in paperwork — you’re designing the table.