EVIKA ACADEMY · DATA ANALYTICS SERIES · CHAPTER 84

SQL Views, Stored Procedures & Triggers

Server-side SQL every data analyst should know — views, materialised views, stored procedures with error handling, UDFs, triggers, and transactions.

⏱ 24 min read🛠 SQL · MySQL · PostgreSQL
← Ch 83: Advanced Python Data AnalysisCh 85: Data Analyst Career Growth →

Most data analysts are comfortable writing SELECT queries, JOINs, and aggregations. Fewer know how to use the server-side features that make SQL genuinely powerful for production work: views that hide complexity, stored procedures that encapsulate repeatable logic, functions that extend SQL's vocabulary, and triggers that enforce data integrity automatically.

These features matter for analysts because they appear in every production database you will query. Understanding views helps you navigate inherited schemas confidently. Understanding stored procedures helps you collaborate with database engineers and automate ETL steps. Understanding triggers explains behaviour you might otherwise mistake for bugs — why a row changed when you didn't change it.

This chapter covers all four areas with working examples in MySQL / PostgreSQL syntax (differences called out where they exist).

Views

A view is a named, saved SELECT query stored in the database. Querying a view is identical to querying a table — SELECT * FROM view_name — but the database runs the underlying query live each time. Views do not store data (unless materialised); they store the definition.

Why analysts use views

Hide complexity
Wrap a 12-table JOIN into a clean view — analysts query vw_sales_summary instead of re-writing the JOIN every time
Enforce consistency
One source of truth for derived metrics. Margin % calculated in one place, used everywhere
Access control
Grant a user access to a view without granting access to the underlying sensitive tables
Simplify BI tool setup
Power BI and Tableau connect to views just like tables — clean views mean clean data models

Creating and using views

-- Simple view: clean column names and a calculated field
CREATE OR REPLACE VIEW vw_order_summary AS
SELECT
    o.order_id,
    o.order_date,
    c.customer_name,
    c.region,
    p.product_name,
    p.category,
    oi.quantity,
    oi.unit_price,
    oi.quantity * oi.unit_price              AS line_revenue,
    oi.unit_price - p.cost_price             AS unit_margin,
    (oi.unit_price - p.cost_price) * 100.0
        / NULLIF(oi.unit_price, 0)           AS margin_pct
FROM orders          o
JOIN customers       c  ON o.customer_id  = c.customer_id
JOIN order_items     oi ON o.order_id     = oi.order_id
JOIN products        p  ON oi.product_id  = p.product_id;

-- Now analysts query the view instead of the raw join
SELECT region, SUM(line_revenue) AS revenue
FROM   vw_order_summary
WHERE  order_date >= '2026-01-01'
GROUP  BY region
ORDER  BY revenue DESC;

Updatable views

-- A view is updatable when it maps directly to one base table
-- and has no aggregations, DISTINCT, GROUP BY, UNION, or subqueries

CREATE OR REPLACE VIEW vw_active_customers AS
SELECT customer_id, customer_name, email, region
FROM   customers
WHERE  is_active = 1;

-- UPDATE through the view — modifies the underlying table
UPDATE vw_active_customers
SET    region = 'South'
WHERE  customer_id = 1042;

-- Complex views (JOINs, aggregations) are NOT updatable — read-only only

Dropping and listing views

-- Drop a view
DROP VIEW IF EXISTS vw_order_summary;

-- List all views in the current database
-- MySQL:
SELECT TABLE_NAME, VIEW_DEFINITION
FROM   INFORMATION_SCHEMA.VIEWS
WHERE  TABLE_SCHEMA = DATABASE();

-- PostgreSQL:
SELECT viewname, definition
FROM   pg_views
WHERE  schemaname = 'public';

Materialised Views (PostgreSQL)

A materialised view stores the query result physically on disk. Queries against it do not re-execute the underlying SQL — they read the stored snapshot. This makes them dramatically faster for complex aggregations over large tables, at the cost of data freshness.

-- Create a materialised view (PostgreSQL syntax)
CREATE MATERIALIZED VIEW mv_monthly_revenue AS
SELECT
    DATE_TRUNC('month', order_date)  AS month,
    region,
    category,
    SUM(line_revenue)                AS total_revenue,
    COUNT(DISTINCT customer_id)      AS unique_customers,
    AVG(line_revenue)                AS avg_order_value
FROM   vw_order_summary
GROUP  BY 1, 2, 3;

-- Create an index on the materialised view for fast filtering
CREATE INDEX idx_mv_monthly_region ON mv_monthly_revenue(region);
CREATE INDEX idx_mv_monthly_month  ON mv_monthly_revenue(month);

-- Refresh the snapshot manually
REFRESH MATERIALIZED VIEW mv_monthly_revenue;

-- Refresh without locking (allows reads during refresh)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_revenue;

-- Schedule automatic refresh using pg_cron (once installed):
SELECT cron.schedule(
    'refresh_monthly_revenue',
    '0 2 * * *',   -- every day at 2am
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_revenue'
);

-- Drop
DROP MATERIALIZED VIEW IF EXISTS mv_monthly_revenue;
MySQL equivalent:

MySQL does not have native materialised views. The common pattern is to create a regular table, populate it with INSERT INTO … SELECT …, and refresh it via a scheduled stored procedure or event. BigQuery and Snowflake both support materialised views natively.

Stored Procedures

A stored procedure is a named block of SQL that can accept parameters, contain conditional logic and loops, execute multiple statements, and be called by name. Think of it as a reusable SQL function that can do far more than return a result set — it can insert, update, delete, log, and branch.

Basic stored procedure (MySQL)

DELIMITER $$

CREATE PROCEDURE GetRegionSales(
    IN  p_region     VARCHAR(50),
    IN  p_start_date DATE,
    IN  p_end_date   DATE,
    OUT p_total      DECIMAL(15, 2)
)
BEGIN
    SELECT SUM(line_revenue)
    INTO   p_total
    FROM   vw_order_summary
    WHERE  region     = p_region
      AND  order_date BETWEEN p_start_date AND p_end_date;
END$$

DELIMITER ;

-- Call the procedure
CALL GetRegionSales('North', '2026-01-01', '2026-09-30', @total);
SELECT @total AS north_revenue;

Procedure with conditional logic and multiple operations

DELIMITER $$

CREATE PROCEDURE ProcessMonthEndReport(IN p_year INT, IN p_month INT)
BEGIN
    DECLARE v_start_date DATE;
    DECLARE v_end_date   DATE;
    DECLARE v_row_count  INT DEFAULT 0;

    -- Build date range from parameters
    SET v_start_date = DATE(CONCAT(p_year, '-', LPAD(p_month, 2, '0'), '-01'));
    SET v_end_date   = LAST_DAY(v_start_date);

    -- Clear existing report data for this period
    DELETE FROM monthly_report_cache
    WHERE  report_month = v_start_date;

    -- Populate with fresh aggregation
    INSERT INTO monthly_report_cache
        (report_month, region, total_revenue, unique_customers, avg_order_value)
    SELECT
        v_start_date,
        region,
        SUM(line_revenue),
        COUNT(DISTINCT customer_id),
        AVG(line_revenue)
    FROM   vw_order_summary
    WHERE  order_date BETWEEN v_start_date AND v_end_date
    GROUP  BY region;

    SET v_row_count = ROW_COUNT();

    -- Log the run
    INSERT INTO procedure_run_log (proc_name, run_at, rows_affected, status)
    VALUES ('ProcessMonthEndReport', NOW(), v_row_count, 'SUCCESS');

    SELECT CONCAT('Processed ', v_row_count, ' rows for ',
                  DATE_FORMAT(v_start_date, '%M %Y')) AS result;
END$$

DELIMITER ;

CALL ProcessMonthEndReport(2026, 9);

Error handling with DECLARE … HANDLER

DELIMITER $$

CREATE PROCEDURE SafeUpdatePrice(
    IN p_product_id INT,
    IN p_new_price  DECIMAL(10, 2)
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        INSERT INTO error_log (error_time, procedure_name, details)
        VALUES (NOW(), 'SafeUpdatePrice',
                CONCAT('Failed for product_id=', p_product_id));
        RESIGNAL;   -- re-raise so the caller knows it failed
    END;

    START TRANSACTION;

        -- Archive the old price
        INSERT INTO product_price_history (product_id, old_price, changed_at)
        SELECT product_id, price, NOW()
        FROM   products
        WHERE  product_id = p_product_id;

        -- Apply the new price
        UPDATE products
        SET    price = p_new_price,
               updated_at = NOW()
        WHERE  product_id = p_product_id;

        IF ROW_COUNT() = 0 THEN
            SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = 'Product not found';
        END IF;

    COMMIT;
END$$

DELIMITER ;

User-Defined Functions (UDFs)

A UDF is like a stored procedure that returns a single value and can be used directly inside a SELECT statement — exactly like a built-in function such as UPPER() or ROUND().

-- MySQL: scalar UDF
DELIMITER $$

CREATE FUNCTION TierLabel(revenue DECIMAL(15,2))
RETURNS VARCHAR(20)
DETERMINISTIC
BEGIN
    DECLARE tier VARCHAR(20);
    IF     revenue >= 100000 THEN SET tier = 'Platinum';
    ELSEIF revenue >= 50000  THEN SET tier = 'Gold';
    ELSEIF revenue >= 10000  THEN SET tier = 'Silver';
    ELSE                          SET tier = 'Bronze';
    END IF;
    RETURN tier;
END$$

DELIMITER ;

-- Use inside a SELECT like any built-in function
SELECT
    customer_id,
    SUM(line_revenue) AS ltv,
    TierLabel(SUM(line_revenue)) AS tier
FROM   vw_order_summary
GROUP  BY customer_id;


-- PostgreSQL: same concept, different syntax
CREATE OR REPLACE FUNCTION tier_label(revenue NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
    RETURN CASE
        WHEN revenue >= 100000 THEN 'Platinum'
        WHEN revenue >= 50000  THEN 'Gold'
        WHEN revenue >= 10000  THEN 'Silver'
        ELSE 'Bronze'
    END;
END;
$$;

Table-valued functions (PostgreSQL — return a result set)

-- Returns a table — use in FROM clause like a parameterised view
CREATE OR REPLACE FUNCTION get_top_customers(
    p_region   TEXT,
    p_top_n    INT DEFAULT 10
)
RETURNS TABLE (
    customer_id   INT,
    customer_name TEXT,
    total_revenue NUMERIC,
    order_count   BIGINT
)
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN QUERY
    SELECT
        s.customer_id,
        c.customer_name,
        SUM(s.line_revenue)::NUMERIC,
        COUNT(DISTINCT s.order_id)
    FROM   vw_order_summary s
    JOIN   customers c USING (customer_id)
    WHERE  s.region = p_region
    GROUP  BY s.customer_id, c.customer_name
    ORDER  BY SUM(s.line_revenue) DESC
    LIMIT  p_top_n;
END;
$$;

-- Use in a query
SELECT * FROM get_top_customers('North', 5);

Triggers

A trigger is SQL that runs automatically when a specified event (INSERT, UPDATE, or DELETE) occurs on a table. Triggers are invisible to the application — they fire silently inside the database engine. This makes them powerful for audit logging, data validation, and denormalised cache maintenance.

BEFORE trigger: validate before insert

DELIMITER $$

CREATE TRIGGER validate_order_before_insert
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
    -- Reject negative order totals
    IF NEW.order_total < 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Order total cannot be negative';
    END IF;

    -- Default status if not provided
    IF NEW.status IS NULL OR NEW.status = '' THEN
        SET NEW.status = 'pending';
    END IF;

    -- Set created_at automatically (even if app forgets)
    SET NEW.created_at = COALESCE(NEW.created_at, NOW());
END$$

DELIMITER ;

AFTER trigger: audit log on update

-- Track every price change with before/after values
DELIMITER $$

CREATE TRIGGER log_price_changes
AFTER UPDATE ON products
FOR EACH ROW
BEGIN
    IF OLD.price <> NEW.price THEN
        INSERT INTO price_audit_log (
            product_id, old_price, new_price,
            changed_at, changed_by
        )
        VALUES (
            OLD.product_id, OLD.price, NEW.price,
            NOW(), CURRENT_USER()
        );
    END IF;
END$$

DELIMITER ;

-- After this trigger exists, every UPDATE on products that changes price
-- automatically creates an audit record — no application code needed

AFTER DELETE trigger: soft-delete archive

DELIMITER $$

CREATE TRIGGER archive_deleted_customers
AFTER DELETE ON customers
FOR EACH ROW
BEGIN
    INSERT INTO customers_archive (
        customer_id, customer_name, email, region,
        deleted_at, deleted_by
    )
    VALUES (
        OLD.customer_id, OLD.customer_name, OLD.email, OLD.region,
        NOW(), CURRENT_USER()
    );
END$$

DELIMITER ;

Listing and dropping triggers

-- MySQL: list triggers on a table
SHOW TRIGGERS FROM your_database LIKE 'orders';

-- MySQL: full details
SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE,
       ACTION_TIMING, CREATED
FROM   INFORMATION_SCHEMA.TRIGGERS
WHERE  TRIGGER_SCHEMA = DATABASE();

-- Drop a trigger
DROP TRIGGER IF EXISTS log_price_changes;

Transactions: ACID Guarantees

Transactions group multiple SQL statements into a single unit of work. Either all statements succeed (COMMIT) or all are rolled back (ROLLBACK) as if none ran. The four ACID properties — Atomicity, Consistency, Isolation, Durability — describe this behaviour.

-- Pattern: wrap multi-step operations in a transaction
START TRANSACTION;

    -- Step 1: deduct inventory
    UPDATE inventory
    SET    quantity_on_hand = quantity_on_hand - 5
    WHERE  product_id = 101;

    -- Step 2: insert the order
    INSERT INTO orders (customer_id, product_id, quantity, order_date)
    VALUES (2047, 101, 5, CURDATE());

    -- Step 3: log the sale
    INSERT INTO sales_log (order_id, logged_at)
    VALUES (LAST_INSERT_ID(), NOW());

-- Only commit if all three succeeded
COMMIT;

-- If anything failed (or you detect a problem), undo everything
-- ROLLBACK;


-- SAVEPOINT: partial rollback within a transaction
START TRANSACTION;

    INSERT INTO staging_orders SELECT * FROM raw_import;
    SAVEPOINT after_load;

    -- Validate
    IF (SELECT COUNT(*) FROM staging_orders WHERE total < 0) > 0 THEN
        ROLLBACK TO SAVEPOINT after_load;  -- undo only the bad rows
        -- handle error
    END IF;

COMMIT;

Quick-Reference: Views vs Procedures vs Functions vs Triggers

ObjectReturnsCan modify data?Called how?Use for
VIEWResult set (virtual table)❌ No (unless updatable)SELECT FROM viewSimplify complex queries, access control
MATERIALISED VIEWStored result set❌ No (refreshed separately)SELECT FROM mat_viewFast read on slow aggregations
STORED PROCEDUREOptional result sets + OUT params✅ YesCALL proc()ETL steps, automation, complex logic
SCALAR UDFSingle value❌ No (by convention)Inside SELECTReusable calculations, clean SELECT
TABLE UDFResult set❌ NoSELECT FROM fn()Parameterised view
TRIGGERNothing (side-effects only)✅ Yes (invisibly)Auto — on DML eventAudit logs, validation, cache maintenance
← Ch 83: Advanced Python Data AnalysisCh 85: Data Analyst Career Growth →
Master SQL in Noida
EVIKA ACADEMY · Sector 51, Noida · Live classes online & offline
Book Free DemoWhatsApp Us
🎓 Free Demo Class — Online & Offline · Noida Sector 51