PostgreSQL Cheatsheet

Tables and DDL

Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

CREATE TABLE

One statement with the clauses you actually reach for. IF NOT EXISTS makes it idempotent, which matters in migration scripts that may be replayed.

CREATE TABLE IF NOT EXISTS orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers (id) ON DELETE CASCADE,
  status      text   NOT NULL DEFAULT 'pending'
                     CHECK (status IN ('pending','paid','shipped','cancelled')),
  subtotal    numeric(12,2) NOT NULL CHECK (subtotal >= 0),
  tax         numeric(12,2) NOT NULL DEFAULT 0,
  total       numeric(12,2) GENERATED ALWAYS AS (subtotal + tax) STORED,
  metadata    jsonb  NOT NULL DEFAULT '{}'::jsonb,
  tags        text[] NOT NULL DEFAULT '{}',
  placed_at   timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz,
  UNIQUE (customer_id, placed_at)
);
ClauseEffect
GENERATED ALWAYS AS IDENTITYAuto key, 10+, replaces serial
GENERATED ALWAYS AS (expr) STOREDComputed column, 12+, written on insert and update
DEFAULT exprApplied only when the column is omitted, not when you send NULL
NOT NULLCheap, always add it to columns that are not optional
REFERENCES t (c)Inline foreign key, see Constraints
CHECK (...)Row-level predicate, may not reference other tables
COLLATE "C"Byte-order comparison, faster and locale-independent
TEMPORARYSession-scoped, dropped at disconnect
UNLOGGEDSkips WAL, fast, truncated after a crash and not replicated

A generated column must be STORED on 12 through 17. VIRTUAL generated columns arrived in 18.

Deriving tables

LIKE copies the column definitions, AS SELECT copies the data and infers the columns. AS SELECT copies no constraints, no indexes, and no defaults.

-- Structure only, with the extras you name
CREATE TABLE orders_archive (LIKE orders INCLUDING ALL);
CREATE TABLE orders_shell   (LIKE orders INCLUDING DEFAULTS EXCLUDING INDEXES);

-- Data, structure inferred
CREATE TABLE orders_2023 AS
SELECT * FROM orders WHERE placed_at < '2024-01-01';

-- Empty copy of a query's shape
CREATE TABLE report_shape AS SELECT * FROM orders WITH NO DATA;

CREATE TEMP TABLE ids (id bigint PRIMARY KEY);
CREATE UNLOGGED TABLE import_staging (raw jsonb);

INCLUDING ALL covers defaults, constraints, indexes, identity, generated columns, comments, statistics, and storage settings.

ALTER TABLE

Adding a nullable column, or a column with a constant default, is a catalog-only operation since 11 and does not rewrite the table. Changing a type generally does rewrite it and takes an ACCESS EXCLUSIVE lock.

ALTER TABLE orders ADD COLUMN notes text;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'USD';
ALTER TABLE orders DROP COLUMN IF EXISTS notes;
ALTER TABLE orders RENAME COLUMN notes TO internal_notes;
ALTER TABLE orders RENAME TO sales_orders;

ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'draft';
ALTER TABLE orders ALTER COLUMN status DROP DEFAULT;
ALTER TABLE orders ALTER COLUMN updated_at SET NOT NULL;
ALTER TABLE orders ALTER COLUMN updated_at DROP NOT NULL;

-- Type change with an explicit conversion
ALTER TABLE orders
  ALTER COLUMN legacy_total TYPE numeric(12,2)
  USING nullif(btrim(legacy_total), '')::numeric;

ALTER TABLE orders ALTER COLUMN metadata SET STORAGE EXTERNAL;
ALTER TABLE orders SET (fillfactor = 90);
ALTER TABLE orders OWNER TO app_owner;
ALTER TABLE orders SET SCHEMA archive;
OperationRewrites the table?Lock
ADD COLUMN nullableNoACCESS EXCLUSIVE, brief
ADD COLUMN ... DEFAULT <constant>No, 11+ACCESS EXCLUSIVE, brief
ADD COLUMN ... DEFAULT <volatile fn>YesACCESS EXCLUSIVE, long
DROP COLUMNNo, marked deadACCESS EXCLUSIVE, brief
ALTER COLUMN TYPEUsually yesACCESS EXCLUSIVE, long
varchar(50) → varchar(100) or textNoBrief
SET NOT NULLNo, but full scan to verifyACCESS EXCLUSIVE for the scan
RENAMENoBrief

On a busy table, set lock_timeout before any ALTER TABLE so a blocked statement fails fast instead of queueing every reader behind it.

SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN shipped_at timestamptz;

Schemas and search_path

A schema is a namespace inside one database. search_path decides which schema an unqualified name resolves to, and which schema a CREATE lands in.

CREATE SCHEMA IF NOT EXISTS analytics;
CREATE SCHEMA reporting AUTHORIZATION report_owner;

SHOW search_path;                      -- "$user", public
SET search_path TO analytics, public;  -- session only
ALTER ROLE app SET search_path TO app, public;         -- per role
ALTER DATABASE appdb SET search_path TO app, public;   -- per database

SELECT current_schema();
DROP SCHEMA analytics CASCADE;         -- drops everything inside, no undo

The first writable entry in search_path receives new objects. Always schema-qualify inside SECURITY DEFINER functions and inside migrations, and set search_path explicitly rather than trusting the default.

Dropping and emptying

DROP refuses when another object depends on the target. CASCADE drops the dependents too, which is exactly as dangerous as it sounds.

DROP TABLE IF EXISTS orders;
DROP TABLE orders CASCADE;             -- also drops dependent views and FKs
DROP VIEW IF EXISTS v_recent;
DROP SCHEMA staging CASCADE;

TRUNCATE orders;                                   -- fast, needs ACCESS EXCLUSIVE
TRUNCATE orders RESTART IDENTITY;                  -- also resets the sequence
TRUNCATE orders, order_items RESTART IDENTITY CASCADE;
StatementSpeedReclaims diskFires row triggersTransactional
DELETE FROM tSlow, row by rowNo, needs VACUUMYesYes
TRUNCATE tInstantYesNo (only TRUNCATE triggers)Yes, rollback works
DROP TABLE tInstantYesNoYes

TRUNCATE is transactional in Postgres, unlike some other engines. It still needs ACCESS EXCLUSIVE, so it blocks every reader, and CASCADE silently truncates every table with a foreign key pointing at yours.

Views and materialized views

A view is a stored query with no storage. A materialized view stores the result and goes stale until refreshed.

CREATE OR REPLACE VIEW v_paid_orders AS
SELECT o.id, o.customer_id, o.total, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid';

CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT date_trunc('day', placed_at) AS day, sum(total) AS revenue
FROM orders WHERE status = 'paid'
GROUP BY 1
WITH DATA;

CREATE UNIQUE INDEX ON mv_daily_revenue (day);   -- required for CONCURRENTLY
REFRESH MATERIALIZED VIEW mv_daily_revenue;                   -- locks readers out
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;      -- 9.4+, readers stay

REFRESH ... CONCURRENTLY requires at least one unique index on the view and cannot run on a view that has never been populated. CREATE OR REPLACE VIEW may add trailing columns but cannot rename, reorder, retype, or drop existing ones, so a real change means DROP VIEW plus recreate, which cascades into anything built on it.

Updatable views: a simple single-table view is updatable automatically. Anything with a join, an aggregate, or a DISTINCT needs an INSTEAD OF trigger. Add WITH CHECK OPTION to reject writes that would fall outside the view predicate.

Partitioning and comments

Declarative partitioning (10+) routes rows to child tables by a key. Range for time series, list for tenant or region, hash for even spread.

CREATE TABLE events (
  id bigint GENERATED ALWAYS AS IDENTITY,
  occurred_at timestamptz NOT NULL,
  region text NOT NULL,
  payload jsonb NOT NULL,
  PRIMARY KEY (id, occurred_at)          -- key must include the partition column
) PARTITION BY RANGE (occurred_at);

CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_default PARTITION OF events DEFAULT;   -- 11+

CREATE TABLE tenants (id bigint, region text NOT NULL, name text)
  PARTITION BY LIST (region);
CREATE TABLE tenants_eu PARTITION OF tenants FOR VALUES IN ('eu-west','eu-central');

-- Detach and drop is the point: instant data expiry
ALTER TABLE events DETACH PARTITION events_2025_01 CONCURRENTLY;   -- 14+
DROP TABLE events_2025_01;

Range bounds are inclusive at the lower end and exclusive at the upper, so adjacent partitions share a boundary date without overlapping. Every unique constraint and primary key must contain the partition key. DETACH ... CONCURRENTLY refuses to run while a DEFAULT partition exists, and it cannot run inside a transaction block. Attaching an existing table takes a full scan unless a matching CHECK constraint already proves the bound.

COMMENT ON TABLE orders IS 'One row per customer order, immutable after shipping.';
COMMENT ON COLUMN orders.total IS 'subtotal + tax, generated.';
COMMENT ON INDEX orders_customer_id_idx IS NULL;   -- removes the comment

Comments show up in \d+ and \dt+, which makes them the cheapest documentation in the system.