SQL Stored Procedures and Functions: Complete Guide
Learn how to create reusable SQL code with stored procedures and functions. Encapsulate complex business logic, improve performance, and build maintainable database applications with these essential patterns.
Why Stored Procedures Are Essential for SQL Developers
Stored procedures and functions let you encapsulate complex SQL logic into reusable, named code blocks stored in the database. Instead of embedding complex queries throughout your application, you call a single procedure that handles all the logic. This improves code organization, performance, security, and maintainability.
Whether you're building enterprise applications that need centralized business logic, optimizing performance with precompiled query plans, or enforcing security through controlled database access, stored procedures are essential tools. They reduce network traffic, simplify application code, and provide a single source of truth for database operations.
Many developers struggle with stored procedures because the syntax varies between databases (PostgreSQL's PL/pgSQL, SQL Server's T-SQL, MySQL's stored procedure language), debugging is harder than application code, and overuse can make systems difficult to maintain. This guide covers the essential patterns and best practices you need to use stored procedures effectively.
Common Mistakes When Learning Stored Procedures
Putting too much logic in procedures
Stored procedures are great for data access and validation, but complex business rules often belong in application code where they're easier to test, debug, and version control. Keep procedures focused on database operations, not complex workflows or calculations better done in your application.
Not handling errors properly
Procedures that don't include error handling can fail silently or leave databases in inconsistent states. Use TRY-CATCH blocks (SQL Server) or EXCEPTION handlers (PostgreSQL) to catch errors, log them properly, and return meaningful error codes to calling applications.
Ignoring SQL injection in dynamic SQL
Building dynamic SQL with string concatenation creates SQL injection vulnerabilities even inside stored procedures. Always use parameterized queries or proper escaping when constructing dynamic SQL. The database doesn't automatically protect against injection in dynamic statements.
Creating procedures that are hard to test
Procedures with side effects, external dependencies, or complex logic become difficult to test and maintain. Design procedures with clear inputs and outputs, minimize side effects, and consider breaking complex procedures into smaller, testable functions.
8 Essential Procedure Patterns
From basic procedures to advanced optimization techniques
Basic Stored Procedures
Creating and executing reusable SQL procedures with parameters
Scalar Functions
Functions that return single values for calculations and transformations
Table-Valued Functions
Functions that return result sets like tables for complex queries
Input and Output Parameters
Passing data in and out of procedures with IN, OUT, and INOUT parameters
Control Flow and Logic
Using IF/ELSE, CASE, loops, and error handling in procedures
Cursors and Iteration
Processing result sets row-by-row with cursor operations
Transaction Management
Implementing transaction control within stored procedures
Best Practices and Optimization
Writing maintainable, performant, and secure procedures
1. Basic Stored Procedures
A stored procedure is a named SQL code block saved in the database that you can execute repeatedly. Procedures can accept parameters, perform operations, and return results. They're compiled once and reused, making them faster than sending the same SQL from your application each time.
-- PostgreSQL: Create a basic stored procedure
CREATE OR REPLACE PROCEDURE get_customer_orders(
customer_id_param INT
)
LANGUAGE plpgsql
AS $$
BEGIN
SELECT o.order_id, o.order_date, o.total
FROM orders o
WHERE o.customer_id = customer_id_param
ORDER BY o.order_date DESC;
END;
$$;
-- Call the procedure
CALL get_customer_orders(123);
-- SQL Server version
CREATE PROCEDURE get_customer_orders
@customer_id_param INT
AS
BEGIN
SELECT o.order_id, o.order_date, o.total
FROM orders o
WHERE o.customer_id = @customer_id_param
ORDER BY o.order_date DESC;
END;
-- Call in SQL Server
EXEC get_customer_orders @customer_id_param = 123;This simple procedure encapsulates a query for getting customer orders. Instead of writing this query in every part of your application, you call the procedure. If the query logic changes, you update it in one place.
2. Scalar Functions
Scalar functions return a single value and can be used in SELECT statements, WHERE clauses, or anywhere you need a computed value. They're perfect for calculations, transformations, or business logic that returns a single result.
-- PostgreSQL: Calculate customer lifetime value
CREATE OR REPLACE FUNCTION calculate_customer_ltv(
customer_id_param INT
)
RETURNS DECIMAL(10,2)
LANGUAGE plpgsql
AS $$
DECLARE
total_value DECIMAL(10,2);
BEGIN
SELECT COALESCE(SUM(total), 0)
INTO total_value
FROM orders
WHERE customer_id = customer_id_param;
RETURN total_value;
END;
$$;
-- Use the function in queries
SELECT
customer_id,
name,
calculate_customer_ltv(customer_id) as lifetime_value
FROM customers
WHERE calculate_customer_ltv(customer_id) > 1000
ORDER BY lifetime_value DESC;This function calculates a customer's lifetime value by summing all their orders. You can use it anywhere in your queries, making your SQL more readable and maintainable. The calculation logic lives in one place.
3. Table-Valued Functions
Table-valued functions return entire result sets that you can query like regular tables. They're useful for complex filtering, data transformations, or encapsulating multi-table joins that you reuse throughout your application.
-- PostgreSQL: Get top products for a category
CREATE OR REPLACE FUNCTION get_top_products(
category_name_param VARCHAR,
limit_param INT DEFAULT 10
)
RETURNS TABLE (
product_id INT,
product_name VARCHAR,
price DECIMAL,
total_sales DECIMAL
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
p.product_id,
p.product_name,
p.price,
COALESCE(SUM(oi.quantity * oi.price), 0) as total_sales
FROM products p
LEFT JOIN order_items oi ON p.product_id = oi.product_id
WHERE p.category = category_name_param
GROUP BY p.product_id, p.product_name, p.price
ORDER BY total_sales DESC
LIMIT limit_param;
END;
$$;
-- Use like a regular table
SELECT * FROM get_top_products('Electronics', 5);
-- Join with other tables
SELECT t.*, c.customer_name
FROM get_top_products('Electronics', 5) t
JOIN customers c ON c.preferred_category = 'Electronics';This function returns a result set of top products. You can query it like a table, join it with other tables, or filter its results. This encapsulates the complex logic for finding top products in one place.
4. Input and Output Parameters
Procedures can use output parameters to return values to the caller in addition to result sets. This is useful for returning status codes, calculated values, or multiple related values from a single procedure call.
-- PostgreSQL: Create order with output parameters
CREATE OR REPLACE PROCEDURE create_order(
customer_id_param INT,
total_amount_param DECIMAL,
INOUT order_id_out INT,
OUT success_out BOOLEAN,
OUT message_out VARCHAR
)
LANGUAGE plpgsql
AS $$
BEGIN
-- Check if customer exists
IF NOT EXISTS (SELECT 1 FROM customers WHERE customer_id = customer_id_param) THEN
success_out := FALSE;
message_out := 'Customer not found';
RETURN;
END IF;
-- Create the order
INSERT INTO orders (customer_id, order_date, total)
VALUES (customer_id_param, CURRENT_TIMESTAMP, total_amount_param)
RETURNING order_id INTO order_id_out;
success_out := TRUE;
message_out := 'Order created successfully';
END;
$$;
-- Call with output parameters
CALL create_order(
123, -- customer_id
299.99, -- total_amount
NULL, -- order_id (will be set by procedure)
NULL, -- success (will be set by procedure)
NULL -- message (will be set by procedure)
);This procedure creates an order and returns the new order ID, a success flag, and a message. Output parameters let you return multiple values without using result sets, making it easier to handle success/error states in your application.
5. Control Flow and Logic
Stored procedures support control flow statements like IF/ELSE, CASE, WHILE loops, and error handling. This lets you implement business logic and conditional operations directly in the database.
-- PostgreSQL: Apply discount based on customer tier
CREATE OR REPLACE PROCEDURE apply_discount(
order_id_param INT
)
LANGUAGE plpgsql
AS $$
DECLARE
customer_tier VARCHAR;
discount_rate DECIMAL;
current_total DECIMAL;
BEGIN
-- Get customer tier and current total
SELECT c.tier, o.total
INTO customer_tier, current_total
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_id = order_id_param;
-- Determine discount based on tier
IF customer_tier = 'GOLD' THEN
discount_rate := 0.15; -- 15% discount
ELSIF customer_tier = 'SILVER' THEN
discount_rate := 0.10; -- 10% discount
ELSIF customer_tier = 'BRONZE' THEN
discount_rate := 0.05; -- 5% discount
ELSE
discount_rate := 0.00; -- No discount
END IF;
-- Apply the discount
IF discount_rate > 0 THEN
UPDATE orders
SET total = current_total * (1 - discount_rate),
discount_applied = discount_rate
WHERE order_id = order_id_param;
RAISE NOTICE 'Applied % discount to order %', discount_rate, order_id_param;
END IF;
END;
$$;This procedure uses IF/ELSIF to apply different discounts based on customer tier. Control flow statements let you implement business rules directly in the database, ensuring consistency across all applications that access the data.
6. Cursors and Iteration
Cursors let you process query results row-by-row, useful when you need to perform complex operations on each row that can't be done with set-based operations. However, cursors are slower than set-based queries, so use them only when necessary.
-- PostgreSQL: Process pending orders with cursor
CREATE OR REPLACE PROCEDURE process_pending_orders()
LANGUAGE plpgsql
AS $$
DECLARE
order_cursor CURSOR FOR
SELECT order_id, customer_id, total
FROM orders
WHERE status = 'PENDING'
ORDER BY order_date;
order_record RECORD;
inventory_available BOOLEAN;
BEGIN
OPEN order_cursor;
LOOP
FETCH order_cursor INTO order_record;
EXIT WHEN NOT FOUND;
-- Check inventory for this order
SELECT check_inventory(order_record.order_id)
INTO inventory_available;
-- Process based on inventory
IF inventory_available THEN
UPDATE orders
SET status = 'PROCESSING'
WHERE order_id = order_record.order_id;
RAISE NOTICE 'Processing order %', order_record.order_id;
ELSE
UPDATE orders
SET status = 'BACKORDER'
WHERE order_id = order_record.order_id;
RAISE NOTICE 'Backordered order %', order_record.order_id;
END IF;
END LOOP;
CLOSE order_cursor;
END;
$$;This procedure uses a cursor to process pending orders one by one, checking inventory and updating status accordingly. While this could potentially be done with set-based operations, cursors provide clear row-by-row control when you need it.
7. Transaction Management in Procedures
Procedures can include transaction control to ensure multiple operations succeed or fail together. Proper error handling with transactions ensures data consistency even when errors occur.
-- PostgreSQL: Transfer money with transaction control
CREATE OR REPLACE PROCEDURE transfer_money(
from_account_id INT,
to_account_id INT,
amount DECIMAL,
OUT success BOOLEAN,
OUT message VARCHAR
)
LANGUAGE plpgsql
AS $$
DECLARE
from_balance DECIMAL;
BEGIN
-- Start transaction (implicit in PostgreSQL procedures)
-- Check source account balance
SELECT balance INTO from_balance
FROM accounts
WHERE account_id = from_account_id
FOR UPDATE; -- Lock the row
IF from_balance < amount THEN
success := FALSE;
message := 'Insufficient funds';
ROLLBACK;
RETURN;
END IF;
-- Debit from source account
UPDATE accounts
SET balance = balance - amount
WHERE account_id = from_account_id;
-- Credit to destination account
UPDATE accounts
SET balance = balance + amount
WHERE account_id = to_account_id;
-- Log the transfer
INSERT INTO transfer_log (from_account, to_account, amount, transfer_date)
VALUES (from_account_id, to_account_id, amount, CURRENT_TIMESTAMP);
COMMIT;
success := TRUE;
message := 'Transfer successful';
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
success := FALSE;
message := 'Transfer failed: ' || SQLERRM;
END;
$$;This procedure transfers money between accounts with full transaction control. If any operation fails, the entire transfer rolls back. The EXCEPTION block catches any errors and ensures cleanup happens properly.
8. Best Practices and Optimization
Writing maintainable and performant stored procedures requires following best practices for naming, error handling, documentation, and optimization. Well-designed procedures are easier to debug, test, and maintain over time.
Key Best Practices
Use clear naming conventions: Prefix procedures with "sp_" or "proc_", use descriptive names like "create_customer_order" not "proc1". Make parameter names obvious with suffixes like "_param" or "_in".
Always include error handling: Use TRY-CATCH or EXCEPTION blocks to catch errors. Return meaningful error codes and messages. Log errors for debugging.
Minimize network roundtrips: Batch operations together in procedures to reduce communication between application and database. Return multiple result sets if needed.
Use parameters, not dynamic SQL: Avoid building SQL strings with concatenation. Use parameters to prevent SQL injection and improve query plan reuse.
Keep procedures focused: Each procedure should do one thing well. Don't create massive procedures that do everything. Break complex logic into smaller, reusable procedures.
Document inputs and outputs: Add comments explaining what parameters do, what the procedure returns, and any side effects. Future developers (including you) will appreciate it.
Prefer set-based operations over cursors: Set-based SQL is much faster than row-by-row cursor operations. Use cursors only when absolutely necessary.
Version control your procedures: Store procedure definitions in version control alongside application code. Use migration scripts to deploy changes.
Frequently Asked Questions
What is the difference between a stored procedure and a function?
Functions must return a value and can be used in SELECT statements and expressions. Procedures don't have to return values, can't be used in SELECT, and typically perform actions like INSERT, UPDATE, or DELETE. Functions should be deterministic and side-effect free, while procedures often modify data.
Are stored procedures faster than application queries?
Stored procedures can be faster because they're precompiled, reduce network traffic by batching operations, and run close to the data. However, the performance benefit depends on your use case. Modern ORMs with query caching can perform similarly for simple operations.
How do I debug stored procedures?
Use PRINT or RAISE NOTICE statements to log values during execution. Most database IDEs (SSMS, pgAdmin, DataGrip) include debuggers that let you step through procedures. Add comprehensive error handling to log errors. Test procedures in isolation with known inputs before using them in production.
Can stored procedures prevent SQL injection?
Stored procedures with parameters prevent SQL injection for the parameters they use. However, if your procedure builds dynamic SQL with string concatenation, it's still vulnerable. Always use parameterized queries even inside stored procedures when constructing dynamic SQL.
Should I put all my database logic in stored procedures?
No. Use stored procedures for data access, validation, and database-specific operations. Keep complex business logic, workflows, and calculations in your application where they're easier to test, debug, and deploy. The best approach depends on your team's skills and deployment process.
How do I test stored procedures?
Write test scripts that call procedures with known inputs and verify outputs. Use transactions to roll back test data. Tools like tSQLt (SQL Server) or pgTAP (PostgreSQL) provide unit testing frameworks for database code. Test edge cases, error conditions, and performance with realistic data volumes.
What is the syntax difference between PostgreSQL and SQL Server procedures?
PostgreSQL uses PL/pgSQL language with CREATE PROCEDURE and CALL. SQL Server uses T-SQL with CREATE PROCEDURE and EXEC. PostgreSQL uses := for assignment, SQL Server uses SET. Error handling differs: PostgreSQL uses EXCEPTION blocks, SQL Server uses TRY-CATCH. Variable declaration syntax varies.
Can I version control stored procedures?
Yes, and you should. Store procedure definitions as .sql files in version control. Use migration tools like Flyway, Liquibase, or Alembic to deploy changes. Include procedures in code reviews. Use CREATE OR REPLACE to update existing procedures without breaking dependencies.
Ready to Master Stored Procedures?
This guide covers essential procedure patterns. Get the complete SQL Crash Course with 350+ examples, 140+ interview questions, and comprehensive coverage of stored procedures, functions, and advanced database programming across PostgreSQL, SQL Server, and MySQL.