Dynamic SQL and Stored Procedures
Dynamic SQL and Stored Procedures
Dynamic SQL — SQL built as a string and executed at runtime — is necessary when table names, column names, or operators cannot be known at query-writing time. PostgreSQL provides EXECUTE for dynamic SQL inside PL/pgSQL, and FORMAT for safe string construction.
Stored procedures (created with CREATE PROCEDURE, not CREATE FUNCTION) can manage their own transactions — committing and rolling back within the procedure body. This is different from functions, which always run within the caller's transaction.
Example 1: Dynamic table operations with EXECUTE
-- ❌
-- Create a summary table for any parent table dynamically
CREATE OR REPLACE FUNCTION create_monthly_summary(
p_source_table TEXT,
p_date_column TEXT,
p_amount_column TEXT
)
RETURNS INT AS $$
DECLARE
v_summary_table TEXT;
v_row_count INT;
BEGIN
v_summary_table := p_source_table || '_monthly';
-- Safely construct and execute dynamic SQL
EXECUTE FORMAT(
$sql$CREATE TABLE IF NOT EXISTS %I AS
SELECT
DATE_TRUNC('month', %I)::DATE AS month,
COUNT(*) AS row_count,
SUM(%I) AS total_amount,
AVG(%I) AS avg_amount
FROM %I
GROUP BY 1$sql$,
v_summary_table, -- %I = identifier (quoted safely)
p_date_column,
p_amount_column,
p_amount_column,
p_source_table
);
EXECUTE 'SELECT COUNT(*) FROM ' || quote_ident(v_summary_table)
INTO v_row_count;
RETURN v_row_count;
END;
$$ LANGUAGE plpgsql;
-- Usage:
SELECT create_monthly_summary('orders', 'created_at', 'total_amount');
SELECT create_monthly_summary('invoices', 'issue_date', 'invoice_total');
FORMAT('%I', value) — %I produces a properly double-quoted identifier (safe against SQL injection and reserved words). %L produces a properly single-quoted literal. Never use string concatenation with user-supplied values — always use %I and %L.
Example 2: EXECUTE with dynamic WHERE clauses
-- Generic search function with dynamic filters
CREATE OR REPLACE FUNCTION search_orders(
p_status TEXT DEFAULT NULL,
p_min_amount NUMERIC DEFAULT NULL,
p_max_amount NUMERIC DEFAULT NULL,
p_channel TEXT DEFAULT NULL,
p_limit INT DEFAULT 50
)
RETURNS SETOF orders AS $$
DECLARE
v_sql TEXT;
v_params TEXT[] := '{}';
v_idx INT := 1;
BEGIN
v_sql := 'SELECT * FROM ec_orders WHERE 1=1';
IF p_status IS NOT NULL THEN
v_sql := v_sql || ' AND status = $' || v_idx;
v_params := v_params || p_status;
v_idx := v_idx + 1;
END IF;
IF p_min_amount IS NOT NULL THEN
v_sql := v_sql || ' AND total_amount >= $' || v_idx;
v_params := v_params || p_min_amount::TEXT;
v_idx := v_idx + 1;
END IF;
IF p_max_amount IS NOT NULL THEN
v_sql := v_sql || ' AND total_amount <= $' || v_idx;
v_params := v_params || p_max_amount::TEXT;
v_idx := v_idx + 1;
END IF;
v_sql := v_sql || ' ORDER BY created_at DESC LIMIT ' || p_limit;
RETURN QUERY EXECUTE v_sql USING
(v_params[1])::TEXT, (v_params[2])::TEXT, (v_params[3])::TEXT;
END;
$$ LANGUAGE plpgsql;
Example 3: Stored procedures with transaction control
-- Procedure that processes a batch of overdue invoices
-- Can commit after each chunk (important for long-running processes)
CREATE OR REPLACE PROCEDURE process_overdue_batch(p_batch_size INT DEFAULT 100)
LANGUAGE plpgsql AS $$
DECLARE
v_processed INT := 0;
v_total INT;
BEGIN
SELECT COUNT(*) INTO v_total
FROM invoices
WHERE status = 'sent' AND due_date < CURRENT_DATE - 7;
RAISE NOTICE 'Processing % overdue invoices in batches of %', v_total, p_batch_size;
LOOP
-- Update a batch
WITH batch AS (
SELECT invoice_id FROM invoices
WHERE status = 'sent' AND due_date < CURRENT_DATE - 7
LIMIT p_batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE invoices SET status = 'overdue'
FROM batch WHERE invoices.invoice_id = batch.invoice_id;
GET DIAGNOSTICS v_processed = ROW_COUNT;
-- COMMIT inside a procedure is allowed (not in functions)
COMMIT;
EXIT WHEN v_processed < p_batch_size;
RAISE NOTICE 'Committed batch of % rows', v_processed;
END LOOP;
RAISE NOTICE 'Batch processing complete';
END;
$$;
-- Call with CALL (not SELECT)
CALL process_overdue_batch(50);
Key Takeaway
EXECUTE FORMAT('%I', ...) is the safe way to build dynamic SQL with identifiers — never concatenate user-supplied strings directly. %L for literals, %I for identifiers. Stored procedures (CREATE PROCEDURE) can COMMIT mid-execution; functions cannot. Use procedures for long-running maintenance tasks that need to commit in chunks to avoid locking the whole table. Use functions for computations that must be atomic with the caller's transaction.