PostgreSQL Cheatsheet
Data Types
Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Text
Three char types exist. Use text unless something forces your hand. varchar(n) and
text have identical performance in Postgres, so varchar(n) buys you only a length
check that a CHECK constraint expresses better and that ALTER TABLE has to rewrite
to change.
| Type | Behavior | Verdict |
|---|---|---|
text | Unlimited length, no padding | The default answer |
varchar(n) | Errors above n characters | Fine when a spec really caps it |
varchar | Same as text | Pointless alias |
char(n) / bpchar | Blank-padded to n, padding ignored in comparison and length() | Avoid |
citext (extension) | Case-insensitive equality and indexing | See Extensions |
"char" | 1-byte internal type, not char(1) | Never use it deliberately |
SELECT length('abc '::char(10)); -- 3, the padding is invisible SELECT 'abc '::char(10) = 'abc'; -- true, padding ignored SELECT 'abc '::text = 'abc'; -- false
Numbers
numeric is exact decimal arithmetic. real and double precision are IEEE binary
floats and cannot represent 0.1 exactly. Money is always numeric.
| Type | Alias | Size | Range / precision |
|---|---|---|---|
smallint | int2 | 2 B | -32768 to 32767 |
integer | int4, int | 4 B | ±2.1 billion |
bigint | int8 | 8 B | ±9.2 quintillion |
numeric(p,s) | decimal(p,s) | variable | p total digits, s after the point, exact |
numeric | — | variable | Unconstrained precision, still exact |
real | float4 | 4 B | ~6 decimal digits, inexact |
double precision | float8 | 8 B | ~15 decimal digits, inexact |
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8; -- false SELECT (0.1::float8 + 0.2::float8)::text; -- 0.30000000000000004 SELECT 0.1::numeric + 0.2::numeric = 0.3::numeric; -- true
Store currency as numeric(12,2) (or as integer cents). Never float. Skip the
built-in money type as well, it carries a locale-dependent output format and only
two fixed decimal places.
Dates and times
The blunt rule: always timestamptz. timestamp (without time zone) stores a
wall-clock reading with no offset, so two rows written from two servers are not
comparable. timestamptz stores a single absolute instant in UTC and renders it in the
session TimeZone.
| Type | Stores | Notes |
|---|---|---|
timestamptz | Absolute instant, µs precision | timestamp with time zone, the correct default |
timestamp | Wall clock with no zone | Only for a genuinely zoneless local time |
date | Calendar day | date + interval yields a timestamp |
time / timetz | Time of day | timetz is almost never what you want |
interval | Duration | Months, days, and microseconds tracked separately |
SELECT now(); -- timestamptz, transaction start time SELECT clock_timestamp(); -- real wall clock, moves inside a txn SELECT now() AT TIME ZONE 'UTC'; -- timestamptz -> timestamp in UTC SELECT (now() AT TIME ZONE 'America/New_York'); SELECT date_trunc('month', now()); SELECT '2024-03-01'::date + '1 month'::interval; -- 2024-04-01 00:00:00 SELECT extract(epoch FROM '1 day'::interval); -- 86400 SELECT age(now(), '2000-01-01'::timestamptz); SELECT generate_series(date '2024-01-01', date '2024-01-05', '1 day');
An interval keeps months and days apart on purpose, so '1 month' is 28 to 31 real
days depending on where you add it. Use '30 days' if you mean 30 days.
Identity, UUIDs, and booleans
serial is legacy. It is not a type, it is macro sugar for an integer plus a
sequence plus a default, and it leaves the sequence ownership and grants slightly off.
The SQL-standard GENERATED ... AS IDENTITY (10+) is the modern form.
-- Modern CREATE TABLE t (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY); -- Allows an explicit override on INSERT CREATE TABLE t (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY); -- Legacy equivalent of bigserial CREATE TABLE t (id bigserial PRIMARY KEY);
ALWAYS rejects a client-supplied id unless the statement says
OVERRIDING SYSTEM VALUE. BY DEFAULT accepts one, which is what data loaders want.
gen_random_uuid() has been built in since 13, so a plain uuid primary key needs
no extension. Random v4 UUIDs scatter btree inserts, which is a real write-amplification
cost on a large table, so a bigint identity is still the better default for internal
keys.
| Type | Literal | Notes |
|---|---|---|
uuid | 'a0ee...'::uuid | 16 bytes, gen_random_uuid() (13+) |
boolean | true/false, 't'/'f', 'yes'/'no', 1/0 | 3 states with NULL |
bytea | '\x48656c6c6f' | Raw bytes, encode()/decode() to hex or base64 |
inet | '192.168.1.5/24' | Host address, optional netmask |
cidr | '192.168.1.0/24' | Network only, host bits must be zero |
macaddr / macaddr8 | '08:00:2b:01:02:03' | 6-byte and 8-byte MAC |
SELECT '192.168.1.5'::inet << '192.168.1.0/24'::cidr; -- true, is contained by SELECT encode('Hello'::bytea, 'hex'); -- 48656c6c6f
Do not store files in bytea if you can avoid it. Rows carry into every backup and
every replica.
Enums, arrays, ranges, and JSON
| Type family | Declare | Notes |
|---|---|---|
enum | CREATE TYPE mood AS ENUM ('sad','ok','happy') | Ordered, 4 bytes, sorts by declaration order |
| array | text[], int[] | 1-based, any type, see Arrays and Ranges |
| range | int4range, tstzrange, daterange | Bound notation [), see Arrays and Ranges |
jsonb | '{"a":1}'::jsonb | Parsed, deduped keys, indexable, use this |
json | '{"a":1}'::json | Stored as text verbatim, no indexing worth having |
hstore (extension) | 'a=>1'::hstore | Flat string map, predates jsonb |
ALTER TYPE mood ADD VALUE 'ecstatic' AFTER 'happy'; -- 9.1+, cannot remove values SELECT enum_range(NULL::mood); -- {sad,ok,happy,ecstatic}
Adding an enum value is cheap. Removing or reordering one is not possible, you rebuild
the type. If the value set churns, use text with a CHECK constraint or a lookup
table instead.
jsonb deduplicates keys, discards insignificant whitespace, and does not preserve
key order. json preserves the input byte for byte. Choose jsonb unless you need
exact round-tripping of the original document.
Casting and NULL
Two cast spellings. :: is the Postgres shorthand, CAST(x AS type) is the standard
and is required inside some grammars.
SELECT '42'::int, CAST('42' AS int); SELECT pg_typeof(1), pg_typeof(1.0); -- integer, numeric SELECT '2024-13-01'::date; -- error, out of range for a month SELECT nullif(total, 0) FROM orders; -- 0 becomes NULL SELECT coalesce(nickname, name, 'anon');
NULL is not a value, it is the absence of one. Every comparison against it is NULL,
which is not true, so it never satisfies a WHERE.
| Expression | Result |
|---|---|
NULL = NULL | NULL |
NULL IS NULL | true |
NULL IS DISTINCT FROM NULL | false |
1 IS DISTINCT FROM NULL | true |
NULL + 1 | NULL |
'a' || NULL | NULL |
concat('a', NULL) | 'a' |
count(col) | Skips NULLs, count(*) does not |
sum(col) over all NULLs | NULL, not 0 |
IS DISTINCT FROM is the NULL-safe inequality, and IS NOT DISTINCT FROM the NULL-safe
equality. Use them in change-detection updates so a NULL-to-value transition is caught.
UPDATE customers SET phone = $1 WHERE id = $2 AND phone IS DISTINCT FROM $1;