DS DevShelfHub Projects · AI tools
Cheatsheets / PostgreSQL
Cheatsheet · Dev tooling

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 ≥ 16 psql ≥ 16 libpq ≥ 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.
\conninfoShow current connection.
\lList databases.
\dnList schemas.
\dt [schema.*]List tables; \dt+ adds size.
\d tableDescribe table (cols, indexes, FKs).
\d+ tableDescribe + storage, stats, comments.
\di [schema.*]List indexes.
\df nameList functions matching pattern.
\duList roles.
\dp tableShow privileges.

Workflow

\i path/file.sqlRun a script file.
\copy t FROM 'data.csv' CSV HEADERClient-side CSV import. Preferred over COPY when you’re not on the server.
\copy (SELECT ...) TO 'out.csv' CSV HEADERExport a query result.
\timing onShow query elapsed time.
\x [on|off]Toggle expanded display (vertical rows).
\watch 2Re-run last query every 2s.
\eOpen last query in $EDITOR.
\set VAR value · :VARDefine + interpolate a psql variable.
\qQuit.
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 · BIGINT2 / 4 / 8 byte integers.
NUMERIC(p,s)Exact decimal. Use for money.
REAL · DOUBLE PRECISION4 / 8 byte floats. Lossy — not money.
TEXTVariable-length string. Preferred over VARCHAR(n) unless you want a hard length cap.
CITEXTCase-insensitive text (extension citext). Great for emails.
BYTEARaw bytes. Avoid storing large blobs — use object storage.

Time, identity, structured

TIMESTAMPTZUTC-stored, tz-aware. Preferred over plain TIMESTAMP.
DATE · TIME · INTERVALCalendar date, time-of-day, span.
BIGSERIALLegacy sequence-backed bigint.
BIGINT GENERATED ALWAYS AS IDENTITYPreferred standard identity column.
UUID128-bit. Use gen_random_uuid() (pgcrypto).
JSONBBinary JSON, indexable. Preferred over JSON.
ARRAY (e.g. INT[])Native array. Great for tags, less great for FK targets.
tsvectorPre-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_userLogical namespace.
CREATE TABLE t (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ...)Modern identity PK.
ALTER TABLE t ADD COLUMN c TEXTAdd nullable column — instant on Postgres 11+.
ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0Default is stored as metadata — no rewrite (11+).
ALTER TABLE t ALTER COLUMN c TYPE BIGINT USING c::BIGINTType change with explicit cast.
ALTER TABLE t ADD CONSTRAINT name CHECK (price > 0) NOT VALIDSkip back-fill check; validate later with VALIDATE CONSTRAINT.
ALTER TABLE t ADD FOREIGN KEY (u) REFERENCES users(id) ON DELETE CASCADEFK 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 CASCADEDrop + dependents. Use sparingly.

SELECT · JOIN · aggregationQueries

Basics

SELECT col, expr AS alias FROM t WHERE ... ORDER BY ... LIMIT n OFFSET mRead shape.
SELECT DISTINCT col FROM tUnique values.
SELECT DISTINCT ON (col) ... ORDER BY col, score DESCFirst row per group. Postgres-specific.
FETCH FIRST n ROWS ONLYSQL-standard alias for LIMIT.
WHERE col = ANY(ARRAY[1,2,3])Array membership. Same as col IN (...).
WHERE col IS DISTINCT FROM vNULL-safe inequality.

Joins

a INNER JOIN b ON a.b_id = b.idMatch 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 bCartesian product.
a LEFT JOIN LATERAL (SELECT ... WHERE x = a.id LIMIT 3) sub ON truePer-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 / CUBEMultiple 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 cteName 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 movedData-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"}'::jsonbMerge / 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 idGet the generated id back in the same round-trip.
INSERT INTO t (...) SELECT ... FROM srcBulk insert from a query.
INSERT ... ON CONFLICT (col) DO UPDATE SET c = EXCLUDED.cUpsert. EXCLUDED = the proposed row.
INSERT ... ON CONFLICT DO NOTHINGIdempotent insert.
UPDATE t SET c = v FROM other WHERE t.id = other.idUpdate using a join.
DELETE FROM t USING other WHERE t.id = other.idDelete 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 CASCADEFast 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 NULLPartial 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 idxRebuild bloated index without downtime.

Read the plan, fix the queryEXPLAIN & planner

EXPLAIN queryPlan only. Cheap.
EXPLAIN (ANALYZE, BUFFERS) queryPlan + real timing + cache hits. Actually runs the query.
EXPLAIN (ANALYZE, FORMAT JSON) queryMachine-readable. Paste into a plan visualizer.
Seq ScanFull table read. Fine on small tables; suspect on large ones.
Index Scan / Index Only ScanWalks index. Only means served from index alone.
Bitmap Heap ScanIndex → bitmap → batched heap reads. Good for medium-selective filters.
Nested Loop / Hash / Merge JoinThree join algorithms. Hash usually wins for big-big.
rows= vs actual rows=Big mismatch → stale stats. Run ANALYZE.
VACUUM (ANALYZE) tblReclaim space + refresh stats.
SET work_mem = '64MB'Per-operation memory. Raise before big sorts / hashes.
CREATE STATISTICS ext_stats ON a, b FROM tTell 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;

MVCC · isolation · locksTransactions & concurrency

BEGIN; ... COMMIT; / ROLLBACK;Explicit transaction.
SAVEPOINT s; ROLLBACK TO SAVEPOINT sNested rollback within a tx.
SET TRANSACTION ISOLATION LEVEL READ COMMITTEDDefault. Each statement sees a fresh snapshot.
REPEATABLE READWhole tx sees one snapshot. Use for reporting.
SERIALIZABLEDetects + aborts conflicting writes. App must retry on 40001.
SELECT ... FOR UPDATEPessimistic row lock.
SELECT ... FOR UPDATE SKIP LOCKEDWork-queue pattern — pick rows nobody else holds.
SELECT ... FOR SHAREShared row lock; blocks writers.
LOCK TABLE t IN SHARE ROW EXCLUSIVE MODEHeavy lock. Rarely needed.
pg_advisory_lock(123)App-defined mutex keyed by bigint.
SHOW transaction_isolationCheck 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 CONCURRENTLYCheap 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 / logicalpostgresql.conf setting controlling what WAL carries.
pg_basebackup -D data -X stream -RBootstrap 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, t2Logical replication source.
CREATE SUBSCRIPTION sub CONNECTION '...' PUBLICATION pubLogical replication target.
pg_dump -Fc -d mydb -f mydb.dumpCustom-format logical dump. Use with pg_restore.
pg_dumpall --globals-onlyRoles + tablespaces only.

Users, grants, RLSRoles & security

CREATE ROLE app LOGIN PASSWORD '...' Login role (user).
CREATE ROLE readonly NOLOGINGroup role.
GRANT readonly TO aliceAdd member.
GRANT SELECT ON ALL TABLES IN SCHEMA app TO readonlyBulk privilege.
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO readonlyApply to future tables too.
ALTER TABLE t ENABLE ROW LEVEL SECURITYTurn 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.confWho can connect from where, with which auth method (scram-sha-256, md5, peer).

Stuff you’ll bolt onExtensions

CREATE EXTENSION pgcryptogen_random_uuid(), crypt().
CREATE EXTENSION citextCase-insensitive text type.
CREATE EXTENSION pg_trgmTrigram similarity + GIN index for fuzzy text.
CREATE EXTENSION hstoreKey-value column type (mostly superseded by JSONB).
CREATE EXTENSION postgisGeographic / geometric types + indexes.
CREATE EXTENSION vectorpgvector — embeddings + ANN indexes.
CREATE EXTENSION timescaledbTime-series hypertables, compression.
CREATE EXTENSION pg_stat_statementsTrack per-query call count + time. Mandatory in prod.
CREATE EXTENSION pg_cronIn-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.

Go deeperSee also

PostgreSQL FAQ

What is PostgreSQL?

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.