Stored Procedures

A stored procedure is a named program of SQL statements and procedural logic (variables, IF, loops, error handling) stored in the database and executed with a call.

Concept

A stored procedure is a named program of SQL statements and procedural logic (variables, IF, loops, error handling) stored in the database and executed with a call. Procedures can accept parameters, modify data, manage transactions (in most DBMSs), and return result sets or output parameters.

PL/pgSQL (also PL/Python, PL/Perl...) CREATE PROCEDURE (11+, can COMMIT); functions for most logic MySQL / MariaDB SQL/PSM-style stored programs CREATE PROCEDURE CREATE PROCEDURE / CREATE PROC

CREATE PROCEDURE, usually inside packages No stored procedures (logic lives in the application)

Why It Matters

Encapsulation: complex business operations (place order, transfer funds) become one call with consistent rules.

Performance: fewer network round trips; plans may be cached.

Security: grant EXECUTE on a procedure without granting direct table access.

Consistency: every application uses the same logic.

Trade-offs: logic split between app and DB, harder version control/testing if not managed carefully, vendor lock-in.

Syntax

-- MySQL
DELIMITER //
CREATE PROCEDURE proc_name(IN p1 INT, OUT p2 DECIMAL(10,2), INOUT p3 INT)
BEGIN
    DECLARE v_local INT DEFAULT 0;
    -- statements
END //
DELIMITER ;
CALL proc_name(1, @out_value, @inout_value);

-- SQL Server
CREATE PROCEDURE dbo.proc_name
    @p1 INT,
    @p2 DECIMAL(10,2) OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @v_local INT = 0;
    -- statements
END;
EXEC dbo.proc_name @p1 = 1, @p2 = @result OUTPUT;

-- PostgreSQL 11+
CREATE OR REPLACE PROCEDURE proc_name(p1 INT, INOUT p2 NUMERIC DEFAULT NULL)
LANGUAGE plpgsql
AS $$
DECLARE
    v_local INT := 0;
BEGIN
    -- statements
END;
$$;
CALL proc_name(1, NULL);

-- Oracle
CREATE OR REPLACE PROCEDURE proc_name(p1 IN NUMBER, p2 OUT NUMBER) AS
    v_local NUMBER := 0;
BEGIN
    -- statements
    NULL;
END;
/
EXEC proc_name(1, :result);   -- SQL*Plus; or BEGIN proc_name(1, v); END;

Example

25.1 Simple procedure with an IN parameter (returns a result set) (DELIMITER // in the MySQL client temporarily changes the statement terminator so the ; inside the body does not end the CREATE statement.) 25.2 OUT parameters 25.3 INOUT parameters

SQL Server has no INOUT keyword; an OUTPUT parameter is both input and output.

25.4 Variables, conditional logic, and loops Walk through for threshold 20: products under 20 are Laptop (15) and Standing Desk (0). Standing Desk → URGENT. Laptop: is 15 < 20 / 2 = 10? No → "OK-ish". Two products restocked. (The loop order is not guaranteed without ORDER BY in the loop query.)

Prefer set-based SQL (one UPDATE for many rows) over loops whenever possible; loops process rows one by one and are much slower on large data. The PostgreSQL example above could be a single UPDATE ... WHERE stock < p_threshold.

25.5 Error handling and transactions: place-order procedure When an exception is raised, all changes made by the call are rolled back (the CALL runs within a transaction). To catch errors inside PL/pgSQL:

MySQL error handling:

SQL Server uses BEGIN TRY ... END TRY BEGIN CATCH ... END CATCH with THROW (Module 21). Oracle uses EXCEPTION WHEN ... THEN blocks and RAISE_APPLICATION_ERROR(-20001, 'message').

25.6 Calling and dropping procedures CALL proc(args); DROP PROCEDURE [IF EXISTS] proc(arg types); DROP PROCEDURE [IF EXISTS] proc; EXEC proc @p = v; DROP PROCEDURE [IF EXISTS] dbo.proc;

EXEC proc(args); / BEGIN proc(args); END; / CALL proc(args) DROP PROCEDURE proc;

-- MySQL
DELIMITER //
CREATE PROCEDURE get_customer_orders(IN p_customer_id INT)
BEGIN
    SELECT order_id, order_date, status, total_amount
    FROM orders
    WHERE customer_id = p_customer_id
    ORDER BY order_date;
END //
DELIMITER ;

CALL get_customer_orders(1);

-- SQL Server
CREATE PROCEDURE dbo.get_customer_orders @customer_id INT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT order_id, order_date, status, total_amount
    FROM orders
    WHERE customer_id = @customer_id
    ORDER BY order_date;
END;

EXEC dbo.get_customer_orders @customer_id = 1;
-- MySQL
DELIMITER //
CREATE PROCEDURE customer_stats(
    IN  p_customer_id INT,
    OUT p_order_count INT,
    OUT p_total_spent DECIMAL(12,2)
)
BEGIN
    SELECT COUNT(*), COALESCE(SUM(total_amount), 0)
    INTO p_order_count, p_total_spent
    FROM orders
    WHERE customer_id = p_customer_id AND status <> 'CANCELLED';
END //
DELIMITER ;

CALL customer_stats(2, @cnt, @total);
SELECT @cnt AS order_count, @total AS total_spent;
-- SQL Server
CREATE PROCEDURE dbo.customer_stats
    @customer_id INT,
    @order_count INT OUTPUT,
    @total_spent DECIMAL(12,2) OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT @order_count = COUNT(*), @total_spent = COALESCE(SUM(total_amount), 0)
    FROM orders
    WHERE customer_id = @customer_id AND status <> 'CANCELLED';
END;

DECLARE @c INT, @t DECIMAL(12,2);
EXEC dbo.customer_stats @customer_id = 2, @order_count = @c OUTPUT, @total_spent = @t OUTPUT;
SELECT @c AS order_count, @t AS total_spent;
-- MySQL
DELIMITER //
CREATE PROCEDURE apply_discount(INOUT p_amount DECIMAL(12,2), IN p_percent DECIMAL(5,2))
BEGIN
    SET p_amount = ROUND(p_amount * (1 - p_percent / 100), 2);
END //
DELIMITER ;

SET @amt = 18000.00;
CALL apply_discount(@amt, 10);
SELECT @amt;     -- 16200.00

-- PostgreSQL (procedures support IN and INOUT; OUT supported from PostgreSQL 14)
CREATE OR REPLACE PROCEDURE apply_discount(INOUT p_amount NUMERIC, p_percent NUMERIC)
LANGUAGE plpgsql AS $$
BEGIN
    p_amount := ROUND(p_amount * (1 - p_percent / 100), 2);
END;
$$;
CALL apply_discount(18000.00, 10);   -- returns p_amount = 16200.00
-- PostgreSQL: restock low-inventory products, categorized by urgency
CREATE OR REPLACE PROCEDURE restock_low_inventory(p_threshold INT, p_restock_qty INT)
LANGUAGE plpgsql
AS $$
DECLARE
    rec          RECORD;
    v_restocked  INT := 0;
BEGIN
    FOR rec IN SELECT product_id, product_name, stock FROM products WHERE stock < p_threshold
    LOOP
        IF rec.stock = 0 THEN
            RAISE NOTICE 'URGENT: % is out of stock', rec.product_name;
        ELSIF rec.stock < p_threshold / 2 THEN
            RAISE NOTICE 'LOW: % has % units', rec.product_name, rec.stock;
        ELSE
            RAISE NOTICE 'OK-ish: % has % units', rec.product_name, rec.stock;
        END IF;

        UPDATE products SET stock = stock + p_restock_qty WHERE product_id = rec.product_id;
        v_restocked := v_restocked + 1;
    END LOOP;

    RAISE NOTICE 'Restocked % products', v_restocked;
END;
$$;

CALL restock_low_inventory(20, 50);
-- NOTICE:  OK-ish: Laptop has 15 units
-- NOTICE:  URGENT: Standing Desk is out of stock
-- NOTICE:  Restocked 2 products
-- MySQL: WHILE loop generating a calendar table
DELIMITER //
CREATE PROCEDURE fill_calendar(IN p_start DATE, IN p_end DATE)
BEGIN
    DECLARE d DATE DEFAULT p_start;
    WHILE d <= p_end DO
        INSERT INTO calendar (cal_date, is_weekend)
        VALUES (d, DAYOFWEEK(d) IN (1, 7));
        SET d = DATE_ADD(d, INTERVAL 1 DAY);
    END WHILE;
END //
DELIMITER ;

-- SQL Server: IF / WHILE
DECLARE @i INT = 1;
WHILE @i <= 5
BEGIN
    IF @i % 2 = 0 PRINT CONCAT(@i, ' is even');
    ELSE PRINT CONCAT(@i, ' is odd');
    SET @i += 1;
END;
-- PostgreSQL 11+ procedure
CREATE OR REPLACE PROCEDURE place_order(
    p_order_id     INT,
    p_customer_id  INT,
    p_product_id   INT,
    p_quantity     INT
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_price  NUMERIC(10,2);
    v_stock  INT;
BEGIN
    SELECT price, stock INTO v_price, v_stock
    FROM products
    WHERE product_id = p_product_id
    FOR UPDATE;                                   -- lock the product row

    IF NOT FOUND THEN
        RAISE EXCEPTION 'Product % does not exist', p_product_id;
    END IF;

    IF v_stock < p_quantity THEN
        RAISE EXCEPTION 'Insufficient stock for product %: requested %, available %',
                        p_product_id, p_quantity, v_stock;
    END IF;

    INSERT INTO orders (order_id, customer_id, order_date, status, total_amount)
    VALUES (p_order_id, p_customer_id, CURRENT_DATE, 'PENDING', v_price * p_quantity);

    INSERT INTO order_items (order_id, product_id, quantity, unit_price)
    VALUES (p_order_id, p_product_id, p_quantity, v_price);

    UPDATE products SET stock = stock - p_quantity WHERE product_id = p_product_id;
END;
$$;

CALL place_order(3001, 3, 105, 2);     -- succeeds: Monitor stock 25 → 23
CALL place_or
…
BEGIN
    -- risky statements
EXCEPTION
    WHEN unique_violation THEN
        RAISE NOTICE 'Duplicate order id';
    WHEN OTHERS THEN
        RAISE NOTICE 'Error: %', SQLERRM;
        RAISE;   -- re-throw
END;
DELIMITER //
CREATE PROCEDURE safe_transfer(IN p_from VARCHAR(20), IN p_to VARCHAR(20), IN p_amount DECIMAL(15,2))
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;                            -- re-raise to the caller
    END;

    START TRANSACTION;
    UPDATE bank_accounts SET balance = balance - p_amount WHERE account_no = p_from AND balance >= p_amount;
    IF ROW_COUNT() = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Insufficient funds or unknown account';
    END IF;
    UPDATE bank_accounts SET balance = balance + p_amount WHERE account_no = p_to;
    COMMIT;
END //
DELIMITER ;

Expected Result

After CALL place_order(3001, 3, 105, 2);:

SELECT o.order_id, o.total_amount, p.stock
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE o.order_id = 3001;

Common Mistakes

Forgetting DELIMITER in the MySQL client.

Row-by-row loops where a single set-based statement would do.

Swallowing errors (catching and not re-raising), hiding failures from the caller.

Dynamic SQL built by concatenating parameters (SQL injection inside the database — use EXECUTE ... USING in PostgreSQL, sp_executesql with parameters in SQL Server, prepared statements in MySQL).

Parameter names equal to column names (WHERE customer_id = customer_id is always true in some dialects). Prefix parameters (p_, @).

Not keeping procedure source in version control.

Best Practices

Use procedures for multi-step business operations that must be atomic.

Keep business logic set-based; loops only when unavoidable.

Validate inputs and raise meaningful errors.

Use SET NOCOUNT ON in SQL Server procedures.

Version, review, and test procedures like application code (migration tools, unit tests like tSQLt, pgTAP, utPLSQL).

Interview Relevance

Medium-high (especially for SQL Server/Oracle roles): "What is a stored procedure?", "Advantages and disadvantages?", "IN vs OUT vs INOUT?", "Procedure vs function?", "How do you handle errors?".

Practice Questions

Write a procedure that returns all employees in a given department.

Write a procedure with an OUT parameter returning the number of products in a category.

Write a procedure that raises salaries by a percentage for a department, rejecting percentages above 20.

Rewrite a cursor loop that updates rows one by one as a single UPDATE.

Add error handling to the transfer procedure so that it logs failed attempts to an error_log table.