SQL Stored Procedures: Creating and Calling

Stored procedures are precompiled SQL statements stored directly in your database. They act as reusable functions that encapsulate business logic, data validation, and complex queries in a single...

Key Insights

  • Stored procedures encapsulate SQL logic on the database server, providing better performance through execution plan caching and reduced network traffic compared to sending multiple ad-hoc queries from application code.
  • Parameter handling varies significantly between database systems—SQL Server uses OUTPUT parameters, PostgreSQL uses INOUT and function return values, while MySQL supports both IN and OUT parameters with different calling conventions.
  • Always use parameterized stored procedures to prevent SQL injection, implement proper error handling with TRY-CATCH blocks, and avoid cursors in favor of set-based operations for better performance.

Introduction to Stored Procedures

Stored procedures are precompiled SQL statements stored directly in your database. They act as reusable functions that encapsulate business logic, data validation, and complex queries in a single callable unit.

The primary benefits are tangible: execution plans are cached after the first run, reducing compilation overhead. Network traffic decreases because you send a single procedure call instead of multiple round-trips with individual queries. Security improves through controlled data access—you can grant execute permissions on procedures without exposing underlying tables.

Use stored procedures for complex business logic that touches multiple tables, batch operations, or scenarios requiring transaction control. Skip them for simple CRUD operations where ORMs provide sufficient abstraction, or when you need maximum portability across database systems since syntax varies considerably.

Basic Stored Procedure Syntax

The syntax differs across database platforms, but the concept remains consistent. Here’s how to create a basic procedure in the three major systems.

SQL Server:

CREATE PROCEDURE GetAllCustomers
AS
BEGIN
    SELECT CustomerID, CustomerName, Email, CreatedDate
    FROM Customers
    ORDER BY CustomerName;
END;

MySQL:

DELIMITER //
CREATE PROCEDURE GetAllCustomers()
BEGIN
    SELECT CustomerID, CustomerName, Email, CreatedDate
    FROM Customers
    ORDER BY CustomerName;
END //
DELIMITER ;

MySQL requires the DELIMITER change because the procedure body contains semicolons. We temporarily switch to // as the statement terminator.

PostgreSQL:

CREATE OR REPLACE PROCEDURE GetAllCustomers()
LANGUAGE plpgsql
AS $$
BEGIN
    -- PostgreSQL procedures don't return result sets directly
    -- Use functions for that purpose
    RAISE NOTICE 'Use functions for SELECT queries in PostgreSQL';
END;
$$;

PostgreSQL distinguishes between procedures and functions. For returning result sets, use CREATE FUNCTION instead:

CREATE OR REPLACE FUNCTION GetAllCustomers()
RETURNS TABLE(CustomerID INT, CustomerName VARCHAR, Email VARCHAR, CreatedDate DATE)
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN QUERY
    SELECT c.CustomerID, c.CustomerName, c.Email, c.CreatedDate
    FROM Customers c
    ORDER BY c.CustomerName;
END;
$$;

Working with Parameters

Parameters make stored procedures flexible and reusable. They come in three flavors: INPUT (data going in), OUTPUT (data coming out), and INOUT (both directions).

Input Parameters (SQL Server):

CREATE PROCEDURE GetCustomersByDateRange
    @StartDate DATE,
    @EndDate DATE
AS
BEGIN
    SELECT CustomerID, CustomerName, Email, CreatedDate
    FROM Customers
    WHERE CreatedDate BETWEEN @StartDate AND @EndDate
    ORDER BY CreatedDate DESC;
END;

Output Parameters (SQL Server):

CREATE PROCEDURE CreateCustomerWithCount
    @CustomerName NVARCHAR(100),
    @Email NVARCHAR(100),
    @NewCustomerID INT OUTPUT,
    @TotalCustomers INT OUTPUT
AS
BEGIN
    INSERT INTO Customers (CustomerName, Email, CreatedDate)
    VALUES (@CustomerName, @Email, GETDATE());
    
    SET @NewCustomerID = SCOPE_IDENTITY();
    
    SELECT @TotalCustomers = COUNT(*)
    FROM Customers;
END;

MySQL with INOUT:

DELIMITER //
CREATE PROCEDURE UpdateCustomerStatus(
    IN customer_id INT,
    IN new_status VARCHAR(20),
    OUT rows_affected INT
)
BEGIN
    UPDATE Customers
    SET Status = new_status
    WHERE CustomerID = customer_id;
    
    SET rows_affected = ROW_COUNT();
END //
DELIMITER ;

Control Flow and Logic

Stored procedures support variables, conditional logic, and loops. This enables complex business rules without application-level code.

SQL Server Example with Conditional Logic:

CREATE PROCEDURE ManageCustomer
    @Action VARCHAR(10),
    @CustomerID INT = NULL,
    @CustomerName NVARCHAR(100) = NULL,
    @Email NVARCHAR(100) = NULL,
    @ResultMessage NVARCHAR(255) OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    
    DECLARE @RowCount INT;
    
    BEGIN TRY
        IF @Action = 'CREATE'
        BEGIN
            INSERT INTO Customers (CustomerName, Email, CreatedDate)
            VALUES (@CustomerName, @Email, GETDATE());
            
            SET @ResultMessage = 'Customer created successfully. ID: ' + 
                                CAST(SCOPE_IDENTITY() AS NVARCHAR);
        END
        ELSE IF @Action = 'UPDATE'
        BEGIN
            UPDATE Customers
            SET CustomerName = @CustomerName,
                Email = @Email
            WHERE CustomerID = @CustomerID;
            
            SET @RowCount = @@ROWCOUNT;
            
            IF @RowCount = 0
                SET @ResultMessage = 'Customer not found';
            ELSE
                SET @ResultMessage = 'Customer updated successfully';
        END
        ELSE IF @Action = 'DELETE'
        BEGIN
            DELETE FROM Customers
            WHERE CustomerID = @CustomerID;
            
            SET @RowCount = @@ROWCOUNT;
            
            IF @RowCount = 0
                SET @ResultMessage = 'Customer not found';
            ELSE
                SET @ResultMessage = 'Customer deleted successfully';
        END
        ELSE
        BEGIN
            SET @ResultMessage = 'Invalid action specified';
        END
    END TRY
    BEGIN CATCH
        SET @ResultMessage = 'Error: ' + ERROR_MESSAGE();
    END CATCH
END;

This procedure demonstrates variables (@RowCount), conditional branching, and error handling—all essential for production-grade stored procedures.

Calling Stored Procedures

Execution syntax varies by database and client library.

Direct SQL Execution:

-- SQL Server
EXEC GetCustomersByDateRange @StartDate = '2024-01-01', @EndDate = '2024-12-31';

-- With output parameters
DECLARE @NewID INT, @Total INT;
EXEC CreateCustomerWithCount 
    @CustomerName = 'Acme Corp',
    @Email = 'contact@acme.com',
    @NewCustomerID = @NewID OUTPUT,
    @TotalCustomers = @Total OUTPUT;
SELECT @NewID AS NewCustomerID, @Total AS TotalCustomers;

-- MySQL
CALL GetCustomersByDateRange('2024-01-01', '2024-12-31');

-- PostgreSQL (for functions)
SELECT * FROM GetAllCustomers();

Python with psycopg2 (PostgreSQL):

import psycopg2

conn = psycopg2.connect(
    host="localhost",
    database="mydb",
    user="user",
    password="password"
)

cursor = conn.cursor()

# Calling a function that returns a table
cursor.execute("SELECT * FROM GetAllCustomers()")
customers = cursor.fetchall()

for customer in customers:
    print(f"ID: {customer[0]}, Name: {customer[1]}")

cursor.close()
conn.close()

Python with pymysql (MySQL):

import pymysql

conn = pymysql.connect(
    host='localhost',
    user='user',
    password='password',
    database='mydb'
)

cursor = conn.cursor()

# Calling procedure with output parameter
args = (123, 'Active', 0)
cursor.callproc('UpdateCustomerStatus', args)

# Fetch output parameter
cursor.execute("SELECT @_UpdateCustomerStatus_2")
rows_affected = cursor.fetchone()[0]
print(f"Rows affected: {rows_affected}")

conn.commit()
cursor.close()
conn.close()

C# with ADO.NET (SQL Server):

using System.Data.SqlClient;

string connectionString = "Server=localhost;Database=mydb;Trusted_Connection=true;";

using (SqlConnection conn = new SqlConnection(connectionString))
{
    using (SqlCommand cmd = new SqlCommand("CreateCustomerWithCount", conn))
    {
        cmd.CommandType = CommandType.StoredProcedure;
        
        cmd.Parameters.AddWithValue("@CustomerName", "Acme Corp");
        cmd.Parameters.AddWithValue("@Email", "contact@acme.com");
        
        SqlParameter newIdParam = new SqlParameter("@NewCustomerID", SqlDbType.Int);
        newIdParam.Direction = ParameterDirection.Output;
        cmd.Parameters.Add(newIdParam);
        
        SqlParameter totalParam = new SqlParameter("@TotalCustomers", SqlDbType.Int);
        totalParam.Direction = ParameterDirection.Output;
        cmd.Parameters.Add(totalParam);
        
        conn.Open();
        cmd.ExecuteNonQuery();
        
        int newId = (int)newIdParam.Value;
        int total = (int)totalParam.Value;
        
        Console.WriteLine($"New ID: {newId}, Total: {total}");
    }
}

Best Practices and Common Pitfalls

Security: Never concatenate user input into dynamic SQL within procedures. Always use parameters. Even inside stored procedures, dynamic SQL with concatenation is vulnerable to SQL injection.

Naming Conventions: Use consistent prefixes like usp_ (user stored procedure) or organize by function: Customer_Create, Customer_Update. Avoid generic names like sp_ which SQL Server reserves for system procedures.

Transaction Handling: Wrap multi-statement operations in explicit transactions. Always include error handling that rolls back on failure:

CREATE PROCEDURE TransferFunds
    @FromAccount INT,
    @ToAccount INT,
    @Amount DECIMAL(10,2)
AS
BEGIN
    BEGIN TRANSACTION;
    BEGIN TRY
        UPDATE Accounts SET Balance = Balance - @Amount WHERE AccountID = @FromAccount;
        UPDATE Accounts SET Balance = Balance + @Amount WHERE AccountID = @ToAccount;
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        ROLLBACK TRANSACTION;
        THROW;
    END CATCH
END;

Performance: Avoid cursors. They process row-by-row and kill performance. Use set-based operations instead. Keep procedures focused—one procedure should do one thing well.

Advanced Features

Returning Result Sets with Temporary Tables:

CREATE PROCEDURE GetCustomerOrderSummary
    @Year INT
AS
BEGIN
    CREATE TABLE #OrderSummary (
        CustomerID INT,
        CustomerName NVARCHAR(100),
        TotalOrders INT,
        TotalAmount DECIMAL(10,2)
    );
    
    INSERT INTO #OrderSummary
    SELECT 
        c.CustomerID,
        c.CustomerName,
        COUNT(o.OrderID) AS TotalOrders,
        SUM(o.OrderAmount) AS TotalAmount
    FROM Customers c
    LEFT JOIN Orders o ON c.CustomerID = o.CustomerID
    WHERE YEAR(o.OrderDate) = @Year OR o.OrderDate IS NULL
    GROUP BY c.CustomerID, c.CustomerName;
    
    SELECT * FROM #OrderSummary
    ORDER BY TotalAmount DESC;
    
    DROP TABLE #OrderSummary;
END;

Temporary tables are useful for complex multi-step transformations, but remember they consume tempdb resources. For simple queries, CTEs or derived tables perform better.

Stored procedures remain a powerful tool for encapsulating database logic. Use them judiciously where they provide clear benefits in performance, security, or code organization. Avoid overuse that creates tight coupling between your application and database implementation details.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.