PostgreSQL Cheat Sheet

Everything below runs on PostgreSQL 12 and later, with the version notes called out where behaviour changed. The reference is grouped the way you actually work: tables and columns, rows, querying, indexes, administration, and the psql meta-commands. Every line links to a guide explaining what it locks, what it rewrites, and what it breaks.

Tables and columns

Create a table
CREATE TABLE orders (
  id bigserial PRIMARY KEY,
  customer_id bigint NOT NULL,
  total numeric(10,2) NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE orders RENAME TO customer_orders;
CREATE TABLE orders_backup AS SELECT * FROM orders;
ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'new';
ALTER TABLE orders RENAME COLUMN total TO amount;
ALTER TABLE orders DROP COLUMN status;
ALTER TABLE orders
  ALTER COLUMN total TYPE numeric(12,2) USING total::numeric(12,2);
Make a column NOT NULL
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
Set or drop a default
ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'new';
ALTER TABLE orders ALTER COLUMN status DROP DEFAULT;
TRUNCATE TABLE orders RESTART IDENTITY CASCADE;
Drop a table
DROP TABLE IF EXISTS orders_backup;
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'orders'
ORDER BY ordinal_position;

Rows and data

INSERT INTO orders (customer_id, total)
VALUES (1, 49.90), (2, 12.00);
Insert the result of a query
INSERT INTO orders_archive (id, total)
SELECT id, total FROM orders WHERE created_at < now() - interval '1 year';
Return the generated id
INSERT INTO orders (customer_id, total)
VALUES (1, 49.90)
RETURNING id;
UPDATE orders SET status = 'shipped' WHERE id = 42;
Update from another table
UPDATE orders o
SET total = t.amount
FROM order_totals t
WHERE t.order_id = o.id;
DELETE FROM orders WHERE created_at < now() - interval '2 years';
INSERT INTO orders (id, total)
VALUES (1, 49.90)
ON CONFLICT (id) DO UPDATE SET total = EXCLUDED.total;
Insert and skip duplicates
INSERT INTO orders (id, total)
VALUES (1, 49.90)
ON CONFLICT DO NOTHING;
SELECT email, count(*)
FROM customers
GROUP BY email
HAVING count(*) > 1;
DELETE FROM customers a
USING customers b
WHERE a.id > b.id AND a.email = b.email;

Querying and dates

Page through results
SELECT * FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 100;
SELECT coalesce(nickname, first_name, 'there') AS greeting FROM customers;
CREATE OR REPLACE VIEW recent_orders AS
SELECT * FROM orders WHERE created_at > now() - interval '30 days';
Create a materialized view
CREATE MATERIALIZED VIEW daily_totals AS
SELECT date_trunc('day', created_at) AS day, sum(total) AS total
FROM orders GROUP BY 1;

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_totals;
SELECT to_char(created_at, 'YYYY-MM-DD HH24:MI') FROM orders;
SELECT delivered_at::date - created_at::date AS days,
       age(delivered_at, created_at)      AS interval
FROM orders;
Group by month
SELECT date_trunc('month', created_at) AS month, count(*)
FROM orders GROUP BY 1 ORDER BY 1;
SELECT generate_series('2026-01-01'::date, '2026-01-31'::date, '1 day') AS day;
SELECT customer_id, string_agg(sku, ', ' ORDER BY sku) AS skus
FROM order_items GROUP BY customer_id;
SELECT payload->>'status' AS status
FROM events
WHERE payload->'user'->>'id' = '42';
Running total
SELECT created_at, sum(total) OVER (ORDER BY created_at) AS running_total
FROM orders;

Indexes and constraints

CREATE INDEX orders_created_at_idx ON orders (created_at);
Create one without locking writes
CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status);
Create a unique index
CREATE UNIQUE INDEX customers_email_idx ON customers (lower(email));
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'orders';
Drop an index
DROP INDEX CONCURRENTLY IF EXISTS orders_status_idx;
ALTER TABLE orders ADD CONSTRAINT orders_pkey PRIMARY KEY (id);
ALTER TABLE orders
  ADD CONSTRAINT orders_customer_fk
  FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE;
Add a check constraint
ALTER TABLE orders ADD CONSTRAINT orders_total_positive CHECK (total >= 0);

Databases and administration

SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;
SELECT datname FROM pg_database WHERE datistemplate = false;
CREATE DATABASE analytics WITH ENCODING 'UTF8' TEMPLATE template0;
Table and index size on disk
SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total,
       pg_size_pretty(pg_relation_size('orders'))       AS table_only;
See what is running now
SELECT pid, state, now() - query_start AS runtime, query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY runtime DESC;
SELECT pg_cancel_backend(12345);    -- ask it to stop
SELECT pg_terminate_backend(12345); -- drop the connection
VACUUM (ANALYZE) orders;
Grant read access on a schema
GRANT USAGE ON SCHEMA public TO analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analyst;
Version and current connection
SELECT version(), current_database(), current_user;

psql meta-commands

Connect to a database
psql "postgresql://user@host:5432/analytics"
\c analytics
List databases
\l
List tables, views, indexes
\dt
\dv
\di
Describe a table
\d orders
\d+ orders
List schemas, roles, functions
\dn
\du
\df
Readable output for wide rows
\x auto
Show how long each query takes
\timing on
Export a query to CSV
\copy (SELECT * FROM orders) TO 'orders.csv' WITH CSV HEADER
Run a file, then quit
\i migration.sql
\q

All 30 PostgreSQL how-to guides

Each guide is a short answer with examples you can copy and run, plus the gotchas and errors that come with it.

Run PostgreSQL queries without a desktop client

SaturnSQL is a browser-based SQL editor for teams: shared query library, schema-aware autocomplete, and scheduled exports to Google Sheets and Slack.

Try it free

Do more with PostgreSQL

The same tasks in other databases