Query plan caching and prepared statements
Maria Garcia
Feb 2026
2 tabs
-- 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;
2 files · sql
Explain with highlit
Query plan caching improves performance by reusing execution plans. I use prepared statements to parse once, execute many times. PostgreSQL caches plans after 5 executions. Plan invalidation occurs when statistics change. Generic plans vs custom plans—PostgreSQL chooses based on cost. Prepared statements prevent SQL injection by separating code from data. Named parameters improve readability. Connection pooling requires proper prepared statement management. Plan cache monitoring helps identify inefficient plans. Understanding caching reduces database load, improves latency. Essential for high-performance applications.