Schema mistakes that are expensive to undo
Most schema mistakes are cheap. You add a column, drop a column, rename something during a quiet hour, and nobody remembers by Friday.
A few are not. They are expensive because fixing them means rewriting every row, or changing every query, or admitting that data you needed was never recorded. This lesson is those, in rough order of how much they cost.
The theme worth noticing: the expensive mistakes are the ones that destroy information. You can always add a column. You cannot recover a fact you never stored.
1. float for money
price numeric NOT NULL, -- fine
price_paise int NOT NULL, -- fine
price double precision -- a bug
0.1 + 0.2 is not 0.3 in binary floating point. Proved in PostgreSQL:
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8; -- f
SELECT (0.1::float8 + 0.2::float8)::text; -- 0.30000000000000004
Be precise about the damage, because this is usually described badly. Summing ₹93.30 ten thousand times:
float_sum | numeric_sum
-------------------+-------------
933000.0000001579 | 933000.00
The error is 0.00000016 — a tiny fraction of a paisa, not "a few rupees". Anybody telling you floating point will visibly mangle your totals at this scale is overstating it, and I had written exactly that before I measured it.
The real damage is different and worse:
-
Equality stops working, which is the one that costs you:
paid | due | settled ---------------------+-----+--------- 0.30000000000000004 | 0.3 | fWHERE paid = amount_dueis how you decide whether an invoice is settled. A customer who has paid in full shows as owing money, and staring at the two figures on a screen never explains it, because rounded to two places they are identical. -
The digits leak out.
933000.0000001579reaches a report, an export, an API response. -
The error compounds with scale and with mixed magnitudes. Add a ₹1 item to a ₹10,00,000 total repeatedly and it grows.
Why it is expensive: by the time somebody notices, the affected numbers are in invoices, reconciliation reports and customer emails. Changing the column type is the easy part. Working out which historical comparisons were wrong is not.
The fix: numeric(12,2), or integer paise with the unit in the column name. Never float
for anything you add up and show to a person.
float is right for genuinely measured, approximate quantities — a temperature, a latitude, a
model score.
2. Storing local time, or timestamp without a zone
created_at timestamp NOT NULL DEFAULT now(), -- wrong
created_at timestamptz NOT NULL DEFAULT now(), -- right
timestamp (without time zone) stores the digits you gave it and nothing about what they mean.
Two rows written at the same instant, one from a session in Asia/Kolkata and one in
Europe/London:
a | b
----------------------------+-------------------------------
2026-09-27 22:17:03.659288 | 2026-09-27 17:47:03.659288+01
2026-09-27 17:47:03.660456 | 2026-09-27 17:47:03.660456+01
Column a is timestamp, column b is timestamptz. The two a values differ by four and a
half hours for two events a millisecond apart, and nothing in the row tells you why. The two
b values are correctly one millisecond apart.
Why it is expensive: the data is genuinely ambiguous. Converting later means guessing what zone each row was written in, and if your servers moved region or you had one client in another country, the information is gone.
ALTER TABLE t ALTER COLUMN created_at TYPE timestamptz
USING created_at AT TIME ZONE 'Asia/Kolkata';
That is a guess, applied to every row. If it is wrong for some of them, you cannot tell which.
The fix: timestamptz everywhere, always. It stores an instant; the client's zone decides how
it is displayed. Use plain date for a genuine calendar date — a birthday, a due date — where
there is no instant involved.
Note that PostgreSQL's timestamptz does not store a zone; it stores UTC and converts on the
way out. That is exactly what you want.
3. int for a primary key that will grow
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY -- max 2,147,483,647
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY -- max 9.2 quintillion
Why it is expensive: when you run out, every INSERT fails immediately:
ERROR: nextval: reached maximum value of sequence "ovf_id_seq" (2147483647)
The fix is
int → bigint, which rewrites the table and every index referencing it, holding
ACCESS EXCLUSIVE. On a table big enough to have hit the limit that is hours of downtime, and
every foreign key pointing at it needs the same change. It has taken down well-known services.
Note that you hit it at 2.1 billion sequence values, not rows — and a sequence is not reused when a transaction rolls back, so a table with a few hundred million rows and a lot of failed inserts can get there.
The fix: bigint from the start. Eight bytes instead of four, which is nothing.
4. No foreign keys
"We enforce it in the application." Every application eventually has a second writer — an admin
script, a data fix, a new service, somebody in psql.
Why it is expensive: you end up with orphans — loans rows whose member_id points at
nothing. Adding the constraint later fails:
ERROR: insert or update on table "loans" violates foreign key constraint "loans_member_id_fkey"
DETAIL: Key (member_id)=(999) is not present in table "members".
Note that it names one offending key. You fix that row, re-run, and it names the next one.
So first you have to decide what to do with every orphan, one at a time, with no information about what it was meant to reference. That is a data-archaeology project.
The fix: declare them. Add them NOT VALID then VALIDATE if the table is large, and index
the referencing column.
5. Booleans where you needed dates
is_active boolean, -- when did it stop being active?
is_paid boolean, -- when was it paid?
is_deleted boolean -- when, and by whom?
Why it is expensive: the first time somebody asks "how long between order and payment", or
"how many were cancelled last month", the answer is we did not record that. Adding
paid_at gives you the answer from that day forward and nothing for the past.
The fix: store the timestamp. paid_at timestamptz — NULL means not paid, so you get the
boolean for free:
WHERE paid_at IS NOT NULL
One column, strictly more information. This is the cheapest of these fixes to apply and the most commonly skipped, which makes it the best value in the lesson.
6. One table for things that are not the same thing
CREATE TABLE items (
id bigint PRIMARY KEY,
type text, -- 'product' | 'service' | 'subscription'
name text,
price int,
weight_grams int, -- products only
duration_minutes int, -- services only
billing_period text -- subscriptions only
);
Every column is nullable, so the database can no longer enforce anything. A product with a
billing_period is nonsense the schema permits, and every query carries
WHERE type = '…' that you will one day forget.
Why it is expensive: by the time it hurts, the table has millions of rows and hundreds of queries. Splitting it means rewriting all of them.
The fix: separate tables, or — when they genuinely share behaviour — a shared table with the
common columns plus one table per subtype holding the specific ones, joined on the same id. And
CHECK constraints that make the nonsense impossible:
CHECK ((type = 'product' AND weight_grams IS NOT NULL AND duration_minutes IS NULL)
OR (type = 'service' AND duration_minutes IS NOT NULL AND weight_grams IS NULL))
Ugly, and much better than nothing if you are stuck with the shape.
The test: do the two things have the same columns, and do you ever query them together? If no to both, two tables.
7. jsonb as a way of avoiding a decision
CREATE TABLE events (id bigint PRIMARY KEY, data jsonb NOT NULL);
jsonb is excellent for genuinely variable data — third-party webhook payloads, user-defined
fields, an audit snapshot.
It is a poor place for fields you know you have. Inside jsonb you lose NOT NULL, foreign
keys, type checking, and readable names; every query gets data->>'customer_id' with a cast; and
'42' and 42 are different values that both look right.
Why it is expensive: the data drifts. After two years the same logical field is
customer_id, customerId and customer in different rows, with two of them as strings. Nothing
rejected any of it. Extracting it into columns means writing a parser and deciding what to do
with the rows that do not fit.
The fix: promote the fields you know to real columns and keep jsonb for the rest. This is
the best of both, and is what a mature schema usually looks like:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
kind text NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'
);
8. Soft delete everywhere, thoughtlessly
deleted_at timestamptz -- NULL = not deleted
Reasonable for things people restore — a document, an account. The cost is that every query
everywhere must remember WHERE deleted_at IS NULL, and one that forgets is a bug that shows
deleted data to users. Unique constraints stop working, because two "deleted" rows may
legitimately share a value.
The fix, when you need it: partial unique indexes,
CREATE UNIQUE INDEX ON customers (phone) WHERE deleted_at IS NULL;
and a view that hides the deleted rows so the default is safe:
CREATE VIEW active_customers AS SELECT * FROM customers WHERE deleted_at IS NULL;
Then only soft-delete what somebody will actually restore. For an audit trail, an append-only history table is a better tool.
9. Not recording who and when
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by bigint REFERENCES users(id)
Why it is expensive: the first serious bug report, or the first dispute about who changed a price, has no answer. And unlike most things on this list you cannot backfill it at all — the information never existed.
The fix: put created_at and updated_at on every table from the start. They cost 16 bytes
and repay themselves the first time you debug anything.
10. Meaning inside a string
order_ref text -- 'PUN-2026-00123'
Encoding city, year and sequence in one text column means parsing with substring to query by
any of them, no index can help, and the day somebody uses a four-letter city code every parser
breaks.
The fix: store the parts as columns and generate the display reference:
city_code text NOT NULL,
year int NOT NULL,
seq int NOT NULL,
order_ref text GENERATED ALWAYS AS (city_code || '-' || year || '-' || lpad(seq::text, 5, '0')) STORED
Queryable parts, one authoritative format, and the string cannot disagree with the columns.
The ranking, and what it tells you
| Mistake | Cost to fix later |
|---|---|
float for money |
Data is wrong; may need customer contact |
| Local time | Information genuinely lost |
| Boolean instead of a date | History genuinely lost |
No created_at |
History genuinely lost |
int primary key |
Hours of downtime, urgent |
| No foreign keys | Data archaeology on every orphan |
jsonb for known fields |
Write a parser, decide about bad rows |
| One table for several things | Rewrite every query |
| Soft delete everywhere | A long tail of leak bugs |
| Meaning in a string | Rewrite the parsers |
The four worst share a property: the fix does not recover the past. Everything else is laborious; those are irreversible.
The five-minute checklist
Before creating any table:
-
bigintprimary key. -
timestamptz, nevertimestamp.dateonly for genuine calendar dates. - Money as
numericor integer minor units, with the unit in the name. -
created_atandupdated_at. - Every foreign key declared, and indexed.
-
NOT NULLon everything that should never be missing — the default should beNOT NULL, with nullability the exception you justify. - Every boolean interrogated: should it be a timestamp?
-
CHECKconstraints for the rules you can state. -
jsonbonly for genuinely variable data. - Does this table describe one kind of thing?
Ten items, two minutes, and it prevents almost everything in this lesson.
Check your work
Why float for money is expensive. The wrong numbers are already in invoices and reports.
Why float8 fails. Binary floating point cannot represent decimal fractions exactly —
0.1 + 0.2 gives 0.30000000000000004.
The real damage, stated accurately. Not large arithmetic errors — measured at 1.6×10⁻⁷ over
10,000 additions — but broken equality (paid = amount_due is false for a settled invoice) and
digits leaking into reports.
When float is right. Genuinely approximate measured quantities.
Why timestamp without a zone is expensive. The rows are ambiguous, and converting later is
a guess you cannot verify.
What timestamptz actually stores. An instant as UTC, converted on output.
Why an int primary key is urgent when it fails. Every insert fails, and the fix rewrites
the table and its indexes.
Why you can exhaust it below 2.1 billion rows. Sequence values are consumed by rolled-back transactions.
Why adding foreign keys later is hard. Existing orphans must each be resolved with no information about their intent.
Why a boolean is worse than a timestamp. NULL gives you the boolean for free, and the
timestamp answers questions the boolean cannot — and never retroactively.
What a multi-purpose table costs. Every column nullable, so no constraint can be enforced.
The test for splitting it. Same columns, and ever queried together?
Why jsonb for known fields drifts. Nothing rejects a misspelled key or a wrong type.
The mature pattern. Known fields as columns, jsonb for the remainder.
Two costs of soft delete. Every query must filter, and unique constraints break.
Two mitigations. Partial unique indexes, and a view that excludes deleted rows.
Why created_at cannot be backfilled. The information never existed.
The four irreversible mistakes. Local time, boolean-instead-of-date, no created_at, and
float money.
Practice
- Run
SELECT (0.1::float8 + 0.2::float8)::textand then thenumericequivalent. - Sum 10,000 rows of
93.30asfloat8and asnumeric. Compare with 933000. - Insert the same instant as
timestampandtimestamptzfrom two differentTimeZonesettings and compare. - Write the
USING ... AT TIME ZONEconversion and explain what it assumes. - Create a table with an
intidentity,ALTER SEQUENCE ... RESTART WITH 2147483646, and insert three rows. Read the error. - Time an
int→bigintconversion on a million-row table with an index. - Create two tables without a foreign key, insert an orphan, then try to add the constraint. Read the error.
- Write the query to find every orphan, then decide what you would do with each.
- Take a table with
is_paid booleanand write the query for "average days from order to payment". Explain why you cannot. - Replace it with
paid_atand write the query. - Build the multi-purpose
itemstable, insert a product with abilling_period, and note that nothing stopped you. Add theCHECKand try again. - Store an event as
jsonbthree times withcustomer_id,customerIdand"42". Write the query that has to cope with all three. - Add a partial unique index on a soft-deleted table and confirm two deleted rows can share a phone number.
- Write a
SELECTthat forgetsWHERE deleted_at IS NULLand count the rows it wrongly returns. - Build the generated
order_refcolumn and try to make the string disagree with its parts. - Run the ten-item checklist against the largest table in a project you work on. Write down what you find.
Official documentation
- PostgreSQL — Numeric types — The floating-point warning, and why
numericis slower but exact. - PostgreSQL — Date/time types — What
timestamptzstores, and theAT TIME ZONEconversion. - PostgreSQL — Identity columns —
GENERATED ALWAYS AS IDENTITY, and sequence behaviour on rollback. - PostgreSQL — Constraints —
CHECK, foreign keys andNOT VALID. - PostgreSQL — JSON types — And the "Designing JSON documents" advice in the JSON functions chapter.
- PostgreSQL — Generated columns — For the
order_refpattern.
Next module: connecting a real program to a real database.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship