-- Prepared statements basics
-- PostgreSQL syntax
PREPARE get_user (INT) AS
SELECT id, username, email
FROM users
WHERE id = $1;
-- Execute prepared statement
EXECUTE get_user(123);
EXECUTE get_user(456);
-- Deallocate when done
DEALLOCATE get_user;
-- Named prepared statement
PREPARE user_search (VARCHAR) AS
SELECT id, username, email
FROM users
WHERE username LIKE $1 || '%'
ORDER BY username
LIMIT 20;
EXECUTE user_search('john');
-- Multiple parameters
PREPARE create_order (INT, DECIMAL, VARCHAR) AS
INSERT INTO orders (user_id, total, status)
VALUES ($1, $2, $3)
RETURNING id, created_at;
EXECUTE create_order(123, 99.99, 'pending');
-- View prepared statements
SELECT * FROM pg_prepared_statements;
/*
Application-level prepared statements (Node.js example):
const { Pool } = require('pg');
const pool = new Pool();
// Automatic prepared statement (pg driver)
async function getUser(id) {
const result = await pool.query(
'SELECT id, username, email FROM users WHERE id = $1',
[id]
);
return result.rows[0];
}
// After 5 executions, pg driver automatically prepares
await getUser(123);
await getUser(456);
// ...
// Named prepared statement
const preparedQuery = {
name: 'get-user',
text: 'SELECT id, username, email FROM users WHERE id = $1',
};
const result = await pool.query(preparedQuery, [123]);
// Reuses prepared statement
const result2 = await pool.query(preparedQuery, [456]);
// Transaction with prepared statements
const client = await pool.connect();
try {
await client.query('BEGIN');
// Prepared within transaction
const result = await client.query({
name: 'insert-order',
text: 'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id'
}, [123, 99.99]);
const orderId = result.rows[0].id;
await client.query({
name: 'insert-item',
text: 'INSERT INTO order_items (order_id, product_id, quantity) VALUES ($1, $2, $3)'
}, [orderId, 456, 2]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
// Connection pooling considerations
// Prepared statements are per-connection
// With connection pool, each connection may have different prepared statements
// Option 1: Use unnamed prepared statements (pg driver default)
await pool.query('SELECT * FROM users WHERE id = $1', [123]);
// Option 2: Prepare on each connection
client.on('connect', async (client) => {
await client.query({
name: 'common-query',
text: 'SELECT * FROM users WHERE id = $1'
});
});
*/
-- Plan caching behavior
-- PostgreSQL switches to generic plan after 5 custom plans
-- Custom plan (uses actual parameter values)
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123;
-- Uses index if selective
-- Generic plan (uses placeholder)
PREPARE orders_by_user (INT) AS
SELECT * FROM orders WHERE user_id = $1;
-- First 5 executions: custom plans
EXECUTE orders_by_user(123);
EXECUTE orders_by_user(456);
EXECUTE orders_by_user(789);
EXECUTE orders_by_user(101);
EXECUTE orders_by_user(102);
-- 6th execution: switches to generic plan if cheaper
EXECUTE orders_by_user(103);
-- Force custom plan every time
SET plan_cache_mode = 'force_custom_plan';
-- Force generic plan
SET plan_cache_mode = 'force_generic_plan';
-- Auto (default): Choose based on cost
SET plan_cache_mode = 'auto';
-- Monitor plan cache
SELECT
name,
statement,
parameter_types,
from_sql,
generic_plans,
custom_plans
FROM pg_prepared_statements;
-- Shared plan cache (across connections)
-- Only for identical queries
SELECT * FROM users WHERE id = 123;
SELECT * FROM users WHERE id = 456;
-- Different parameters -> different plans initially
-- With prepared statement:
PREPARE get_user (INT) AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(123);
EXECUTE get_user(456);
-- Same plan reused
-- Plan invalidation
-- Plans invalidated when:
-- 1. Table statistics change significantly
ANALYZE users;
-- All plans involving 'users' may be invalidated
-- 2. Schema changes
ALTER TABLE users ADD COLUMN last_login TIMESTAMP;
-- Plans invalidated
-- 3. Index created/dropped
CREATE INDEX idx_users_email ON users(email);
-- Plans invalidated
-- 4. Configuration changes
SET work_mem = '100MB';
-- May affect plans
-- Monitoring plan cache effectiveness
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Top queries by execution time
SELECT
calls,
mean_exec_time,
total_exec_time,
query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 20;
-- Check plan cache hit ratio
SELECT
query,
calls,
plans,
total_plan_time,
total_exec_time,
total_plan_time / NULLIF(plans, 0) AS avg_plan_time,
total_exec_time / NULLIF(calls, 0) AS avg_exec_time
FROM pg_stat_statements
WHERE plans > 0
ORDER BY total_plan_time DESC
LIMIT 20;
-- Reset statistics
SELECT pg_stat_statements_reset();
-- Best practices for prepared statements
-- 1. Use for repeated queries
-- Good:
PREPARE frequent_query (INT) AS
SELECT * FROM products WHERE category_id = $1;
-- Execute many times
EXECUTE frequent_query(1);
EXECUTE frequent_query(2);
-- 2. Clean up unused statements
DEALLOCATE frequent_query;
-- Or deallocate all
DEALLOCATE ALL;
-- 3. Be careful with data distribution
-- If some parameter values have very different selectivity:
CREATE TABLE orders_skewed (
id SERIAL PRIMARY KEY,
user_id INT,
status VARCHAR(20)
);
-- 99% of orders have status='completed', 1% have status='pending'
INSERT INTO orders_skewed (user_id, status)
SELECT
(random() * 1000)::INT,
CASE WHEN random() < 0.99 THEN 'completed' ELSE 'pending' END
FROM generate_series(1, 100000);
CREATE INDEX idx_orders_skewed_status ON orders_skewed(status);
ANALYZE orders_skewed;
-- Custom plan for specific value
EXPLAIN ANALYZE
SELECT * FROM orders_skewed WHERE status = 'pending';
-- Uses index (selective)
EXPLAIN ANALYZE
SELECT * FROM orders_skewed WHERE status = 'completed';
-- May use seq scan (not selective)
-- Generic plan compromises
PREPARE get_orders_by_status (VARCHAR) AS
SELECT * FROM orders_skewed WHERE status = $1;
-- May choose suboptimal plan
EXECUTE get_orders_by_status('pending');
EXECUTE get_orders_by_status('completed');
-- Solution: Force custom plans
SET plan_cache_mode = 'force_custom_plan';
-- Or: Separate queries for different cases
PREPARE get_pending_orders AS
SELECT * FROM orders_skewed WHERE status = 'pending';
PREPARE get_completed_orders AS
SELECT * FROM orders_skewed WHERE status = 'completed';
-- 4. Named vs unnamed prepared statements
/*
// Unnamed (automatically managed by driver)
await client.query('SELECT * FROM users WHERE id = $1', [123]);
// Named (manually managed)
await client.query({
name: 'get-user',
text: 'SELECT * FROM users WHERE id = $1'
}, [123]);
// Named statements persist across queries
// Unnamed may be automatically deallocated
*/
-- 5. Prepared statements and security
-- Prevents SQL injection
-- VULNERABLE (don't do this):
-- query = "SELECT * FROM users WHERE username = '" + userInput + "'";
-- SAFE (prepared statement):
PREPARE safe_query (VARCHAR) AS
SELECT * FROM users WHERE username = $1;
EXECUTE safe_query('anything; DROP TABLE users;--');
-- Treated as literal string, not executed
-- Application-level caching
/*
// Redis for query result caching
const redis = require('redis');
const client = redis.createClient();
async function getUser(id) {
const cacheKey = `user:${id}`;
// Check cache
const cached = await client.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Query database with prepared statement
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
const user = result.rows[0];
// Cache result
await client.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
*/
-- View current prepared statements
SELECT
name,
statement,
prepare_time,
parameter_types::text[] AS param_types,
from_sql
FROM pg_prepared_statements
ORDER BY prepare_time DESC;