03 · PostgreSQL Data Types That Matter¶
PostgreSQL has more built-in types than any other mainstream database — geometric shapes, network addresses, ranges, multiranges, full-text vectors. You do not need all of them. You do need to make five or six decisions correctly on every table you design, because a wrong column type is one of the most expensive mistakes to fix once a table holds a billion rows.
This lesson covers those decisions. Every result below came from PostgreSQL 18.6.
Numbers: exact or approximate?¶
real and double precision (float4/float8) are IEEE 754 binary floating point: fast, compact
and approximate. (0.1 + 0.2) = 0.3 is false for them. numeric is exact decimal arithmetic in
software — slower, but correct to the last digit.
| Use case | Type |
|---|---|
| Money, quantities with fixed decimals, anything audited | numeric(12,2) or integer cents in bigint |
| Measurements, scientific data, ML features | double precision |
| Counters, IDs | integer or bigint |
Avoid the money type: its formatting depends on the lc_monetary setting, so the same value can
print differently on two servers, and it has no currency.
Integers fail loudly rather than wrapping:
That number — 2,147,483,647 — is a real ceiling. Tables with a serial/integer primary key do
reach it, and the fix (changing the column to bigint) rewrites the whole table under an exclusive
lock. Use bigint for primary keys on any table that might grow; the extra 4 bytes per row is
cheap insurance.
SELECT pg_column_size(1::smallint) s, pg_column_size(1::int) i,
pg_column_size(1::bigint) b, pg_column_size(1::numeric) n,
pg_column_size(123456789.123456::numeric) n2;
numeric is variable-length: its size grows with the number of digits.
Text: just use text¶
In PostgreSQL, text, varchar and varchar(n) are stored identically. There is no performance
benefit to varchar(255) — it is a habit carried over from other databases. A length limit is a
constraint, and like any constraint it should express a real business rule.
Watch how varchar(n) behaves:
SELECT 'abc'::varchar(2);
varchar
---------
ab
INSERT INTO t (code) VALUES ('abcd'); -- column is varchar(3)
ERROR: value too long for type character varying(3)
An insert that is too long fails, but an explicit cast silently truncates. If you need a limit,
text with CHECK (char_length(code) <= 3) behaves consistently and can be changed later without
touching the type.
Avoid char(n): it pads with spaces, and the padding causes surprising comparisons.
Length is measured in characters, not bytes:
SELECT 'naïve' AS s, length('naïve') AS chars, octet_length('naïve') AS bytes;
s | chars | bytes
-------+-------+-------
naïve | 5 | 6
Time: timestamptz almost always¶
This is the decision most often made wrong. Two columns, same input:
SET timezone = 'UTC';
CREATE TEMP TABLE times (ts timestamp, tstz timestamptz);
INSERT INTO times VALUES ('2026-03-29 09:00', '2026-03-29 09:00');
SELECT * FROM times;
ts | tstz
---------------------+------------------------
2026-03-29 09:00:00 | 2026-03-29 09:00:00+00
ts | tstz
---------------------+---------------------------
2026-03-29 09:00:00 | 2026-03-29 14:30:00+05:30
timestamptz ("timestamp with time zone") does not store a time zone. It stores an absolute
instant (microseconds since 2000-01-01 UTC) and converts to the session's timezone for display.
timestamp ("without time zone") stores a wall-clock reading with no idea where it was taken. When
the session time zone changed, ts stayed "09:00" — but 09:00 where? Nobody knows any more.
Use timestamptz for anything that happened at a moment: created_at, paid_at, log events. Use
timestamp only for genuinely zone-less concepts like "the store opens at 09:00 local time
wherever the store is", and store the zone in another column. Use date when there is no time of
day.
Interval arithmetic is calendar-aware¶
SET timezone = 'Europe/London';
SELECT '2026-03-28 12:00'::timestamptz + interval '1 day' AS plus_1_day,
'2026-03-28 12:00'::timestamptz + interval '24 hours' AS plus_24_hours;
plus_1_day | plus_24_hours
------------------------+------------------------
2026-03-29 12:00:00+01 | 2026-03-29 13:00:00+01
The UK moved its clocks forward on 29 March 2026. "One day later" means same wall-clock time tomorrow; "24 hours later" means exactly 86,400 seconds later. Both are correct; they answer different questions. Month arithmetic clamps to the end of the month:
Primary keys: identity columns and UUIDs¶
serial is the old way: it creates a sequence and a default, loosely attached to the column. Since
PostgreSQL 10, the SQL-standard identity column is preferred:
CREATE TABLE items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text
);
INSERT INTO items (id, name) VALUES (42, 'manual');
ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.
GENERATED ALWAYS protects you from application code inventing IDs that will later collide with
the sequence. (GENERATED BY DEFAULT allows explicit values, which is occasionally needed for data
migrations.) The identity's sequence is owned by the table, so dropping or copying the table with
CREATE TABLE ... (LIKE ... INCLUDING IDENTITY) behaves sensibly.
Sequences never go backwards, even on rollback:
INSERT INTO items (id, name) OVERRIDING SYSTEM VALUE VALUES (42, 'manual');
BEGIN; INSERT INTO items (name) VALUES ('rolled back'); ROLLBACK;
INSERT INTO items (name) VALUES ('kept') RETURNING id;
Value 1 was consumed by the rolled-back insert. Gaps in IDs are normal and you should never
rely on IDs being contiguous. (And note the trap just set: the sequence will reach 42 eventually
and collide with the manually inserted row. After bulk-loading explicit IDs, move the sequence with
ALTER TABLE items ALTER COLUMN id RESTART WITH 1000.)
UUIDs¶
When IDs must be generated outside the database or must not reveal row counts, use uuid (16 bytes,
stored in binary — never store UUIDs as text). PostgreSQL 18 added uuidv7():
SELECT uuidv7() AS v7, gen_random_uuid() AS v4;
v7 | v4
--------------------------------------+--------------------------------------
01a129a5-50b1-7c7b-bb43-8180d4f79fca | 42e06896-8eb1-4627-97fc-2b5018f07700
A version 4 UUID is fully random, so consecutive inserts land on random pages of the primary key
index. A version 7 UUID starts with a millisecond timestamp, so new keys are roughly increasing and
inserts append to the right edge of the index like a sequence does — much friendlier to the buffer
cache on large tables. The trade-off: a v7 UUID reveals when the row was created
(uuid_extract_timestamp() reads it back). On older versions, gen_random_uuid() (v4) is
built in since PostgreSQL 13; v7 needs an extension or application-side generation.
Enums: fixed, ordered sets¶
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped');
SELECT 'paid'::order_status < 'shipped'::order_status; -- t: enums sort in declared order
SELECT 'refunded'::order_status;
ALTER TYPE order_status ADD VALUE 'refunded' AFTER 'shipped';
SELECT enum_range(NULL::order_status);
Enums take 4 bytes and validate input. But you can add values and rename them, not remove them. If the list changes often or carries extra data (a label, a sort order, an "is active" flag), use a small lookup table with a foreign key instead.
Arrays¶
SELECT ARRAY['sql','pg'] AS tags, (ARRAY['sql','pg'])[1] AS first_is_1, (ARRAY['sql','pg'])[0] AS index_zero;
tags | first_is_1 | index_zero
----------+------------+------------
{sql,pg} | sql |
Arrays are 1-based, and an out-of-range subscript returns NULL instead of an error. Test
membership with = ANY(...) or containment with @>; the latter can use a GIN index (Level 2 · 05).
Arrays suit small, self-contained lists such as tags. If you ever need to join on an element, enforce a foreign key on it, or update one element under concurrency, you wanted a child table.
Ranges and multiranges¶
A range is a single value holding a lower and upper bound:
SELECT int4range(10, 20) AS r, int4range(10,20) @> 15 AS has15,
int4range(10,20) @> 20 AS has20,
tstzrange('2026-01-01','2026-01-02') && tstzrange('2026-01-01 12:00','2026-01-03') AS overlaps;
r | has15 | has20 | overlaps
---------+-------+-------+----------
[10,20) | t | f | t
[10,20) means inclusive lower, exclusive upper — the convention that makes adjacent ranges such
as booking slots fit together without overlap. Multiranges (PostgreSQL 14+) hold several
non-overlapping ranges and merge automatically:
Ranges combine with exclusion constraints to make "no two bookings of the same room overlap" a rule the database enforces — lesson 6 builds exactly that.
The rest, briefly¶
booleanaccepts't','yes','on','1'and their opposites as input.byteafor binary data ('\xDEADBEEF'::byteahas length 4). Large files belong in object storage with the path in the database.inetandcidrfor IP addresses, with operators such as<<=("is contained in subnet").jsonbfor semi-structured data — a whole lesson in Level 3.
How It Actually Works¶
Every type in PostgreSQL is a row in the pg_type catalog that points to C functions for input
(text → internal), output (internal → text), and optionally binary send/receive. That is why
'2026-03-29'::date works: the literal is an untyped string until the input function for date
parses it. It is also why extensions can add types that behave exactly like built-ins.
Types are either fixed-length (int4 is always 4 bytes, timestamptz always 8) or
variable-length ("varlena": text, numeric, bytea, arrays, jsonb). A varlena value carries
a 1- or 4-byte length header, and once a row gets large, big varlena values are compressed or moved
out of line into a TOAST table (Level 2 · 03). Fixed-length values also have alignment
requirements: an 8-byte bigint must start at an 8-byte boundary, so a boolean followed by a
bigint wastes 7 bytes of padding. On very wide, very tall tables, ordering columns from largest
fixed-width to smallest can save measurable space.
timestamptz is an 8-byte integer count of microseconds from 2000-01-01 00:00 UTC. Conversion to
and from local time uses the IANA time-zone database compiled into PostgreSQL (or the operating
system's copy), which is why it correctly knows the UK's 2026 clock change.
Common mistakes¶
- Using
floatfor money. integerprimary keys on tables that will grow; migrating tobigintlater is a full rewrite.timestampwithout time zone for event times, then discovering mixed zones in the data.varchar(255)everywhere "for performance" — there is none — and silent truncation on casts.- Storing UUIDs, dates or numbers as
text: you lose validation, compact storage and correct sorting ('10' < '9'as text). - Expecting IDs without gaps.
Exercise¶
- Create an
invoicestable with: abigintidentity key, auuidpublic ID defaulting touuidv7()(orgen_random_uuid()before PostgreSQL 18), an amount that cannot lose cents, a currency code limited to exactly 3 uppercase letters with aCHECK, and an issue time. - Insert three rows, then change your session's
timezonetwice and observe how the issue time displays. - Compute the due date as "issued + 1 month" for an invoice issued on 31 January. Is the result what your billing team would expect? Write down the rule you would use instead.
- Create an enum for invoice status. Try to remove a value. What options do you have?
- Use
pg_column_sizeon a row (SELECT pg_column_size(i.*) FROM invoices i) and experiment with column order to see whether padding changes the size.