Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate

Stored Procedures

Reusable named programs stored inside the database — parameters, variables, control flow, exception handling, and when database-side logic beats application-layer logic

14–20 min April 2026
Section 10 · Advanced SQL Objects
Advanced SQL Objects · 7 modulesModule 49

// Part 01

What a Stored Procedure Is

A stored procedure is a named, reusable program stored inside the database. It is written in a procedural extension of SQL (PL/pgSQL in PostgreSQL, T-SQL in SQL Server, PL/SQL in Oracle) and can contain variables, conditional logic, loops, exception handling, and multiple SQL statements — all executed server-side in a single round trip.

Unlike a view (which is a saved SELECT) or a function (which returns a value), a stored procedure is called with CALL and performs actions — it can INSERT, UPDATE, DELETE, manage transactions, and modify database state. The procedure runs entirely inside the database engine, with no data transferred to the application until the procedure finishes.

Stored procedure anatomy — PostgreSQL PL/pgSQL
-- Create a stored procedure
CREATE OR REPLACE PROCEDURE procedure_name(
  param1  data_type,
  param2  data_type DEFAULT default_value,
  INOUT  result_param data_type DEFAULT NULL  -- INOUT for output values
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_variable   data_type;           -- local variable declaration
  v_another    data_type := 0;      -- with initial value
BEGIN
  -- SQL statements
  SELECT col INTO v_variable FROM table WHERE condition;

  -- Control flow
  IF v_variable > 100 THEN
    UPDATE table SET col = col * 1.1;
  ELSE
    RAISE NOTICE 'Value is %', v_variable;   -- log message
  END IF;

  -- Exception handling
  EXCEPTION
    WHEN unique_violation THEN
      RAISE EXCEPTION 'Duplicate entry: %', SQLERRM;
    WHEN OTHERS THEN
      RAISE;   -- re-raise any other exception
END;
$$;

-- Call a stored procedure
CALL procedure_name(arg1, arg2);

// Part 02

Stored Procedure vs Function vs View — The Differences

Stored Procedure
  • Called with CALL
  • Returns nothing (or INOUT params)
  • Can COMMIT / ROLLBACK inside
  • Can modify database state (DML)
  • Cannot be used in SELECT or WHERE
  • Best for: batch jobs, business workflows, multi-step operations
Function
  • Called with SELECT or inline
  • Must return a value or table
  • Cannot manage transactions
  • Can modify state (but discouraged)
  • Usable inside SELECT, WHERE, JOIN
  • Best for: computations, transformations, derived values
View
  • Queried with SELECT
  • Returns rows (read-only)
  • No procedural logic
  • Cannot modify state
  • Usable anywhere a table is
  • Best for: saved queries, access control, abstraction

// Part 03

Your First Stored Procedure — Variables and Basic Logic

Procedure: upgrade customer loyalty tier
-- Procedure: calculate and upgrade a customer's loyalty tier
-- based on their delivered order history
CREATE OR REPLACE PROCEDURE sp_upgrade_loyalty_tier(
  p_customer_id  INTEGER
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_lifetime_value  NUMERIC;
  v_current_tier    TEXT;
  v_new_tier        TEXT;
BEGIN
  -- Step 1: get current tier and lifetime value
  SELECT
    c.loyalty_tier,
    COALESCE(SUM(o.total_amount), 0)
  INTO
    v_current_tier,
    v_lifetime_value
  FROM customers AS c
  LEFT JOIN orders AS o
    ON c.customer_id = o.customer_id
    AND o.order_status = 'Delivered'
  WHERE c.customer_id = p_customer_id
  GROUP BY c.loyalty_tier;

  -- Step 2: determine the earned tier
  v_new_tier := CASE
    WHEN v_lifetime_value >= 5000 THEN 'Platinum'
    WHEN v_lifetime_value >= 2000 THEN 'Gold'
    WHEN v_lifetime_value >= 500  THEN 'Silver'
    ELSE 'Bronze'
  END;

  -- Step 3: update only if tier changed
  IF v_new_tier != v_current_tier THEN
    UPDATE customers
    SET loyalty_tier = v_new_tier
    WHERE customer_id = p_customer_id;

    RAISE NOTICE 'Customer % upgraded: % → %',
      p_customer_id, v_current_tier, v_new_tier;
  ELSE
    RAISE NOTICE 'Customer % tier unchanged: %',
      p_customer_id, v_current_tier;
  END IF;
END;
$$;

-- Call it:
CALL sp_upgrade_loyalty_tier(1);
CALL sp_upgrade_loyalty_tier(42);
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 04

Parameters — IN, OUT, and INOUT

Parameter modes — IN, OUT, INOUT
-- IN (default): value passed into the procedure — read-only inside
-- OUT: value returned from the procedure — write-only inside
-- INOUT: both passed in AND returned — readable and writable

-- Example: procedure that returns computed values via INOUT/OUT
CREATE OR REPLACE PROCEDURE sp_get_store_stats(
  p_store_id      TEXT,                 -- IN: which store
  INOUT p_orders  INTEGER DEFAULT 0,    -- OUT: order count
  INOUT p_revenue NUMERIC DEFAULT 0     -- OUT: total revenue
)
LANGUAGE plpgsql
AS $$
BEGIN
  SELECT
    COUNT(*),
    ROUND(SUM(total_amount), 2)
  INTO
    p_orders,
    p_revenue
  FROM orders
  WHERE store_id     = p_store_id
    AND order_status = 'Delivered';
END;
$$;

-- Call and read the INOUT outputs:
DO $$
DECLARE
  v_orders  INTEGER;
  v_revenue NUMERIC;
BEGIN
  CALL sp_get_store_stats('ST001', v_orders, v_revenue);
  RAISE NOTICE 'ST001 — Orders: %, Revenue: ₹%', v_orders, v_revenue;
END;
$$;

-- Note: in PostgreSQL, functions are often preferred over procedures
-- when returning values — functions work naturally in SELECT queries
-- Procedures with INOUT are useful when transaction control is also needed
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 05

Control Flow — IF, CASE, Loops

IF / ELSIF / ELSE

IF / ELSIF / ELSE in PL/pgSQL
CREATE OR REPLACE PROCEDURE sp_apply_discount(
  p_order_id  INTEGER,
  p_tier      TEXT
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_discount  NUMERIC;
  v_original  NUMERIC;
BEGIN
  -- Get original amount
  SELECT total_amount INTO v_original
  FROM orders WHERE order_id = p_order_id;

  -- Determine discount by loyalty tier
  IF p_tier = 'Platinum' THEN
    v_discount := 0.20;
  ELSIF p_tier = 'Gold' THEN
    v_discount := 0.15;
  ELSIF p_tier = 'Silver' THEN
    v_discount := 0.10;
  ELSE
    v_discount := 0.05;
  END IF;

  -- Apply discount
  UPDATE orders
  SET total_amount = ROUND(v_original * (1 - v_discount), 2)
  WHERE order_id = p_order_id;

  RAISE NOTICE 'Order %: ₹% → ₹% (% discount applied)',
    p_order_id,
    v_original,
    ROUND(v_original * (1 - v_discount), 2),
    (v_discount * 100)::TEXT || '%';
END;
$$;

LOOP, WHILE, and FOR loops

Loop constructs in PL/pgSQL
CREATE OR REPLACE PROCEDURE sp_batch_upgrade_all_customers()
LANGUAGE plpgsql
AS $$
DECLARE
  v_customer   RECORD;        -- holds one row at a time
  v_count      INTEGER := 0;
BEGIN
  -- FOR loop over a query result — iterate every customer
  FOR v_customer IN
    SELECT customer_id FROM customers ORDER BY customer_id
  LOOP
    -- Call the upgrade procedure for each customer
    CALL sp_upgrade_loyalty_tier(v_customer.customer_id);
    v_count := v_count + 1;
  END LOOP;

  RAISE NOTICE 'Processed % customers', v_count;
END;
$$;

-- WHILE loop example:
CREATE OR REPLACE PROCEDURE sp_retry_failed_payments(p_max_attempts INTEGER)
LANGUAGE plpgsql
AS $$
DECLARE
  v_attempt INTEGER := 0;
  v_pending INTEGER;
BEGIN
  LOOP
    -- Count remaining failed payments
    SELECT COUNT(*) INTO v_pending
    FROM payment_log WHERE status = 'failed';

    EXIT WHEN v_pending = 0 OR v_attempt >= p_max_attempts;

    -- Process one batch of retries
    UPDATE payment_log SET status = 'retrying'
    WHERE status = 'failed'
    LIMIT 100;

    v_attempt := v_attempt + 1;
    RAISE NOTICE 'Retry attempt %, remaining: %', v_attempt, v_pending;
  END LOOP;
END;
$$;
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 06

Exception Handling — RAISE and EXCEPTION Blocks

Stored procedures handle errors through exception blocks. When a statement raises an error, execution jumps to the EXCEPTION section. You can catch specific error types, log them, and either recover gracefully or re-raise to the caller.

Exception handling — RAISE and EXCEPTION
CREATE OR REPLACE PROCEDURE sp_place_order(
  p_customer_id  INTEGER,
  p_store_id     TEXT,
  p_product_id   INTEGER,
  p_quantity     INTEGER
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_unit_price   NUMERIC;
  v_in_stock     BOOLEAN;
  v_order_id     INTEGER;
BEGIN
  -- Validate product
  SELECT unit_price, in_stock
  INTO   v_unit_price, v_in_stock
  FROM   products
  WHERE  product_id = p_product_id;

  -- Not found: FOUND is FALSE if SELECT INTO returned no rows
  IF NOT FOUND THEN
    RAISE EXCEPTION 'Product % does not exist', p_product_id
      USING ERRCODE = 'P0001';   -- custom error code
  END IF;

  IF NOT v_in_stock THEN
    RAISE EXCEPTION 'Product % is out of stock', p_product_id
      USING ERRCODE = 'P0002';
  END IF;

  -- Insert order
  INSERT INTO orders (customer_id, store_id, order_date, order_status, total_amount)
  VALUES (p_customer_id, p_store_id, CURRENT_DATE, 'Processing',
          v_unit_price * p_quantity)
  RETURNING order_id INTO v_order_id;

  -- Insert order item
  INSERT INTO order_items (order_id, product_id, quantity, unit_price, line_total)
  VALUES (v_order_id, p_product_id, p_quantity, v_unit_price,
          v_unit_price * p_quantity);

  RAISE NOTICE 'Order % placed successfully for customer %', v_order_id, p_customer_id;

EXCEPTION
  WHEN foreign_key_violation THEN
    RAISE EXCEPTION 'Invalid customer_id or store_id: %', SQLERRM;
  WHEN unique_violation THEN
    RAISE EXCEPTION 'Duplicate order detected: %', SQLERRM;
  WHEN OTHERS THEN
    -- Log the error and re-raise
    RAISE NOTICE 'Unexpected error in sp_place_order: %', SQLERRM;
    RAISE;   -- re-raises the original exception
END;
$$;
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 07

Transaction Control Inside Procedures

In PostgreSQL, stored procedures (not functions) can contain COMMIT and ROLLBACK statements — something functions cannot do. This makes procedures the right tool for multi-step batch jobs where each step should commit independently rather than waiting for the entire batch to finish.

Transaction control inside a procedure
-- Procedure that processes orders in batches
-- Each batch commits independently — no single giant transaction
CREATE OR REPLACE PROCEDURE sp_process_pending_orders(
  p_batch_size  INTEGER DEFAULT 100
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_order       RECORD;
  v_processed   INTEGER := 0;
  v_failed      INTEGER := 0;
BEGIN
  FOR v_order IN
    SELECT order_id, customer_id, total_amount
    FROM orders
    WHERE order_status = 'Processing'
    ORDER BY order_date
    LIMIT p_batch_size
  LOOP
    BEGIN
      -- Try to process each order
      UPDATE orders
      SET order_status = 'Confirmed'
      WHERE order_id = v_order.order_id;

      INSERT INTO processing_log (order_id, processed_at, status)
      VALUES (v_order.order_id, NOW(), 'success');

      v_processed := v_processed + 1;

      -- COMMIT each order individually — not waiting for the whole batch
      COMMIT;

    EXCEPTION WHEN OTHERS THEN
      -- One order failing does not stop the batch
      ROLLBACK;
      v_failed := v_failed + 1;

      INSERT INTO error_log (order_id, error_msg, logged_at)
      VALUES (v_order.order_id, SQLERRM, NOW());
      COMMIT;   -- commit the error log entry
    END;
  END LOOP;

  RAISE NOTICE 'Batch complete: % processed, % failed', v_processed, v_failed;
END;
$$;

-- Note: COMMIT/ROLLBACK inside procedures is PostgreSQL 11+
-- MySQL stored procedures also support COMMIT/ROLLBACK
-- Cannot use COMMIT/ROLLBACK inside PostgreSQL FUNCTIONS
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 08

Stored Functions vs Stored Procedures

PostgreSQL uses functions for computations that return values and procedures for actions. Functions can be called inside SQL queries (SELECT, WHERE, JOIN). Procedures cannot. Functions cannot COMMIT or ROLLBACK. Procedures can. In practice, many teams use functions for most database-side logic and procedures only when transaction control is needed.

Function vs procedure — when to use each
-- FUNCTION: returns a value — usable in SELECT/WHERE/JOIN
CREATE OR REPLACE FUNCTION fn_customer_lifetime_value(
  p_customer_id INTEGER
) RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
  v_total NUMERIC;
BEGIN
  SELECT COALESCE(SUM(total_amount), 0)
  INTO   v_total
  FROM   orders
  WHERE  customer_id   = p_customer_id
    AND  order_status  = 'Delivered';
  RETURN v_total;
END;
$$;

-- Function is callable in any SQL context:
SELECT customer_id, fn_customer_lifetime_value(customer_id) AS ltv
FROM customers;

WHERE fn_customer_lifetime_value(customer_id) > 1000

-- PROCEDURE: performs actions — called with CALL only
CREATE OR REPLACE PROCEDURE sp_upgrade_all_tiers()
LANGUAGE plpgsql AS $$
BEGIN
  UPDATE customers AS c
  SET loyalty_tier = CASE
    WHEN fn_customer_lifetime_value(c.customer_id) >= 5000 THEN 'Platinum'
    WHEN fn_customer_lifetime_value(c.customer_id) >= 2000 THEN 'Gold'
    WHEN fn_customer_lifetime_value(c.customer_id) >= 500  THEN 'Silver'
    ELSE 'Bronze'
  END;
  -- Can COMMIT here (procedure can manage transactions)
END;
$$;

CALL sp_upgrade_all_tiers();
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

Table-valued functions — return a result set

Table-valued function — returns rows like a table
-- Function that returns a table (like a parameterised view)
CREATE OR REPLACE FUNCTION fn_store_report(
  p_store_id TEXT,
  p_from     DATE DEFAULT '2024-01-01',
  p_to       DATE DEFAULT CURRENT_DATE
)
RETURNS TABLE (
  order_date     DATE,
  order_count    INTEGER,
  daily_revenue  NUMERIC,
  avg_order      NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT
    o.order_date,
    COUNT(*)::INTEGER,
    ROUND(SUM(o.total_amount), 2),
    ROUND(AVG(o.total_amount), 2)
  FROM orders AS o
  WHERE o.store_id     = p_store_id
    AND o.order_status = 'Delivered'
    AND o.order_date BETWEEN p_from AND p_to
  GROUP BY o.order_date
  ORDER BY o.order_date;
END;
$$;

-- Call exactly like a table:
SELECT * FROM fn_store_report('ST001');
SELECT * FROM fn_store_report('ST001', '2024-01-01', '2024-01-31');

-- Join to other tables:
SELECT s.city, r.daily_revenue
FROM fn_store_report('ST001') AS r
JOIN stores AS s ON s.store_id = 'ST001';
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 09

Managing Procedures — List, View, Alter, Drop

Procedure DDL — lifecycle management
-- List all procedures in the current database (PostgreSQL)
SELECT
  routine_name,
  routine_type,
  data_type AS return_type,
  external_language AS language
FROM information_schema.routines
WHERE routine_schema = 'public'
  AND routine_type IN ('PROCEDURE', 'FUNCTION')
ORDER BY routine_type, routine_name;

-- View a procedure's definition
SELECT prosrc
FROM pg_proc
WHERE proname = 'sp_upgrade_loyalty_tier';

-- Or use pg_get_functiondef:
SELECT pg_get_functiondef(oid)
FROM pg_proc
WHERE proname = 'sp_upgrade_loyalty_tier';

-- Update a procedure (CREATE OR REPLACE — no DROP needed)
CREATE OR REPLACE PROCEDURE sp_upgrade_loyalty_tier(p_customer_id INTEGER)
LANGUAGE plpgsql AS $$
BEGIN
  -- ... updated logic ...
END;
$$;

-- Drop a procedure
DROP PROCEDURE sp_upgrade_loyalty_tier(INTEGER);
DROP PROCEDURE IF EXISTS sp_upgrade_loyalty_tier(INTEGER);

-- Drop with CASCADE (also drops dependent triggers/rules)
DROP PROCEDURE sp_upgrade_loyalty_tier(INTEGER) CASCADE;

-- Procedure permissions
GRANT EXECUTE ON PROCEDURE sp_upgrade_loyalty_tier(INTEGER) TO app_role;
REVOKE EXECUTE ON PROCEDURE sp_upgrade_loyalty_tier(INTEGER) FROM PUBLIC;
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 10

When to Use Stored Procedures vs Application Logic

The debate between database-side logic (stored procedures) and application-side logic has no universal answer. Both have legitimate use cases. The decision depends on what the logic does, who needs to call it, and how the organisation maintains code.

ScenarioRecommended approachWhy
Batch job processing millions of rowsStored procedureSet-based updates in the database avoid transferring millions of rows to the app layer and back
Business logic called from multiple apps (mobile, web, admin)Stored procedureSingle implementation in the DB enforces consistency — no risk of apps implementing rules differently
Complex validation before INSERT/UPDATEStored procedure or triggerValidation runs server-side regardless of which client is writing data
Simple CRUD for a single applicationApplication codeORMs and application frameworks handle this cleanly; stored procedures add overhead
Business rules that change frequentlyApplication codeStored procedure changes require DB migrations — slower to deploy than app deploys
Multi-step atomic workflow (order → payment → inventory)Stored procedureSingle CALL wraps entire workflow in one transaction with proper rollback
Reporting and analytics queriesViews or application layerViews provide the abstraction; application layer has better tooling for complex report logic
Access control — prevent direct table accessStored procedure + GRANTGrant EXECUTE only on the procedure; revoke direct table access — only the procedure can write

// Part 11

What This Looks Like at Work

You are a backend engineer at Shopify. Every night at 2 AM a batch job runs to: (1) calculate every seller's monthly revenue, (2) update their seller_tier based on thresholds, (3) credit loyalty cashback to high-tier sellers, and (4) log the run. Previously this was application code that fetched sellers one by one, computed in Python, and issued individual UPDATEs — 50,000 sellers took 25 minutes. You migrate it to a stored procedure that does everything server-side — the same job runs in 18 seconds.

1:55 AM
Old job — 25 minutes, Python fetching rows one by one
Each seller: SELECT → compute in Python → UPDATE. 50,000 × 3 round trips = 150,000 database calls, each paying network latency.
2:00 AM
New job — stored procedure, everything runs server-side
One CALL. The procedure computes and updates all 50,000 sellers in a single set-based UPDATE. Zero round trips per seller.
sp_nightly_seller_tier_update — production-style procedure
CREATE OR REPLACE PROCEDURE sp_nightly_seller_tier_update(
  p_month_start  DATE DEFAULT DATE_TRUNC('month', CURRENT_DATE)::DATE
)
LANGUAGE plpgsql
AS $$
DECLARE
  v_updated      INTEGER;
  v_run_id       INTEGER;
  v_month_end    DATE;
BEGIN
  v_month_end := (p_month_start + INTERVAL '1 month' - INTERVAL '1 day')::DATE;

  -- Log the run start
  INSERT INTO batch_log (job_name, started_at, status)
  VALUES ('nightly_tier_update', NOW(), 'running')
  RETURNING id INTO v_run_id;

  -- Step 1: compute monthly revenue per seller and update tier
  -- Single set-based UPDATE — no row-by-row looping needed
  WITH seller_revenue AS (
    SELECT
      seller_id,
      ROUND(SUM(order_amount), 2)  AS monthly_revenue
    FROM seller_orders
    WHERE order_date BETWEEN p_month_start AND v_month_end
      AND order_status = 'Delivered'
    GROUP BY seller_id
  )
  UPDATE sellers AS s
  SET
    current_month_revenue = sr.monthly_revenue,
    seller_tier = CASE
      WHEN sr.monthly_revenue >= 500000 THEN 'Diamond'
      WHEN sr.monthly_revenue >= 100000 THEN 'Platinum'
      WHEN sr.monthly_revenue >= 25000  THEN 'Gold'
      WHEN sr.monthly_revenue >= 5000   THEN 'Silver'
      ELSE 'Bronze'
    END,
    tier_updated_at = NOW()
  FROM seller_revenue AS sr
  WHERE s.seller_id = sr.seller_id;

  GET DIAGNOSTICS v_updated = ROW_COUNT;

  -- Step 2: credit cashback to Diamond and Platinum sellers
  UPDATE seller_wallets AS w
  SET balance = balance + (s.current_month_revenue * 0.02)
  FROM sellers AS s
  WHERE w.seller_id = s.seller_id
    AND s.seller_tier IN ('Diamond', 'Platinum')
    AND s.tier_updated_at >= NOW() - INTERVAL '1 minute';

  -- Step 3: commit and update log
  UPDATE batch_log
  SET
    finished_at    = NOW(),
    rows_processed = v_updated,
    status         = 'success'
  WHERE id = v_run_id;

  COMMIT;

  RAISE NOTICE 'Tier update complete: % sellers updated in % → %',
    v_updated, p_month_start, v_month_end;

EXCEPTION WHEN OTHERS THEN
  UPDATE batch_log
  SET status = 'failed', error_msg = SQLERRM, finished_at = NOW()
  WHERE id = v_run_id;
  COMMIT;
  RAISE;
END;
$$;

-- Scheduled with pg_cron every night at 2:00 AM:
-- SELECT cron.schedule('nightly-tier-update', '0 2 * * *',
--   $$CALL sp_nightly_seller_tier_update()$$);
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…
2:00:18 AM
Job complete — 18 seconds instead of 25 minutes
The set-based UPDATE eliminates all Python-to-database round trips. The entire computation runs inside PostgreSQL's query engine, which is optimised exactly for this kind of bulk update. Logging and cashback crediting are part of the same procedure call — atomic, consistent, and auditable.

🎯 Pro Tip

The biggest performance win from stored procedures is eliminating row-by-row processing. If your application fetches rows, processes each one in a loop, and issues individual UPDATEs — you can almost always replace that with a single set-based UPDATE inside a stored procedure. Set-based operations in the database are orders of magnitude faster than row-by-row loops because they avoid network round trips and allow the query engine to optimise the entire operation as a unit.

// Part 12

Interview Prep — 5 Questions With Complete Answers

Q: What is a stored procedure and how does it differ from a function?

A stored procedure is a named, reusable program stored inside the database that performs actions — INSERT, UPDATE, DELETE, manage transactions, and execute complex multi-step logic. It is called with CALL and typically does not return a value (though PostgreSQL procedures support INOUT parameters for output values). Stored procedures in PostgreSQL (11+) can contain COMMIT and ROLLBACK statements, making them suitable for batch jobs where each step should commit independently.

A function also lives in the database as named reusable code, but it must return a value — a scalar value, a row, or a full table. Functions are called inline in SQL expressions: SELECT fn_name(args), WHERE fn_name(col) > threshold, or SELECT * FROM table_valued_fn(args). Functions cannot contain COMMIT or ROLLBACK — they run within the caller's transaction. Functions are preferred when you need to return a computed value or result set that other SQL queries can use.

In practice: use a function when you need a reusable computation that returns a value and you want to use it in SELECT, WHERE, or JOIN. Use a procedure when you need to perform a multi-step workflow with transaction control, or when the operation is an action (process orders, upgrade tiers, archive data) rather than a computation. Many PostgreSQL teams use functions for almost everything and reach for procedures only when they need COMMIT/ROLLBACK inside the database-side logic.

Q: What are the advantages of using stored procedures over application-layer code?

Performance: stored procedures eliminate round trips between the application and database for multi-step operations. A procedure that updates 50,000 rows executes entirely inside the database engine with no network latency per row. The equivalent application code (fetch → compute → update per row) pays network latency and serialization overhead for each row. For bulk operations, the speedup is often 10-100x.

Consistency: business logic in a stored procedure is enforced for all clients regardless of which application, script, or tool is writing to the database. A validation rule implemented in a Python service does not protect against a Ruby script or a direct psql command bypassing it. A stored procedure (with appropriate GRANT/REVOKE permissions) ensures every write goes through the same validation path.

Security: stored procedures enable a principle of least privilege — applications can be granted EXECUTE on the procedure but have no direct INSERT/UPDATE/DELETE permission on the underlying tables. This prevents applications from bypassing business rules via direct DML. Reduced network exposure: sensitive data (raw customer PII, financial records) stays inside the database rather than being sent to application servers for processing. Atomicity: complex multi-step workflows are wrapped in a single procedure call with proper exception handling and transaction control — simpler and more reliable than coordinating multi-step atomicity from application code.

Q: How do you handle errors inside a stored procedure?

PL/pgSQL uses BEGIN...EXCEPTION...END blocks. The normal logic goes in BEGIN. If any statement raises an exception, execution jumps to the EXCEPTION section. You can catch specific exception types by name (unique_violation, foreign_key_violation, division_by_zero) or use WHEN OTHERS to catch any exception.

The EXCEPTION block has access to special variables: SQLERRM contains the error message text, SQLSTATE contains the five-character error code. You can use these to log errors, construct meaningful messages, or branch differently based on the error type. To raise your own exceptions: RAISE EXCEPTION 'message %', variable re-raises the current exception. RAISE NOTICE logs a message (visible to the client). RAISE WARNING generates a warning. RAISE EXCEPTION with USING ERRCODE = 'P0001' sets a custom error code that the caller can check.

An important subtlety: when an exception is caught in the EXCEPTION block, the database implicitly rolls back all changes made in the BEGIN block up to that point — the EXCEPTION block starts with a clean slate. This means you can insert into an error log table within the EXCEPTION block and those inserts will succeed even though the main operation failed. If you COMMIT within the procedure before the error, those committed changes are permanent — only changes after the last COMMIT are rolled back. For batch procedures, catching exceptions per row (via a nested BEGIN...EXCEPTION inside the FOR loop) allows one failing row to be logged and skipped while the rest of the batch continues successfully.

Q: What is the difference between RAISE NOTICE, RAISE WARNING, and RAISE EXCEPTION?

All three are PL/pgSQL messages but they have different severity levels and different effects on execution. RAISE NOTICE prints an informational message to the client — it does not affect execution. The procedure continues normally after RAISE NOTICE. Messages appear in psql output and in application log captures depending on the client_min_messages setting. RAISE NOTICE is used for debugging and progress reporting during development or long-running jobs.

RAISE WARNING is similar to NOTICE but carries a higher severity — it signals that something unusual happened but the procedure can still continue. Warnings are logged at a different level and may appear in database logs depending on log_min_messages. Like NOTICE, execution continues normally after RAISE WARNING.

RAISE EXCEPTION is the only one that stops execution. It raises a database exception that propagates up through the call stack like any other error. If uncaught, it rolls back the current transaction and returns the error to the caller. If caught in an EXCEPTION block, that block handles it. RAISE EXCEPTION is the mechanism for enforcing business rules — when an invalid state is detected, RAISE EXCEPTION communicates the error to the caller and ensures the transaction is aborted. The optional USING clause allows attaching an ERRCODE: RAISE EXCEPTION 'message' USING ERRCODE = 'P0001' lets the caller distinguish between different types of business rule violations by inspecting the SQLSTATE code.

Q: When would you choose a stored procedure over a trigger?

A stored procedure is explicitly called — an application, scheduler, or another procedure invokes it. It runs when you tell it to run. A trigger is implicitly called — the database fires it automatically in response to an event (INSERT, UPDATE, DELETE, or TRUNCATE on a specific table). The choice depends on whether the logic should be automatic or explicit.

Use a stored procedure when: the logic represents a business workflow that should be invoked by a specific action (process a batch, upgrade tiers, generate reports). The caller needs to know when and how the logic runs. The logic needs parameters that vary per call. The logic should run within a controlled transaction context. The logic is called from multiple places and needs a consistent interface.

Use a trigger when: the logic must always run in response to a table modification, regardless of which application or query caused the modification — ensuring no direct DML can bypass the logic. Classic trigger use cases: automatically maintaining an audit log every time a row changes (a trigger cannot be bypassed by application code the way a stored procedure can), cascading denormalized data (updating a summary table when detail rows change), enforcing complex multi-table constraints that CHECK constraints cannot express, and auto-populating derived columns on INSERT or UPDATE. The key difference: procedures are for explicit, intentional workflows. Triggers are for automatic, always-enforced side effects of table modifications.

// Part 13

Errors You Will Hit — And Exactly Why They Happen

ERROR: control reached end of function without RETURN

Cause: A PL/pgSQL function (not procedure) that declares a return type does not have a RETURN statement in all code paths. If conditional branches in the function can reach the END; without executing a RETURN, PostgreSQL raises this error at runtime. A procedure does not need RETURN (it uses INOUT parameters or nothing), but a function must always return a value.

Fix: Add RETURN statements to all code paths, including the default case. For RETURN QUERY functions, ensure the RETURN QUERY executes in all branches. If the function genuinely has cases where nothing should be returned, use RETURN NULL; for scalar functions or RETURN; for SETOF functions. Use RETURN NEXT inside loops for SETOF functions that accumulate rows. Trace every IF/ELSIF/ELSE branch and verify each has a RETURN.

ERROR: COMMIT is not allowed in a SQL function

Cause: A COMMIT or ROLLBACK statement was placed inside a PostgreSQL FUNCTION (created with CREATE FUNCTION). Functions cannot manage transactions because they run within the caller's transaction. COMMIT inside a function would commit the caller's entire transaction, which is not the function's responsibility and would violate the caller's expectations.

Fix: Convert the routine to a PROCEDURE (CREATE PROCEDURE) instead of a FUNCTION. Procedures in PostgreSQL 11+ support COMMIT and ROLLBACK. Call it with CALL instead of SELECT. If transaction control is needed alongside returning a value, use INOUT parameters on the procedure to pass back computed values. If the routine only needs to return a value without transaction control, keep it as a function and remove COMMIT/ROLLBACK.

ERROR: query has no destination for result data — SELECT INTO required

Cause: A plain SELECT statement inside a PL/pgSQL procedure or function discards its results. In PL/pgSQL, a bare SELECT with no INTO clause raises this error because the results have nowhere to go. PL/pgSQL requires either SELECT INTO variable to capture results or a PERFORM statement to discard results explicitly.

Fix: To capture results: SELECT col INTO v_variable FROM table WHERE condition. For multiple columns: SELECT col1, col2 INTO v_var1, v_var2 FROM table. To discard results intentionally: PERFORM expensive_function_call(); — PERFORM is the PL/pgSQL way of calling a function and discarding its return value. For dynamic SQL: use EXECUTE ... INTO for capturing results from dynamically built queries.

Stored procedure runs correctly in development but causes deadlocks in production

Cause: The procedure acquires locks in an inconsistent order compared to other concurrent procedures or application queries. In production under concurrent load, two sessions run the same procedure simultaneously and each acquires locks in a different order, creating a circular wait. This is invisible in single-session development testing.

Fix: Audit all lock acquisition order within the procedure. Ensure every session that needs to update multiple rows always updates them in the same order (by primary key ascending, for example). Add ORDER BY to any FOR loop that performs updates: FOR v_row IN SELECT ... ORDER BY id LOOP — this ensures all sessions process rows in the same order. For queue-style procedures, use SELECT ... FOR UPDATE SKIP LOCKED to ensure each session picks up a different row rather than competing for the same one. Test with multiple concurrent sessions in a staging environment before deploying to production.

Procedure changes are not visible after calling it — rows not updated

Cause: Three possible causes: (1) The procedure lacks a COMMIT and the changes are being rolled back by the caller's transaction or at session end. (2) The UPDATE's WHERE condition does not match any rows — FOUND variable is FALSE but no exception was raised. (3) The procedure is committing to a different database/schema than the one being queried for verification.

Fix: Check whether the procedure needs explicit COMMIT (procedures) or whether the caller should COMMIT after CALL. Verify the WHERE condition matches rows: add SELECT COUNT(*) INTO v_count FROM table WHERE condition; IF v_count = 0 THEN RAISE NOTICE 'No rows matched'; END IF; before the UPDATE. Use GET DIAGNOSTICS v_rows = ROW_COUNT; immediately after the UPDATE to check how many rows were actually modified. Check the search_path: the procedure may be updating a table in a different schema than the one being queried for verification.

Try It Yourself

Design two stored procedures for FreshCart. (1) Write the CREATE PROCEDURE statement for sp_daily_store_summary that takes a p_summary_date DATE parameter (defaulting to CURRENT_DATE - 1) and computes for each store: total delivered orders, total revenue, average order value, and the store's best-selling product category (by revenue). The procedure should INSERT these results into a store_daily_summary table and log the run with a RAISE NOTICE showing how many stores were processed. Use a CTE inside the procedure for the category calculation. (2) Write the CREATE PROCEDURE statement for sp_reorder_low_stock that takes a p_threshold INTEGER parameter (default 5) and for every product where in_stock = false (simulating low stock), INSERTs a reorder request into a reorder_queue table with product_id, product_name, category, unit_price, and requested_at = NOW(). It should use a FOR loop over the low-stock products and count how many reorder requests were created. Show the DECLARE section, the logic, exception handling, and what a CALL would look like for each.

🎯 Key Takeaways

  • A stored procedure is a named program stored in the database, called with CALL. It performs actions (DML, transaction control), takes parameters, and can contain variables, conditional logic, loops, and exception handling.
  • Procedure vs function: functions return a value and are usable in SELECT/WHERE/JOIN. Procedures return nothing (or INOUT params) and cannot be used inline in SQL. Only procedures can COMMIT/ROLLBACK in PostgreSQL.
  • Parameter modes: IN (input, default), OUT (output to caller), INOUT (both). In PostgreSQL, functions are often preferred over OUT/INOUT procedures when return values are needed.
  • DECLARE block: local variables are declared here with their types. Variables can have default values. RECORD type holds one row of any shape for use in FOR loops.
  • Control flow: IF/ELSIF/ELSE for conditionals. FOR loop over a query for row iteration. WHILE and plain LOOP with EXIT WHEN for condition-based loops.
  • Exception handling: EXCEPTION block after BEGIN catches errors. WHEN unique_violation, WHEN foreign_key_violation, WHEN OTHERS for different error types. SQLERRM for the error message, SQLSTATE for the error code.
  • RAISE NOTICE logs a message without stopping execution. RAISE WARNING signals a concern without stopping. RAISE EXCEPTION aborts execution and propagates an error to the caller.
  • Transaction control: PostgreSQL procedures (11+) can COMMIT and ROLLBACK inside the body. This enables batch jobs where each row or batch commits independently — one failure does not roll back the entire job.
  • The biggest performance win: replacing row-by-row application loops (fetch → compute → update per row) with a single set-based UPDATE inside a procedure eliminates network round trips and is 10-100x faster for bulk operations.
  • Stored procedures enforce business rules for all clients — no application can bypass logic in a procedure if direct table access is revoked. Grant EXECUTE on the procedure, revoke direct DML on the table.

What comes next

In Module 50, you learn User-Defined Functions — scalar functions, table-valued functions, SQL functions vs PL/pgSQL functions, IMMUTABLE vs STABLE vs VOLATILE, and building a reusable function library.

Module 50 → User-Defined Functions
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...