M
MeshWorld.
Cheatsheet PostgreSQL SQL Database Developer Tools Backend Query Optimization 8 min read

PostgreSQL Cheat Sheet: SQL, Indexes, EXPLAIN & psql

By Vishnu Damwala

Quick reference tables

psql — command line client

CommandWhat it does
psql -U postgresConnect as postgres user
psql -U user -d mydb -h localhostFull connection
psql "postgresql://user:pass@host/db"Connection string
\lList all databases
\c mydbConnect to a database
\dtList tables in current database
\dt schema.*List tables in a schema
\d tablenameDescribe table (columns, types, indexes)
\diList indexes
\dvList views
\dfList functions
\duList users/roles
\timingToggle query execution time display
\xToggle expanded output (great for wide tables)
\eOpen last query in editor
\i file.sqlRun SQL from a file
\o file.txtSend output to file
\qQuit

Database operations

CommandWhat it does
CREATE DATABASE mydb;Create a database
DROP DATABASE mydb;Delete a database
CREATE DATABASE mydb OWNER myuser;Create with owner
\c mydbSwitch to database

Table operations

CommandWhat it does
CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, created_at TIMESTAMPTZ DEFAULT NOW());Create table
DROP TABLE users;Delete table
DROP TABLE IF EXISTS users;Delete if exists
TRUNCATE TABLE users;Delete all rows (fast)
ALTER TABLE users ADD COLUMN age INT;Add column
ALTER TABLE users DROP COLUMN age;Remove column
ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(255);Change column type
ALTER TABLE users RENAME COLUMN name TO full_name;Rename column
ALTER TABLE users RENAME TO customers;Rename table

CRUD

CommandWhat it does
INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');Insert one row
INSERT INTO users (name, email) VALUES (...), (...) RETURNING id;Insert multiple, return IDs
SELECT * FROM users;Select all
SELECT name, email FROM users WHERE id = 1;Select specific columns
SELECT * FROM users ORDER BY created_at DESC LIMIT 10;Sort + limit
UPDATE users SET name = 'Bob' WHERE id = 1;Update one row
UPDATE users SET active = false WHERE last_login < NOW() - INTERVAL '90 days';Conditional update
DELETE FROM users WHERE id = 1;Delete one row
DELETE FROM users WHERE active = false RETURNING id;Delete and return

Filtering

SyntaxWhat it does
WHERE status = 'active'Equality
WHERE age > 18 AND age < 65Range
WHERE name LIKE 'Al%'Pattern match
WHERE name ILIKE 'al%'Case-insensitive LIKE
WHERE id IN (1, 2, 3)In a list
WHERE id NOT IN (...)Not in list
WHERE email IS NULLNull check
WHERE email IS NOT NULLNot null
WHERE created_at BETWEEN '2025-01-01' AND '2026-01-01'Date range

Joins

JoinWhat it does
INNER JOIN posts ON posts.user_id = users.idOnly matching rows
LEFT JOIN posts ON posts.user_id = users.idAll users, matched posts or NULL
RIGHT JOINAll posts, matched users or NULL
FULL OUTER JOINAll rows from both, NULLs for non-matches
CROSS JOINEvery combination (cartesian product)

Indexes

CommandWhat it does
CREATE INDEX ON users(email);Simple index
CREATE UNIQUE INDEX ON users(email);Unique index
CREATE INDEX ON users(last_name, first_name);Composite index
CREATE INDEX CONCURRENTLY ON users(email);Non-blocking index creation
CREATE INDEX ON posts USING GIN(tags);GIN index for arrays/JSONB
CREATE INDEX ON posts USING GiST(location);GiST for geometric/range types
DROP INDEX index_name;Remove index
\diList all indexes

Aggregations

FunctionWhat it does
COUNT(*)Count all rows
COUNT(DISTINCT user_id)Count unique values
SUM(amount)Sum
AVG(score)Average
MIN(price)Minimum
MAX(price)Maximum
GROUP BY statusGroup rows
HAVING COUNT(*) > 5Filter groups (not rows)

JSON & JSONB

OperatorWhat it does
data->>'key'Get text value from JSON
data->'key'Get JSON value (keeps type)
data#>>'{a,b}'Nested path as text
data @> '{"role":"admin"}'Contains (JSONB)
data ? 'key'Key exists (JSONB)
jsonb_set(data, '{key}', '"value"')Update a field
data - 'key'Remove a key (JSONB)

Detailed sections

EXPLAIN ANALYZE — understanding query plans

-- See the query plan without running it
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';

-- Run it and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'alice@example.com';

-- Full output with buffers (shows cache hits)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 42;

What to look for:

  • Seq Scan on a large table = missing index
  • Nested Loop with high rows = join might need an index
  • actual time much higher than estimated time = stale statistics, run ANALYZE tablename
  • Rows Removed by Filter: 99000 = index exists but not selective enough

CTEs — Common Table Expressions

-- Basic CTE (readable subquery)
WITH active_users AS (
  SELECT id, name FROM users WHERE active = true
),
recent_orders AS (
  SELECT user_id, COUNT(*) AS order_count
  FROM orders
  WHERE created_at > NOW() - INTERVAL '30 days'
  GROUP BY user_id
)
SELECT u.name, COALESCE(o.order_count, 0) AS orders_last_30_days
FROM active_users u
LEFT JOIN recent_orders o ON o.user_id = u.id
ORDER BY orders_last_30_days DESC;

-- Recursive CTE — walk a tree (e.g. org chart, file system)
WITH RECURSIVE org_tree AS (
  -- base case: top-level managers
  SELECT id, name, manager_id, 1 AS depth
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive case: their reports
  SELECT e.id, e.name, e.manager_id, t.depth + 1
  FROM employees e
  JOIN org_tree t ON e.manager_id = t.id
)
SELECT * FROM org_tree ORDER BY depth, name;

Window functions

-- Rank users by order count, partitioned by country
SELECT
  name,
  country,
  order_count,
  RANK() OVER (PARTITION BY country ORDER BY order_count DESC) AS country_rank
FROM user_stats;

-- Running total
SELECT
  created_at::date AS day,
  revenue,
  SUM(revenue) OVER (ORDER BY created_at::date) AS running_total
FROM daily_revenue;

-- Previous row value
SELECT
  month,
  revenue,
  LAG(revenue) OVER (ORDER BY month) AS prev_month,
  revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly_revenue;

-- Row number per group
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
  FROM orders
) sub
WHERE rn = 1;  -- most recent order per user

JSONB — practical queries

-- Create table with JSONB column
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT,
  attributes JSONB
);

-- Insert
INSERT INTO products (name, attributes) VALUES
  ('Laptop', '{"brand": "Dell", "ram": 16, "tags": ["electronics", "computers"]}');

-- Query by JSONB field
SELECT * FROM products WHERE attributes->>'brand' = 'Dell';

-- Query nested
SELECT * FROM products WHERE attributes->'specs'->>'cpu' = 'M3';

-- Contains check (uses GIN index)
SELECT * FROM products WHERE attributes @> '{"brand": "Dell"}';

-- Array contains
SELECT * FROM products WHERE attributes->'tags' ? 'electronics';

-- Index for JSONB (required for @> and ? to be fast)
CREATE INDEX ON products USING GIN(attributes);

-- Update a field inside JSONB
UPDATE products
SET attributes = jsonb_set(attributes, '{ram}', '32')
WHERE id = 1;

Useful admin queries

-- Show running queries
SELECT pid, now() - query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

-- Kill a long-running query
SELECT pg_cancel_backend(pid);   -- graceful
SELECT pg_terminate_backend(pid); -- force kill

-- Table sizes
SELECT
  relname AS table,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  pg_size_pretty(pg_relation_size(relid)) AS table_size,
  pg_size_pretty(pg_indexes_size(relid)) AS index_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

-- Index usage (find unused indexes)
SELECT
  relname AS table,
  indexrelname AS index,
  idx_scan AS times_used,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;

-- Cache hit ratio (should be > 99%)
SELECT
  sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS ratio
FROM pg_statio_user_tables;

-- Locks
SELECT pid, relation::regclass, mode, granted
FROM pg_locks
WHERE relation IS NOT NULL;

Backup and restore

# Dump a database
pg_dump -U postgres mydb > mydb_backup.sql

# Dump compressed
pg_dump -U postgres -Fc mydb > mydb_backup.dump

# Restore from SQL
psql -U postgres mydb < mydb_backup.sql

# Restore from compressed dump
pg_restore -U postgres -d mydb mydb_backup.dump

# Dump specific table
pg_dump -U postgres -t users mydb > users_backup.sql

# Dump all databases
pg_dumpall -U postgres > all_databases.sql

Hands-on guides: Run PostgreSQL Locally with Docker | Debug a Slow SQL Query