postgresql-expert
Expert PostgreSQL database guidance using Bun.sql client. Provides comprehensive patterns for queries, schema design, JSON/JSONB operations, full-text search, indexing, PL/pgSQL, pgvector, and performance optimization. Use when working with PostgreSQL databases, writing SQL queries, optimizing performance, designing schemas, or implementing database features. Complements bun-expert skill for Bun.sql integration.
$ 설치
git clone https://github.com/majiayu000/claude-skill-registry /tmp/claude-skill-registry && cp -r /tmp/claude-skill-registry/skills/data/postgresql-expert ~/.claude/skills/claude-skill-registry// tip: Run this command in your terminal to install the skill
name: postgresql-expert description: | Expert PostgreSQL database guidance using Bun.sql client. Provides comprehensive patterns for queries, schema design, JSON/JSONB operations, full-text search, indexing, PL/pgSQL, pgvector, and performance optimization. Use when working with PostgreSQL databases, writing SQL queries, optimizing performance, designing schemas, or implementing database features. Complements bun-expert skill for Bun.sql integration. allowed-tools: Read, Write, Edit, Bash, Grep, Glob
PostgreSQL Expert Skill
Expert guidance for PostgreSQL development using Bun's native SQL client. This skill provides comprehensive patterns for all PostgreSQL features while integrating seamlessly with Bun.sql.
Prerequisite: This skill works alongside the
bun-expertskill. For Bun-specific patterns (runtime, bundler, package management), refer to that skill.
Bun.sql PostgreSQL Integration
Connection Setup
import { sql, SQL } from "bun";
// Environment-based (recommended) - uses POSTGRES_URL, DATABASE_URL, or PG* vars
const db = sql;
// Explicit connection with options
const db = new SQL({
hostname: "localhost",
port: 5432,
database: "myapp",
username: "dbuser",
password: "secretpass",
// Connection pool settings
max: 20, // Maximum connections (default: 10)
idleTimeout: 30, // Close idle connections after 30s
maxLifetime: 3600, // Max connection lifetime in seconds
connectionTimeout: 30, // Connection timeout
// SSL/TLS
tls: true, // or { rejectUnauthorized: true, ca: "..." }
// BigInt handling
bigint: true, // Return large numbers as BigInt
// Prepared statements
prepare: true, // Enable named prepared statements (default)
});
// Dynamic passwords (AWS RDS IAM, etc.)
const db = new SQL(url, {
password: async () => await signer.getAuthToken(),
});
Tagged Template Queries
// All interpolated values are safely parameterized
const users = await sql`
SELECT * FROM users
WHERE status = ${status}
AND created_at > ${date}
`;
// Object insertion helper
const [user] = await sql`
INSERT INTO users ${sql({ name, email, role })}
RETURNING *
`;
// Bulk insert
await sql`INSERT INTO users ${sql(usersArray)}`;
// Pick specific columns
await sql`INSERT INTO users ${sql(userData, "name", "email")}`;
// Dynamic updates
await sql`UPDATE users SET ${sql(updates)} WHERE id = ${id}`;
// WHERE IN queries
await sql`SELECT * FROM users WHERE id IN ${sql([1, 2, 3])}`;
// PostgreSQL arrays
await sql`INSERT INTO tags (items) VALUES (${sql.array(["a", "b", "c"])})`;
await sql`SELECT * FROM products WHERE id = ANY(${sql.array(ids)})`;
// Conditional query fragments
const filter = showActive ? sql`AND active = true` : sql``;
await sql`SELECT * FROM users WHERE 1=1 ${filter}`;
Transactions
// Auto-commit/rollback transaction
await sql.begin(async (tx) => {
const [user] = await tx`INSERT INTO users (name) VALUES (${"Alice"}) RETURNING *`;
await tx`INSERT INTO accounts (user_id) VALUES (${user.id})`;
// Auto-commits on success, auto-rollbacks on error
});
// Transaction with options
await sql.begin("read write", async (tx) => {
// Transaction body
});
// Savepoints (nested transactions)
await sql.begin(async (tx) => {
await tx`INSERT INTO users (name) VALUES (${"Alice"})`;
await tx.savepoint(async (sp) => {
await sp`UPDATE users SET status = 'pending'`;
if (shouldRollback) throw new Error("Rollback savepoint");
});
// Continues even if savepoint rolled back
await tx`INSERT INTO audit_log (action) VALUES ('user_created')`;
});
// Reserved connections
const reserved = await sql.reserve();
try {
await reserved`SELECT * FROM locked_table FOR UPDATE`;
} finally {
reserved.release();
}
// Using Symbol.dispose (auto-release)
{
using conn = await sql.reserve();
await conn`SELECT 1`;
} // Auto-released
Error Handling
import { SQL } from "bun";
try {
await sql`INSERT INTO users (email) VALUES (${email})`;
} catch (error) {
if (error instanceof SQL.PostgresError) {
switch (error.code) {
case "23505": // unique_violation
throw new ConflictError(`Email already exists: ${error.detail}`);
case "23503": // foreign_key_violation
throw new NotFoundError(`Referenced record not found`);
case "23514": // check_violation
throw new ValidationError(`Check constraint failed: ${error.constraint}`);
default:
console.error({
code: error.code,
message: error.message,
detail: error.detail,
hint: error.hint,
table: error.table,
column: error.column,
constraint: error.constraint,
});
throw error;
}
}
throw error;
}
Type Mapping (PostgreSQL ↔ JavaScript)
| PostgreSQL | JavaScript | Notes |
|---|---|---|
INTEGER, SMALLINT | number | Within safe integer range |
BIGINT | string or BigInt | BigInt if bigint: true option |
NUMERIC, DECIMAL | string | Preserves precision |
REAL, DOUBLE PRECISION | number | |
BOOLEAN | boolean | |
TEXT, VARCHAR, CHAR | string | |
DATE, TIMESTAMP, TIMESTAMPTZ | Date | JavaScript Date object |
JSON, JSONB | object or array | Auto-parsed |
BYTEA | Buffer | Binary data |
UUID | string | |
ARRAY | Array | Automatic conversion |
INTERVAL | string | PostgreSQL interval format |
Core SQL Patterns
SELECT with All Clauses
-- Full SELECT syntax
SELECT DISTINCT ON (customer_id)
o.id,
o.order_date,
c.name AS customer_name,
SUM(oi.quantity * oi.price) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'completed'
AND o.order_date >= NOW() - INTERVAL '30 days'
GROUP BY o.id, o.order_date, c.name
HAVING SUM(oi.quantity * oi.price) > 100
ORDER BY customer_id, order_date DESC
LIMIT 10 OFFSET 0
FOR UPDATE SKIP LOCKED;
// Bun.sql implementation
const orders = await sql`
SELECT DISTINCT ON (customer_id)
o.id,
o.order_date,
c.name AS customer_name,
SUM(oi.quantity * oi.price) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = ${status}
AND o.order_date >= NOW() - INTERVAL '30 days'
GROUP BY o.id, o.order_date, c.name
HAVING SUM(oi.quantity * oi.price) > ${minTotal}
ORDER BY customer_id, order_date DESC
LIMIT ${limit} OFFSET ${offset}
`;
UPSERT (INSERT ON CONFLICT)
// Upsert single record
const [product] = await sql`
INSERT INTO products (sku, name, price, quantity)
VALUES (${sku}, ${name}, ${price}, ${quantity})
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
price = EXCLUDED.price,
quantity = products.quantity + EXCLUDED.quantity,
updated_at = NOW()
RETURNING *
`;
// Bulk upsert
await sql`
INSERT INTO inventory ${sql(items)}
ON CONFLICT (product_id, warehouse_id) DO UPDATE SET
quantity = EXCLUDED.quantity,
updated_at = NOW()
`;
// Upsert with condition
await sql`
INSERT INTO prices (product_id, price, effective_date)
VALUES (${productId}, ${price}, ${date})
ON CONFLICT (product_id)
WHERE effective_date < ${date}
DO UPDATE SET price = EXCLUDED.price, effective_date = EXCLUDED.effective_date
`;
UPDATE with FROM and RETURNING
// Update with join
const updated = await sql`
UPDATE orders o
SET
status = 'shipped',
shipped_at = NOW(),
shipped_by = ${userId}
FROM shipments s
WHERE s.order_id = o.id
AND s.status = 'ready'
RETURNING o.id, o.status, o.shipped_at
`;
// Update with subquery
await sql`
UPDATE employees
SET salary = (
SELECT AVG(salary) * 1.1
FROM employees e2
WHERE e2.department_id = employees.department_id
)
WHERE performance_rating > 4
`;
DELETE with USING
// Delete with join
const deleted = await sql`
DELETE FROM order_items oi
USING orders o
WHERE oi.order_id = o.id
AND o.status = 'cancelled'
AND o.cancelled_at < NOW() - INTERVAL '90 days'
RETURNING oi.id, oi.order_id
`;
Common Table Expressions (CTEs)
// Basic CTE
const topCustomers = await sql`
WITH customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_spent,
COUNT(*) AS order_count
FROM orders
WHERE created_at >= NOW() - INTERVAL '1 year'
GROUP BY customer_id
)
SELECT
c.name,
c.email,
ct.total_spent,
ct.order_count
FROM customer_totals ct
JOIN customers c ON c.id = ct.customer_id
WHERE ct.total_spent > ${threshold}
ORDER BY ct.total_spent DESC
`;
// Recursive CTE (hierarchical data)
const orgChart = await sql`
WITH RECURSIVE org_tree AS (
-- Base case: root nodes
SELECT
id, name, manager_id,
1 AS level,
ARRAY[id] AS path,
name AS full_path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case
SELECT
e.id, e.name, e.manager_id,
t.level + 1,
t.path || e.id,
t.full_path || ' > ' || e.name
FROM employees e
JOIN org_tree t ON e.manager_id = t.id
WHERE NOT e.id = ANY(t.path) -- Cycle detection
)
SELECT * FROM org_tree
ORDER BY path
`;
// CTE for modifying data
await sql`
WITH deleted_orders AS (
DELETE FROM orders
WHERE status = 'cancelled' AND created_at < NOW() - INTERVAL '1 year'
RETURNING *
)
INSERT INTO archived_orders
SELECT * FROM deleted_orders
`;
Window Functions
// Ranking functions
const rankedProducts = await sql`
SELECT
category,
name,
price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS row_num,
RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS dense_rank,
PERCENT_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS pct_rank
FROM products
WHERE active = true
`;
// LAG/LEAD for time-series analysis
const salesTrend = await sql`
SELECT
date,
revenue,
LAG(revenue, 1) OVER (ORDER BY date) AS prev_day,
LEAD(revenue, 1) OVER (ORDER BY date) AS next_day,
revenue - LAG(revenue, 1) OVER (ORDER BY date) AS daily_change,
SUM(revenue) OVER (ORDER BY date) AS running_total,
AVG(revenue) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7day
FROM daily_sales
WHERE date >= ${startDate}
`;
// FIRST_VALUE, LAST_VALUE, NTH_VALUE
const categoryStats = await sql`
SELECT DISTINCT
category,
FIRST_VALUE(name) OVER w AS cheapest,
LAST_VALUE(name) OVER w AS most_expensive,
NTH_VALUE(name, 2) OVER w AS second_cheapest
FROM products
WINDOW w AS (
PARTITION BY category
ORDER BY price
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
`;
GROUPING SETS, CUBE, ROLLUP
// Multi-dimensional aggregation
const salesReport = await sql`
SELECT
COALESCE(region, 'All Regions') AS region,
COALESCE(category, 'All Categories') AS category,
COALESCE(TO_CHAR(sale_date, 'YYYY-MM'), 'All Months') AS month,
SUM(amount) AS total_sales,
COUNT(*) AS transaction_count,
GROUPING(region, category, sale_date) AS grouping_level
FROM sales
WHERE sale_date >= ${startDate}
GROUP BY CUBE (region, category, DATE_TRUNC('month', sale_date))
ORDER BY
GROUPING(region) DESC,
GROUPING(category) DESC,
region, category, month
`;
// ROLLUP for hierarchical totals
const hierarchicalReport = await sql`
SELECT
year,
quarter,
month,
SUM(revenue) AS total_revenue
FROM sales
GROUP BY ROLLUP (year, quarter, month)
ORDER BY year, quarter, month
`;
Lateral Joins
// Get top N items per category
const topPerCategory = await sql`
SELECT c.name AS category, p.*
FROM categories c
CROSS JOIN LATERAL (
SELECT id, name, price
FROM products
WHERE category_id = c.id
ORDER BY sales_count DESC
LIMIT 3
) p
`;
// Correlated subquery as lateral join
const userActivity = await sql`
SELECT
u.id,
u.name,
recent.order_count,
recent.total_spent
FROM users u
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS order_count,
COALESCE(SUM(amount), 0) AS total_spent
FROM orders o
WHERE o.user_id = u.id
AND o.created_at > NOW() - INTERVAL '30 days'
) recent ON true
WHERE u.active = true
`;
JSON/JSONB Operations
Extraction and Querying
// JSON extraction
const users = await sql`
SELECT
id,
data->>'name' AS name, -- Text extraction
data->'address'->>'city' AS city, -- Nested text
data->'address'->'coordinates' AS coords, -- JSON value
data#>>'{contacts,0,email}' AS primary_email, -- Path extraction
data->'tags'->0 AS first_tag -- Array index
FROM users
WHERE data->>'status' = ${status}
`;
// JSONB containment queries (uses GIN index)
const products = await sql`
SELECT * FROM products
WHERE metadata @> ${sql({ category: "electronics", inStock: true })}
`;
// Key existence
const withEmail = await sql`
SELECT * FROM users WHERE data ? 'email'
`;
// Any/all keys exist
const withContact = await sql`
SELECT * FROM users
WHERE data ?| ARRAY['email', 'phone'] -- Any of these
`;
const complete = await sql`
SELECT * FROM users
WHERE data ?& ARRAY['email', 'phone', 'address'] -- All of these
`;
JSON Path Queries
// JSON path existence
const filtered = await sql`
SELECT * FROM products
WHERE data @? '$.tags[*] ? (@ == "sale")'
`;
// JSON path query functions
const extracted = await sql`
SELECT
id,
jsonb_path_query_array(data, '$.items[*].price') AS all_prices,
jsonb_path_query_first(data, '$.items[0].name') AS first_item
FROM orders
WHERE jsonb_path_exists(data, '$.items[*] ? (@.quantity > 10)')
`;
// JSON path with variables
const expensiveItems = await sql`
SELECT jsonb_path_query(
data,
'$.items[*] ? (@.price > $min_price)',
${sql({ min_price: 100 })}
) AS expensive_items
FROM orders
`;
JSON Modification
// Update nested value
await sql`
UPDATE users
SET data = jsonb_set(
data,
'{address,city}',
${sql(JSON.stringify(newCity))}::jsonb
)
WHERE id = ${userId}
`;
// Add to array
await sql`
UPDATE products
SET data = jsonb_insert(
data,
'{tags,0}',
${sql(JSON.stringify(newTag))}::jsonb
)
WHERE id = ${productId}
`;
// Concatenate/merge objects
await sql`
UPDATE users
SET data = data || ${sql({ lastLogin: new Date().toISOString() })}::jsonb
WHERE id = ${userId}
`;
// Remove key
await sql`
UPDATE users
SET data = data - 'temporaryField'
WHERE data ? 'temporaryField'
`;
// Remove at path
await sql`
UPDATE users
SET data = data #- '{address,apartment}'
WHERE id = ${userId}
`;
JSON Aggregation
// Build JSON from query results
const orderWithItems = await sql`
SELECT
o.id,
o.created_at,
json_build_object(
'id', c.id,
'name', c.name,
'email', c.email
) AS customer,
json_agg(
json_build_object(
'product', p.name,
'quantity', oi.quantity,
'price', oi.price
) ORDER BY p.name
) AS items,
json_object_agg(p.sku, oi.quantity) AS quantities_by_sku
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.id = ${orderId}
GROUP BY o.id, c.id
`;
// Expand JSON to rows
const expandedItems = await sql`
SELECT
o.id,
item->>'name' AS item_name,
(item->>'price')::numeric AS item_price
FROM orders o,
jsonb_array_elements(o.data->'items') AS item
WHERE o.status = 'pending'
`;
// JSON to record
const structured = await sql`
SELECT *
FROM jsonb_to_record(${sql(jsonData)}::jsonb)
AS x(name text, age int, email text)
`;
For complete JSON/JSONB reference, see references/json-operations.md.
Full-Text Search
Basic Full-Text Search
// Simple search
const results = await sql`
SELECT
id,
title,
ts_headline('english', body, query, 'StartSel=<mark>, StopSel=</mark>') AS snippet,
ts_rank(search_vector, query) AS rank
FROM articles,
to_tsquery('english', ${searchTerms}) AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT ${limit}
`;
// Phrase search
const phraseResults = await sql`
SELECT * FROM articles
WHERE search_vector @@ phraseto_tsquery('english', ${phrase})
`;
// Web search syntax (supports OR, quotes, -)
const webSearch = await sql`
SELECT * FROM articles
WHERE search_vector @@ websearch_to_tsquery('english', ${userQuery})
`;
Weighted Search
// Create weighted search vector
await sql`
UPDATE articles SET search_vector =
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(subtitle, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(abstract, '')), 'C') ||
setweight(to_tsvector('english', COALESCE(body, '')), 'D')
`;
// Search with custom weights
const weighted = await sql`
SELECT
id, title,
ts_rank(search_vector, query, 1) AS rank -- 1 = normalize by document length
FROM articles, to_tsquery('english', ${terms}) AS query
WHERE search_vector @@ query
ORDER BY rank DESC
`;
Full-Text Search Indexes
// GIN index for full-text search
await sql`
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector)
`;
// Expression-based index
await sql`
CREATE INDEX articles_title_search_idx
ON articles
USING GIN (to_tsvector('english', title))
`;
// Combined with other columns
await sql`
CREATE INDEX articles_search_idx
ON articles
USING GIN (search_vector)
WHERE status = 'published'
`;
Trigger for Auto-Updating Search Vector
-- Create trigger function
CREATE OR REPLACE FUNCTION articles_search_vector_update()
RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.subtitle, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.body, '')), 'D');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger
CREATE TRIGGER articles_search_vector_trigger
BEFORE INSERT OR UPDATE OF title, subtitle, body ON articles
FOR EACH ROW
EXECUTE FUNCTION articles_search_vector_update();
For complete full-text search guide, see references/full-text-search.md.
Indexing Strategies
Index Type Selection Guide
| Use Case | Index Type | Example |
|---|---|---|
| Equality/range queries | B-tree (default) | WHERE status = 'active' |
| Equality only | Hash | WHERE id = 123 |
| Array containment | GIN | WHERE tags @> ARRAY['sql'] |
| JSONB queries | GIN | WHERE data @> '{"key": "value"}' |
| Full-text search | GIN | WHERE search_vector @@ query |
| Geometric/range types | GiST | WHERE box @> point |
| Nearest neighbor | GiST | ORDER BY location <-> point |
| Large sequential data | BRIN | WHERE created_at > '2024-01-01' |
| Fuzzy text matching | GIN + pg_trgm | WHERE name % 'Jon' |
| Vector similarity | HNSW/IVFFlat | ORDER BY embedding <-> vector |
Creating Indexes
// B-tree (default) - equality and range
await sql`CREATE INDEX orders_customer_id_idx ON orders (customer_id)`;
await sql`CREATE INDEX orders_date_idx ON orders (created_at DESC)`;
// Multi-column index
await sql`CREATE INDEX orders_customer_date_idx ON orders (customer_id, created_at DESC)`;
// Partial index (filtered)
await sql`CREATE INDEX orders_pending_idx ON orders (created_at) WHERE status = 'pending'`;
// Expression index
await sql`CREATE INDEX users_email_lower_idx ON users (LOWER(email))`;
// GIN for arrays
await sql`CREATE INDEX products_tags_idx ON products USING GIN (tags)`;
// GIN for JSONB
await sql`CREATE INDEX users_data_idx ON users USING GIN (data)`;
await sql`CREATE INDEX users_data_path_idx ON users USING GIN (data jsonb_path_ops)`;
// GiST for geometric/range
await sql`CREATE INDEX locations_point_idx ON locations USING GiST (coordinates)`;
// BRIN for large sequential tables
await sql`CREATE INDEX logs_created_idx ON logs USING BRIN (created_at) WITH (pages_per_range = 128)`;
// Covering index (include columns for index-only scans)
await sql`
CREATE INDEX orders_customer_covering_idx
ON orders (customer_id)
INCLUDE (order_date, total_amount)
`;
// Concurrent index creation (no blocking)
await sql`CREATE INDEX CONCURRENTLY users_email_idx ON users (email)`;
Fuzzy Text Matching (pg_trgm)
// Enable extension
await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`;
// Create trigram index
await sql`CREATE INDEX users_name_trgm_idx ON users USING GIN (name gin_trgm_ops)`;
// Similarity search
const similar = await sql`
SELECT name, similarity(name, ${searchTerm}) AS sim
FROM users
WHERE name % ${searchTerm}
ORDER BY sim DESC
LIMIT 10
`;
// ILIKE with index support
const matches = await sql`
SELECT * FROM products
WHERE name ILIKE ${`%${searchTerm}%`}
`;
For complete indexing guide, see references/indexing-strategies.md.
pgvector - Vector Similarity Search
Setup
await sql`CREATE EXTENSION IF NOT EXISTS vector`;
// Create table with vector column
await sql`
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI embedding dimension
metadata JSONB DEFAULT '{}'
)
`;
Storing and Querying Vectors
// Insert with embedding
await sql`
INSERT INTO documents (content, embedding, metadata)
VALUES (
${content},
${sql.array(embeddingArray)}::vector,
${sql({ source: "upload", category })}
)
`;
// Similarity search (L2 distance)
const similar = await sql`
SELECT
id,
content,
embedding <-> ${sql.array(queryEmbedding)}::vector AS distance
FROM documents
ORDER BY embedding <-> ${sql.array(queryEmbedding)}::vector
LIMIT ${k}
`;
// Cosine similarity
const cosineSimilar = await sql`
SELECT
id,
content,
1 - (embedding <=> ${sql.array(queryEmbedding)}::vector) AS similarity
FROM documents
ORDER BY embedding <=> ${sql.array(queryEmbedding)}::vector
LIMIT ${k}
`;
// Inner product (for normalized vectors)
const innerProduct = await sql`
SELECT id, content
FROM documents
ORDER BY embedding <#> ${sql.array(queryEmbedding)}::vector
LIMIT ${k}
`;
// Filtered similarity search
const filtered = await sql`
SELECT id, content
FROM documents
WHERE metadata @> ${sql({ category: "technical" })}
ORDER BY embedding <-> ${sql.array(queryEmbedding)}::vector
LIMIT ${k}
`;
Vector Indexes
// HNSW index (better query performance, can build before data)
await sql`
CREATE INDEX documents_embedding_hnsw_idx
ON documents
USING hnsw (embedding vector_l2_ops)
WITH (m = 16, ef_construction = 64)
`;
// IVFFlat index (build after data loaded)
await sql`
CREATE INDEX documents_embedding_ivfflat_idx
ON documents
USING ivfflat (embedding vector_l2_ops)
WITH (lists = 100)
`;
// Cosine distance index
await sql`
CREATE INDEX documents_embedding_cosine_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
`;
// Set search parameters for better recall
await sql`SET ivfflat.probes = 10`;
await sql`SET hnsw.ef_search = 100`;
For complete pgvector guide, see references/pgvector-guide.md.
PL/pgSQL Functions and Triggers
Function Examples
// Create function
await sql`
CREATE OR REPLACE FUNCTION calculate_order_total(order_id INTEGER)
RETURNS NUMERIC AS $$
DECLARE
total NUMERIC := 0;
BEGIN
SELECT COALESCE(SUM(quantity * unit_price), 0)
INTO total
FROM order_items
WHERE order_id = calculate_order_total.order_id;
RETURN total;
END;
$$ LANGUAGE plpgsql STABLE;
`;
// Table-returning function
await sql`
CREATE OR REPLACE FUNCTION get_customer_orders(
p_customer_id INTEGER,
p_limit INTEGER DEFAULT 10
)
RETURNS TABLE(
order_id INTEGER,
order_date TIMESTAMP,
total NUMERIC,
item_count BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
o.id,
o.created_at,
SUM(oi.quantity * oi.unit_price),
COUNT(oi.id)
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = p_customer_id
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql STABLE;
`;
// Call from Bun.sql
const orders = await sql`SELECT * FROM get_customer_orders(${customerId}, ${10})`;
Trigger Examples
// Audit trigger
await sql`
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (table_name, operation, old_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, 'D', row_to_json(OLD), current_user, NOW());
RETURN OLD;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, operation, old_data, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, 'U', row_to_json(OLD), row_to_json(NEW), current_user, NOW());
RETURN NEW;
ELSIF TG_OP = 'INSERT' THEN
INSERT INTO audit_log (table_name, operation, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, 'I', row_to_json(NEW), current_user, NOW());
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
`;
// Auto-update timestamps trigger
await sql`
CREATE OR REPLACE FUNCTION update_timestamps()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at := NOW();
IF TG_OP = 'INSERT' THEN
NEW.created_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_timestamps
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION update_timestamps();
`;
// Validation trigger
await sql`
CREATE OR REPLACE FUNCTION validate_order()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.quantity <= 0 THEN
RAISE EXCEPTION 'Quantity must be positive';
END IF;
IF NEW.unit_price < 0 THEN
RAISE EXCEPTION 'Price cannot be negative';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`;
For complete PL/pgSQL reference, see references/plpgsql-reference.md.
Performance Optimization
EXPLAIN ANALYZE
// Get execution plan with actual timing
const plan = await sql`
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > NOW() - INTERVAL '30 days'
`;
// Interpretation guide in the result
console.log("Key metrics to analyze:");
console.log("- Seq Scan on large tables (consider indexes)");
console.log("- High actual rows vs estimated rows (run ANALYZE)");
console.log("- Buffers read >> hit (I/O bottleneck)");
console.log("- Nested Loop with high rows (consider Hash/Merge Join)");
Query Optimization Patterns
// Use EXISTS instead of IN for large subqueries
// Bad:
await sql`SELECT * FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE active)`;
// Good:
await sql`SELECT * FROM orders o WHERE EXISTS (SELECT 1 FROM customers c WHERE c.id = o.customer_id AND c.active)`;
// Use LIMIT early in CTEs when possible
await sql`
WITH recent_orders AS (
SELECT * FROM orders
WHERE created_at > NOW() - INTERVAL '1 day'
LIMIT 1000 -- Early limit
)
SELECT * FROM recent_orders WHERE status = 'pending'
`;
// Batch operations for better performance
await sql.begin(async (tx) => {
// Process in batches
for (let i = 0; i < items.length; i += 1000) {
const batch = items.slice(i, i + 1000);
await tx`INSERT INTO products ${tx(batch)}`;
}
});
Statistics and Maintenance
// Update statistics
await sql`ANALYZE orders`;
await sql`ANALYZE VERBOSE orders`;
// Vacuum table
await sql`VACUUM orders`;
await sql`VACUUM (ANALYZE, VERBOSE) orders`;
// Check table bloat
const bloatCheck = await sql`
SELECT
relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10
`;
// Check index usage
const indexUsage = await sql`
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan
LIMIT 20
`;
For complete performance guide, see references/performance-optimization.md.
Row-Level Security (RLS)
// Enable RLS
await sql`ALTER TABLE documents ENABLE ROW LEVEL SECURITY`;
// Create policies
await sql`
CREATE POLICY documents_owner_policy ON documents
FOR ALL
USING (owner_id = current_setting('app.current_user_id')::INTEGER)
WITH CHECK (owner_id = current_setting('app.current_user_id')::INTEGER)
`;
// Set user context before queries
await sql`SET app.current_user_id = ${userId}`;
const userDocs = await sql`SELECT * FROM documents`; // Only sees owned docs
// Create tenant isolation policy
await sql`
CREATE POLICY tenant_isolation ON data
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::UUID)
`;
// Admin bypass policy
await sql`
CREATE POLICY admin_all_access ON documents
FOR ALL
TO admin_role
USING (true)
`;
For complete security patterns, see references/security-patterns.md.
Migration Patterns
Migration Template
// migrations/001_initial_schema.ts
import { sql } from "bun";
export async function up() {
await sql.begin(async (tx) => {
await tx`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`;
await tx`CREATE INDEX users_email_idx ON users (email)`;
await tx`
INSERT INTO schema_migrations (version, applied_at)
VALUES ('001_initial_schema', NOW())
`;
});
}
export async function down() {
await sql.begin(async (tx) => {
await tx`DROP TABLE IF EXISTS users CASCADE`;
await tx`DELETE FROM schema_migrations WHERE version = '001_initial_schema'`;
});
}
Migration Runner
// migrate.ts
import { sql } from "bun";
async function migrate() {
// Create migrations table if not exists
await sql`
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`;
// Get applied migrations
const applied = await sql`SELECT version FROM schema_migrations`;
const appliedVersions = new Set(applied.map(r => r.version));
// Get pending migrations
const glob = new Bun.Glob("./migrations/*.ts");
const files = Array.from(glob.scanSync(".")).sort();
for (const file of files) {
const version = file.match(/(\d+_[a-z_]+)/)?.[1];
if (version && !appliedVersions.has(version)) {
console.log(`Applying migration: ${version}`);
const migration = await import(`./${file}`);
await migration.up();
console.log(`Applied: ${version}`);
}
}
}
Testing Patterns
// test/db.test.ts
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
import { SQL } from "bun";
const testDb = new SQL({
database: "myapp_test",
// ... other config
});
describe("User Repository", () => {
beforeAll(async () => {
// Run migrations
await testDb`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE,
name VARCHAR(255)
)`;
});
beforeEach(async () => {
// Clean up before each test
await testDb`TRUNCATE users RESTART IDENTITY CASCADE`;
});
afterAll(async () => {
await testDb.close();
});
test("creates a user", async () => {
const [user] = await testDb`
INSERT INTO users (email, name)
VALUES ('test@example.com', 'Test User')
RETURNING *
`;
expect(user.id).toBe(1);
expect(user.email).toBe("test@example.com");
});
test("enforces unique email", async () => {
await testDb`INSERT INTO users (email, name) VALUES ('test@example.com', 'User 1')`;
expect(async () => {
await testDb`INSERT INTO users (email, name) VALUES ('test@example.com', 'User 2')`;
}).toThrow();
});
test("transactions rollback on error", async () => {
try {
await testDb.begin(async (tx) => {
await tx`INSERT INTO users (email, name) VALUES ('a@example.com', 'A')`;
throw new Error("Intentional rollback");
});
} catch {}
const [{ count }] = await testDb`SELECT COUNT(*) FROM users`;
expect(count).toBe("0");
});
});
Common Anti-Patterns to Avoid
- N+1 Queries: Use JOINs or batch queries instead of loops
- **SELECT ***: Only select needed columns, especially with JSONB
- Missing indexes on foreign keys: Always index FK columns
- OFFSET pagination on large tables: Use keyset/cursor pagination
- Not using prepared statements: Bun.sql handles this automatically
- Ignoring EXPLAIN output: Always analyze slow queries
- Large transactions: Keep transactions short to avoid lock contention
- Not vacuuming: Ensure autovacuum is enabled and tuned
Quick Reference
Essential PostgreSQL Error Codes
| Code | Name | Description |
|---|---|---|
| 23505 | unique_violation | Duplicate key value |
| 23503 | foreign_key_violation | FK constraint failed |
| 23502 | not_null_violation | NULL in non-null column |
| 23514 | check_violation | Check constraint failed |
| 42P01 | undefined_table | Table doesn't exist |
| 42703 | undefined_column | Column doesn't exist |
| 57014 | query_canceled | Query was cancelled |
| 40001 | serialization_failure | Transaction conflict |
| 40P01 | deadlock_detected | Deadlock occurred |
Connection Environment Variables
| Variable | Description |
|---|---|
POSTGRES_URL | Primary connection URL |
DATABASE_URL | Alternative URL |
PGHOST | Database host |
PGPORT | Database port (default: 5432) |
PGUSER | Database user |
PGPASSWORD | Database password |
PGDATABASE | Database name |
PGSSLMODE | SSL mode (disable/prefer/require/verify-full) |
Related Documentation
| Document | Description |
|---|---|
| references/sql-patterns.md | Complete SQL syntax reference |
| references/json-operations.md | JSONB operators and functions |
| references/full-text-search.md | FTS configuration guide |
| references/indexing-strategies.md | Index selection guide |
| references/plpgsql-reference.md | PL/pgSQL complete reference |
| references/pgvector-guide.md | Vector search patterns |
| references/performance-optimization.md | Query tuning guide |
| references/security-patterns.md | RLS and permissions |
Sub-Agents
| Agent | Use When |
|---|---|
| pg-query | Writing complex SQL queries, CTEs, window functions, JSON operations |
| pg-schema | Designing schemas, creating tables, defining constraints, planning migrations |
| pg-performance | Optimizing slow queries, analyzing EXPLAIN output, tuning configuration |
When This Skill Activates
This skill automatically activates when:
- Working with
Bun.sqlor PostgreSQL - Writing SQL queries or designing schemas
- Implementing full-text search
- Working with JSON/JSONB data
- Using pgvector for similarity search
- Writing PL/pgSQL functions or triggers
- Optimizing database performance
- Implementing Row-Level Security
Repository
