PostgreSQL Cheatsheet: Indexes, JSONB, CTEs and Window Functions
By DevShelfHub
psql, types, indexes, JSONB, CTEs, window functions, EXPLAIN, partitioning, replication, vacuum — the Postgres surface you actually use.
118 items
◷ 9 min
psql
JSONB
EXPLAIN
Start hereQuick start · 6 you’ll reach for daily
Connectpsql "postgresql://u:p@h/db"
List + describe\dt · \d+ table
Explain a queryEXPLAIN (ANALYZE, BUFFERS) ...
UpsertINSERT ... ON CONFLICT DO UPDATE
JSONB filterdata @> '{"k":"v"}'::jsonb
Refresh statsVACUUM (ANALYZE) tbl
Target versions · paceVersions
Targets:postgresql ≥ 16psql ≥ 16libpq ≥ 16
Postgres ships a new major every September. Features land fast — MERGE
(15), SQL/JSON path + jsonb_path_query (12+),
logical replication (10+), INCLUDE on indexes (11),
incremental sort (13). Pin extension versions in production. This sheet targets Postgres 16;
most snippets work on 14+ unless noted.
Install · connectSetup
bash
# macOS — Homebrew
brew install postgresql@16
brew services start postgresql@16
# Linux — Debian/Ubuntu
sudo apt install postgresql-16
sudo systemctl enable --now postgresql
# Docker — disposable dev instance
docker run --name pg -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16
# Connect (URL form is the modern default)
psql "postgresql://user:pass@localhost:5432/mydb"
psql -h localhost -U postgres -d mydb # flag form
psql # local socket, current user
# Create role + db (run as superuser)
createuser -P app
createdb -O app shop
The CLIpsql · meta-commands
Connection & navigation
\c dbname [user]
Reconnect to another db / role.
\conninfo
Show current connection.
\l
List databases.
\dn
List schemas.
\dt [schema.*]
List tables; \dt+ adds size.
\d table
Describe table (cols, indexes, FKs).
\d+ table
Describe + storage, stats, comments.
\di [schema.*]
List indexes.
\df name
List functions matching pattern.
\du
List roles.
\dp table
Show privileges.
Workflow
\i path/file.sql
Run a script file.
\copy t FROM 'data.csv' CSV HEADER
Client-side CSV import. Preferred over COPY when you’re not on the server.
\copy (SELECT ...) TO 'out.csv' CSV HEADER
Export a query result.
\timing on
Show query elapsed time.
\x [on|off]
Toggle expanded display (vertical rows).
\watch 2
Re-run last query every 2s.
\e
Open last query in $EDITOR.
\set VAR value · :VAR
Define + interpolate a psql variable.
\q
Quit.
Single-shot from the shell:psql -c "SELECT now()" for one command,
psql -f script.sql for a file. Pair with
-v ON_ERROR_STOP=1 in scripts so failures abort.
Pick once, pay laterData types
Numeric & text
SMALLINT · INTEGER · BIGINT
2 / 4 / 8 byte integers.
NUMERIC(p,s)
Exact decimal. Use for money.
REAL · DOUBLE PRECISION
4 / 8 byte floats. Lossy — not money.
TEXT
Variable-length string. Preferred over VARCHAR(n) unless you want a hard length cap.
CITEXT
Case-insensitive text (extension citext). Great for emails.
BYTEA
Raw bytes. Avoid storing large blobs — use object storage.
Time, identity, structured
TIMESTAMPTZ
UTC-stored, tz-aware. Preferred over plain TIMESTAMP.
DATE · TIME · INTERVAL
Calendar date, time-of-day, span.
BIGSERIAL
Legacy sequence-backed bigint.
BIGINT GENERATED ALWAYS AS IDENTITY
Preferred standard identity column.
UUID
128-bit. Use gen_random_uuid() (pgcrypto).
JSONB
Binary JSON, indexable. Preferred over JSON.
ARRAY (e.g. INT[])
Native array. Great for tags, less great for FK targets.
tsvector
Pre-tokenized full-text search payload.
CREATE TYPE mood AS ENUM ('lo','mid','hi')
Closed-set values. Adding members later requires ALTER TYPE.
DDL · constraintsSchema & constraints
CREATE SCHEMA app AUTHORIZATION app_user
Logical namespace.
CREATE TABLE t (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ...)
Modern identity PK.
ALTER TABLE t ADD COLUMN c TEXT
Add nullable column — instant on Postgres 11+.
ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0
Default is stored as metadata — no rewrite (11+).
ALTER TABLE t ALTER COLUMN c TYPE BIGINT USING c::BIGINT
Type change with explicit cast.
ALTER TABLE t ADD CONSTRAINT name CHECK (price > 0) NOT VALID
Skip back-fill check; validate later with VALIDATE CONSTRAINT.
ALTER TABLE t ADD FOREIGN KEY (u) REFERENCES users(id) ON DELETE CASCADE
FK with cascade.
CREATE UNIQUE INDEX CONCURRENTLY u_email ON users(email)
Online unique index. Drop in ADD CONSTRAINT ... USING INDEX.
CREATE TABLE child () INHERITS (parent)
Legacy — prefer declarative partitioning.
DROP TABLE IF EXISTS t CASCADE
Drop + dependents. Use sparingly.
SELECT · JOIN · aggregationQueries
Basics
SELECT col, expr AS alias FROM t WHERE ... ORDER BY ... LIMIT n OFFSET m
Read shape.
SELECT DISTINCT col FROM t
Unique values.
SELECT DISTINCT ON (col) ... ORDER BY col, score DESC
First row per group. Postgres-specific.
FETCH FIRST n ROWS ONLY
SQL-standard alias for LIMIT.
WHERE col = ANY(ARRAY[1,2,3])
Array membership. Same as col IN (...).
WHERE col IS DISTINCT FROM v
NULL-safe inequality.
Joins
a INNER JOIN b ON a.b_id = b.id
Match rows in both.
a LEFT JOIN b ON ...
Keep all from a; b nullable.
a FULL JOIN b ON ...
Union of left + right.
a CROSS JOIN b
Cartesian product.
a LEFT JOIN LATERAL (SELECT ... WHERE x = a.id LIMIT 3) sub ON true
Per-row subquery. Preferred for top-n-per-group.
USING (id)
Shorthand when both sides share a column name.
Aggregation
COUNT(*) · COUNT(col)
* counts rows; col skips NULL.
COUNT(DISTINCT col)
Cardinality.
array_agg(col ORDER BY x)
Collect into array.
string_agg(col, ', ' ORDER BY x)
Join into a string.
jsonb_agg(to_jsonb(t.*))
Aggregate rows into JSONB array.
FILTER (WHERE cond)
Per-aggregate filter. SUM(x) FILTER (WHERE x > 0).
GROUPING SETS / ROLLUP / CUBE
Multiple groupings in one pass.
Rank, lag, running totalsWindow functions
ROW_NUMBER() OVER (PARTITION BY g ORDER BY x)
1..N within each group.
RANK() · DENSE_RANK()
Ranking with / without gaps on ties.
LAG(col, 1) OVER (ORDER BY ts)
Previous row’s value.
LEAD(col, 1) OVER (ORDER BY ts)
Next row’s value.
SUM(x) OVER (ORDER BY ts ROWS BETWEEN ... AND ...)
Frame-bounded running total.
NTILE(4) OVER (ORDER BY x)
Bucket rows into N tiles.
PERCENT_RANK() · CUME_DIST()
Distribution within partition.
sql
-- Rank orders per customer by amount (top 3 per customer)
SELECT *
FROM (
SELECT
customer_id,
order_id,
amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
FROM orders
) t
WHERE rn <= 3;
-- Running total by day
SELECT
day,
revenue,
SUM(revenue) OVER (ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM daily_revenue;
-- Lead/lag: compare with previous row
SELECT
day, revenue,
LAG(revenue) OVER (ORDER BY day) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY day) AS delta
FROM daily_revenue;
WITH · recursiveCTEs
WITH cte AS (SELECT ...) SELECT * FROM cte
Name a subquery for reuse.
WITH cte AS MATERIALIZED (...)
Force optimization fence. Use to cache an expensive subquery.
WITH cte AS NOT MATERIALIZED (...)
Inline into the outer query (planner’s default in 12+).
WITH moved AS (DELETE FROM t WHERE ... RETURNING *) INSERT INTO archive SELECT * FROM moved
Data-modifying CTE.
WITH RECURSIVE tree AS (... UNION ALL SELECT ... FROM tree JOIN ...)
Walk trees / graphs.
Schemaless inside a relational dbJSONB
data -> 'key'
Get value as JSONB.
data ->> 'key'
Get value as TEXT.
data #> '{a,b,0}'
Path access. Returns JSONB.
data #>> '{a,b,0}'
Path access. Returns TEXT.
data @> '{"k":"v"}'
Contains. Pairs with GIN index.
data ? 'key'
Top-level key exists.
data ?| ARRAY['a','b']
Any of these keys.
data || '{"k":"v"}'::jsonb
Merge / patch.
data - 'key'
Remove key.
jsonb_set(data, '{a,b}', '"x"')
Set a nested path.
jsonb_path_query(data, '$.user.id')
SQL/JSON path. Returns set.
jsonb_array_elements(data->'arr')
Expand array into rows.
CREATE INDEX ON t USING GIN (data jsonb_path_ops)
Index for @> contains queries. Smaller / faster than default ops.
sql
-- Table with JSONB column + GIN index
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
data JSONB NOT NULL
);
CREATE INDEX events_data_gin ON events USING GIN (data jsonb_path_ops);
-- Insert
INSERT INTO events (data) VALUES
('{"type":"login","user":{"id":42,"plan":"pro"}}'),
('{"type":"click","user":{"id":7,"plan":"free"},"path":"/pricing"}');
-- Query: extract + filter (operators)
SELECT data->'user'->>'id' AS user_id, -- -> keeps JSONB
data->>'type' AS event_type -- ->> casts to text
FROM events
WHERE data @> '{"user":{"plan":"pro"}}'; -- contains
-- jsonb_path_query for nested traversal
SELECT jsonb_path_query(data, '$.user.id')
FROM events
WHERE data @? '$.user.plan ? (@ == "pro")';
INSERT · UPDATE · DELETEUpsert & DML
INSERT INTO t (...) VALUES (...) RETURNING id
Get the generated id back in the same round-trip.
INSERT INTO t (...) SELECT ... FROM src
Bulk insert from a query.
INSERT ... ON CONFLICT (col) DO UPDATE SET c = EXCLUDED.c
Upsert. EXCLUDED = the proposed row.
INSERT ... ON CONFLICT DO NOTHING
Idempotent insert.
UPDATE t SET c = v FROM other WHERE t.id = other.id
Update using a join.
DELETE FROM t USING other WHERE t.id = other.id
Delete using a join.
MERGE INTO t USING src ON ... WHEN MATCHED THEN UPDATE ...
SQL-standard merge (15+). Useful for ETL.
TRUNCATE TABLE t RESTART IDENTITY CASCADE
Fast wipe + reset sequences.
sql
-- Upsert: insert or update on conflict
INSERT INTO users (email, name, login_count)
VALUES ('a@x.com', 'Ana', 1)
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
login_count = users.login_count + 1;
-- Conflict on partial unique index → name the constraint
ON CONFLICT ON CONSTRAINT users_email_active_key DO NOTHING;
-- RETURNING — get the row(s) back in one round-trip
INSERT INTO posts (slug, title)
VALUES ('hello', 'Hello')
ON CONFLICT (slug) DO UPDATE SET title = EXCLUDED.title
RETURNING id, (xmax = 0) AS inserted; -- inserted vs updated
-- Bulk upsert from a VALUES list
INSERT INTO inventory (sku, qty)
VALUES ('a', 5), ('b', 2), ('c', 9)
ON CONFLICT (sku) DO UPDATE SET qty = EXCLUDED.qty;
B-tree, GIN, BRIN, partial, expressionIndexes
CREATE INDEX ON t (col)
Default B-tree.
CREATE INDEX CONCURRENTLY ...
Online build. No table lock. Preferred in production.
CREATE INDEX ON t (a, b DESC)
Composite. Left-anchored prefix is usable.
CREATE INDEX ON t (lower(email))
Expression index. Query must match expression exactly.
CREATE INDEX ON t (col) WHERE deleted_at IS NULL
Partial index — smaller, faster.
CREATE INDEX ON t USING GIN (tags)
Inverted index for arrays / JSONB / tsvector.
CREATE INDEX ON t USING BRIN (created_at)
Tiny index for append-only ordered data. Big tables only.
CREATE INDEX ON t USING HASH (col)
Equality-only. Rarely beats B-tree post-10.
CREATE INDEX ON t (a) INCLUDE (b, c)
Covering index — b/c served from index, no heap fetch.
REINDEX INDEX CONCURRENTLY idx
Rebuild bloated index without downtime.
Read the plan, fix the queryEXPLAIN & planner
EXPLAIN query
Plan only. Cheap.
EXPLAIN (ANALYZE, BUFFERS) query
Plan + real timing + cache hits. Actually runs the query.
EXPLAIN (ANALYZE, FORMAT JSON) query
Machine-readable. Paste into a plan visualizer.
Seq Scan
Full table read. Fine on small tables; suspect on large ones.
Index Scan / Index Only Scan
Walks index. Only means served from index alone.
Bitmap Heap Scan
Index → bitmap → batched heap reads. Good for medium-selective filters.
Nested Loop / Hash / Merge Join
Three join algorithms. Hash usually wins for big-big.
rows= vs actual rows=
Big mismatch → stale stats. Run ANALYZE.
VACUUM (ANALYZE) tbl
Reclaim space + refresh stats.
SET work_mem = '64MB'
Per-operation memory. Raise before big sorts / hashes.
CREATE STATISTICS ext_stats ON a, b FROM t
Tell planner about correlated columns.
sql
-- Plan only — fast, no execution
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- Plan + real execution stats (ANALYZE actually runs the query)
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= now() - interval '7 days'
ORDER BY o.created_at DESC
LIMIT 50;
-- What to read in the output:
-- Seq Scan → table scan; want Index/Bitmap on large tables
-- rows=... → planner estimate
-- actual rows=... → reality; big mismatch means stats are stale (ANALYZE)
-- Buffers: shared hit=… read=… → cache-hot vs disk reads
-- Sort Method: external merge → spilled to disk; raise work_mem
-- Refresh planner stats after big writes
VACUUM (ANALYZE) orders;
Detects + aborts conflicting writes. App must retry on 40001.
SELECT ... FOR UPDATE
Pessimistic row lock.
SELECT ... FOR UPDATE SKIP LOCKED
Work-queue pattern — pick rows nobody else holds.
SELECT ... FOR SHARE
Shared row lock; blocks writers.
LOCK TABLE t IN SHARE ROW EXCLUSIVE MODE
Heavy lock. Rarely needed.
pg_advisory_lock(123)
App-defined mutex keyed by bigint.
SHOW transaction_isolation
Check the current level.
Split big tablesPartitioning
CREATE TABLE events (...) PARTITION BY RANGE (created_at)
Time-range partition parent.
CREATE TABLE events_2026_05 PARTITION OF events FOR VALUES FROM ('2026-05-01') TO ('2026-06-01')
Monthly partition.
PARTITION BY LIST (region)
Discrete-value partitioning.
PARTITION BY HASH (user_id)
Even spread when no natural range exists.
ATTACH PARTITION events_old FOR VALUES FROM (...) TO (...)
Add a pre-built partition.
DETACH PARTITION events_old CONCURRENTLY
Cheap drop-by-rotation.
CREATE INDEX ON events (created_at)
Indexes propagate to partitions.
Postgres prunes partitions at plan + execution time when the filter touches the partition key. Always include the key in WHERE — or pruning won’t happen.
Streaming · logicalReplication & backups
wal_level = replica / logical
postgresql.conf setting controlling what WAL carries.
pg_basebackup -D data -X stream -R
Bootstrap a streaming replica.
primary_conninfo (standby.signal)
Standby connects to primary via this DSN.
SELECT pg_is_in_recovery()
true on standbys.
SELECT pg_promote()
Promote standby to primary.
CREATE PUBLICATION pub FOR TABLE t1, t2
Logical replication source.
CREATE SUBSCRIPTION sub CONNECTION '...' PUBLICATION pub
Logical replication target.
pg_dump -Fc -d mydb -f mydb.dump
Custom-format logical dump. Use with pg_restore.
pg_dumpall --globals-only
Roles + tablespaces only.
Users, grants, RLSRoles & security
CREATE ROLE app LOGIN PASSWORD '...'
Login role (user).
CREATE ROLE readonly NOLOGIN
Group role.
GRANT readonly TO alice
Add member.
GRANT SELECT ON ALL TABLES IN SCHEMA app TO readonly
Bulk privilege.
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO readonly
Apply to future tables too.
ALTER TABLE t ENABLE ROW LEVEL SECURITY
Turn on RLS.
CREATE POLICY tenant_isolation ON t USING (tenant_id = current_setting('app.tenant')::bigint)
Per-row visibility rule.
SET ROLE readonly; RESET ROLE;
Drop / regain privileges within a session.
pg_hba.conf
Who can connect from where, with which auth method (scram-sha-256, md5, peer).
Stuff you’ll bolt onExtensions
CREATE EXTENSION pgcrypto
gen_random_uuid(), crypt().
CREATE EXTENSION citext
Case-insensitive text type.
CREATE EXTENSION pg_trgm
Trigram similarity + GIN index for fuzzy text.
CREATE EXTENSION hstore
Key-value column type (mostly superseded by JSONB).
CREATE EXTENSION postgis
Geographic / geometric types + indexes.
CREATE EXTENSION vector
pgvector — embeddings + ANN indexes.
CREATE EXTENSION timescaledb
Time-series hypertables, compression.
CREATE EXTENSION pg_stat_statements
Track per-query call count + time. Mandatory in prod.
CREATE EXTENSION pg_cron
In-database scheduled jobs.
Tiny shop schemaEnd-to-end · Order analytics
Three tables, one composite index, one analytical query. Copy-paste runs as-is on a fresh database.
sql
-- A tiny shop schema with the choices that age well
CREATE TABLE customer (
id BIGSERIAL PRIMARY KEY,
email CITEXT UNIQUE NOT NULL, -- case-insensitive
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE product (
id BIGSERIAL PRIMARY KEY,
sku TEXT UNIQUE NOT NULL,
price NUMERIC(10,2) NOT NULL CHECK (price >= 0)
);
CREATE TABLE "order" (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT REFERENCES customer(id) ON DELETE RESTRICT,
total NUMERIC(12,2) NOT NULL,
placed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX order_customer_placed_idx ON "order" (customer_id, placed_at DESC);
-- Top 5 spenders in the last 30 days
SELECT c.email, SUM(o.total) AS spend
FROM "order" o
JOIN customer c ON c.id = o.customer_id
WHERE o.placed_at >= now() - interval '30 days'
GROUP BY c.email
ORDER BY spend DESC
LIMIT 5;
Best practiceGood to know
Default to TIMESTAMPTZ, never TIMESTAMP.TIMESTAMPTZ stores UTC and applies the session tz on read. Plain TIMESTAMP drops tz info silently — you’ll regret it the first DST boundary.
Build indexes with CONCURRENTLY in production.
A normal CREATE INDEX takes an ACCESS EXCLUSIVE lock and freezes writes. CONCURRENTLY is slower but writes keep flowing.
Install pg_stat_statements on day one.
It’s the cheapest way to find slow queries. SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; — that’s your hit list.
Common trapsWatch out for
NULL = NULL is NULL, not true.
Comparisons with NULL yield NULL and silently drop rows. Use IS NULL or IS DISTINCT FROM.
Long-running transactions block VACUUM.
Idle-in-transaction connections hold an MVCC snapshot — dead rows can’t be reclaimed, the table bloats, queries slow down. Set idle_in_transaction_session_timeout.
Adding NOT NULL without a default rewrites the table.
Use ADD COLUMN ... NOT NULL DEFAULT ... (instant on 11+) or back-fill in batches and add the constraint NOT VALID, then VALIDATE.
PostgreSQL is an open-source object-relational database known for its standards compliance, extensibility, and rich feature set. It supports ACID transactions, complex joins, full-text search, JSONB semi-structured storage, window functions, CTEs, partitioning, logical replication, and a wide ecosystem of extensions including pgvector for AI workloads.
How do PostgreSQL indexes work?
A B-tree index is the default and suits equality and range queries on sortable columns. GIN indexes index composite values like arrays, JSONB keys, and tsvector for full-text search. BRIN is efficient for naturally ordered data like timestamps in append-only tables. Use EXPLAIN (ANALYZE, BUFFERS) to confirm the planner is using your index and not doing a sequential scan.
What is JSONB in PostgreSQL?
JSONB stores JSON data in a decomposed binary format rather than raw text. It supports indexing with GIN or expression indexes, allows containment operators (@> and <@), and lets you extract fields with the ->> or #>> operators. JSONB is the preferred choice over plain JSON because it is faster to query; use it for semi-structured data where full relational normalisation is overkill.
What are CTEs in PostgreSQL?
A Common Table Expression (CTE) is a named subquery defined with the WITH clause at the start of a query. CTEs improve readability for complex queries and can be referenced multiple times in the main query body. In PostgreSQL 12+, CTEs are inlined by the planner by default (no materialisation fence), so they are generally as fast as equivalent subqueries.
How do I use EXPLAIN in PostgreSQL?
EXPLAIN shows the query planner's execution plan with estimated costs and row counts. Add ANALYZE to actually run the query and show real timings, and BUFFERS to see cache hit/miss counts. Look for Seq Scan on large tables (missing index), high rows-estimated vs. rows-actual divergence (stale statistics — run ANALYZE), and nested loop joins on unsorted large sets as common performance red flags.