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.

8
Patterns
350+
SQL Examples
140+
Interview Questions
270+
Pages

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.