Postgres deep-dive · Part 4 of 11. Prev: VACUUM, autovacuum, and the dead-tuple problem. Next: The index toolbox: beyond the B-tree.
Your MySQL type instincts mostly transfer. Where they don’t, it’s usually because Postgres made a different tradeoff between flexibility and ceremony, or because it offers a native type that MySQL handles with extensions or application code.
This part walks the categories that matter: strings, numbers, timestamps, primary keys, and a few types MySQL doesn’t have at all.
Strings: text over varchar(n)
In Postgres, text, varchar(n), and varchar are the same underlying
type — all stored as varlena (variable-length arrays) with the same
performance characteristics, the same TOAST escalation, the same everything.
varchar(n) is just text with an inline CHECK (char_length(value) <= n)
automatically applied. char(n) pads with blanks and has no performance
advantage; avoid it.
The idiomatic Postgres choice is text. If your domain has a real maximum
length, express it as a CHECK constraint:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL,
bio text,
CONSTRAINT chk_username_len CHECK (char_length(username) <= 50),
CONSTRAINT chk_bio_len CHECK (char_length(bio) <= 500)
);
This is strictly better than varchar(50) for two reasons. First, the
constraint documents intent just as clearly. Second, you can ALTER TABLE DROP CONSTRAINT chk_username_len to relax the limit at any time — an instant
metadata operation. Increasing varchar(50) to varchar(100) in MySQL
requires a full table scan or an ALGORITHM=INPLACE DDL. In Postgres it also
requires a table scan (the stored type doesn’t change, but the check must be
validated). DROP CONSTRAINT is instant; ALTER COLUMN TYPE is not.
Numbers: numeric, bigint, and money
numeric(p,s) (standard SQL DECIMAL) is exact arbitrary-precision
arithmetic stored in software. It’s correct for money and anything where a
rounding error would be a bug. Postgres’s money type exists but is
locale-dependent for formatting and awkward to do arithmetic with. Use
numeric(10,2) for monetary columns.
bigint (8 bytes) is the workhorse for IDs and counts. Postgres has no
UNSIGNED modifier — the positive range of bigint (up to about 9.2 quintillion)
is sufficient for any practical primary key. For hot arithmetic paths — summing
prices, counting rows — an integer or bigint beat numeric because they use
native CPU instructions. Store prices as bigint cents and divide at the
display layer if you need that speed.
float8 / double precision is for scientific/measurement data where
approximate arithmetic is acceptable. Not for money.
Timestamps: always timestamptz
timestamptz (timestamp with time zone) is almost always the right choice for
any instant in time. Despite the name, it does not store a time zone label.
It stores 8 bytes of UTC microseconds. The “tz” means Postgres converts on the
way in — accepting input with any timezone offset or IANA zone — and converts
on the way out to the session’s TimeZone setting.
-- timestamptz stores UTC; session TimeZone controls display
SET TimeZone = 'UTC';
SELECT '2026-09-07 10:00:00+05:30'::timestamptz;
-- 2026-09-07 04:30:00+00 ← stored and displayed as UTC
SET TimeZone = 'America/New_York';
SELECT '2026-09-07 10:00:00+05:30'::timestamptz;
-- 2026-09-07 00:30:00-04 ← same instant, different display
timestamp (without tz) stores a naive wall-clock reading with no timezone
semantics. It’s right for “store-hours open at 9:00 AM” where the value is
a local time, not an instant. For created_at, updated_at, event timestamps
— use timestamptz.
The range is years 4713 BC to 294276 AD — no 2038 problem.
Primary keys: identity over serial, uuidv7 over uuidv4
GENERATED ALWAYS AS IDENTITY is the modern integer sequence. It’s SQL
standard, the sequence is owned by the column, and ALWAYS prevents accidental
manual inserts that skip the sequence:
CREATE TABLE items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
serial is the legacy equivalent — it creates a loose sequence and sets a
column default. The sequence isn’t tied to the column and can be dropped
independently, leaving the table in a broken state. All new tables should use
IDENTITY.
UUID: Postgres stores UUIDs natively as 16-byte binary (uuid type). No
BINARY(16) dance, no UUID_TO_BIN() helpers — just uuid. Postgres 18 ships
uuidv7() in core:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
user_id bigint NOT NULL,
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- uuidv7 encodes creation time — you can extract it:
SELECT uuid_extract_timestamp('0193d6e1-1234-7abc-8def-000000000001');
-- 2026-09-07 04:30:00.123+00
UUIDv7 is time-ordered (millisecond timestamp in the leading bits), so new
values sort after existing ones — the PK B-tree gets the same sequential-insert
benefit as AUTO_INCREMENT, without leaking a guessable counter. For
distributed systems where you need to generate IDs in application code without
a round-trip to the database, uuidv7() on the client side (or gen_random_uuid()
for v4 when ordering doesn’t matter) both work with Postgres’s native uuid type.
As Part 1 explained, the UUID-PK page-split problem in Postgres is an index problem, not a table problem — but it’s still a problem. UUIDv7 fixes it.
Arrays: native, indexable, first-class
Postgres supports array columns for any type. text[], int[], uuid[] — all
stored natively:
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
tags text[]
);
INSERT INTO articles (title, tags)
VALUES ('Postgres MVCC', ARRAY['postgres', 'internals', 'mvcc']);
-- Array containment query — indexable with GIN (Part 5)
SELECT title FROM articles WHERE tags @> ARRAY['postgres'];
Arrays are not the relational anti-pattern they are in other contexts. Postgres arrays are a real type with real operators and real indexes. They’re the right choice for small, ordered value-lists owned by the row that don’t need foreign keys or their own metadata. When the list items need references, constraints, or metadata of their own — use a join table.
Enums: schema-level types with caveats
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'cancelled');
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
status order_status NOT NULL DEFAULT 'pending'
);
Enums are schema-level types (reusable across tables), stored as 4 bytes, and sort in declaration order. Adding a value is cheap:
ALTER TYPE order_status ADD VALUE 'returned' AFTER 'shipped';
Renaming or removing a value, or reordering the list, is painful — it requires
creating a new type and migrating the column. For a state machine where the
values are stable, enums are clean. For fast-evolving statuses, text plus a
CHECK constraint or a lookup table stays more flexible.
The schema exercise
Here’s the schema from our learning session, with all the type decisions applied:
CREATE TYPE payment_status AS ENUM ('pending', 'captured', 'refunded', 'failed');
CREATE TABLE payments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
order_id uuid NOT NULL REFERENCES orders(id),
amount numeric(8,2) NOT NULL,
currency char(3) NOT NULL DEFAULT 'USD',
status payment_status NOT NULL DEFAULT 'pending',
customer_note text CHECK (char_length(customer_note) <= 500),
metadata jsonb,
tags text[],
created_at timestamptz NOT NULL DEFAULT now(),
captured_at timestamptz
);
Type decisions spelled out:
uuid DEFAULT uuidv7()— globally unique, time-ordered, no round-tripnumeric(8,2)— exact for money, with explicit precisionchar(3)— the one case for fixed-length: ISO 4217 currency codes are always 3payment_statusenum — small stable value set with sort semanticstextwith aCHECKfor the note — relaxable limit, same storage asvarcharjsonbfor metadata — variable schema, queryable, GIN-indexabletext[]for tags — small list, GIN-indexable with@>(Part 5)timestamptzfor all timestamps — instants in time, always
The misconception
“I’ll use
varchar(n)for discipline and performance.”
No performance gain. The only difference between varchar(50) and text with
CHECK (char_length(col) <= 50) is that the CHECK constraint can be dropped
in a metadata-only operation, and varchar(50) cannot be widened without a
table-level DDL. The discipline is real in either case. Route it through CHECK
and preserve your future flexibility.
Where this goes next
Types decide what your columns hold. Part 5 decides how to query them efficiently — which index type to reach for when B-tree isn’t the answer, and the two orthogonal dimensions of index design (type and form) that give Postgres its edge.