🗄️
SQL
SQL fundamentals — querying, joins, aggregation, window functions, indexes, CTEs and performance patterns
SELECT & Filtering
Query rows, filter with WHERE, sort and limit results
sql·SELECT basics
-- All columns
SELECT * FROM users;
-- Specific columns
SELECT id, name, email FROM users;
-- Column aliases
SELECT
id,
first_name || ' ' || last_name AS full_name,
created_at AS joined_at
FROM users;
-- Distinct values
SELECT DISTINCT country FROM users;
SELECT DISTINCT ON (country) country, name FROM users ORDER BY country, name;
-- Limit and offset
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 40;
-- Conditional expression
SELECT
name,
CASE
WHEN age < 18 THEN 'minor'
WHEN age < 65 THEN 'adult'
ELSE 'senior'
END AS age_group
FROM users;sql·WHERE — filtering rows
-- Comparison operators
SELECT * FROM orders WHERE status = 'pending';
SELECT * FROM orders WHERE total > 100;
SELECT * FROM orders WHERE total BETWEEN 50 AND 200;
SELECT * FROM users WHERE age != 30;
SELECT * FROM users WHERE name IS NULL;
SELECT * FROM users WHERE name IS NOT NULL;
-- Logical operators
SELECT * FROM orders
WHERE status = 'pending'
AND total > 100
AND created_at >= '2025-01-01';
SELECT * FROM users WHERE role = 'admin' OR role = 'moderator';
-- IN and NOT IN
SELECT * FROM orders WHERE status IN ('pending', 'processing', 'shipped');
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM banned_users);
-- LIKE — pattern matching
SELECT * FROM users WHERE email LIKE '%@gmail.com';
SELECT * FROM users WHERE name LIKE 'Al%'; -- starts with Al
SELECT * FROM users WHERE name ILIKE 'alice%'; -- case-insensitive (PostgreSQL)
SELECT * FROM users WHERE phone LIKE '___-____'; -- _ matches one char
-- EXISTS
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 500
);sql·ORDER BY, LIMIT and NULL handling
-- Sort ascending (default) and descending SELECT * FROM products ORDER BY price ASC; SELECT * FROM products ORDER BY price DESC; -- Multiple sort keys SELECT * FROM users ORDER BY country ASC, name ASC; -- NULL placement (PostgreSQL) SELECT * FROM users ORDER BY last_login DESC NULLS LAST; SELECT * FROM users ORDER BY last_login ASC NULLS FIRST; -- Top N per group using LIMIT SELECT * FROM orders ORDER BY total DESC LIMIT 10; -- COALESCE — return first non-NULL value SELECT COALESCE(nickname, first_name, 'Anonymous') AS display_name FROM users; -- NULLIF — return NULL if two values are equal (avoids division by zero) SELECT revenue / NULLIF(sessions, 0) AS revenue_per_session FROM stats;
JOINs
Combine rows from multiple tables
sql·INNER, LEFT, RIGHT and FULL JOIN
-- INNER JOIN — only matching rows from both tables SELECT o.id, u.name, o.total FROM orders o INNER JOIN users u ON o.user_id = u.id; -- LEFT JOIN — all rows from left, matched from right (NULL if no match) SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name; -- RIGHT JOIN — all rows from right, matched from left SELECT o.id, u.name FROM orders o RIGHT JOIN users u ON o.user_id = u.id; -- FULL OUTER JOIN — all rows from both sides SELECT u.name, o.id AS order_id FROM users u FULL OUTER JOIN orders o ON o.user_id = u.id; -- CROSS JOIN — every combination (Cartesian product) SELECT p.name AS product, c.name AS colour FROM products p CROSS JOIN colours c;
sql·Self join, multi-table and join filters
-- Self join — join a table to itself SELECT e.name AS employee, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id; -- Three-table join SELECT o.id AS order_id, u.name AS customer, p.name AS product, oi.quantity, oi.unit_price FROM orders o JOIN users u ON o.user_id = u.id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON oi.product_id = p.id WHERE o.status = 'completed'; -- Filter on JOIN condition vs WHERE (different results with LEFT JOIN) -- Filter in ON — filters before join (keeps all left rows) SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'completed'; -- Filter in WHERE — filters after join (removes non-matching left rows) SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'completed'; -- turns it into an INNER JOIN effectively
Aggregation & Grouping
GROUP BY, aggregate functions and HAVING
sql·Aggregate functions
-- COUNT SELECT COUNT(*) FROM orders; -- all rows including NULLs SELECT COUNT(1) FROM orders; -- same SELECT COUNT(coupon_id) FROM orders; -- non-NULL coupon_id only SELECT COUNT(DISTINCT user_id) FROM orders; -- unique users who ordered -- SUM, AVG, MIN, MAX SELECT SUM(total) AS revenue, AVG(total) AS avg_order_value, MIN(total) AS smallest_order, MAX(total) AS largest_order, ROUND(AVG(total), 2) AS avg_rounded FROM orders WHERE status = 'completed'; -- String aggregation (PostgreSQL) SELECT user_id, STRING_AGG(tag, ', ' ORDER BY tag) AS tags FROM user_tags GROUP BY user_id; -- Array aggregation (PostgreSQL) SELECT user_id, ARRAY_AGG(product_id ORDER BY created_at) AS product_ids FROM order_items GROUP BY user_id;
sql·GROUP BY and HAVING
-- Basic grouping
SELECT status, COUNT(*) AS count
FROM orders
GROUP BY status;
-- Multiple group keys
SELECT
DATE_TRUNC('month', created_at) AS month,
status,
COUNT(*) AS count,
SUM(total) AS revenue
FROM orders
GROUP BY 1, 2 -- reference by column position
ORDER BY 1 DESC, 2;
-- HAVING — filter on aggregate (WHERE runs before GROUP BY, HAVING after)
SELECT user_id, COUNT(*) AS order_count, SUM(total) AS total_spent
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 5 -- only users with 5+ orders
AND SUM(total) > 1000
ORDER BY total_spent DESC;
-- FILTER clause (PostgreSQL) — conditional aggregation
SELECT
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded,
SUM(total) FILTER (WHERE status = 'completed') AS completed_revenue
FROM orders;Subqueries & CTEs
Nested queries, WITH clauses and recursive CTEs
sql·Subqueries
-- Scalar subquery — returns single value SELECT name, salary, (SELECT AVG(salary) FROM employees) AS company_avg FROM employees; -- Subquery in WHERE SELECT * FROM products WHERE price > (SELECT AVG(price) FROM products); -- Subquery in FROM (derived table / inline view) SELECT dept, avg_salary FROM ( SELECT department AS dept, AVG(salary) AS avg_salary FROM employees GROUP BY department ) AS dept_stats WHERE avg_salary > 80000; -- Correlated subquery — references outer query row by row SELECT name, salary FROM employees e WHERE salary > ( SELECT AVG(salary) FROM employees WHERE department = e.department -- correlated to outer row ); -- ANY / ALL SELECT name FROM products WHERE price > ANY (SELECT price FROM products WHERE category = 'budget'); SELECT name FROM products WHERE price > ALL (SELECT price FROM products WHERE category = 'budget');
sql·CTEs — WITH clause
-- Basic CTE
WITH active_users AS (
SELECT id, name, email
FROM users
WHERE last_login > NOW() - INTERVAL '30 days'
)
SELECT u.name, COUNT(o.id) AS recent_orders
FROM active_users u
LEFT JOIN orders o ON o.user_id = u.id
AND o.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name;
-- Multiple CTEs
WITH
monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY 1
),
ranked AS (
SELECT *,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue
FROM monthly_revenue
)
SELECT
month,
revenue,
prev_revenue,
ROUND((revenue - prev_revenue) / NULLIF(prev_revenue, 0) * 100, 1) AS pct_change
FROM ranked
ORDER BY month;
-- Recursive CTE — walk a hierarchy
WITH RECURSIVE org_tree AS (
-- Base case: top-level managers
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case: employees reporting to previous level
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 depth, name FROM org_tree ORDER BY depth, name;Window Functions
ROW_NUMBER, RANK, LAG, LEAD, running totals and percentiles
sql·Ranking functions
-- ROW_NUMBER — unique sequential number per partition
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num
FROM employees;
-- RANK — same rank for ties, gaps after ties (1,2,2,4)
SELECT name, score,
RANK() OVER (ORDER BY score DESC) AS rank
FROM leaderboard;
-- DENSE_RANK — same rank for ties, no gaps (1,2,2,3)
SELECT name, score,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM leaderboard;
-- NTILE — divide rows into N buckets
SELECT name, salary,
NTILE(4) OVER (ORDER BY salary) AS quartile -- 1=bottom 4=top
FROM employees;
-- Top 1 per group using ROW_NUMBER
SELECT department, name, salary
FROM (
SELECT department, name, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
FROM employees
) ranked
WHERE rn = 1;sql·LAG, LEAD, running totals and frames
-- LAG / LEAD — access previous / next row value
SELECT
month,
revenue,
LAG(revenue, 1, 0) OVER (ORDER BY month) AS prev_month_revenue,
LEAD(revenue, 1) OVER (ORDER BY month) AS next_month_revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS month_over_month_change
FROM monthly_revenue;
-- Running total (cumulative sum)
SELECT
created_at::date AS day,
daily_revenue,
SUM(daily_revenue) OVER (ORDER BY created_at) AS running_total
FROM daily_stats;
-- Moving average (7-day)
SELECT
day,
revenue,
AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM daily_revenue;
-- FIRST_VALUE / LAST_VALUE
SELECT
name, salary, department,
FIRST_VALUE(name) OVER (
PARTITION BY department ORDER BY salary DESC
) AS highest_earner
FROM employees;
-- PERCENT_RANK and CUME_DIST
SELECT name, score,
ROUND(PERCENT_RANK() OVER (ORDER BY score)::numeric, 2) AS percentile,
ROUND(CUME_DIST() OVER (ORDER BY score)::numeric, 2) AS cumulative_dist
FROM results;DDL — Schema Definition
CREATE, ALTER, DROP tables, columns and constraints
sql·CREATE TABLE with constraints
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user', 'admin', 'moderator')),
age INT CHECK (age >= 0 AND age <= 150),
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
total NUMERIC(10,2) NOT NULL CHECK (total >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Named table constraint
CONSTRAINT orders_status_check
CHECK (status IN ('pending','processing','shipped','completed','refunded'))
);
-- If not exists
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);sql·ALTER TABLE — modify schema
-- Add column ALTER TABLE users ADD COLUMN phone TEXT; ALTER TABLE users ADD COLUMN verified_at TIMESTAMPTZ; -- Add with default (PostgreSQL rewrites table — use batched backfill for large tables) ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE; -- Drop column ALTER TABLE users DROP COLUMN phone; ALTER TABLE users DROP COLUMN IF EXISTS legacy_field; -- Rename column ALTER TABLE users RENAME COLUMN name TO full_name; -- Change type ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(12,2); -- Set / drop default ALTER TABLE users ALTER COLUMN role SET DEFAULT 'user'; ALTER TABLE users ALTER COLUMN role DROP DEFAULT; -- Set / drop NOT NULL ALTER TABLE users ALTER COLUMN phone SET NOT NULL; ALTER TABLE users ALTER COLUMN phone DROP NOT NULL; -- Add constraint ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id); -- Drop constraint ALTER TABLE orders DROP CONSTRAINT fk_user; -- Rename table ALTER TABLE user_sessions RENAME TO sessions;
Indexes
Create, choose and analyse indexes for query performance
sql·Creating indexes
-- Basic index
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Unique index (enforces uniqueness like a constraint)
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- Composite index — column order matters
-- Useful for: WHERE user_id = ? AND status = ?
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Partial index — index a subset of rows
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
-- Index on expression
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- Enables: WHERE LOWER(email) = LOWER('User@Example.com')
-- JSONB index (PostgreSQL)
CREATE INDEX idx_users_meta ON users USING GIN(metadata);
-- Full-text search index
CREATE INDEX idx_posts_search ON posts USING GIN(to_tsvector('english', title || ' ' || body));
-- Concurrent index build (no table lock)
CREATE INDEX CONCURRENTLY idx_orders_created ON orders(created_at);
-- Drop index
DROP INDEX IF EXISTS idx_orders_user_id;
DROP INDEX CONCURRENTLY idx_orders_created;sql·EXPLAIN and query analysis
-- Show query plan (no execution) EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'pending'; -- Execute and show actual timing and row counts EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42; -- Full detail — buffers, I/O, format EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.created_at > NOW() - INTERVAL '7 days'; -- Key terms in EXPLAIN output: -- Seq Scan — full table scan (no index used) -- Index Scan — uses index to find rows then fetches from heap -- Index Only Scan — all needed columns in index (fastest) -- Bitmap Scan — multi-index combine, good for large result sets -- Hash Join — good for large unsorted datasets -- Nested Loop — good for small inner datasets -- Sort — check if it could be eliminated with an index -- cost=X..Y — estimated startup..total cost (arbitrary units) -- rows=N — estimated row count (check against actual) -- actual time=X..Y rows=N — actual execution data (ANALYZE only)
Data Modification
INSERT, UPDATE, DELETE, UPSERT and RETURNING
sql·INSERT
-- Single row
INSERT INTO users (name, email, role)
VALUES ('Alice', 'alice@example.com', 'admin');
-- Multiple rows
INSERT INTO tags (name, slug) VALUES
('JavaScript', 'javascript'),
('Python', 'python'),
('Rust', 'rust');
-- Insert from SELECT
INSERT INTO archived_orders (id, user_id, total, created_at)
SELECT id, user_id, total, created_at
FROM orders
WHERE created_at < NOW() - INTERVAL '1 year';
-- RETURNING — get back inserted values
INSERT INTO users (name, email)
VALUES ('Bob', 'bob@example.com')
RETURNING id, created_at;
-- ON CONFLICT (UPSERT)
INSERT INTO user_settings (user_id, theme, notifications)
VALUES (42, 'dark', true)
ON CONFLICT (user_id) DO UPDATE
SET theme = EXCLUDED.theme,
notifications = EXCLUDED.notifications,
updated_at = NOW();
-- ON CONFLICT DO NOTHING
INSERT INTO event_log (event_id, user_id)
VALUES (101, 42)
ON CONFLICT (event_id, user_id) DO NOTHING;sql·UPDATE and DELETE
-- UPDATE single column
UPDATE users SET name = 'Alice Smith' WHERE id = 42;
-- UPDATE multiple columns
UPDATE orders
SET status = 'shipped',
shipped_at = NOW(),
updated_at = NOW()
WHERE id = 101;
-- UPDATE with JOIN (PostgreSQL — use FROM)
UPDATE orders o
SET status = 'vip_pending'
FROM users u
WHERE o.user_id = u.id
AND u.role = 'vip'
AND o.status = 'pending';
-- UPDATE with subquery
UPDATE products
SET price = price * 1.10
WHERE category_id IN (
SELECT id FROM categories WHERE name = 'Electronics'
);
-- UPDATE RETURNING
UPDATE users SET verified_at = NOW()
WHERE email_token = 'abc123'
RETURNING id, email, verified_at;
-- DELETE
DELETE FROM sessions WHERE expires_at < NOW();
DELETE FROM users WHERE id = 42;
-- DELETE with JOIN (PostgreSQL)
DELETE FROM order_items oi
USING orders o
WHERE oi.order_id = o.id AND o.status = 'cancelled';
-- DELETE RETURNING
DELETE FROM notifications WHERE read = true
RETURNING id;
-- TRUNCATE — fast full table delete (no WHERE, no RETURNING)
TRUNCATE TABLE sessions;
TRUNCATE TABLE orders, order_items CASCADE; -- truncate dependents tooTransactions & Locking
ACID transactions, savepoints, isolation levels and locking
sql·Transactions and savepoints
-- Basic transaction BEGIN; UPDATE accounts SET balance = balance - 500 WHERE id = 1; UPDATE accounts SET balance = balance + 500 WHERE id = 2; COMMIT; -- Rollback on error BEGIN; INSERT INTO orders (user_id, total) VALUES (42, 99.99); -- something goes wrong ROLLBACK; -- Savepoints — partial rollback within a transaction BEGIN; INSERT INTO orders (user_id, total) VALUES (42, 99.99) RETURNING id; SAVEPOINT after_order; INSERT INTO payments (order_id, amount) VALUES (1001, 99.99); -- payment fails ROLLBACK TO SAVEPOINT after_order; -- try alternative payment INSERT INTO payment_attempts (order_id, error) VALUES (1001, 'card_declined'); COMMIT; -- Isolation levels (ascending strictness) BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; -- default in PostgreSQL BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- strongest, slowest
sql·Locking — SELECT FOR UPDATE and advisory locks
-- SELECT FOR UPDATE — lock rows for update within transaction
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- no other transaction can update this row until COMMIT/ROLLBACK
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- SKIP LOCKED — skip rows locked by other transactions (queue pattern)
BEGIN;
SELECT * FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
COMMIT;
-- FOR SHARE — allow other readers but block writers
SELECT * FROM products WHERE id = 42 FOR SHARE;
-- Advisory locks — application-level locks (PostgreSQL)
-- Useful for ensuring only one process runs a job
SELECT pg_try_advisory_lock(42); -- returns true if acquired
SELECT pg_advisory_lock(42); -- blocks until acquired
SELECT pg_advisory_unlock(42); -- release
SELECT pg_advisory_unlock_all(); -- release all for this session
-- Session-level advisory lock pattern
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('send_weekly_email'));
-- only one process enters here at a time
-- lock released automatically at transaction end
COMMIT;Useful Patterns
Pagination, pivot, gaps, deduplication and full-text search
sql·Pagination — offset vs keyset
-- OFFSET pagination (simple but slow on large pages)
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 200; -- gets slower as offset grows
-- Keyset (cursor) pagination — fast regardless of depth
-- Page 1
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Next page — pass last row's (created_at, id) as cursor
SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ('2025-03-01 12:00:00', 9876)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Total count alongside results (CTE pattern)
WITH filtered AS (
SELECT id, title, created_at
FROM posts
WHERE author_id = 42
),
counted AS (SELECT COUNT(*) AS total FROM filtered)
SELECT f.*, c.total
FROM filtered f, counted c
ORDER BY f.created_at DESC
LIMIT 20 OFFSET 0;sql·Pivot / crosstab and conditional aggregation
-- Conditional aggregation — manual pivot
SELECT
DATE_TRUNC('month', created_at)::date AS month,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
SUM(total) FILTER (WHERE status = 'completed') AS revenue
FROM orders
GROUP BY 1
ORDER BY 1;
-- Find gaps in a sequence (missing IDs)
SELECT s.id AS missing_id
FROM generate_series(1, (SELECT MAX(id) FROM orders)) AS s(id)
LEFT JOIN orders o ON o.id = s.id
WHERE o.id IS NULL;
-- Deduplicate — keep one row per duplicate group
DELETE FROM users
WHERE id NOT IN (
SELECT MIN(id)
FROM users
GROUP BY email -- keep the oldest row per email
);
-- OR using CTE with ROW_NUMBER (safer — preview before deleting)
WITH dupes AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
)
DELETE FROM users WHERE id IN (SELECT id FROM dupes WHERE rn > 1);sql·Full-text search (PostgreSQL)
-- to_tsvector converts text to lexemes
-- to_tsquery parses a search query
-- Basic search
SELECT title, body
FROM posts
WHERE to_tsvector('english', title || ' ' || body)
@@ to_tsquery('english', 'database & performance');
-- plainto_tsquery — plain text input (no operators needed)
SELECT title FROM posts
WHERE to_tsvector('english', title) @@ plainto_tsquery('english', 'sql tutorial');
-- websearch_to_tsquery — Google-style syntax
SELECT * FROM posts
WHERE fts_vector @@ websearch_to_tsquery('english', '"window functions" -basic');
-- Rank results by relevance
SELECT title,
ts_rank(to_tsvector('english', title || ' ' || body),
to_tsquery('english', 'index & performance')) AS rank
FROM posts
WHERE to_tsvector('english', title || ' ' || body)
@@ to_tsquery('english', 'index & performance')
ORDER BY rank DESC;
-- Highlight matching terms
SELECT ts_headline('english', body,
to_tsquery('english', 'window & function'),
'StartSel=<mark>, StopSel=</mark>, MaxWords=30'
) AS excerpt
FROM posts;