12 Types are a contract
This error has woken up more than one on-call engineer:
That’s the largest value a 4-byte integer holds. When an integer id’s sequence reaches it, every insert fails. Not some: every one. The fix rewrites the table, its indexes, and every table whose foreign key points at it. The decision behind it took two seconds, years earlier, in a migration nobody remembers.
Types are the cheapest decision you make and the most expensive to change. A type is a contract: how many bytes every row spends, which values can exist at all, and how comparison and arithmetic behave. For text, the collation is part of that contract, and it’s the one part that can change under you without a migration.
Every byte is paid per row
A query costs roughly the pages it touches, and a page holds 8 KB. Shrink a row from 125 bytes to 100 and every scan touches about 20 percent fewer pages. pg_column_size() tells you what any value costs, and alignment can pad a narrow column out to 8 bytes (Column order is free space).
Keep it in proportion: correctness first, then the freedom to change your mind, then bytes.
Integers run out sooner than you think
| Type | Bytes | Range |
|---|---|---|
smallint | 2 | about plus or minus 32 thousand |
integer | 4 | about plus or minus 2.1 billion |
bigint | 8 | about plus or minus 9.2 quintillion |
Ids are bigint
Use bigint for every primary key and every column that references one. Rolled-back inserts, on conflict attempts, and discarded bulk loads all consume sequence values, and the 4 bytes you’d save are small next to the 23-byte row header. Changing integer to bigint later rewrites every referencing table, a multi-week project on a large system (The int to bigint problem).
The running example’s events table holds 2,000,000 rows, leaving 2,145,483,647 values of integer headroom. That feels safe until you do the arithmetic: a pipeline writing 1,000 rows a second, counting retries, uses it up in about 25 days.
When unsure, pick the bigger type. Nobody has been paged because a column was too wide. The one exception to bigint is a key that something outside the database must mint first, which is a uuid (The database can’t always mint the id).
Money is numeric, never float
Floating-point types (real, double precision) store binary approximations, and most decimal fractions have no exact binary form. Add up a million dimes:
Prove it · lab 11-types
That stray 0.00000133288 is how a one-cent discrepancy in an invoice reconciliation starts. Use numeric(p, s) with the currency’s minor-unit scale, or bigint cents, and either way a currency column. Floating point is for sensor readings, scores, and coordinates, where a tiny relative error is fine.
Deep dive: Where numeric rounds, and where it doesn’t
numeric(12, 2) rounds on input, but the declared scale applies only when a value is stored or cast, not to arithmetic:
You decide when and how to round, which is right for money: banker’s rounding, per line or per invoice, is a business rule. numeric is variable width (pg_column_size gives 8 bytes for 1, 14 for 123456789.12) and slower than integer math, which matters only when you sum hundreds of millions of rows. There, integer cents win.
Use text, and put the length in a check
text and varchar(n) are stored identically: 'hello' takes 9 bytes as either. char(n) pads, and takes 54 bytes as char(50). varchar(n) is text with a length check bolted onto the type, and that bolt is the problem. Length rules change, and a rule written into the type reads like a storage decision when it’s really validation. Say it as a constraint:
A check is named, can be swapped without touching the column type, can be added not valid and validated later without a long lock (Add a CHECK NOT VALID, then validate it), and can say things a length can’t, like “not empty”. varchar(255) is a MySQL habit with MySQL reasons (index prefix limits) that don’t exist here.
Deep dive: char(n) pads, then forgets the padding
Trailing spaces are insignificant in comparisons and stripped on concatenation. Even ISO country codes are text with check (length(code) = 2). To be fair to varchar(n): raising n has been metadata-only for years. Lowering it still means work.
Collations decide what equal means
A collation is the set of rules for comparing and sorting text. It decides whether 'a' < 'B', where '_draft' sorts, and whether 'Ada@Example.com' equals 'ada@example.com'. Every text index is built in collation order, so the collation is baked into your indexes as firmly as the data.
The same six words, sorted under four collations on one server:
(Turned on its side to fit.) C and builtin sort by code point: capitals first, é after z. glibc’s en_US all but ignores punctuation; ICU puts it first. Four collations, three orders: same data, three answers to “what comes next”.
Pick a collation provider whose order never changes
| Provider | Rules come from | Changes when |
|---|---|---|
| libc | the OS C library (glibc) | you upgrade the OS |
| ICU | the ICU library | you upgrade ICU |
| builtin | Postgres itself | never, for sort order |
Unless someone chose otherwise, your database uses libc, because initdb copies the OS locale (this one: en_US.utf8 on glibc 2.41). The builtin provider arrived in PostgreSQL 17. Our default for a new database is builtin C.UTF-8, code-point order that never changes:
Byte comparisons, a sort order that survives every OS upgrade, and a plain B-tree that serves like 'abc%' (Prefix search needs the right operator class). Where people read the order, ask for it: order by name collate "unicode". Linguistic sorting where you need it, stability everywhere else.
Deep dive: Builtin sorts like C, but knows a capital letter
PostgreSQL 18 adds pg_unicode_fast, which also does full Unicode case mapping (ß folds to ss). The lab checks all four orders from the sort demo.
Prove it · lab 11-types
For emails, make lower() the unique key
Every collation above is deterministic: strings are equal only if their bytes are. So where email = 'USER1@EXAMPLE.COM' finds nothing when user1@example.com exists, and unique (account_id, email) happily accepts Ada@x.com next to ada@x.com: two accounts the user thinks are one. Four tools fix it:
lower()plus a unique expression index. Works everywhere; queries must saylower(email)exactly as the index does (An index stores exactly what you gave it).citext, a contrib type that callslower()for you.- A nondeterministic ICU collation (PostgreSQL 12+), where different bytes can compare equal. The most correct, but
likeworks on it only from PostgreSQL 18, and never through a B-tree. casefold()(PostgreSQL 18+), the Unicode-correctlower(). Under an ICU orpg_unicode_fastcollation,STRASSEandStraßematch; under libc or builtinC.UTF-8,ßstaysß.
For emails and usernames, lower() with a unique expression index is our default: boring and predictable.
MySQL’s default collation ignores case and accents; Postgres’s deterministic ones don’t. Pick a tool per column before the import (Strings compare exactly).
Deep dive: A case-insensitive collation, and the scan it costs you
ks-level2 ignores case; ks-level1 ignores accents too. Equality uses the index, 7 pages for the uppercase lookup. like works only from PostgreSQL 18, and then as a scan:
All 834 pages for one row. Regular expressions and ilike fail outright.
Prove it · lab 11-types
An OS upgrade can corrupt your indexes
A B-tree on text keeps keys in the order the collation said was right on the day each was inserted. Change the rules and a search walks the old order by the new rules, down the wrong branch. Nothing errors. Rows just stop being found.
This is not hypothetical. glibc 2.28 (2018) rewrote its collation data for most locales, and the OS releases that shipped it (Debian 10, RHEL 8, Ubuntu 18.10 among them) could reorder the keys of any libc-collated index (other than C or POSIX) upgraded in place or restored as a physical copy. A physical replica on a newer OS image has the same problem.
We simulated it on a scratch database. The primary key stopped finding 'Zebra', which was still in the table. Then it accepted a second 'Zebra'. Then reindex, the usual fix, failed on that duplicate. Missed rows first, then duplicates that break the repair.
Deep dive: Watch an index lose a row it still holds
Point a collation at different rules in the catalog, which is what an OS upgrade looks like from Postgres’s side (scratch database only):
In a new session, name = 'Zebra' returns nothing and name >= 'Tag1' and name < 'Tag2' counts 0; with index scans off, Zebra and 1,111 rows. Then:
Prove it · lab 11-types
Detect it before it bites
Postgres records the library’s version with each collation (and, since PostgreSQL 15, the database default) and warns on use when it changes. Your application won’t show that warning to anyone, so ask after every OS or image change:
Treat any mismatch as “rebuild before trusting”. Fix duplicates, reindex (concurrently) every text index under the collation, check with amcheck (which verifies each index’s order under today’s rules), then record the new version with alter collation ... refresh version.
refresh version fixes nothing. It only silences the warning. Treat OS upgrades, base image bumps, and new replica images as collation events, or use the builtin provider and stop worrying.
Deep dive: The warning, the exposed indexes, and amcheck
The session that uses a stale collation gets:
Prove it · lab 11-types
For the database default, compare datcollversion with pg_database_collation_actual_version(oid). Since PostgreSQL 15 the database warns on every new connection; we edited a scratch database’s catalog row to claim an older glibc:
alter database w_edges refresh collation version answered NOTICE: changing version from 2.31 to 2.41, and fixed nothing else. Rebuild first, as the hint says, then refresh.
Prove it · lab 11-types
The exposed indexes are those on text in a libc or ICU collation:
The default collation appears as provider d, so this lists default-collated indexes even in a builtin database. amcheck then verifies an index’s order under today’s rules, taking only a light lock:
Run it on a restored copy before an upgrade cutover, and on the big text indexes after one.
Timestamps: always timestamptz
Store moments as timestamptz, and never use timestamp (without time zone) for something that happened. No rule in this chapter has fewer exceptions.
The names mislead. timestamptz doesn’t store a zone; both types are 8 bytes. timestamptz is an absolute instant, kept as UTC. timestamp is a wall-clock reading: “22:51” in which city? Two app servers insert at the same instant, one session in New York time (a misconfigured pool, a forgotten cron box), one in UTC:
timestamptz recorded one instant twice. timestamp recorded two readings four hours apart, and every “last hour” dashboard on it is now quietly wrong. You can’t repair it: the zone was never stored.
Keep every connection at timezone = UTC, send timestamps with an explicit offset, and convert at the edge.
now() is the moment the transaction started, so an hour-long batch stamps every row with the same time. clock_timestamp() is the wall clock (date/time functions).
The honest exception is a future local time that must survive zone rule changes (“opens at 09:00 local on 2027-03-01”): store timestamp plus the zone name.
Deep dive: Daylight saving: one hour, one day, and 24 hours differ
On March 8, 2026, New York jumped from 02:00 to 03:00:
1 day keeps the wall-clock time; 24 hours adds 86,400 seconds. This works with a named session zone, not a fixed offset. A plain timestamp gives 02:30 for the first, a time that never existed in New York.
Prove it · lab 11-types
Deep dive: now() stands still for the whole transaction
now() and clock_timestamp(), read before and after a two-second pg_sleep inside one transaction:
statement_timestamp() is the third option: the start of the current statement.
Prove it · lab 11-types
A timestamp beats a boolean; a check beats a new type
date is for things that really are days, like a birthday, not for “created on”, which is an instant you display as a day. Query time ranges half-open, >= '2026-10-01' and < '2026-11-01' (Date ranges are half-open), and know that '2026-01-31'::date + interval '1 month' is February 28.
Before adding a boolean, ask whether you’ll want to know when it became true. archived_at timestamptz answers “is it archived?” with is not null, and also when, for 7 more bytes. For state an audit cares about (verified, archived, cancelled), the timestamp wins.
To restrict a column to a set, our default is text plus a check, like the running example’s check (plan in ('free', 'team', 'enterprise')). Every tool understands it, and you can swap it online. Use a lookup table when values carry data of their own.
Enums are hard to take back
An enum takes 4 bytes and sorts in declaration order: good for a set that is truly stable and ordered, like log severity. Two traps. There is no drop value, so retiring one means a new type and a table rewrite. And a value added inside a transaction can’t be used until it commits, which breaks migration tools that add a value and seed rows in one transaction (ERROR: unsafe use of new value "audit" of enum type event_kind).
Store UUIDs as uuid
uuid is 16 bytes; as text it takes 40, and every index grows to match (Pick your keys once measures it).
The same goes for every type that knows its domain. inet and cidr validate addresses and answer “is this IP in that network” with one operator. Range types (tstzrange, daterange) make “no two bookings overlap” an exclusion constraint. And create domain email_address as text check (...) writes a repeated rule once.
Arrays are for lists that live and die with the row
Arrays fit small lists that belong to one row: tags, notification channels. They’re wrong when the elements are rows in disguise: they reference other tables (no foreign keys on elements), you update them one at a time (each update rewrites the whole row), or the list grows without bound. An array of user ids on a project is a membership table that hasn’t been created yet.
Use jsonb, and give the keys you query their own columns
json stores text and reparses it on every access; jsonb parses once and supports GIN indexes. Use jsonb, and keep json only for exact input, like a webhook body whose signature you verify.
jsonb isn’t free. Every row repeats every key: events.payload spends 63 to 69 bytes a row spelling out "status", "duration_ms", and "region", about five times what typed columns would take. And the planner has almost no statistics inside a document (Most documents belong in a jsonb column). Keys you filter, join, sort, or constrain on deserve columns.
Deep dive: What jsonb does to your input
Nor is it smaller: {"status":"ok","duration_ms":120,"region":"iad"} is 52 bytes as json, 68 as jsonb.
Prove it · lab 11-types
Generated columns are virtual by default (PostgreSQL 18+)
PostgreSQL 18 makes generated columns virtual by default: computed on read, taking no space, and impossible to index. Stored ones are computed on write. Use virtual for cheap values you display, stored for values you filter or sort on, like a tsvector (Store the tsvector; don’t index the expression). Before PostgreSQL 18, stored was required syntax, so existing migrations and upgraded columns are safe; it’s new DDL written without the keyword that now gets a virtual column. Write stored when you mean it.
Deep dive: Virtual by default, and why you can’t index it
Four types cost more than they save
serial is a sequence, not a type
serial isn’t a type. It’s a sequence plus a nextval() default on a 4-byte integer, the type this chapter opened with, and the sequence never hears about ids inserted by hand:
bigint generated always as identity refuses the hand-picked id instead (Let the database number rows, and never trust the order).
Also avoid money (formatted by the server’s locale, ignorant of currency), char(n) (see above), and timetz (an offset without a date can’t know about daylight saving; the docs themselves discourage it).
The short version
bigintfor every id and every reference to one.- Money is
numericorbigintcents, plus a currency column. Never floating point. - Use
textwith a check constraint, notvarchar(n). Neverchar(n). - Default collations are case-sensitive. For emails, a unique index on
lower(email). - Treat every OS, glibc, or ICU upgrade as a collation event: check versions, reindex text indexes, then
refresh version. Or use builtinC.UTF-8(PostgreSQL 17+). timestamptzfor every moment, sessions in UTC, explicit offsets on input.- Prefer
archived_attois_archived,textplus a check to an enum,jsonbtojson, and columns for the JSON keys you query. - Identity, not
serial. In PostgreSQL 18, generated columns are virtual unless you writestored.
Further reading
- Data Types, PostgreSQL documentation. Every built-in type, with its storage size and input rules.
- Collation Support, PostgreSQL documentation. Providers, deterministic and nondeterministic collations.
- amcheck, PostgreSQL documentation. What
bt_index_checkverifies, and the locks it takes. - Locale data changes, PostgreSQL wiki. Which glibc and distribution versions changed sort order.
- How To Correct and Identify Indexes Affected by the GNU C 2.28 Update, Crunchy Data. A practitioner’s walk through finding and rebuilding affected indexes.
- Case-insensitive pattern matching in PostgreSQL, Laurenz Albe, CYBERTEC. The trade-offs between
lower(),citext, and nondeterministic collations, in depth. - Don’t Do This, PostgreSQL wiki. The community’s short list of type mistakes, with when each is acceptable.