01 ยท Setup

Dataset

The three-layer retail_dwh architecture: stage lands raw data, dwh holds conformed dimensions/facts, data_mart holds reporting-ready tables โ€” plus a handful of small standalone tables used later for joins, window functions, and hierarchy queries. Run these top to bottom once, before anything else in this guide.

00_setup.sql
CREATE DATABASE IF NOT EXISTS retail_dwh;
USE DATABASE retail_dwh;

CREATE SCHEMA IF NOT EXISTS stage;
CREATE SCHEMA IF NOT EXISTS dwh;
CREATE SCHEMA IF NOT EXISTS data_mart;

USE SCHEMA stage;

stage.warehouse 10 rows

stg_warehouse.sql
CREATE OR REPLACE TABLE stage.warehouse (
    wid           INT,
    warehouse_name VARCHAR(100),
    location      VARCHAR(100),
    city          VARCHAR(50),
    state         VARCHAR(50),
    country       VARCHAR(50),
    pincode       VARCHAR(20),
    capacity      INT,
    created_at    TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

INSERT INTO stage.warehouse (wid, warehouse_name, location, city, state, country, pincode, capacity) VALUES
(1, 'Mumbai Central Warehouse', 'Andheri East', 'Mumbai', 'Maharashtra', 'India', '400059', 5000),
(2, 'Delhi Distribution Hub', 'Okhla Industrial Area', 'Delhi', 'Delhi', 'India', '110020', 4500),
(3, 'Kolkata Storage Facility', 'Salt Lake Sector V', 'Kolkata', 'West Bengal', 'India', '700091', 4000),
(4, 'Ahmedabad Logistics Center', 'SG Highway', 'Ahmedabad', 'Gujarat', 'India', '380015', 3500),
(5, 'Pune Warehouse', 'Hinjewadi Phase 2', 'Pune', 'Maharashtra', 'India', '411057', 3000),
(6, 'Bangalore Tech Park Warehouse', 'Whitefield', 'Bangalore', 'Karnataka', 'India', '560066', 6000),
(7, 'Chennai Port Warehouse', 'Ennore', 'Chennai', 'Tamil Nadu', 'India', '600057', 5500),
(8, 'Hyderabad Distribution Center', 'Gachibowli', 'Hyderabad', 'Telangana', 'India', '500032', 4800),
(9, 'Kerala Coastal Warehouse', 'Kochi', 'Kochi', 'Kerala', 'India', '682030', 2500),
(10, 'Jaipur Industrial Warehouse', 'Sitapura Industrial Area', 'Jaipur', 'Rajasthan', 'India', '302022', 2700);

stage.vendors 16 rows

stg_vendors.sql
CREATE OR REPLACE TABLE stage.vendors (
    vendor_id     INT,
    vendor_name   VARCHAR(100),
    country       VARCHAR(50),
    contact_email VARCHAR(100),
    phone         VARCHAR(20),
    created_at    TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

INSERT INTO stage.vendors (vendor_id, vendor_name, country, contact_email, phone) VALUES
(101, 'LG Electronics', 'South Korea', 'support@lg.com', '080-123456'),
(102, 'Apple Inc.', 'USA', 'contact@apple.com', '408-996-1010'),
(103, 'Samsung Electronics', 'South Korea', 'info@samsung.com', '02-2255-0114'),
(104, 'HP Inc.', 'USA', 'support@hp.com', '650-857-1501'),
(105, 'Sony Corporation', 'Japan', 'help@sony.com', '03-6748-2111'),
(106, 'Canon Inc.', 'Japan', 'service@canon.com', '03-3758-2111'),
(107, 'BOAT Lifestyle', 'India', 'care@boat-lifestyle.com', '022-6918-1920'),
(108, 'Logitech', 'Switzerland', 'support@logitech.com', '041-798-2600'),
(109, 'IKEA', 'Sweden', 'info@ikea.com', '046-332-1000'),
(110, 'Philips', 'Netherlands', 'contact@philips.com', '020-597-7777'),
(111, 'Dyson', 'UK', 'support@dyson.com', '0800-298-0298'),
(112, 'Panasonic', 'Japan', 'help@panasonic.com', '06-6908-1121'),
(113, 'Google', 'USA', 'support@google.com', '650-253-0000'),
(114, 'Microsoft', 'USA', 'contact@microsoft.com', '425-882-8080'),
(115, 'Amazon', 'USA', 'support@amazon.com', '206-266-1000'),
(116, 'Fitbit', 'USA', 'help@fitbit.com', '877-623-4997');

stage.products 31 rows ยท has dupes & NULL vendors

stg_products.sql
CREATE OR REPLACE TABLE stage.products (
    product_id    INT,
    product_name  VARCHAR(100),
    category      VARCHAR(50),
    sub_category  VARCHAR(50),
    price         DECIMAL(10,2),
    stock         INT,
    vendor        VARCHAR(50),
    wid           INT,
    vendor_id     INT,
    created_at    TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

INSERT INTO stage.products (product_id, product_name, category, sub_category, price, stock, vendor, wid, vendor_id) VALUES
(1, 'Dell Laptop', 'Electronics', 'Computers', 800.50, 50, 'LG', 1, 101),
(11, 'Ergonomic Desk Chair', 'Furniture', 'Seating', 120.50, 70, 'IKEA', 1, 111),
(12, 'Wooden Office Table', 'Furniture', 'Tables', 300.50, 30, 'IKEA', 2, 112),
(13, 'bookshelf', 'Furniture', 'Storage', 180.50, 40, 'IKEA', 2, 113),
(14, 'Sofa Set', 'Furniture', 'Seating', 600.50, 20, NULL, 2, 114),
(2, 'iphone', 'Electronics', 'Mobile Phones', 500.50, 150, 'LG', 3, 102),
(16, 'Philips Coffee Maker', 'Home Appliance', 'Kitchen', 75.20, 40, 'Philips', 3, 116),
(17, 'Samsung Refrigerator', 'Home Appliance', 'Kitchen', 700.50, 15, 'Samsung', 3, 117),
(18, 'LG Washing Machine', 'Home Appliance', 'Laundry', 500.50, 20, 'LG', 1, 118),
(19, 'Dyson Vacuum Cleaner', 'Home Appliance', 'Cleaning', 350.50, 25, 'Dyson', 1, 119),
(3, 'Samsung Galaxy Smartphone', 'Electronics', 'Mobile Phones', 550.50, 120, 'LG', 1, 103),
(4, 'HP Laptop', 'Electronics', 'Computers', 900.50, 40, 'LG', 5, 104),
(5, 'LG Monitor', 'Electronics', 'Displays', 200.50, 100, 'LG', 5, 105),
(6, 'Canon Printer', 'Electronics', 'Printers', 149.80, 80, NULL, 5, 106),
(7, 'Sony Headphones', 'Electronics', 'Audio', 50.50, 200, NULL, 6, 107),
(8, 'Logitech Mouse', 'Accessories', 'Computer Peripherals', 25.50, 300, 'BOAT', 7, 108),
(9, 'Mechanical Keyboard', 'Accessories', 'Computer Peripherals', 40.50, 250, 'BOAT', 7, 109),
(10, ' IPAD', 'Electronics', 'Tablets', 450.50, 70, 'Apple', 20, 110),
(15, 'Dining Table', 'Furniture', 'Tables', 350.50, 25, 'IKEA', 25, 115),
(20, 'Panasonic Microwave Oven', 'Home Appliance', 'Kitchen', 200.50, 30, 'Panasonic', 2, 120),
(21, 'Car Key Chain', 'Accessories', 'Lifestyle', 70.50, 150, 'BOAT', 3, 121),
(22, 'Mouse Key Chain', 'Accessories', 'Lifestyle', 60.50, 1000, 'BOAT', 4, 122),
(23, ' Leather Wallet', 'Accessories', 'Lifestyle', 45.50, 200, 'BOAT', 5, 123),
(24, 'Wrist Watch', 'Accessories', 'Lifestyle', 120.50, 80, 'BOAT', 5, 124),
(25, 'Sunglasses', 'Accessories', 'Lifestyle', 90.50, 150, NULL, 6, 125),
(26, 'Apple AirPods', 'Electronics', 'Audio', 199.50, 300, 'Apple', 7, 126),
(31, 'Car  Key Chain', 'Accessories', 'Lifestyle', 60.50, 1000, 'BOAT', 1, 122),
(27, 'Google Pixel Phone', 'Electronics', 'Mobile Phones', 650.50, 100, 'Google', 1, 127),
(28, 'Microsoft Surface Laptop', 'Electronics', 'Computers', 1200.50, 40, 'Microsoft', 1, 128),
(29, 'Amazon Echo Speaker', 'Electronics', 'Smart Devices', 99.50, 200, 'Amazon', 1, 129),
(30, 'Fitbit Smartwatch', 'Electronics', 'Wearables', 150.50, 120, 'Fitbit', 1, 130);

Products 21 and 31 are the intentional duplicate (same item, slightly different spelling). Products 10 and 23 have leading spaces. Products 14, 6, 7, 25 have NULL vendors.

stage.customers 51 rows ยท has a duplicate customer

stg_customers.sql
CREATE OR REPLACE TABLE stage.customers (
    customer_id      INT,
    customer_name    VARCHAR(100),
    home_number      VARCHAR(20),
    email             VARCHAR(100),
    address           VARCHAR(200),
    phone             VARCHAR(20),
    landline_number   VARCHAR(20),
    second_number     VARCHAR(20),
    country           VARCHAR(50),
    gender            VARCHAR(10),
    created_at        TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

INSERT INTO stage.customers
(customer_id, customer_name, home_number, email, address, phone, landline_number, second_number, country, gender) VALUES
(1, 'Rahul Sharma', '9876543210', 'rahul.sharma@gmail.com', 'Mumbai, Maharashtra', NULL, NULL, NULL, 'India', 'Male'),
(2, 'Anjali Verma', '9123456789', 'anjali.verma@gmail.com', 'Delhi, Delhi', '9988771122', NULL, NULL, 'India', 'Female'),
(3, 'Vikas Gupta', '9988776655', 'vikas.gupta@gmail.com', 'Kolkata, West Bengal', NULL, '03322334455', NULL, 'India', 'Male'),
(4, 'Pooja Desai', '9876512345', 'pooja.desai@gmail.com', 'Ahmedabad, Gujarat', '9876500000', NULL, NULL, 'India', 'Female'),
(5, 'Rohan Patil', '9123456709', 'rohan.patil@gmail.com', 'Pune, Maharashtra', NULL, NULL, '9123456708', 'India', 'Male'),
(6, 'Sakshi Jain', '8765432190', 'sakshi.jain@gmail.com', 'Jaipur, Rajasthan', '8765432100', NULL, NULL, 'India', 'Female'),
(7, 'Amitabh Das', '9098765432', 'amitabh.das@gmail.com', 'Bangalore, Karnataka', NULL, '08022334455', NULL, 'India', 'Male'),
(8, 'Priya Iyer', '9654321098', 'priya.iyer@gmail.com', 'Chennai, Tamil Nadu', '9654321000', NULL, NULL, 'India', 'Female'),
(9, 'Kiran Reddy', '9543210987', 'kiran.reddy@gmail.com', 'Hyderabad, Telangana', NULL, NULL, '9543210986', 'India', 'Male'),
(10, 'Meera Nair', '9765432109', 'meera.nair@gmail.com', 'Thiruvananthapuram, Kerala', '9765432111', NULL, NULL, 'India', 'Female'),
(11, ' Arjun Mehta', '9812345670', 'arjun.mehta@gmail.com', 'Surat, Gujarat', NULL, NULL, NULL, 'India', 'Male'),
(12, 'Neha Kapoor', '9823456781', 'neha.kapoor@gmail.com', 'Lucknow, Uttar Pradesh', '9823456000', NULL, NULL, 'India', 'Female'),
(13, 'Suresh Rao', '9834567892', 'suresh.rao@gmail.com', 'Visakhapatnam, Andhra Pradesh', NULL, '08912233445', NULL, 'India', 'Male'),
(14, 'Kavita Joshi', '9845678903', 'kavita.joshi@gmail.com', 'Nagpur, Maharashtra', '9845678000', NULL, NULL, 'India', 'Female'),
(15, 'Manish Singh', '9856789014', 'manish.singh@gmail.com', 'Patna, Bihar', NULL, NULL, '9856789013', 'India', 'Male'),
(16, 'Ritika Malhotra', '9867890125', 'ritika.malhotra@gmail.com', 'Chandigarh, Punjab', '9867890100', NULL, NULL, 'India', 'Female'),
(17, 'Deepak Kumar', '9878901236', 'deepak.kumar@gmail.com', 'Noida, Uttar Pradesh', NULL, '01202233445', NULL, 'India', 'Male'),
(18, 'Shreya Banerjee', '9889012347', 'shreya.banerjee@gmail.com', 'Howrah, West Bengal', '9889012300', NULL, NULL, 'India', 'Female'),
(19, 'Rajesh Pillai', '9890123458', 'rajesh.pillai@gmail.com', 'Kochi, Kerala', NULL, NULL, '9890123457', 'India', 'Male'),
(20, 'Sneha Rathi', '9901234569', 'sneha.rathi@gmail.com', 'Indore, Madhya Pradesh', '9901234500', NULL, NULL, 'India', 'Female'),
(21, 'Harish Chandra', '9912345670', 'harish.chandra@gmail.com', 'Varanasi, Uttar Pradesh', NULL, NULL, NULL, 'India', 'Male'),
(22, 'Megha Sinha', '9923456781', 'megha.sinha@gmail.com', 'Ranchi, Jharkhand', '9923456700', NULL, NULL, 'India', 'Female'),
(23, 'Anand Prakash', '9934567892', 'anand.prakash@gmail.com', 'Bhopal, Madhya Pradesh', NULL, '07552233445', NULL, 'India', 'Male'),
(24, 'Divya Menon', '9945678903', 'divya.menon@gmail.com', 'Kozhikode, Kerala', '9945678000', NULL, NULL, 'India', 'Female'),
(25, 'Sunil Yadav', '9956789014', 'sunil.yadav@gmail.com', 'Kanpur, Uttar Pradesh', NULL, NULL, '9956789013', 'India', 'Male'),
(26, 'Pallavi Ghosh', '9967890125', 'pallavi.ghosh@gmail.com', 'Siliguri, West Bengal', '9967890100', NULL, NULL, 'India', 'Female'),
(27, 'Naveen Reddy', '9978901236', 'naveen.reddy@gmail.com', 'Warangal, Telangana', NULL, '08702233445', NULL, 'India', 'Male'),
(28, 'AARTI SHARMA', '9989012347', 'aarti.sharma@gmail.com', 'Jodhpur, Rajasthan', '9989012300', NULL, NULL, 'India', 'Female'),
(29, 'Vivek Nair', '9990123458', 'vivek.nair@gmail.com', 'Kollam, Kerala', NULL, NULL, '9990123457', 'India', 'Male'),
(30, 'Renu Mishra', '9001234569', 'renu.mishra@gmail.com', 'Gorakhpur, Uttar Pradesh', '9001234500', NULL, NULL, 'India', 'Female'),
(31, 'John Smith', '2025550101', 'john.smith@gmail.com', 'New York, USA', '2025550199', NULL, NULL, 'US', 'Male'),
(32, 'Emily Johnson', '2135550202', 'emily.johnson@gmail.com', 'Los Angeles, USA', NULL, '2135550303', NULL, 'US', 'Female'),
(33, 'Michael Brown', '3125550303', 'michael.brown@gmail.com', 'Chicago, USA', NULL, NULL, '3125550404', 'US', 'Male'),
(34, 'Sophia Davis', '4155550404', 'sophia.davis@gmail.com', 'San Francisco, USA', '4155550505', NULL, NULL, 'US', 'Female'),
(35, 'David Wilson', '6175550505', 'david.wilson@gmail.com', 'Boston, USA', NULL, NULL, NULL, 'US', 'Male'),
(36, 'Oliver Taylor', '0207946001', 'oliver.taylor@gmail.com', 'London, UK', NULL, '0207946002', NULL, 'UK', 'Male'),
(37, 'Amelia Evans', '0161123456', 'amelia.evans@gmail.com', 'Manchester, UK', '0161123457', NULL, NULL, 'UK', 'Female'),
(38, 'George Harris', '0121456789', 'george.harris@gmail.com', 'Birmingham, UK', NULL, NULL, '0121456790', 'UK', 'Male'),
(39, 'Isla Lewis', '0131554321', 'isla.lewis@gmail.com', 'Edinburgh, UK', '0131554322', NULL, NULL, 'UK', 'Female'),
(40, 'Jack Walker', '0292045678', 'jack.walker@gmail.com', 'Cardiff, UK', NULL, NULL, NULL, 'UK', 'Male'),
(41, 'Lukas Muller', '0301234567', 'lukas.muller@gmail.com', 'Berlin, Germany', '0307654321', NULL, NULL, 'EU', 'Male'),
(42, 'Marie Dubois', '0141234567', 'marie.dubois@gmail.com', 'Paris, France', NULL, '0147654321', NULL, 'EU', 'Female'),
(43, 'Carlos Garcia', '0912345678', 'carlos.garcia@gmail.com', 'Madrid, Spain', NULL, NULL, '0912345679', 'EU', 'Male'),
(44, 'Giulia Rossi', '0651234567', 'giulia.rossi@gmail.com', 'Rome, Italy', '0657654321', NULL, NULL, 'EU', 'Female'),
(45, 'Peter Novak', '0212345678', 'peter.novak@gmail.com', 'Prague, Czech Republic', NULL, NULL, NULL, 'EU', 'Male'),
(46, 'Ahmed Al Mansoori', '0501234567', 'ahmed.mansoori@gmail.com', 'Dubai, UAE', '0507654321', NULL, NULL, 'UAE', 'Male'),
(47, 'Fatima Al Zahra', '0552345678', 'fatima.zahra@gmail.com', 'Abu Dhabi, UAE', NULL, '0558765432', NULL, 'UAE', 'Female'),
(48, 'Omar Al Khalifa', '0563456789', 'omar.khalifa@gmail.com', 'Sharjah, UAE', NULL, NULL, '0569876543', 'UAE', 'Male'),
(49, 'Layla Al Noor', '0524567890', 'layla.noor@gmail.com', 'Ajman, UAE', '0527654321', NULL, NULL, 'UAE', 'Female'),
(50, 'Hassan Al Farsi', '0585678901', 'hassan.farsi@gmail.com', 'Ras Al Khaimah, UAE', NULL, NULL, NULL, 'UAE', 'Male'),
(28, 'aarti sharma', '9989012347', 'AARTI.SHARMA@gmail.com  ', 'Jodhpur, Rajasthan', '9989012300', NULL, NULL, 'India', 'Female');

stage.addresses 18 rows

stg_addresses.sql
CREATE OR REPLACE TABLE stage.addresses (
    address_id  INT,
    customer_id INT,
    full_address VARCHAR(200),
    city        VARCHAR(50),
    state       VARCHAR(50),
    country     VARCHAR(50),
    region      VARCHAR(50),
    pincode     VARCHAR(20),
    created_at  TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

INSERT INTO stage.addresses (address_id, customer_id, full_address, city, state, country, region, pincode) VALUES
(201, 11, '123 MG Road', 'Mumbai', 'Maharashtra', 'India', 'West', '400001'),
(202, 12, '45 Connaught Place', 'Delhi', 'Delhi', 'India', 'North', '110001'),
(203, 13, '78 Park Street', 'Kolkata', 'West Bengal', 'India', 'East', '700016'),
(204, 14, '12 Ashram Road', 'Ahmedabad', 'Gujarat', 'India', 'West', '380009'),
(205, 15, '56 FC Road', 'Pune', 'Maharashtra', 'India', 'West', '411004'),
(206, 16, '89 MI Road', 'Jaipur', 'Rajasthan', 'India', 'North', '302001'),
(207, 17, '101 MG Road', 'Bangalore', 'Karnataka', 'India', 'South', '560001'),
(208, 18, '22 Anna Salai', 'Chennai', 'Tamil Nadu', 'India', 'South', '600002'),
(209, 19, '33 Banjara Hills', 'Hyderabad', 'Telangana', 'India', 'South', '500034'),
(210, 20, '44 MG Road', 'Thiruvananthapuram', 'Kerala', 'India', 'South', '695001'),
(211, 21, '12 Civil Lines', 'Varanasi', 'Uttar Pradesh', 'India', 'North', '221001'),
(212, 22, '34 Main Road', 'Ranchi', 'Jharkhand', 'India', 'East', '834001'),
(213, 23, '56 MP Nagar', 'Bhopal', 'Madhya Pradesh', 'India', 'Central', '462011'),
(214, 24, '78 MG Road', 'Kozhikode', 'Kerala', 'India', 'South', '673001'),
(215, 25, '90 GT Road', 'Kanpur', 'Uttar Pradesh', 'India', 'North', '208001'),
(216, 26, '12 Hill Cart Road', 'Siliguri', 'West Bengal', 'India', 'East', '734001'),
(217, 27, '34 Hanamkonda', 'Warangal', 'Telangana', 'India', 'South', '506001'),
(218, 11, '9 Marine Drive', 'Mumbai', 'Maharashtra', 'India', 'West', '400002');

stage.sales 50 rows ยท has a future date & a negative quantity

stg_sales.sql
CREATE OR REPLACE TABLE stage.sales (
    sale_id       INT,
    product_id    INT,
    quantity_sold INT,
    sale_date     DATE,
    customer_id   INT,
    address_id    INT,
    created_at    TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

INSERT INTO stage.sales (sale_id, product_id, quantity_sold, sale_date, customer_id, address_id) VALUES
(101, 1, 5, '2024-01-15', 11, 201),
(102, 2, 10, '2024-02-10', 12, 202),
(103, 3, 2, '2024-03-05', 13, 203),
(104, 4, 1, '2024-04-20', 14, 204),
(105, 5, 20, '2024-05-18', 15, 205),
(106, 6, 15, '2024-06-12', 16, 206),
(107, 7, 30, '2024-07-25', 17, 207),
(108, 8, 10, '2024-08-09', 18, 208),
(109, 9, 8, '2024-09-14', 19, 209),
(110, 10, 3, '2024-10-01', 20, 210),
(111, 11, 6, '2024-11-11', 21, 211),
(112, 12, 12, '2024-12-05', 22, 212),
(113, 13, 4, '2025-01-15', 23, 213),
(114, 14, 2, '2025-02-20', 24, 214),
(115, 15, 18, '2025-03-10', 25, 215),
(116, 16, 9, '2025-04-25', 26, 216),
(117, 17, 14, '2025-05-30', 27, 217),
(118, 18, 7, '2025-06-18', 28, 218),
(119, 19, 5, '2025-07-22', 29, 209),
(120, 20, 11, '2025-08-14', 30, 210),
(121, 21, 3, '2025-09-09', 21, 211),
(122, 22, 25, '2025-10-05', 22, 212),
(123, 23, 8, '2025-11-12', 23, 213),
(124, 24, 6, '2025-12-01', 24, 214),
(125, 25, 10, '2026-01-15', 25, 215),
(126, 26, 12, '2026-02-20', 26, 216),
(127, 27, 9, '2026-03-18', 27, 217),
(128, 28, 4, '2026-04-25', 28, 218),
(129, 29, 15, '2026-05-30', 11, 201),
(130, 30, 7, '2026-06-12', 12, 202),
(131, 1, 5, '2026-07-10', 11, 201),
(132, 2, 10, '2026-07-15', 12, 202),
(133, 3, 2, '2026-07-20', 13, 203),
(134, 4, 1, '2026-07-25', 14, 204),
(135, 5, 20, '2026-08-01', 15, 205),
(136, 6, 15, '2026-08-05', 16, 206),
(137, 7, 30, '2026-08-10', 17, 207),
(138, 8, 10, '2026-08-15', 18, 208),
(139, 9, 8, '2026-08-20', 19, 209),
(140, 10, 3, '2026-08-25', 20, 210),
(141, 11, 6, '2026-09-01', 21, 211),
(142, 12, 12, '2026-09-05', 22, 212),
(143, 13, 4, '2026-09-10', 23, 213),
(144, 14, 2, '2026-09-15', 24, 214),
(145, 15, 18, '2026-09-20', 25, 215),
(146, 16, 9, '2026-09-25', 26, 216),
(147, 17, 14, '2026-09-30', 27, 217),
(148, 18, 7, '2026-10-05', 28, 218),
(149, -8, 5, '2026-10-10', 29, 209),
(150, 20, 11, '2099-12-31', 30, 210);

Sale 149 has a negative quantity, sale 150 is dated 2099 โ€” both are the deliberate bad rows the cleansing queries later filter out.

dwh & data_mart โ€” empty layers, loaded by procedures

dwh_and_mart_ddl.sql
CREATE OR REPLACE TABLE dwh.dim_customer (
    customer_key     NUMBER AUTOINCREMENT START 1 INCREMENT 1,
    customer_id      INT, first_name VARCHAR(50), last_name VARCHAR(50),
    home_number      VARCHAR(20), email VARCHAR(100), address VARCHAR(200),
    phone            VARCHAR(20), landline_number VARCHAR(20), second_number VARCHAR(20),
    country          VARCHAR(50), gender VARCHAR(10),
    is_current       BOOLEAN DEFAULT TRUE, effective_date DATE, expiry_date DATE,
    created_at       TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),
    PRIMARY KEY (customer_key)
);
CREATE OR REPLACE TABLE dwh.dim_product (
    product_key   NUMBER AUTOINCREMENT START 1 INCREMENT 1,
    product_id    INT, product_name VARCHAR(100), category VARCHAR(50), sub_category VARCHAR(50),
    price         DECIMAL(10,2), stock INT, warehouse_id INT, vendor_id INT,
    created_at    TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(), PRIMARY KEY (product_key)
);
CREATE OR REPLACE TABLE dwh.dim_vendor (
    vendor_key    NUMBER AUTOINCREMENT START 1 INCREMENT 1,
    vendor_id     INT, vendor_name VARCHAR(100), country VARCHAR(50),
    contact_email VARCHAR(100), phone VARCHAR(20), PRIMARY KEY (vendor_key)
);
CREATE OR REPLACE TABLE dwh.dim_address (
    address_key  NUMBER AUTOINCREMENT START 1 INCREMENT 1,
    address_id   INT, customer_id INT, full_address VARCHAR(200),
    city VARCHAR(50), state VARCHAR(50), country VARCHAR(50), region VARCHAR(50), pincode VARCHAR(20),
    PRIMARY KEY (address_key)
);
CREATE OR REPLACE TABLE dwh.dim_warehouse (
    warehouse_key  NUMBER AUTOINCREMENT START 1 INCREMENT 1,
    warehouse_id   INT, warehouse_name VARCHAR(100), location VARCHAR(100),
    city VARCHAR(50), state VARCHAR(50), country VARCHAR(50), pincode VARCHAR(20), capacity INT,
    PRIMARY KEY (warehouse_key)
);
CREATE OR REPLACE TABLE dwh.fact_sales (
    sales_key     NUMBER AUTOINCREMENT START 1 INCREMENT 1,
    sale_id INT, customer_id INT, product_id INT, vendor_id INT, warehouse_id INT, address_id INT,
    sale_date DATE, quantity_sold INT, sales_amount DECIMAL(12,2),
    created_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(), PRIMARY KEY (sales_key)
);

CREATE OR REPLACE TABLE data_mart.dm_sales_summary (sale_date DATE, total_orders INT, total_quantity INT, total_sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_customer_sales (customer_id INT, customer_name VARCHAR(150), orders INT, total_quantity INT, total_sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_product_sales (product_id INT, product_name VARCHAR(100), category VARCHAR(50), orders INT, quantity INT, sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_vendor_sales (vendor_id INT, vendor_name VARCHAR(100), products INT, sales DECIMAL(14,2), quantity INT);
CREATE OR REPLACE TABLE data_mart.dm_category_sales (category VARCHAR(50), sub_category VARCHAR(50), sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_state_sales (country VARCHAR(50), state VARCHAR(50), sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_monthly_sales (sales_year INT, sales_month INT, sales DECIMAL(14,2), orders INT);
CREATE OR REPLACE TABLE data_mart.dm_top_products (rank_no INT, product VARCHAR(100), sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_top_customers (rank_no INT, customer VARCHAR(150), sales DECIMAL(14,2));
CREATE OR REPLACE TABLE data_mart.dm_inventory_summary (warehouse_id INT, warehouse_name VARCHAR(100), total_stock INT, total_products INT);

Standalone practice tables joins ยท sets ยท windows ยท hierarchy

practice_tables.sql
-- org hierarchy (self-join / recursive CTE)
CREATE OR REPLACE TABLE workers (
    wid INT PRIMARY KEY, w_name VARCHAR(50), dept_id INT, designation VARCHAR(50),
    mgr_id INT, salary INT, date_of_joining DATE
);
INSERT INTO workers VALUES
(1, 'Alice', 1, 'CEO', NULL, 150000, '2010-01-15'),
(2, 'Bob', 2, 'Head of Finance', 1, 120000, '2012-03-10'),
(3, 'Charlie', 3, 'Head of IT', 1, 125000, '2011-07-01'),
(4, 'David', 4, 'Head of HR', 1, 115000, '2013-05-12'),
(5, 'Eva', 2, 'Manager - Finance', 2, 90000, '2014-06-20'),
(6, 'Frank', 2, 'Sr. Accountant', 5, 70000, '2016-02-15'),
(7, 'Grace', 2, 'Accountant', 5, 60000, '2018-04-05'),
(8, 'Henry', 3, 'Manager - IT Infra', 3, 95000, '2015-09-10'),
(9, 'Ivy', 3, 'Manager - Dev', 3, 93000, '2016-11-20'),
(10, 'Jack', 3, 'Team Lead - Infra', 8, 75000, '2017-03-12'),
(11, 'Kathy', 3, 'System Admin', 10, 55000, '2019-07-22'),
(12, 'Leo', 3, 'Software Engineer', 9, 65000, '2019-10-01'),
(13, 'Mona', 3, 'Jr. Developer', 9, 50000, '2021-06-15'),
(14, 'Nick', 4, 'Manager - HR', 4, 88000, '2014-08-18'),
(15, 'Olivia', 4, 'Recruiter', 14, 55000, '2019-02-01'),
(16, 'Paul', 4, 'HR Executive', 14, 50000, '2020-11-12'),
(17, 'Quincy', 5, 'Head of Sales', 1, 110000, '2013-09-05'),
(18, 'Rachel', 5, 'Sales Manager', 17, 85000, '2015-07-01'),
(19, 'Steve', 5, 'Sales Executive', 18, 60000, '2018-05-20'),
(20, 'Tina', 5, 'Sales Associate', 18, 48000, '2021-01-10');

-- set operators
CREATE OR REPLACE TABLE department (dept_id INT PRIMARY KEY, dept_name VARCHAR(50));
INSERT INTO department VALUES
(1,'HR'),(2,'Finance'),(3,'IT'),(4,'Marketing'),(5,'Sales'),(6,'Admin'),(7,'Support'),
(8,'Legal'),(9,'Operations'),(10,'R&D'),(11,'Procurement'),(12,'Quality'),(13,'Security'),(14,'Training'),(15,'Design');

CREATE OR REPLACE TABLE employee (emp_id INT PRIMARY KEY, emp_name VARCHAR(50), dept_id INT, salary INT);
INSERT INTO employee VALUES
(101,'Alice',1,50000),(102,'Bob',2,60000),(103,'Charlie',3,70000),(104,'David',4,55000),(105,'Eva',5,62000),
(106,'Frank',6,48000),(107,'Grace',7,51000),(108,'Henry',8,53000),(109,'Ivy',9,59000),(110,'Jack',10,64000),
(111,'Kathy',11,47000),(112,'Leo',12,58000),(113,'Mona',13,60000),(114,'Nick',14,52000),(115,'Olivia',15,61000),
(313,'Mira',113,62000),(314,'Nora',114,53000),(315,'Oscar',115,59000);

CREATE OR REPLACE TABLE contractor (contractor_id INT PRIMARY KEY, contractor_name VARCHAR(50), dept_id INT, salary INT);
INSERT INTO contractor VALUES
(101,'Alice',1,50000),(102,'Bob',2,60000),(103,'Charlie',3,70000),(301,'Adam',1,45000),(302,'Bella',2,61000),
(303,'Chris',3,69000),(304,'Derek',4,50000),(305,'Ella',5,64000),(306,'Fred',6,46000),(307,'Gina',7,52000),
(308,'Howard',8,54000),(309,'Isla',9,57000),(310,'Jason',10,65000),(311,'Kara',11,49000),(312,'Liam',12,60000),
(313,'Mira',13,62000),(314,'Nora',14,53000),(315,'Oscar',15,59000);

-- window functions
CREATE OR REPLACE TABLE share_market_data (
    id INT AUTOINCREMENT, company_name VARCHAR(50), stock_symbol VARCHAR(10), trade_date DATE,
    open_price NUMERIC(10,2), close_price NUMERIC(10,2), high_price NUMERIC(10,2), low_price NUMERIC(10,2), volume BIGINT
);
INSERT INTO share_market_data (company_name, stock_symbol, trade_date, open_price, close_price, high_price, low_price, volume) VALUES
('PayPal', 'PYPL', '2025-09-10', 65.50, 66.20, 67.00, 65.00, 1500000),
('Google', 'GOOGL', '2025-09-10', 238.00, 239.50, 240.00, 237.50, 2000000),
('Microsoft', 'MSFT', '2025-09-10', 505.00, 507.00, 510.00, 503.00, 1800000),
('PayPal', 'PYPL', '2025-09-09', 64.80, 65.50, 66.00, 64.50, 1400000),
('Google', 'GOOGL', '2025-09-09', 237.00, 238.00, 239.00, 236.50, 1900000),
('Microsoft', 'MSFT', '2025-09-09', 503.00, 505.00, 507.00, 502.00, 1700000),
('PayPal', 'PYPL', '2025-09-08', 66.00, 65.80, 66.50, 65.00, 1600000),
('Google', 'GOOGL', '2025-09-08', 239.00, 238.50, 240.50, 237.00, 2100000),
('Microsoft', 'MSFT', '2025-09-08', 506.00, 505.50, 508.00, 504.00, 1750000),
('PayPal', 'PYPL', '2025-09-07', 65.30, 65.60, 66.00, 65.00, 1550000);

-- year-over-year snapshots (MERGE / SCD practice)
CREATE OR REPLACE TABLE employees_2023 (emp_id INT, emp_name VARCHAR(50), department VARCHAR(50));
INSERT INTO employees_2023 VALUES
(1,'Alice','HR'),(2,'Bob','IT'),(3,'Charlie','Finance'),(4,'David','IT'),(7,'Grace','Marketing'),
(8,'Hank','Finance'),(9,'Ivy','HR'),(10,'Jack','Sales'),(11,'Karen','Operations'),(12,'Leo','IT');

CREATE OR REPLACE TABLE employees_2024 (emp_id INT, emp_name VARCHAR(50), department VARCHAR(50));
INSERT INTO employees_2024 VALUES
(3,'Charlie','Finance'),(4,'David','IT'),(5,'Eva','HR'),(6,'Frank','Marketing'),(9,'Ivy','HR'),
(10,'Jack','Sales'),(13,'Mona','Finance'),(14,'Nate','Operations'),(15,'Olivia','IT'),(16,'Paul','Sales');

-- CASE + LAG/LEAD puzzle table
CREATE OR REPLACE TABLE orders (order_id INT PRIMARY KEY, item VARCHAR(255) NOT NULL);
INSERT INTO orders (order_id, item) VALUES
(1,'Chow Mein'),(2,'Pizza'),(3,'Veg Nuggets'),(4,'Paneer Butter Masala'),(5,'Spring Rolls'),(6,'Veg Burger'),(7,'Paneer Tikka');

-- window/agg practice table used across P07-P11
CREATE OR REPLACE TABLE employees (
   emp_id INT AUTOINCREMENT, emp_name VARCHAR(50), department VARCHAR(50), salary NUMERIC(10,2)
);
INSERT INTO employees (emp_name, department, salary) VALUES
('Alice','IT',75000),('Bob','IT',60000),('Charlie','IT',90000),('David','HR',50000),('Eva','HR',70000),
('Frank','HR',65000),('Grace','Finance',80000),('Helen','Finance',95000),('Ian','Finance',72000),
('Jack','Sales',55000),('Karen','Sales',68000),('Leo','Sales',88000),('Mona','Marketing',60000),
('Nina','Marketing',75000),('Oscar','Marketing',82000),('Paul','Operations',70000),('Queen','Operations',72000),
('Ravi','Operations',69000),('Steve','Support',50000),('Tina','Support',58000);

employee and contractor share names Alice/Bob/Charlie (101-103) and Mira/Nora/Oscar (313-315) on purpose โ€” that overlap is what makes INTERSECT/MINUS return something interesting in Section 13.

02 ยท Core SQL

DDL

Data Definition Language โ€” statements that define or reshape structure: CREATE, ALTER, DROP, TRUNCATE. Uses a scratch table so you're not touching the retail dataset while practicing.

ddl.sql
CREATE OR REPLACE TABLE stage.fugen_student (
    id         INT,
    first_name VARCHAR,
    last_name  VARCHAR,
    email      VARCHAR,
    dob        DATE,
    status     BOOLEAN
);

-- ALTER: rename table
ALTER TABLE stage.fugen_student RENAME TO stage.fugen_customers;

-- ALTER: add / rename / drop columns
ALTER TABLE stage.fugen_customers ADD COLUMN middle_name VARCHAR;
ALTER TABLE stage.fugen_customers RENAME COLUMN middle_name TO mid_name;
ALTER TABLE stage.fugen_customers DROP COLUMN mid_name;

-- ALTER: change a column's type
ALTER TABLE stage.fugen_customers ALTER COLUMN email SET DATA TYPE VARCHAR(150);

-- TRUNCATE keeps the table, empties the rows (fast, minimal logging)
TRUNCATE TABLE stage.fugen_customers;

-- DROP removes the table entirely
DROP TABLE IF EXISTS stage.fugen_customers;

-- CREATE TABLE ... AS SELECT (CTAS) โ€” define structure AND populate in one step
CREATE OR REPLACE TABLE stage.electronics_only AS
SELECT * FROM stage.products WHERE category = 'Electronics';

-- CREATE TABLE ... LIKE โ€” copy structure only, no data
CREATE OR REPLACE TABLE stage.products_empty_copy LIKE stage.products;
Hint
Snowflake's TRUNCATE and even DROP still record the operation in Time Travel โ€” see Section 35 โ€” so both are recoverable within your retention window, unlike some databases where truncate is unrecoverable.
03 ยท Core SQL

DML

Data Manipulation Language โ€” INSERT, UPDATE, DELETE: the statements that change the rows inside a table you've already defined.

dml.sql
-- Single-row insert, columns named explicitly (safest form)
CREATE OR REPLACE TABLE stage.fugen_student (
    id         INT,
    first_name VARCHAR,
    last_name  VARCHAR,
    email      VARCHAR,
    dob        DATE,
    status     BOOLEAN
);

-- Single-row insert, columns named explicitly (safest form)
insert into stage.fugen_student (id, first_name,last_name,email,dob,status ) values 
(1,'pavan','G', 'pavan@gmail.com','1995-01-01', 1) ;

insert into stage.fugen_student  values 
(2,'sai','A', 'sai@gmail.com','1995-01-01', 1) ;

insert into stage.fugen_student (id, first_name,last_name,email,dob ) values 
(6,'mohan','G', 'pavan@gmail.com','1995-01-01') ;

-- Multi-row insert
insert into stage.fugen_student (id, first_name,last_name,email,dob,status ) values 
(3,'faraz','G', 'faraz@gmail.com','1995-01-01', 1),
(4,'jo','G', 'jo@gmail.com','1995-01-01', 1),
(5,'naga','G', 'naga@gmail.com','1995-01-01', 1);

-- UPDATE a targeted row
update stage.fugen_student  
set last_name='Ganta' where id=1;


-- UPDATE with a subquery-derived value
update stage.fugen_student  
set status=0 where status is null;



-- DELETE a specific row
delete from stage.fugen_student   where id=2;

-- DELETE with a subquery condition
DELETE FROM stage.products_empty_copy WHERE product_id NOT IN (SELECT product_id FROM stage.products);
Hint
Always write the SELECT version of your WHERE clause first and eyeball the rows before switching it to UPDATE/DELETE โ€” Snowflake has no separate "confirm" step once you hit run, and Time Travel (Section 35) is your safety net if you get it wrong.
04 ยท Core SQL

DQL

Data Query Language is just SELECT โ€” but understanding the order Snowflake actually evaluates its clauses in (which is not the order you type them) explains most of the "why doesn't this work" moments in Sections 05-09.

Anatomy of a SELECT

dql_anatomy.sql
SELECT   category, COUNT(*) AS product_count      -- 5. choose & name output columns
FROM     stage.products                            -- 1. start from this table
WHERE    price > 50                                 -- 2. filter individual rows
GROUP BY category                                   -- 3. collapse into groups
HAVING   COUNT(*) > 2                                -- 4. filter the groups
ORDER BY product_count DESC                          -- 6. sort the final output
LIMIT    5;                                          -- 7. cap the row count

Logical evaluation order: FROM โ†’ WHERE โ†’ GROUP BY โ†’ HAVING โ†’ SELECT โ†’ ORDER BY โ†’ LIMIT. That's why a column alias defined in SELECT can be used in ORDER BY (it already exists by then) but not in WHERE (it doesn't exist yet).

DISTINCT & simple projections

dql_basics.sql
SELECT * FROM stage.products;
SELECT product_name, price FROM stage.products;
SELECT DISTINCT category FROM stage.products;
SELECT DISTINCT category, vendor FROM stage.products;

-- a computed column, aliased
SELECT product_name, price, price * 1.18 AS price_with_tax FROM stage.products;
Hint
Column aliases created with AS in SELECT can be reused in ORDER BY and (in Snowflake) GROUP BY/QUALIFY, but never in WHERE โ€” by evaluation order, WHERE runs before the alias has been computed.
05 ยท Core SQL

WHERE

Row-level filtering โ€” comparison operators, logical operators, set membership, ranges, pattern matching, and NULL checks.

Comparison & logical operators

comparisons.sql
SELECT * FROM stage.products WHERE price = 500.50;
SELECT * FROM stage.products WHERE price != 500.50;
SELECT * FROM stage.products WHERE price > 500.50;
SELECT * FROM stage.products WHERE price <= 500.50;

SELECT * FROM stage.products WHERE price <= 500.50 AND category = 'Electronics';
SELECT * FROM stage.products WHERE price <= 500.50 OR  category = 'Electronics';

SELECT * FROM stage.products WHERE category IN ('Electronics','Furniture');
SELECT * FROM stage.products WHERE category NOT IN ('Electronics','Furniture');

SELECT * FROM stage.products WHERE price BETWEEN 300 AND 600;   -- inclusive both ends

LIKE wildcards, NULL checks & arithmetic in WHERE

patterns_and_nulls.sql
-- % = any number of characters, _ = exactly one character
SELECT * FROM stage.products WHERE LOWER(product_name) LIKE 'mouse%';
SELECT * FROM stage.products WHERE UPPER(product_name) LIKE '%CHAIN';
SELECT * FROM stage.products WHERE UPPER(product_name) LIKE '%A%';
SELECT * FROM stage.products WHERE LOWER(product_name) LIKE '_o%';

-- NULL checks โ€” never use = NULL, it silently matches nothing
SELECT * FROM stage.products WHERE vendor IS NOT NULL;
SELECT * FROM stage.products WHERE vendor IS NULL;

-- arithmetic expressions work directly in WHERE
SELECT * FROM stage.products WHERE price * 87 > 20000;
SELECT *, price * 87 AS price_in_inr FROM stage.products WHERE price > 20000 / 87;
Hint
WHERE vendor = NULL is a classic trap โ€” in SQL, NULL means "unknown," and nothing is ever equal to an unknown value, not even another NULL. Always use IS NULL / IS NOT NULL.
06 ยท Core SQL

ORDER BY

Sorting the final result set โ€” including NULL placement and pagination with LIMIT/OFFSET.

order_by.sql
SELECT * FROM stage.products ORDER BY product_id ASC;
SELECT * FROM stage.products ORDER BY product_id DESC;

-- multi-column sort โ€” second column only matters when the first has ties
SELECT * FROM stage.products ORDER BY category ASC, price DESC;

-- controlling where NULLs land
SELECT * FROM stage.products ORDER BY vendor ASC NULLS FIRST;
SELECT * FROM stage.products ORDER BY vendor DESC NULLS LAST;

-- LIMIT / OFFSET, and the ANSI FETCH equivalent
SELECT * FROM stage.products ORDER BY product_id LIMIT 2 OFFSET 4;
SELECT * FROM stage.products ORDER BY product_id OFFSET 1 ROWS FETCH FIRST 2 ROWS ONLY;
SELECT * FROM stage.products ORDER BY product_id OFFSET 1 ROWS FETCH NEXT 2 ROWS ONLY;
Hint
Without an explicit ORDER BY, row order in Snowflake is not guaranteed to be stable between runs โ€” always sort explicitly before you LIMIT/OFFSET, or pagination results can shift under you.
07 ยท Core SQL

Aggregate Functions

Functions that collapse many rows into one value: SUM, AVG, MIN, MAX, COUNT โ€” used here with no GROUP BY, so each returns exactly one row for the whole table.

aggregate_functions.sql
-- single-row function (per row) vs aggregate function (across all rows) side by side
SELECT product_name, UPPER(product_name) AS upper_name FROM stage.products;

SELECT
  SUM(price)               AS total_price,
  MAX(price)               AS max_price,
  MIN(price)               AS min_price,
  COUNT(*)                 AS row_count,
  COUNT(vendor)             AS vendor_count,   -- COUNT ignores NULLs
  COUNT(DISTINCT category)  AS distinct_categories,
  AVG(price)                AS avg_price,
  SUM(price) / COUNT(*)     AS avg_price_manual
FROM stage.products;
Hint
COUNT(*) counts every row; COUNT(vendor) only counts rows where vendor isn't NULL. On stage.products, those two numbers are different by design (Section 01's four NULL-vendor rows).
08 ยท Core SQL

Aggregate Functions with GROUP BY

Mixing a non-aggregated column with an aggregate function requires GROUP BY โ€” this is where per-category, per-department totals come from.

group_by.sql
-- this FAILS: category is not aggregated and not grouped
-- SELECT category, SUM(price) FROM stage.products;

-- this works: category is in GROUP BY
SELECT category, MAX(price), MIN(price), AVG(price), SUM(price), COUNT(*)
FROM stage.products
GROUP BY category;

-- GROUP BY with no aggregate at all behaves like DISTINCT
SELECT category FROM stage.products GROUP BY category;
SELECT DISTINCT category FROM stage.products;   -- same result

-- grouping by more than one column
SELECT category, vendor, COUNT(*), SUM(price)
FROM stage.products
GROUP BY category, vendor;
Hint
The rule is simple even though the error message isn't always: every column in SELECT that isn't wrapped in an aggregate function must appear in GROUP BY. Snowflake will reject the query otherwise rather than guess what you meant.
09 ยท Core SQL

GROUP BY vs HAVING

WHERE filters individual rows before grouping happens; HAVING filters whole groups after aggregation. Mixing them up is the single most common GROUP BY error.

having.sql
-- this FAILS: WHERE runs before SUM(price) exists yet
-- SELECT category, SUM(price) FROM stage.products WHERE SUM(price) > 100 GROUP BY category;

-- correct: aggregate condition goes in HAVING
SELECT category, SUM(price) AS total_price
FROM stage.products
GROUP BY category
HAVING SUM(price) > 100;

-- WHERE and HAVING together โ€” row filter, then group filter, doing different jobs
SELECT category, SUM(price) AS total_price
FROM stage.products
WHERE category = 'Electronics'          -- row filter: only Electronics rows even considered
GROUP BY category
HAVING SUM(price) > 100;                -- group filter: keep the group only if its total clears 100

-- HAVING with a condition on the grouping column itself also works
SELECT category, SUM(price)
FROM stage.products
GROUP BY category
HAVING category = 'Accessories' AND SUM(price) > 100;
Hint
A useful mental shortcut: if the condition mentions an aggregate function (SUM, COUNT, AVG...), it belongs in HAVING. If it's a plain column comparison that could be checked one row at a time, it belongs in WHERE โ€” and putting it there is faster, since it discards rows before the (more expensive) grouping step.
10 ยท Core SQL

Single-Row Functions

Functions computed independently for every row โ€” as opposed to the aggregate functions in Section 07 that need many rows to produce one answer. Grouped here by family: string, numeric, date, conversion, and general/NULL-handling.

String functions

string_functions.sql
SELECT
  UPPER(customer_name)                          AS upper_name,
  LOWER(customer_name)                          AS lower_name,
  INITCAP(customer_name)                        AS proper_case,
  LENGTH(customer_name)                         AS name_length,
  SUBSTR(customer_name, 1, 5)                   AS first_5_chars,
  POSITION(' ' IN customer_name)                AS space_position,
  TRIM(customer_name)                           AS trimmed,
  CONCAT(customer_name, ' - ', country)         AS name_and_country,
  REPLACE(customer_name, ' ', '_')              AS underscored,
  LPAD(customer_id::VARCHAR, 6, '0')            AS padded_id,
  LEFT(customer_name, 3)                        AS left_3,
  RIGHT(customer_name, 3)                       AS right_3
FROM stage.customers;

-- splitting a full name into first/last using the first space as the delimiter
SELECT
  customer_name,
  TRIM(SUBSTR(customer_name, 1, POSITION(' ' IN customer_name)))   AS first_name,
  TRIM(SUBSTR(customer_name, POSITION(' ' IN customer_name)))      AS last_name
FROM stage.customers;

-- deriving a username from an email
SELECT email, SUBSTR(email, 1, POSITION('@' IN email) - 1) AS username FROM stage.customers;

-- data-quality check: leading/trailing whitespace
SELECT * FROM stage.products WHERE LENGTH(product_name) != LENGTH(TRIM(product_name));

Numeric functions

numeric_functions.sql
SELECT
  product_name, price,
  CEIL(price)              AS ceil_price,
  FLOOR(price)             AS floor_price,
  ROUND(price)              AS rounded_price,
  ROUND(price, 1)           AS rounded_1dp,
  TRUNC(price, 1)           AS truncated_1dp,
  MOD(ROUND(price), 2)      AS is_odd_flag        -- 0 = even, 1 = odd
FROM stage.products;

SELECT MAX(price) - MIN(price) AS price_range FROM stage.products;

Date & time functions

date_functions.sql
SELECT CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP;

SELECT
  EXTRACT(YEAR  FROM sale_date) AS sale_year,
  EXTRACT(MONTH FROM sale_date) AS sale_month,
  EXTRACT(DAY   FROM sale_date) AS sale_day
FROM stage.sales;

SELECT
  sale_date,
  DATEADD(DAY,  10, sale_date) AS plus_10_days,
  DATEADD(YEAR, 10, sale_date) AS plus_10_years,
  sale_date + INTERVAL '10 days' AS plus_10_days_alt
FROM stage.sales;

SELECT DATEDIFF(DAY, sale_date, CURRENT_DATE) AS days_since_sale FROM stage.sales;
SELECT DATEDIFF(YEAR, DATE '1995-04-18', CURRENT_DATE) AS age_years;
SELECT TO_CHAR(sale_date, 'DD-MON-YYYY') AS formatted_date FROM stage.sales;

Conversion functions

conversion_functions.sql
SELECT CAST('123' AS INTEGER);
SELECT CAST('123.45' AS NUMERIC(10,2));
SELECT TRY_CAST('not-a-number' AS INTEGER);   -- returns NULL instead of erroring

SELECT TO_DATE('12/12/2024', 'DD/MM/YYYY');
SELECT TO_CHAR(DATE '2024-01-01', 'YYYY-MM-DD') AS formatted_date;

SELECT * FROM stage.sales WHERE sale_id = CAST('101' AS INT);
SELECT * FROM stage.sales WHERE sale_date = CAST('2024-01-15' AS DATE);

General / NULL-handling functions

null_handling.sql
-- IFNULL(a, b) โ€” replace a NULL with a fallback
SELECT product_name, IFNULL(vendor, 'No Vendor') AS vendor_display FROM stage.products;

-- COALESCE(a, b, c, ...) โ€” first non-NULL value, any number of args
SELECT customer_name,
  COALESCE(phone, landline_number, second_number, home_number, 'No contact on file') AS best_contact
FROM stage.customers;

-- NULLIF(a, b) โ€” NULL if a = b, otherwise a (avoids divide-by-zero)
SELECT product_id, price / NULLIF(stock, 0) AS price_per_unit_in_stock FROM stage.products;

-- CASE / IFF / DECODE โ€” conditional branching
SELECT product_name, price,
  CASE
    WHEN price > 500 AND category = 'Electronics' THEN 'Premium Electronics'
    WHEN price > 300 THEN 'Mid-Range'
    ELSE 'Budget'
  END AS price_tier
FROM stage.products;

SELECT product_name, price, IFF(price > 500, 'Costly', 'Affordable') AS tier FROM stage.products;

SELECT vendor,
  DECODE(vendor, 'LG','LG Electronics', 'BOAT','BOAT Lifestyle', NULL,'No Vendor Assigned', vendor) AS vendor_display
FROM stage.products;
Hint
NULLIF(stock, 0) turns a zero into a NULL right before the division, so instead of a divide-by-zero error you cleanly get NULL for out-of-stock products. Reach for IFF on a simple two-way branch, CASE once there are three-plus branches, DECODE only when porting Oracle code that already uses it.
11 ยท Core SQL

Window Functions

Functions that compute across a set of related rows (a "window") without collapsing them the way GROUP BY does โ€” every original row survives, with the calculated value attached alongside it.

OVER() and PARTITION BY

window_basics.sql
-- OVER() with no arguments = "the whole result set" โ€” every row keeps its identity
SELECT *, SUM(price) OVER () AS total_all_products FROM stage.products;
SELECT *, MAX(price) OVER () AS max_price, MIN(price) OVER () AS min_price, AVG(price) OVER () AS avg_price
FROM stage.products;

-- PARTITION BY restarts the calculation within each group, like GROUP BY that doesn't collapse rows
SELECT *, SUM(price) OVER (PARTITION BY category) AS category_total FROM stage.products;
Hint
Core mental model: a normal aggregate + GROUP BY collapses rows to one per group. The same aggregate with OVER (PARTITION BY ...) computes the group total but keeps every original row, repeating the total alongside each one.

Ranking: ROW_NUMBER, RANK & DENSE_RANK

ranking.sql
SELECT *,
  ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,     -- always unique, 1,2,3,4...
  RANK()       OVER (ORDER BY salary DESC) AS rnk,         -- ties share a rank, next rank skips
  DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk    -- ties share a rank, next rank doesn't skip
FROM employees;

-- 2nd highest salary company-wide
SELECT * FROM (SELECT *, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employees) WHERE r = 2;

-- 2nd highest salary PER DEPARTMENT
SELECT * FROM (
  SELECT *, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r FROM employees
) WHERE r = 2;
Hint
With a 3-way tie for 1st: ROW_NUMBER gives 1,2,3 (arbitrary); RANK gives 1,1,1 then jumps to 4; DENSE_RANK gives 1,1,1 then continues at 2. "2nd highest salary" almost always means DENSE_RANK.

LAG, LEAD & positional values

lag_lead.sql
SELECT *,
  LAG(salary)  OVER (ORDER BY salary DESC) AS prev_salary,
  LEAD(salary) OVER (ORDER BY salary DESC) AS next_salary
FROM employees;

SELECT *,
  FIRST_VALUE(salary) OVER (ORDER BY salary DESC) AS highest_salary,
  LAST_VALUE(salary)  OVER (ORDER BY salary DESC
                             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS lowest_salary,
  NTH_VALUE(salary, 3) OVER (ORDER BY salary DESC
                              ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS third_highest
FROM employees;


-- 1. Create the Batsmen Table
CREATE TABLE batsmen (
    player_id INT PRIMARY KEY,
    player_name VARCHAR(100),
    team VARCHAR(50),
    runs_scored INT,
    matches_played INT
);

-- 2. Populate the Batsmen Table
INSERT INTO batsmen (player_id, player_name, team, runs_scored, matches_played) VALUES
(1, 'Virat Kohli', 'India', 13848, 292),
(2, 'Rohit Sharma', 'India', 10709, 262),
(3, 'Babar Azam', 'Pakistan', 5729, 117),
(4, 'Kane Williamson', 'New Zealand', 6810, 165),
(5, 'Joe Root', 'England', 6522, 171),
(6, 'Steve Smith', 'Australia', 5065, 145);

-- 3. Create the Bowlers Table
CREATE TABLE bowlers (
    player_id INT PRIMARY KEY,
    player_name VARCHAR(100),
    team VARCHAR(50),
    wickets_taken INT,
    economy_rate DECIMAL(4,2)
);

-- 4. Populate the Bowlers Table
INSERT INTO bowlers (player_id, player_name, team, wickets_taken, economy_rate) VALUES
(101, 'Jasprit Bumrah', 'India', 149, 4.59),
(102, 'Mitchell Starc', 'Australia', 236, 5.11),
(103, 'Trent Boult', 'New Zealand', 211, 4.93),
(104, 'Shaheen Afridi', 'Pakistan', 104, 5.42),
(105, 'Adil Rashid', 'England', 199, 5.67),
(106, 'Rashid Khan', 'Afghanistan', 183, 4.21);


-- Query 1: Finding previous and next highest run-scorers
SELECT *, 
       LAG(runs_scored) OVER (ORDER BY runs_scored DESC) AS prev_higher_runs, 
       LEAD(runs_scored) OVER (ORDER BY runs_scored DESC) AS next_lower_runs 
FROM batsmen;

-- Query 2: Finding highest, lowest, and 3rd highest run-scorers
SELECT *, 
       FIRST_VALUE(runs_scored) OVER (ORDER BY runs_scored DESC) AS highest_runs, 
       LAST_VALUE(runs_scored) OVER (ORDER BY runs_scored DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS lowest_runs, 
       NTH_VALUE(runs_scored, 3) OVER (ORDER BY runs_scored DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS third_highest_runs 
FROM batsmen;


-- Query 1: Finding previous and next highest wicket-takers
SELECT *, 
       LAG(wickets_taken) OVER (ORDER BY wickets_taken DESC) AS prev_higher_wickets, 
       LEAD(wickets_taken) OVER (ORDER BY wickets_taken DESC) AS next_lower_wickets 
FROM bowlers;

-- Query 2: Finding highest, lowest, and 3rd highest wicket-takers
SELECT *, 
       FIRST_VALUE(wickets_taken) OVER (ORDER BY wickets_taken DESC) AS highest_wickets, 
       LAST_VALUE(wickets_taken) OVER (ORDER BY wickets_taken DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS lowest_wickets, 
       NTH_VALUE(wickets_taken, 3) OVER (ORDER BY wickets_taken DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS third_highest_wickets 
FROM bowlers;
Hint
LAST_VALUE without an explicit frame is a classic gotcha: the default frame is "unbounded preceding to current row," so it just returns the current row's own value. Widen the frame to UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to get the true last value.

Frame clauses: running totals & moving averages

frames.sql
SELECT *,
  SUM(quantity_sold) OVER (ORDER BY sale_date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM stage.sales;

SELECT *,
  SUM(quantity_sold) OVER (ORDER BY sale_date ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moving_3row_total
FROM stage.sales;

SELECT *,
  AVG(quantity_sold) OVER (ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3
FROM stage.sales;
Hint
Read ROWS BETWEEN X PRECEDING AND Y FOLLOWING literally as "how many physical rows to look back and forward from the current one" โ€” UNBOUNDED PRECEDING AND CURRENT ROW is "everything so far" (a running total).

QUALIFY โ€” filtering on a window function without a wrapper subquery

qualify.sql
-- With QUALIFY โ€” same result as the "2nd highest per department" query above, no subquery needed
SELECT *, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r
FROM employees
QUALIFY r = 2;

-- Top earner per department, in one clean pass
SELECT * FROM employees
QUALIFY ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) = 1;
Hint
Full clause order: WHERE โ†’ GROUP BY โ†’ HAVING โ†’ window functions โ†’ QUALIFY โ†’ ORDER BY. That's why QUALIFY can reference a window function directly and WHERE can't โ€” QUALIFY is Snowflake-specific syntax, not ANSI SQL.
12 ยท Core SQL

Constraints

PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, DEFAULT. Snowflake accepts and stores all of these โ€” but unlike Postgres/Oracle, only NOT NULL is actually enforced at insert time. The rest are metadata: useful for documentation, ER diagrams, and query optimizer hints, but they won't stop a bad insert.

constraints.sql
CREATE OR REPLACE TABLE stage.constraint_demo (
    id         INT PRIMARY KEY,
    name       VARCHAR(30) NOT NULL,                 -- this one IS enforced
    email      VARCHAR(50) UNIQUE,                    -- documented, not enforced
    age        INT CHECK (age > 5),                   -- documented, not enforced
    created_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

-- this insert violates the CHECK and reuses a PK, but Snowflake lets it through --
-- real enforcement has to happen in your ETL logic (see Section 39's cleansing procedures)
INSERT INTO stage.constraint_demo (id, name, age) VALUES (1, 'Demo User', 3);
INSERT INTO stage.constraint_demo (id, name, age) VALUES (1, 'Duplicate Id', 30);

-- Foreign key โ€” again, declared for documentation / BI-tool relationship discovery only
CREATE OR REPLACE TABLE stage.departments_demo (dept_id INT PRIMARY KEY, dept_name VARCHAR(50));
CREATE OR REPLACE TABLE stage.employees_demo (
    emp_id   INT PRIMARY KEY,
    emp_name VARCHAR(50),
    dept_id  INT,
    FOREIGN KEY (dept_id) REFERENCES stage.departments_demo(dept_id)
);
-- this "orphan" insert succeeds in Snowflake even though department 100 doesn't exist
INSERT INTO stage.employees_demo VALUES (1, 'Orphan Employee', 100);
Hint
This trips up almost everyone coming from Postgres/MySQL/Oracle. In Snowflake, referential integrity is your ETL logic's job โ€” the database itself won't reject bad foreign keys or duplicate "unique" values.
13 ยท Core SQL

Set Operators & Joins

Two different ways of combining two result sets: joins combine columns side by side based on a matching condition; set operators stack rows from two same-shaped queries on top of each other.

The four directional joins

joins.sql
-- INNER JOIN: only rows that match on both sides
SELECT e.emp_id, e.emp_name, e.salary, d.dept_name
FROM employee e INNER JOIN department d ON e.dept_id = d.dept_id;

-- LEFT JOIN: every employee, department columns NULL if there's no match
SELECT e.emp_id, e.emp_name, e.salary, d.dept_name
FROM employee e LEFT JOIN department d ON e.dept_id = d.dept_id;

-- RIGHT JOIN: every department, employee columns NULL if nobody's in it
SELECT e.emp_id, e.emp_name, e.salary, d.dept_name
FROM department d RIGHT JOIN employee e ON e.dept_id = d.dept_id;

-- FULL JOIN: everything from both sides, matched where possible
SELECT e.emp_id, e.emp_name, e.salary, d.dept_name
FROM department d FULL JOIN employee e ON e.dept_id = d.dept_id;

Anti-joins, CROSS, NATURAL & SELF

special_joins.sql
-- anti-join: employees whose department doesn't exist in department
SELECT e.* FROM employee e
LEFT JOIN department d ON e.dept_id = d.dept_id
WHERE d.dept_name IS NULL;

-- CROSS JOIN: every combination, no ON clause
SELECT e.emp_id, e.emp_name, d.dept_name FROM department d CROSS JOIN employee e;

-- NATURAL JOIN: auto-matches on same-named columns (dept_id in both)
SELECT e.emp_id, e.emp_name, e.salary, d.dept_name FROM department d NATURAL JOIN employee e;

-- SELF JOIN: workers joined to itself to resolve each manager's name
SELECT emp.w_name AS employee_name, mgr.w_name AS manager_name
FROM workers emp LEFT JOIN workers mgr ON emp.mgr_id = mgr.wid;

-- who has no manager at all? (self join + anti-join)
SELECT emp.w_name FROM workers emp LEFT JOIN workers mgr ON emp.mgr_id = mgr.wid WHERE mgr.w_name IS NULL;
Hint
An "anti-join" isn't a separate keyword โ€” it's LEFT JOIN ... WHERE right_side IS NULL. And NATURAL JOIN is convenient but fragile: it silently joins on every identically-named column, so most teams prefer explicit ON clauses in real pipelines.

UNION, UNION ALL, INTERSECT & MINUS

set_operators.sql
-- UNION ALL: stack both, keep every row including duplicates
SELECT emp_name AS person FROM employee UNION ALL SELECT contractor_name FROM contractor;

-- UNION: stack and de-duplicate
SELECT emp_name AS person FROM employee UNION SELECT contractor_name FROM contractor;

-- INTERSECT: only names appearing in BOTH tables
SELECT emp_name FROM employee INTERSECT SELECT contractor_name FROM contractor;

-- MINUS (Snowflake also accepts EXCEPT): in the first, not in the second
SELECT emp_name FROM employee MINUS SELECT contractor_name FROM contractor;
Hint
Default to UNION ALL unless you specifically need de-duplication โ€” plain UNION does real work (sorting/hashing every row) to find and remove duplicates.
14 ยท Core SQL

Subquery

A query nested inside another โ€” as a filter value, a computed column, a derived table, or a correlated per-row check.

Scalar, non-scalar & EXISTS

basic_subqueries.sql
-- scalar subquery: returns exactly one value, used like a constant
SELECT * FROM stage.products WHERE price > (SELECT AVG(price) FROM stage.products);

-- non-scalar (multi-row) subquery with IN
SELECT * FROM stage.products
WHERE product_id IN (SELECT product_id FROM stage.products WHERE category = 'Furniture');

-- EXISTS: stops at the first match, often faster than IN for large subqueries
SELECT * FROM stage.products a
WHERE EXISTS (SELECT 1 FROM stage.products b WHERE a.product_id = b.product_id AND b.category = 'Furniture');

Correlated subqueries

correlated_subqueries.sql
-- a correlated subquery references a column from the OUTER query (a.dept_id) โ€”
-- conceptually re-evaluated once per outer row, unlike a plain subquery which runs once
SELECT a.* FROM employee a
WHERE a.salary > (SELECT AVG(b.salary) FROM employee b WHERE b.dept_id = a.dept_id);
Hint
The tell for "correlated" is whether the inner query mentions the outer table's alias. If it doesn't reference anything from outside itself, it's a plain subquery and only executes once.

Subqueries in FROM, JOIN & SELECT

subquery_placement.sql
-- in SELECT: a constant column, computed once, repeated on every row
SELECT *, (SELECT SUM(salary) FROM workers) AS company_total_salary FROM workers;

-- in FROM (a "derived table"): pre-aggregate before joining
SELECT d.dept_name, s.total_salary
FROM department d
INNER JOIN (SELECT dept_id, SUM(salary) AS total_salary FROM employee GROUP BY dept_id) s
        ON d.dept_id = s.dept_id;

-- in JOIN: filter BEFORE joining instead of after
SELECT a.*, b.*
FROM (SELECT * FROM stage.products WHERE category = 'Electronics') a
INNER JOIN stage.sales b ON a.product_id = b.product_id;
15 ยท Core SQL

CTE (Recursive & Non-Recursive)

A WITH clause names a subquery so you can reference it (even multiple times) by name โ€” dramatically more readable than nesting subqueries several levels deep. Recursive CTEs additionally let a query reference its own output, which is how you walk a hierarchy of unknown depth.

Non-recursive: basic & chained CTEs

cte_basics.sql
WITH high_earners AS (
  SELECT * FROM workers WHERE salary > (SELECT AVG(salary) FROM workers)
)
SELECT * FROM high_earners ORDER BY salary DESC;

-- multiple CTEs, chained together
WITH cte_employees AS (
  SELECT dept_id, emp_id, emp_name, salary FROM employee
),
cte_hr_dept AS (
  SELECT dept_id, dept_name FROM department WHERE dept_name = 'HR'
)
SELECT a.*, b.* FROM cte_employees a INNER JOIN cte_hr_dept b ON a.dept_id = b.dept_id;

Recursive CTE โ€” walking the org chart

recursive_cte.sql
-- every level of the org chart under Alice (wid = 1), with a computed depth
WITH RECURSIVE org_chart AS (
  -- anchor: the starting row
  SELECT wid, w_name, mgr_id, designation, 0 AS depth
  FROM workers WHERE wid = 1

  UNION ALL

  -- recursive step: joins back to org_chart itself, one level down each pass
  SELECT w.wid, w.w_name, w.mgr_id, w.designation, oc.depth + 1
  FROM workers w INNER JOIN org_chart oc ON w.mgr_id = oc.wid
)
SELECT * FROM org_chart ORDER BY depth, wid;

-- building an indented reporting-line string as it recurses
WITH RECURSIVE reporting_line AS (
  SELECT wid, w_name, mgr_id, w_name::VARCHAR AS chain
  FROM workers WHERE mgr_id IS NULL

  UNION ALL

  SELECT w.wid, w.w_name, w.mgr_id, rl.chain || ' -> ' || w.w_name
  FROM workers w INNER JOIN reporting_line rl ON w.mgr_id = rl.wid
)
SELECT * FROM reporting_line ORDER BY wid;
Hint
Every recursive CTE has exactly two parts joined by UNION ALL: an anchor (where recursion starts) and a recursive query that refers back to the CTE's own name. Snowflake caps recursion depth by default, so a bug that never terminates errors out rather than hanging forever.
16 ยท Core SQL

Advanced SQL

PIVOT, UNPIVOT, LISTAGG, MERGE, and both SCD patterns โ€” the techniques that turn raw rows into report-shaped tables and handle changing dimension data over time.

PIVOT & UNPIVOT

pivot_unpivot.sql
-- PIVOT: rows to columns โ€” one row per company, one column per stock symbol
SELECT * FROM share_market_data
PIVOT (SUM(volume) FOR stock_symbol IN ('PYPL', 'GOOGL', 'MSFT'))
AS p (company_name, trade_date, open_price, close_price, high_price, low_price, pypl_vol, googl_vol, msft_vol);

-- UNPIVOT: columns to rows
CREATE OR REPLACE TABLE scratch_prices AS
SELECT stock_symbol, trade_date, open_price, close_price, high_price, low_price FROM share_market_data;

SELECT stock_symbol, trade_date, price_type, price_value
FROM scratch_prices
UNPIVOT (price_value FOR price_type IN (open_price, close_price, high_price, low_price));
Hint
PIVOT needs the target column values named explicitly up front โ€” it can't discover them dynamically. If your category list changes often, conditional aggregation (SUM(CASE WHEN ... THEN ... END)) is more maintainable.

LISTAGG

listagg.sql
SELECT category, LISTAGG(product_name, ', ') WITHIN GROUP (ORDER BY product_name) AS products
FROM stage.products GROUP BY category;

SELECT mgr.w_name AS manager, LISTAGG(emp.w_name, ', ') WITHIN GROUP (ORDER BY emp.w_name) AS direct_reports
FROM workers emp INNER JOIN workers mgr ON emp.mgr_id = mgr.wid
GROUP BY mgr.w_name;
Hint
Always pair LISTAGG with WITHIN GROUP (ORDER BY ...) โ€” without it, item order in the concatenated string is unspecified and can vary between runs.

MERGE (upsert)

merge.sql
MERGE INTO employees_2023 AS target
USING employees_2024 AS source
   ON target.emp_id = source.emp_id
WHEN MATCHED THEN
   UPDATE SET target.emp_name = source.emp_name, target.department = source.department
WHEN NOT MATCHED THEN
   INSERT (emp_id, emp_name, department) VALUES (source.emp_id, source.emp_name, source.department);

SELECT * FROM employees_2023 ORDER BY emp_id;
Hint
MERGE never deletes rows by default โ€” add a WHEN NOT MATCHED BY SOURCE THEN DELETE clause if you also want to remove rows that vanished from the source.

SCD Type 1 โ€” overwrite, no history

scd_type1.sql
MERGE INTO dwh.dim_customer AS target
USING (
    SELECT customer_id, customer_name, email, address, country, gender
    FROM stage.customers
    QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) = 1
) AS source
ON target.customer_id = source.customer_id
WHEN MATCHED THEN
    UPDATE SET target.first_name = SPLIT_PART(source.customer_name, ' ', 1),
               target.last_name  = SPLIT_PART(source.customer_name, ' ', 2),
               target.email = TRIM(LOWER(source.email)), target.address = source.address,
               target.country = source.country, target.gender = source.gender
WHEN NOT MATCHED THEN
    INSERT (customer_id, first_name, last_name, email, address, country, gender, is_current, effective_date)
    VALUES (source.customer_id, SPLIT_PART(source.customer_name, ' ', 1), SPLIT_PART(source.customer_name, ' ', 2),
            TRIM(LOWER(source.email)), source.address, source.country, source.gender, TRUE, CURRENT_DATE());

SCD Type 2 โ€” keep full history

scd_type2.sql
-- Step 1: expire any current row whose details changed
MERGE INTO dwh.dim_customer AS target
USING (
    SELECT customer_id, customer_name, email, address FROM stage.customers
    QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) = 1
) AS source
ON target.customer_id = source.customer_id AND target.is_current = TRUE
WHEN MATCHED AND (target.email != TRIM(LOWER(source.email)) OR target.address != source.address) THEN
    UPDATE SET target.is_current = FALSE, target.expiry_date = CURRENT_DATE();

-- Step 2: insert a fresh "current" row for anyone new, or just-expired
INSERT INTO dwh.dim_customer (customer_id, first_name, last_name, email, address, country, gender, is_current, effective_date)
SELECT s.customer_id, SPLIT_PART(s.customer_name,' ',1), SPLIT_PART(s.customer_name,' ',2),
       TRIM(LOWER(s.email)), s.address, s.country, s.gender, TRUE, CURRENT_DATE()
FROM (
    SELECT customer_id, customer_name, email, address, country, gender FROM stage.customers
    QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) = 1
) s
LEFT JOIN dwh.dim_customer d ON d.customer_id = s.customer_id AND d.is_current = TRUE
WHERE d.customer_id IS NULL;
Hint
SCD2 is always two passes, never one MERGE: expire what changed, then insert new current rows. A single MERGE can't express "close this row AND open a new one" in the same branch.
17 ยท PL/SQL (Scripting)

PL/SQL Intro with Example

Snowflake's procedural layer is officially called Snowflake Scripting โ€” it's Snowflake's answer to PL/SQL (Oracle) or PL/pgSQL (Postgres): DECLARE / BEGIN / END blocks wrapped around ordinary SQL, giving you variables, branching, loops, and error handling. Your class notes used Postgres' plpgsql syntax โ€” this section (and 18-22) show the correct Snowflake equivalent.

Procedure vs. Function โ€” which one do you want?

A stored procedure does things: it can run DDL/DML, has side effects, and is invoked with CALL. A function computes things: it must return a value, is called inside a query's SELECT/WHERE, and generally shouldn't mutate data.

hello_world.sql
CREATE OR REPLACE PROCEDURE sp_hello_world()
RETURNS STRING
LANGUAGE SQL
AS
$$
BEGIN
    RETURN 'Hello from Snowflake Scripting!';
END;
$$;

CALL sp_hello_world();

-- the same idea using real data: how many products are in the catalog right now?
CREATE OR REPLACE PROCEDURE sp_product_count()
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    total INT;
BEGIN
    SELECT COUNT(*) INTO total FROM stage.products;
    RETURN 'Product catalog currently has ' || total::STRING || ' item(s)';
END;
$$;

CALL sp_product_count();
Hint
Every Snowflake Scripting block follows the same skeleton: an optional DECLARE section for variables, then BEGIN ... END; containing statements, all wrapped in $$ ... $$ so the SQL parser treats it as one string body rather than trying to parse each inner statement itself.
18 ยท PL/SQL (Scripting)

Variables

Declaring, defaulting, and assigning local variables inside a script โ€” plus pulling a query result straight into one with SELECT ... INTO.

variables.sql
CREATE OR REPLACE PROCEDURE sp_pricing_summary(exchange_rate NUMBER)
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    total_usd       NUMBER;
    total_inr       NUMBER;
    default_vendor  STRING DEFAULT 'Unassigned';
    summary_msg     STRING;
BEGIN
    -- SELECT ... INTO pulls a single-row query result into one or more variables
    SELECT SUM(price) INTO total_usd FROM stage.products;

    -- assignment uses :=
    total_inr := total_usd * exchange_rate;

    summary_msg := 'Catalog total: $' || total_usd::STRING || ' (~INR ' || total_inr::STRING || ')';
    RETURN summary_msg;
END;
$$;

CALL sp_pricing_summary(83.25);
Hint
SELECT ... INTO requires the query to return exactly one row โ€” if it returns zero or multiple rows, Snowflake Scripting raises a runtime error, which is exactly the kind of thing Section 20's exception handling is for.
19 ยท PL/SQL (Scripting)

Control Flow

IF/ELSEIF/ELSE branching, FOR and WHILE loops, and the two flow-control keywords that skip or exit early: CONTINUE and BREAK.

IF / ELSEIF / ELSE

if_else.sql
CREATE OR REPLACE FUNCTION fn_product_cost_status(exchange_rate NUMBER)
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    total  NUMBER;
    result STRING;
BEGIN
    SELECT SUM(price) * exchange_rate INTO total FROM stage.products;

    IF (total > 200000) THEN
        result := 'Costly Catalog';
    ELSEIF (total > 100000) THEN
        result := 'Normal Catalog';
    ELSE
        result := 'Cheap Catalog';
    END IF;

    RETURN result;
END;
$$;

SELECT fn_product_cost_status(83);

FOR loops โ€” counting range & result set

for_loops.sql
-- counting FOR loop
CREATE OR REPLACE PROCEDURE sp_generate_test_rows(n INT)
RETURNS STRING
LANGUAGE SQL
AS
$$
BEGIN
    FOR i IN 1 TO n DO
        INSERT INTO stage.vendors (vendor_id, vendor_name, country)
        VALUES (900 + i, 'Test Vendor ' || i::STRING, 'Testland');
    END FOR;
    RETURN n::STRING || ' test vendor rows inserted';
END;
$$;
CALL sp_generate_test_rows(3);

-- FOR loop over a query result (an implicit cursor)
CREATE OR REPLACE TABLE stage.low_stock_log (
    product_id INT, product_name VARCHAR(100), stock_at_flag INT, flagged_at TIMESTAMP_NTZ
);

CREATE OR REPLACE PROCEDURE sp_flag_low_stock(threshold INT)
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    flagged_count INT DEFAULT 0;
BEGIN
    FOR record IN (SELECT product_id, product_name, stock FROM stage.products WHERE stock < threshold) DO
        INSERT INTO stage.low_stock_log VALUES (record.product_id, record.product_name, record.stock, CURRENT_TIMESTAMP());
        flagged_count := flagged_count + 1;
    END FOR;
    RETURN flagged_count::STRING || ' low-stock product(s) flagged';
END;
$$;
CALL sp_flag_low_stock(20);

WHILE, CONTINUE & BREAK

while_continue_break.sql
CREATE OR REPLACE PROCEDURE sp_countdown(start_at INT)
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    counter INT DEFAULT start_at;
    log_msg STRING DEFAULT '';
BEGIN
    WHILE (counter > 0) DO
        log_msg := log_msg || counter::STRING || ' ';
        counter := counter - 1;
    END WHILE;
    RETURN 'Countdown: ' || log_msg;
END;
$$;
CALL sp_countdown(5);

-- CONTINUE: skip the rest of this iteration, move to the next
-- BREAK: exit the loop immediately, regardless of how many iterations remain
CREATE OR REPLACE PROCEDURE sp_process_products_demo()
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    processed INT DEFAULT 0;
    stopped_early BOOLEAN DEFAULT FALSE;
BEGIN
    FOR record IN (SELECT product_id, category, price FROM stage.products ORDER BY product_id) DO

        IF (record.category IS NULL) THEN
            CONTINUE;   -- skip uncategorized rows, but keep looping
        END IF;

        IF (record.price > 1000) THEN
            stopped_early := TRUE;
            BREAK;       -- stop entirely once we hit an unusually expensive item
        END IF;

        processed := processed + 1;
    END FOR;

    RETURN 'Processed ' || processed::STRING || ' product(s), stopped early: ' || stopped_early::STRING;
END;
$$;
CALL sp_process_products_demo();
Hint
Think of CONTINUE as "skip this one, keep the loop alive" and BREAK as "kill the loop entirely, right now" โ€” both work inside FOR, WHILE, and REPEAT loops in Snowflake Scripting.
20 ยท PL/SQL (Scripting)

Exception Handling

Wrapping risky statements in a BEGIN ... EXCEPTION ... END block so a bad cast, a missing row, or a divide-by-zero fails gracefully instead of aborting the whole procedure.

exception_handling.sql
CREATE OR REPLACE FUNCTION fn_product_cost_status_safe(exchange_rate_text STRING)
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    exchange_rate NUMBER;
    total         NUMBER;
    result        STRING;
BEGIN
    BEGIN
        -- try to cast the input; a nested block scopes the handler to just this statement
        exchange_rate := exchange_rate_text::NUMBER;
    EXCEPTION
        WHEN OTHER THEN
            RETURN 'Invalid exchange rate: must be numeric';
    END;

    SELECT COALESCE(SUM(price), 0) * exchange_rate INTO total FROM stage.products;

    IF (total > 200000) THEN
        result := 'Costly Catalog';
    ELSEIF (total > 100000) THEN
        result := 'Normal Catalog';
    ELSE
        result := 'Cheap Catalog';
    END IF;

    RETURN result;
END;
$$;

SELECT fn_product_cost_status_safe('83');    -- works
SELECT fn_product_cost_status_safe('abc');   -- caught, returns the friendly message

-- a custom, user-raised exception
CREATE OR REPLACE PROCEDURE sp_apply_discount(product_id_in INT, discount_pct NUMBER)
RETURNS STRING
LANGUAGE SQL
AS
$$
BEGIN
    IF (discount_pct < 0 OR discount_pct > 90) THEN
        RETURN 'ERROR: discount_pct must be between 0 and 90';
    END IF;

    UPDATE stage.products
    SET price = price - (price * discount_pct / 100)
    WHERE product_id = product_id_in;

    IF (SQLROWCOUNT = 0) THEN
        RETURN 'ERROR: no product found with id ' || product_id_in::STRING;
    END IF;

    RETURN 'Discount applied to product ' || product_id_in::STRING;
END;
$$;
CALL sp_apply_discount(1, 10);
CALL sp_apply_discount(9999, 10);   -- handled gracefully, not a crash
Hint
Not every "error" needs a formal EXCEPTION block โ€” checking SQLROWCOUNT or validating inputs with a plain IF first (as in sp_apply_discount) is often clearer than catching an exception after the fact. Reach for EXCEPTION mainly for genuinely unpredictable failures, like a bad cast on external input.
21 ยท PL/SQL (Scripting)

PL/SQL Mini Project

A single applied procedure that ties Sections 17-20 together: variables, a result-set loop, IF/CONTINUE branching, and exception handling โ€” a "Restock Advisor" that reviews every product and recommends how urgently it needs reordering.

restock_advisor_project.sql
CREATE OR REPLACE TABLE dwh.restock_recommendations (
    product_id      INT,
    product_name    VARCHAR(100),
    current_stock   INT,
    avg_daily_sales NUMBER(10,2),
    days_of_cover   NUMBER(10,2),
    priority        VARCHAR(20),
    recommended_at  TIMESTAMP_NTZ
);

CREATE OR REPLACE PROCEDURE dwh.sp_restock_advisor(cover_threshold_days NUMBER)
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
    products_reviewed INT DEFAULT 0;
    urgent_count      INT DEFAULT 0;
    watch_count       INT DEFAULT 0;
    skipped_count     INT DEFAULT 0;
BEGIN
    TRUNCATE TABLE dwh.restock_recommendations;

    FOR p IN (
        SELECT product_id, product_name, stock
        FROM stage.products
        ORDER BY product_id
    ) DO
        BEGIN
            DECLARE
                v_avg_daily_sales NUMBER;
                v_days_of_cover   NUMBER;
                v_priority        VARCHAR(20);
            BEGIN
                -- how many units of this product sell per day, on average, historically
                SELECT COALESCE(AVG(quantity_sold), 0) INTO v_avg_daily_sales
                FROM stage.sales WHERE product_id = p.product_id AND quantity_sold > 0;

                IF (v_avg_daily_sales = 0) THEN
                    -- never sold โ€” nothing meaningful to recommend, skip it
                    skipped_count := skipped_count + 1;
                    CONTINUE;
                END IF;

                -- NULLIF guards the divide-by-zero the EXCEPTION block below also protects against
                v_days_of_cover := p.stock / NULLIF(v_avg_daily_sales, 0);

                IF (v_days_of_cover < cover_threshold_days / 2) THEN
                    v_priority := 'Urgent';
                    urgent_count := urgent_count + 1;
                ELSEIF (v_days_of_cover < cover_threshold_days) THEN
                    v_priority := 'Watch';
                    watch_count := watch_count + 1;
                ELSE
                    v_priority := 'OK';
                END IF;

                INSERT INTO dwh.restock_recommendations
                VALUES (p.product_id, p.product_name, p.stock, v_avg_daily_sales, v_days_of_cover, v_priority, CURRENT_TIMESTAMP());

                products_reviewed := products_reviewed + 1;
            EXCEPTION
                WHEN OTHER THEN
                    skipped_count := skipped_count + 1;
                    CONTINUE;
            END;
        END;
    END FOR;

    RETURN 'Reviewed ' || products_reviewed::STRING ||
           ' product(s) โ€” Urgent: ' || urgent_count::STRING ||
           ', Watch: ' || watch_count::STRING ||
           ', Skipped (no sales history): ' || skipped_count::STRING;
END;
$$;

CALL dwh.sp_restock_advisor(30);

SELECT * FROM dwh.restock_recommendations ORDER BY priority, days_of_cover;
Hint
Notice the shape: a FOR loop (Section 19) drives the whole thing, a nested DECLARE block scopes per-product working variables, CONTINUE skips products with no sales history without aborting the batch, and the inner EXCEPTION block means one bad product can't take down the entire run โ€” exactly the resilience pattern a real ETL job needs.
22 ยท PL/SQL (Scripting)

UDF

User-Defined Functions โ€” SQL (single expression), Scripting (procedural), and Table (returns a whole result set, called with TABLE(...)).

SQL UDF

sql_udf.sql
CREATE OR REPLACE FUNCTION fn_sales_amount(qty INT, unit_price NUMBER)
RETURNS NUMBER(12,2)
LANGUAGE SQL
AS
$$
  qty * unit_price
$$;

SELECT s.sale_id, fn_sales_amount(s.quantity_sold, p.price) AS sales_amount
FROM stage.sales s JOIN stage.products p ON s.product_id = p.product_id;

Scripting UDF

scripting_udf.sql
CREATE OR REPLACE FUNCTION fn_price_tier(price NUMBER)
RETURNS STRING
LANGUAGE SQL
AS
$$
BEGIN
    IF (price > 500) THEN
        RETURN 'Premium';
    ELSEIF (price > 200) THEN
        RETURN 'Standard';
    ELSE
        RETURN 'Budget';
    END IF;
END;
$$;

SELECT product_name, price, fn_price_tier(price) AS tier FROM stage.products;

Table UDF (UDTF)

table_udf.sql
CREATE OR REPLACE FUNCTION fn_top_products_by_category(cat STRING, n INT)
RETURNS TABLE (product_id INT, product_name STRING, price NUMBER)
LANGUAGE SQL
AS
$$
  SELECT product_id, product_name, price
  FROM stage.products
  WHERE category = cat
  ORDER BY price DESC
  LIMIT n
$$;

SELECT * FROM TABLE(fn_top_products_by_category('Electronics', 3));
Hint
A UDTF is the tool when one call needs to return multiple rows โ€” invoke it with TABLE(...) in the FROM clause, not in SELECT, since it produces a result set rather than a single scalar value.
23 ยท Data Loading

File Format & Stage

Before any file can be loaded, Snowflake needs to know two things: where the file lives (a Stage) and how to parse it (a File Format). Both are reusable, named objects you define once and reference from every COPY INTO afterward.

File formats

file_formats.sql
CREATE OR REPLACE FILE FORMAT stage.ff_csv_standard
    TYPE = CSV
    FIELD_DELIMITER = ','
    SKIP_HEADER = 1
    FIELD_OPTIONALLY_ENCLOSED_BY = '"'
    NULL_IF = ('NULL', 'null', '')
    EMPTY_FIELD_AS_NULL = TRUE
    DATE_FORMAT = 'YYYY-MM-DD'
    COMPRESSION = AUTO;

CREATE OR REPLACE FILE FORMAT stage.ff_json_standard
    TYPE = JSON
    STRIP_OUTER_ARRAY = TRUE;

CREATE OR REPLACE FILE FORMAT stage.ff_parquet_standard
    TYPE = PARQUET;

SHOW FILE FORMATS IN SCHEMA stage;
DESC FILE FORMAT stage.ff_csv_standard;
Hint
STRIP_OUTER_ARRAY = TRUE on the JSON format matters when your source file is one big [ {...}, {...}, {...} ] array โ€” without it, Snowflake loads the entire array as a single VARIANT row instead of one row per object.

The four kinds of stage

stages.sql
-- 1. USER STAGE โ€” every user gets one automatically, referenced as @~. No setup needed.
LIST @~;

-- 2. TABLE STAGE โ€” every table gets one automatically, referenced as @%table_name.
LIST @%stage.products;

-- 3. NAMED INTERNAL STAGE โ€” Snowflake-managed storage, files uploaded via PUT (Section 24)
CREATE OR REPLACE STAGE stage.internal_csv_stage
    FILE_FORMAT = stage.ff_csv_standard;

-- 4. NAMED EXTERNAL STAGE โ€” points at YOUR cloud storage (S3/GCS/Azure Blob) โ€” see Sections 25-27
CREATE OR REPLACE STAGE stage.s3_external_stage
    URL = 's3://my-retail-bucket/incoming/'
    STORAGE_INTEGRATION = s3_retail_integration
    FILE_FORMAT = stage.ff_csv_standard;

SHOW STAGES IN SCHEMA stage;
LIST @stage.internal_csv_stage;
Hint
User and table stages exist automatically for every user/table โ€” no CREATE STAGE needed, they're just always there. Named stages (internal or external) are the ones you deliberately create, and they're what a real pipeline almost always uses because they're shared, reusable, and easy to reference by name.
24 ยท Data Loading

Loading Data from On-Prem (PUT Command)

Getting a file from your local machine or on-prem server into Snowflake is a two-step dance: PUT uploads the file to a stage, then COPY INTO loads it from the stage into a table. PUT only works from a client with local filesystem access โ€” SnowSQL (the CLI), the Snowflake driver in a script, or a notebook โ€” not from the Snowsight web worksheet directly.

put_and_load.sql
-- run from SnowSQL (or any client with local file access), not the web UI
-- uploads products_jan.csv from your local machine to the named internal stage
PUT file://C:/data/products_jan.csv @stage.internal_csv_stage
    AUTO_COMPRESS = TRUE
    OVERWRITE = TRUE;

-- confirm the file landed
LIST @stage.internal_csv_stage;

-- now load it into the table โ€” this part DOES run from Snowsight, same as any other SQL
COPY INTO stage.products
FROM @stage.internal_csv_stage/products_jan.csv.gz
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard)
ON_ERROR = 'CONTINUE';

-- PUT to the user stage instead, if you don't want a shared named stage for a one-off file
PUT file://C:/data/adhoc_upload.csv @~/adhoc/;
COPY INTO stage.products FROM @~/adhoc/adhoc_upload.csv.gz FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard);
Hint
PUT and GET (the download equivalent) are the two commands that touch your local filesystem โ€” everything else in Snowflake operates purely on data already inside a stage or table. AUTO_COMPRESS = TRUE gzips the file during upload by default, which is why the COPY INTO above references a .gz filename.
25 ยท Data Loading

Loading Data from AWS (S3)

For cloud storage, an external stage points directly at the bucket โ€” no PUT needed, since the files are already sitting in cloud storage. The secure, production-grade way to connect is a Storage Integration, which uses an IAM role instead of embedding AWS keys in your SQL.

The recommended way: Storage Integration + IAM role

aws_storage_integration.sql
-- Step 1: create the integration object, pointing at the IAM role you set up in AWS
CREATE OR REPLACE STORAGE INTEGRATION s3_retail_integration
    TYPE = EXTERNAL_STAGE
    STORAGE_PROVIDER = 'S3'
    ENABLED = TRUE
    STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/snowflake-retail-role'
    STORAGE_ALLOWED_LOCATIONS = ('s3://my-retail-bucket/incoming/', 's3://my-retail-bucket/archive/');

-- Step 2: Snowflake generates its own AWS IAM user + external ID for the trust relationship โ€”
-- copy these into the IAM role's trust policy back in AWS
DESC INTEGRATION s3_retail_integration;
-- look at STORAGE_AWS_IAM_USER_ARN and STORAGE_AWS_EXTERNAL_ID in the output

-- Step 3: create the external stage using the integration (no AWS keys anywhere in this SQL)
CREATE OR REPLACE STAGE stage.s3_external_stage
    URL = 's3://my-retail-bucket/incoming/'
    STORAGE_INTEGRATION = s3_retail_integration
    FILE_FORMAT = stage.ff_csv_standard;

LIST @stage.s3_external_stage;

COPY INTO stage.products
FROM @stage.s3_external_stage
PATTERN = '.*products.*\\.csv'
ON_ERROR = 'CONTINUE';

The quick-and-dirty way: inline credentials

aws_direct_credentials.sql
-- works, but embeds a secret key in the stage definition โ€” fine for a scratch/demo,
-- avoid in anything that resembles a real pipeline
CREATE OR REPLACE STAGE stage.s3_quick_stage
    URL = 's3://my-retail-bucket/incoming/'
    CREDENTIALS = (AWS_KEY_ID = 'AKIA...' AWS_SECRET_KEY = '...')
    FILE_FORMAT = stage.ff_csv_standard;
Hint
The IAM-role Storage Integration is the answer any interview or real project expects: no long-lived AWS secret keys stored in Snowflake, access is scoped to exactly the bucket paths in STORAGE_ALLOWED_LOCATIONS, and it can be revoked instantly by disabling the IAM role โ€” none of which is true of the inline-credentials approach.
26 ยท Data Loading

Loading Data from GCP (Cloud Storage)

The same Storage Integration pattern as AWS, using a Google service account instead of an IAM role.

gcp_storage_integration.sql
-- Step 1: create the integration
CREATE OR REPLACE STORAGE INTEGRATION gcs_retail_integration
    TYPE = EXTERNAL_STAGE
    STORAGE_PROVIDER = 'GCS'
    ENABLED = TRUE
    STORAGE_ALLOWED_LOCATIONS = ('gcs://my-retail-bucket/incoming/');

-- Step 2: Snowflake generates a Google service account for this integration โ€”
-- grant IT (not your own account) Storage Object Viewer on the bucket in GCP IAM
DESC INTEGRATION gcs_retail_integration;
-- look at STORAGE_GCP_SERVICE_ACCOUNT in the output, then grant that principal bucket access in GCP

-- Step 3: create the external stage
CREATE OR REPLACE STAGE stage.gcs_external_stage
    URL = 'gcs://my-retail-bucket/incoming/'
    STORAGE_INTEGRATION = gcs_retail_integration
    FILE_FORMAT = stage.ff_csv_standard;

LIST @stage.gcs_external_stage;

COPY INTO stage.products
FROM @stage.gcs_external_stage
PATTERN = '.*products.*\\.csv'
ON_ERROR = 'CONTINUE';
Hint
The direction of trust is easy to get backwards: Snowflake creates the service account identity, and you grant that identity access in your GCP project โ€” you never hand Snowflake your own GCP credentials.
27 ยท Data Loading

Loading Data from Azure (Blob Storage)

Same pattern again, this time trusting an Azure AD application tied to your Snowflake account.

azure_storage_integration.sql
-- Step 1: create the integration, referencing your Azure AD tenant
CREATE OR REPLACE STORAGE INTEGRATION azure_retail_integration
    TYPE = EXTERNAL_STAGE
    STORAGE_PROVIDER = 'AZURE'
    ENABLED = TRUE
    AZURE_TENANT_ID = ''
    STORAGE_ALLOWED_LOCATIONS = ('azure://myretailaccount.blob.core.windows.net/incoming/');

-- Step 2: Snowflake generates an Azure AD application (a "multi-tenant app") for this integration โ€”
-- grant IT Storage Blob Data Reader on the container in Azure
DESC INTEGRATION azure_retail_integration;
-- look at AZURE_CONSENT_URL (open it once to grant admin consent) and AZURE_MULTI_TENANT_APP_NAME

-- Step 3: create the external stage
CREATE OR REPLACE STAGE stage.azure_external_stage
    URL = 'azure://myretailaccount.blob.core.windows.net/incoming/'
    STORAGE_INTEGRATION = azure_retail_integration
    FILE_FORMAT = stage.ff_csv_standard;

LIST @stage.azure_external_stage;

COPY INTO stage.products
FROM @stage.azure_external_stage
PATTERN = '.*products.*\\.csv'
ON_ERROR = 'CONTINUE';
Hint
Across all three clouds (Sections 25-27) the shape is identical: CREATE STORAGE INTEGRATION โ†’ DESC INTEGRATION to get the identity Snowflake generated โ†’ grant that identity access on the cloud side โ†’ CREATE STAGE ... STORAGE_INTEGRATION = .... Learn the pattern once, and the provider-specific details (IAM role vs. service account vs. AD app) are just what changes in step 2.
28 ยท Data Loading

COPY INTO Options

COPY INTO is the workhorse of every load in Sections 24-27 โ€” the options below control error tolerance, duplicate-load protection, file filtering, and dry-run validation.

Error handling: ON_ERROR

on_error.sql
-- ABORT_STATEMENT (the default): one bad row fails the entire load, nothing is committed
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) ON_ERROR = 'ABORT_STATEMENT';

-- CONTINUE: skip bad rows, load everything else, report the errors afterward
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) ON_ERROR = 'CONTINUE';

-- SKIP_FILE: if a file has even one bad row, skip that WHOLE file, but still load other files
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) ON_ERROR = 'SKIP_FILE';

-- SKIP_FILE_N / SKIP_FILE_N%: tolerate up to N (or N%) bad rows per file before skipping it
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) ON_ERROR = 'SKIP_FILE_10';
Hint
Production pipelines almost always use CONTINUE or a SKIP_FILE_N threshold, paired with checking the load results afterward โ€” the default ABORT_STATEMENT means a single malformed row in a million-row file blocks the entire batch.

Preventing duplicate loads, filtering files, dry runs

copy_options.sql
-- FORCE: reload files even if Snowflake's load metadata thinks they were already loaded
-- (by default, COPY INTO silently skips files it has already loaded from this stage/table pair)
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) FORCE = TRUE;

-- PATTERN: only load files matching a regex
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) PATTERN = '.*products_2026.*\\.csv';

-- PURGE: delete source files from the stage after a successful load (careful with this one)
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) PURGE = TRUE;

-- VALIDATION_MODE: dry run โ€” parses and validates without loading a single row
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard) VALIDATION_MODE = 'RETURN_ERRORS';

-- MATCH_BY_COLUMN_NAME: map source columns to target columns by name instead of position โ€”
-- essential when loading semi-structured formats (JSON/Parquet/Avro) into a relational table
COPY INTO stage.products FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_parquet_standard) MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE;

-- checking what actually happened after a load
SELECT * FROM TABLE(INFORMATION_SCHEMA.COPY_HISTORY(
    TABLE_NAME => 'stage.products', START_TIME => DATEADD(HOUR, -24, CURRENT_TIMESTAMP())
));
Hint
Run with VALIDATION_MODE = 'RETURN_ERRORS' first against a new file source before ever loading for real โ€” it tells you exactly which rows would fail and why, with zero risk of a partial load.
29 ยท Data Loading

Unloading Data

The reverse direction โ€” COPY INTO <stage> (instead of COPY INTO <table>) exports query results or a whole table out to files, which then either stay in cloud storage or get pulled to your machine with GET.

unloading.sql
-- unload a full table to an internal stage as CSV
COPY INTO @stage.internal_csv_stage/exports/products_export
FROM stage.products
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard)
HEADER = TRUE
OVERWRITE = TRUE;

-- unload the RESULT OF A QUERY, not a whole table โ€” just as common in practice
COPY INTO @stage.internal_csv_stage/exports/top_products
FROM (SELECT product_name, category, price FROM stage.products ORDER BY price DESC LIMIT 20)
FILE_FORMAT = (FORMAT_NAME = stage.ff_csv_standard)
HEADER = TRUE
SINGLE = TRUE;                     -- force exactly one output file instead of Snowflake's default parallel split

-- unload straight to an external stage (S3/GCS/Azure) โ€” common for handing data to another team/tool
COPY INTO @stage.s3_external_stage/exports/
FROM stage.products
FILE_FORMAT = (TYPE = PARQUET)
MAX_FILE_SIZE = 104857600;          -- 100 MB per output file before Snowflake starts a new one

LIST @stage.internal_csv_stage/exports/;

-- GET pulls files from a stage down to your LOCAL machine (the mirror image of PUT, Section 24) โ€”
-- like PUT, this only runs from SnowSQL or another client with local filesystem access
GET @stage.internal_csv_stage/exports/products_export_0_0_0.csv.gz file://C:/data/exports/;
Hint
By default Snowflake splits an unload across multiple files in parallel (for speed on large tables) rather than producing one tidy CSV โ€” if a downstream tool needs exactly one file, add SINGLE = TRUE, at the cost of losing that parallelism.
30 ยท Data Loading

Semi-Structured Data (JSON, Avro, Parquet)

Snowflake stores semi-structured data natively in a VARIANT column โ€” no upfront schema needed. Loading is the easy part (Sections 24-28 already cover it, just point FILE_FORMAT at JSON/AVRO/PARQUET); querying nested structure is the part worth practicing.

Loading JSON into a VARIANT column

load_json.sql
CREATE OR REPLACE TABLE stage.products_raw_json (raw_data VARIANT);

-- PARSE_JSON turns a JSON-formatted string into a queryable VARIANT value โ€”
-- this simulates what COPY INTO does automatically when loading a .json file
INSERT INTO stage.products_raw_json
SELECT PARSE_JSON('{
  "product_id": 1,
  "product_name": "Dell Laptop",
  "category": "Electronics",
  "price": 800.50,
  "tags": ["computers", "electronics", "work-from-home"],
  "vendor": {"id": 101, "name": "LG Electronics", "country": "South Korea"}
}');

-- the real load, once a JSON file is staged (see Sections 24-27 for the stage part)
COPY INTO stage.products_raw_json
FROM @stage.s3_external_stage
FILE_FORMAT = (FORMAT_NAME = stage.ff_json_standard)
ON_ERROR = 'CONTINUE';

Querying nested structure โ€” dot/colon notation & casting

query_json.sql
-- colon notation reaches into an object; :: casts the VARIANT out to a real type
SELECT
    raw_data:product_id::INT           AS product_id,
    raw_data:product_name::STRING      AS product_name,
    raw_data:price::NUMBER(10,2)       AS price,
    raw_data:vendor:name::STRING       AS vendor_name,          -- nested object
    raw_data:tags[0]::STRING           AS first_tag              -- array element by index
FROM stage.products_raw_json;

-- without a :: cast, you get the raw VARIANT back (still JSON-quoted) โ€” usually not what you want
SELECT raw_data:product_name FROM stage.products_raw_json;         -- "Dell Laptop"  (VARIANT)
SELECT raw_data:product_name::STRING FROM stage.products_raw_json; -- Dell Laptop    (STRING)

FLATTEN โ€” expanding a JSON array into rows

flatten.sql
-- one row per tag, per product โ€” this is how you turn a nested array into a proper relational shape
SELECT
    raw_data:product_id::INT      AS product_id,
    raw_data:product_name::STRING AS product_name,
    tag.value::STRING             AS tag
FROM stage.products_raw_json,
     LATERAL FLATTEN(input => raw_data:tags) tag;
Hint
FLATTEN is Snowflake's UNNEST โ€” every element of the array becomes its own row, with tag.value holding that element and tag.index holding its position. Pair it with a LATERAL join (as above) whenever you need to flatten an array while keeping the parent row's other columns alongside it.

Building JSON back up (the reverse direction)

build_json.sql
-- construct a JSON object from relational columns โ€” handy when unloading for an API consumer
SELECT OBJECT_CONSTRUCT(
    'product_id', product_id,
    'product_name', product_name,
    'category', category,
    'price', price
) AS product_json
FROM stage.products;

-- aggregate a whole table into one JSON array
SELECT ARRAY_AGG(OBJECT_CONSTRUCT('product_id', product_id, 'product_name', product_name)) AS all_products
FROM stage.products;

Avro & Parquet โ€” same idea, different file format

Avro and Parquet load into a VARIANT column exactly like JSON does (just change FILE_FORMAT to TYPE = AVRO or TYPE = PARQUET) โ€” but since both formats are already columnar/typed at the source, Snowflake can also map them straight into normal typed columns using MATCH_BY_COLUMN_NAME from Section 28, skipping VARIANT entirely when the schemas line up.

load_parquet_typed.sql
-- Parquet loaded directly into typed columns, matched by name instead of landing in VARIANT
COPY INTO stage.products (product_id, product_name, category, price)
FROM @stage.s3_external_stage
FILE_FORMAT = (TYPE = PARQUET)
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE;
31 ยท Snowflake Platform

Task

A Task runs SQL โ€” often a CALL to a procedure โ€” on a schedule, or in response to another task finishing. This is how the pipeline in Section 39 gets automated instead of run by hand.

tasks.sql
-- a task on a cron schedule (daily at 2 AM UTC)
CREATE OR REPLACE TASK dwh.task_load_dim_product
    WAREHOUSE = COMPUTE_WH
    SCHEDULE = 'USING CRON 0 2 * * * UTC'
AS
    CALL dwh.sp_load_dim_product();

-- tasks are created SUSPENDED โ€” you must explicitly resume them to activate the schedule
ALTER TASK dwh.task_load_dim_product RESUME;

-- a child task that runs only AFTER the parent succeeds โ€” builds a task tree/DAG
CREATE OR REPLACE TASK dwh.task_refresh_data_mart
    WAREHOUSE = COMPUTE_WH
    AFTER dwh.task_load_dim_product
AS
    CALL dwh.sp_refresh_data_mart();

ALTER TASK dwh.task_refresh_data_mart RESUME;

-- run history and current state
SELECT * FROM TABLE(INFORMATION_SCHEMA.TASK_HISTORY()) ORDER BY scheduled_time DESC LIMIT 20;
SHOW TASKS IN SCHEMA dwh;

-- trigger a run right now, without waiting for the schedule
EXECUTE TASK dwh.task_load_dim_product;

-- turn a task off
ALTER TASK dwh.task_load_dim_product SUSPEND;
Hint
In a task tree, only the root task carries a SCHEDULE โ€” child tasks use AFTER <parent_task_name> instead, and every task in the tree (root included) has to be individually RESUMEd before any of it actually runs.
32 ยท Snowflake Platform

Stream

A stream tracks every insert/update/delete on a table since it was last "consumed" โ€” Snowflake's native change-data-capture mechanism, no external tooling required.

streams.sql
CREATE OR REPLACE STREAM stage.products_stream ON TABLE stage.products;

-- make some changes
UPDATE stage.products SET stock = stock - 5 WHERE product_id = 1;
INSERT INTO stage.products (product_id, product_name, category, price, stock)
VALUES (999, 'Test Gadget', 'Electronics', 49.99, 10);

-- the stream shows only what changed, plus metadata about how
SELECT product_id, product_name, stock,
       METADATA$ACTION AS change_type,       -- INSERT or DELETE
       METADATA$ISUPDATE AS is_update         -- TRUE if this INSERT/DELETE pair is really an UPDATE
FROM stage.products_stream;

-- "consuming" the stream: reading it inside a DML statement advances its offset,
-- so the next SELECT from the stream only shows changes AFTER this point
CREATE OR REPLACE TABLE dwh.product_change_log (
    product_id INT, product_name STRING, change_type STRING, logged_at TIMESTAMP_NTZ
);

INSERT INTO dwh.product_change_log
SELECT product_id, product_name, METADATA$ACTION, CURRENT_TIMESTAMP()
FROM stage.products_stream;

-- now empty โ€” the stream has been consumed and its offset moved forward
SELECT * FROM stage.products_stream;
Hint
A plain SELECT * FROM stream doesn't consume it โ€” only reading the stream as part of a DML statement (an INSERT ... SELECT FROM stream, a MERGE ... USING stream) advances its offset. You can query a stream repeatedly for free before deciding how to process it.
33 ยท Snowflake Platform

Warehouse

A virtual warehouse is Snowflake's compute layer โ€” completely separate from storage. Every query, load, and task needs one running to actually execute; sizing, suspension, and multi-clustering are the main levers you control.

Creating & sizing a warehouse

warehouse_basics.sql
CREATE OR REPLACE WAREHOUSE etl_wh
    WAREHOUSE_SIZE = 'SMALL'         -- XSMALL, SMALL, MEDIUM, LARGE, XLARGE, ... 6XLARGE
    AUTO_SUSPEND = 60                -- seconds idle before it suspends (stop paying for idle compute)
    AUTO_RESUME = TRUE               -- automatically wake up when a query needs it
    INITIALLY_SUSPENDED = TRUE;

USE WAREHOUSE etl_wh;

-- resize up for a heavy batch job, then back down afterward
ALTER WAREHOUSE etl_wh SET WAREHOUSE_SIZE = 'LARGE';
-- ... run the heavy ETL ...
ALTER WAREHOUSE etl_wh SET WAREHOUSE_SIZE = 'X-SMALL';

SHOW WAREHOUSES;
SELECT * FROM TABLE(INFORMATION_SCHEMA.WAREHOUSE_LOAD_HISTORY(
    DATE_RANGE_START => DATEADD('hour', -24, CURRENT_TIMESTAMP())
));

Multi-cluster warehouses โ€” scaling for concurrency

multi_cluster.sql
ALTER WAREHOUSE etl_wh SET
    MIN_CLUSTER_COUNT = 1,
    MAX_CLUSTER_COUNT = 3,
    SCALING_POLICY = 'STANDARD';       -- or 'ECONOMY' to favor cost over instant scale-out
Hint
Scale up (bigger warehouse size) when one query is slow because it's scanning a lot of data. Scale out (more clusters via MAX_CLUSTER_COUNT) when many small/medium queries are queuing up behind each other from concurrent users. Sizing up doesn't fix a queuing problem, and scaling out doesn't speed up a single slow query โ€” matching the lever to the actual symptom matters.
34 ยท Snowflake Platform

User & Roles (RBAC)

Snowflake grants access purely through roles โ€” never directly to a user. A user is assigned one or more roles; a role is granted privileges on objects; roles can inherit from other roles, forming a hierarchy.

System-defined roles

Every account ships with a default hierarchy, roughly from most to least powerful: ACCOUNTADMIN โ†’ SECURITYADMIN / SYSADMIN โ†’ USERADMIN โ†’ PUBLIC (which every user implicitly has).

Creating a custom role hierarchy

roles.sql
-- create roles for the retail_dwh project
CREATE ROLE IF NOT EXISTS retail_etl_role;
CREATE ROLE IF NOT EXISTS retail_analyst_role;

-- roles form a hierarchy too โ€” SYSADMIN should sit above your custom roles
-- so admins can still manage objects created under them
GRANT ROLE retail_etl_role TO ROLE SYSADMIN;
GRANT ROLE retail_analyst_role TO ROLE SYSADMIN;

-- grant object privileges to the ETL role: full read/write on stage & dwh
GRANT USAGE ON DATABASE retail_dwh TO ROLE retail_etl_role;
GRANT USAGE ON SCHEMA retail_dwh.stage TO ROLE retail_etl_role;
GRANT USAGE ON SCHEMA retail_dwh.dwh TO ROLE retail_etl_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA retail_dwh.stage TO ROLE retail_etl_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA retail_dwh.dwh TO ROLE retail_etl_role;
GRANT USAGE ON WAREHOUSE etl_wh TO ROLE retail_etl_role;

-- the analyst role only needs read access to the reporting layer
GRANT USAGE ON SCHEMA retail_dwh.data_mart TO ROLE retail_analyst_role;
GRANT SELECT ON ALL TABLES IN SCHEMA retail_dwh.data_mart TO ROLE retail_analyst_role;
GRANT USAGE ON WAREHOUSE etl_wh TO ROLE retail_analyst_role;

-- FUTURE grants: automatically extend the same privilege to tables created LATER, not just today
GRANT SELECT ON FUTURE TABLES IN SCHEMA retail_dwh.data_mart TO ROLE retail_analyst_role;

-- create users and assign roles
CREATE USER IF NOT EXISTS etl_service_user
    PASSWORD = 'TemporaryStrongPassword123!'
    DEFAULT_ROLE = retail_etl_role
    DEFAULT_WAREHOUSE = etl_wh
    MUST_CHANGE_PASSWORD = TRUE;

GRANT ROLE retail_etl_role TO USER etl_service_user;

-- inspecting the setup
SHOW ROLES;
SHOW GRANTS TO ROLE retail_analyst_role;
SHOW GRANTS OF ROLE retail_etl_role;

-- switching role mid-session (a user can hold several)
USE ROLE retail_analyst_role;
Hint
The FUTURE TABLES grant is easy to forget and a common real-world gap: without it, the analyst role would need a fresh GRANT SELECT issued every single time your ETL creates a new data mart table โ€” FUTURE grants apply automatically going forward, closing that gap.
35 ยท Snowflake Platform

Time Travel

Every table keeps a window of historical versions (1 day by default on Standard edition, up to 90 on Enterprise+) โ€” query, clone, or restore from any point inside that window.

time_travel.sql
-- see the table exactly as it was before a specific statement
SELECT * FROM stage.products BEFORE (STATEMENT => '<query_id_of_the_bad_update>');

-- or by a relative offset in time
SELECT * FROM stage.products AT (OFFSET => -60*30);   -- 30 minutes ago

-- or by an absolute timestamp
SELECT * FROM stage.products AT (TIMESTAMP => '2026-07-20 10:00:00'::TIMESTAMP_NTZ);

-- restore lost rows by comparing current state to a past snapshot
INSERT INTO stage.products
SELECT * FROM stage.products BEFORE (STATEMENT => '<query_id>')
WHERE product_id NOT IN (SELECT product_id FROM stage.products);

-- accidentally dropped a whole table? bring it back, no backup needed
DROP TABLE stage.vendors;
UNDROP TABLE stage.vendors;

-- how long Time Travel is retained for a table (in days)
ALTER TABLE stage.products SET DATA_RETENTION_TIME_IN_DAYS = 7;
Hint
Find the exact <query_id> to travel back before by checking Query History in Snowsight, or querying TABLE(INFORMATION_SCHEMA.QUERY_HISTORY()) โ€” BEFORE (STATEMENT => ...) needs that ID, not a timestamp.
36 ยท Snowflake Platform

Data Masking

A masking policy hides sensitive column values from roles that shouldn't see them raw โ€” the same query returns real data or masked data depending purely on who's running it, with no application-side logic needed.

dynamic_data_masking.sql
-- mask everything except the domain, unless you're an admin
CREATE OR REPLACE MASKING POLICY dwh.mask_email AS (val STRING) RETURNS STRING ->
    CASE
        WHEN CURRENT_ROLE() IN ('ACCOUNTADMIN', 'RETAIL_ETL_ROLE') THEN val
        ELSE REGEXP_REPLACE(val, '^.*@', '****@')
    END;

-- fully redact phone numbers for everyone except the ETL role
CREATE OR REPLACE MASKING POLICY dwh.mask_phone AS (val STRING) RETURNS STRING ->
    CASE
        WHEN CURRENT_ROLE() = 'RETAIL_ETL_ROLE' THEN val
        ELSE 'XXX-XXX-XXXX'
    END;

-- attach the policy to a column โ€” masking is enforced from this point on, automatically
ALTER TABLE dwh.dim_customer MODIFY COLUMN email SET MASKING POLICY dwh.mask_email;
ALTER TABLE dwh.dim_customer MODIFY COLUMN phone SET MASKING POLICY dwh.mask_phone;

-- same SELECT, different results depending on who runs it
USE ROLE retail_analyst_role;
SELECT customer_id, email, phone FROM dwh.dim_customer LIMIT 5;   -- masked

USE ROLE retail_etl_role;
SELECT customer_id, email, phone FROM dwh.dim_customer LIMIT 5;   -- unmasked

-- removing a policy
ALTER TABLE dwh.dim_customer MODIFY COLUMN email UNSET MASKING POLICY;
Hint
This is called dynamic data masking specifically because nothing is rewritten in storage โ€” the exact same underlying bytes exist either way, and Snowflake decides what to show at query time based on CURRENT_ROLE(). A related but different tool is a Row Access Policy, which hides entire rows (e.g., a sales rep only seeing their own region's rows) rather than masking individual column values.
37 ยท Snowflake Platform

Partitioning & Clustering

Snowflake automatically slices every table into micro-partitions (roughly 50-500MB of compressed data each) the moment data is loaded โ€” there's no manual PARTITION BY to declare like in some other databases. A clustering key is the one lever you do control: a hint about which column(s) rows should be physically co-located by.

Why this matters: pruning

Every micro-partition stores metadata (min/max values per column). A query with a selective WHERE clause can skip ("prune") entire micro-partitions without reading them, purely from that metadata โ€” clustering makes pruning dramatically more effective by keeping related values physically together.

Clustering keys

clustering.sql
-- on a large fact table, queries that always filter/group by sale_date benefit from
-- clustering on that column โ€” it keeps rows with similar dates physically co-located
ALTER TABLE dwh.fact_sales CLUSTER BY (sale_date);

-- a compound clustering key, when queries commonly filter on both together
ALTER TABLE dwh.fact_sales CLUSTER BY (sale_date, warehouse_id);

-- check how well-clustered a table currently is (0 = perfectly clustered, higher = worse)
SELECT SYSTEM$CLUSTERING_INFORMATION('dwh.fact_sales', '(sale_date)');

-- drop clustering if it's no longer earning its automatic-reclustering cost
ALTER TABLE dwh.fact_sales DROP CLUSTERING KEY;
Hint
Clustering only pays off on genuinely large tables (Snowflake's own guidance is multi-terabyte) where queries consistently filter on the clustering column โ€” Snowflake automatically re-clusters in the background as new data lands, and that background work costs credits. On a table the size of this practice dataset, it's pure overhead: know the concept, don't apply it here.
38 ยท Snowflake Platform

Table Types (Including Iceberg)

Five distinct table types, each trading off Time Travel/Fail-safe retention, cost, and storage ownership differently.

Permanent, Transient & Temporary

table_types_core.sql
-- PERMANENT (the default): full Time Travel (up to 90 days on Enterprise+) + 7-day Fail-safe.
-- Fail-safe is Snowflake-managed disaster recovery AFTER Time Travel expires โ€” you can't query it
-- yourself, only Snowflake support can restore from it. This is what you want for real DWH tables.
CREATE OR REPLACE TABLE dwh.fact_sales_permanent (sale_id INT, sales_amount DECIMAL(12,2));

-- TRANSIENT: Time Travel capped at 1 day, NO Fail-safe at all โ€” cheaper storage, but truly gone
-- once Time Travel expires. Good fit for staging tables you can always reload from source.
CREATE OR REPLACE TRANSIENT TABLE stage.products_transient (
    product_id INT, product_name VARCHAR(100), price DECIMAL(10,2)
);

-- TEMPORARY: exists only for the current session โ€” gone the moment you disconnect, not visible
-- to any other session even while it exists. Perfect for scratch work during ETL development.
CREATE OR REPLACE TEMPORARY TABLE scratch_calculation (id INT, computed_value NUMBER);

External Tables โ€” query files in place, without loading

external_table.sql
-- an EXTERNAL TABLE reads directly from files sitting in cloud storage โ€” no COPY INTO,
-- no data actually stored inside Snowflake, just metadata pointing at the files
CREATE OR REPLACE EXTERNAL TABLE stage.products_external (
    product_id   INT AS (VALUE:product_id::INT),
    product_name STRING AS (VALUE:product_name::STRING),
    price        DECIMAL(10,2) AS (VALUE:price::DECIMAL(10,2))
)
LOCATION = @stage.s3_external_stage
FILE_FORMAT = (TYPE = PARQUET)
AUTO_REFRESH = TRUE;                 -- automatically picks up new files landing in that location

SELECT * FROM stage.products_external WHERE price > 500;

Iceberg Tables โ€” Snowflake as one engine among several

An Apache Iceberg table stores data in open Parquet files with an Iceberg-format metadata layer, in storage you control (not Snowflake's internal storage) โ€” the same table can be read and written by Snowflake, Spark, and other Iceberg-compatible engines simultaneously, avoiding vendor lock-in on the storage layer.

iceberg_table.sql
-- Snowflake as the catalog + write engine, storage lives in your own external volume
CREATE OR REPLACE ICEBERG TABLE dwh.fact_sales_iceberg (
    sale_id INT, product_id INT, customer_id INT, sale_date DATE, sales_amount DECIMAL(12,2)
)
CATALOG = 'SNOWFLAKE'
EXTERNAL_VOLUME = 'retail_iceberg_volume'
BASE_LOCATION = 'fact_sales/';

INSERT INTO dwh.fact_sales_iceberg
SELECT sale_id, product_id, customer_id, sale_date, sales_amount FROM dwh.fact_sales;

-- looks and queries exactly like a normal table from inside Snowflake
SELECT * FROM dwh.fact_sales_iceberg WHERE sale_date >= '2026-01-01';
Hint
The interview-ready one-liner: a normal Snowflake table is stored in Snowflake's proprietary micro-partition format inside Snowflake's own storage; an Iceberg table is stored as open Parquet + Iceberg metadata in storage you own, which is what makes it queryable from other engines too. Everything else about writing SQL against it is deliberately the same.
39 ยท Capstone

Real Project (End-to-End)

Everything from Sections 01-38 assembled into the actual deliverable your project spec asks for: stored procedures that cleanse Stage data, load the DWH dimensions and fact, then refresh the Data Mart โ€” runnable individually or chained by an orchestrator, and hookable to a Task tree (Section 31) for full automation.

Step 1 โ€” load dimensions (dedupe, cleanse, SCD1 upsert)

01_load_dimensions.sql
CREATE OR REPLACE PROCEDURE dwh.sp_load_dim_product()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    MERGE INTO dwh.dim_product AS target
    USING (
        SELECT product_id, TRIM(product_name) AS product_name, category, sub_category, price, stock, wid, vendor_id
        FROM stage.products
        QUALIFY ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at DESC) = 1
    ) AS source
    ON target.product_id = source.product_id
    WHEN MATCHED THEN
        UPDATE SET target.product_name = source.product_name, target.category = source.category,
                   target.sub_category = source.sub_category, target.price = source.price,
                   target.stock = source.stock, target.warehouse_id = source.wid, target.vendor_id = source.vendor_id
    WHEN NOT MATCHED THEN
        INSERT (product_id, product_name, category, sub_category, price, stock, warehouse_id, vendor_id)
        VALUES (source.product_id, source.product_name, source.category, source.sub_category,
                source.price, source.stock, source.wid, source.vendor_id);
    RETURN 'dim_product loaded: ' || SQLROWCOUNT::STRING || ' row(s) affected';
END;
$$;

CREATE OR REPLACE PROCEDURE dwh.sp_load_dim_vendor()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    MERGE INTO dwh.dim_vendor AS target
    USING stage.vendors AS source
    ON target.vendor_id = source.vendor_id
    WHEN MATCHED THEN
        UPDATE SET target.vendor_name = source.vendor_name, target.country = source.country,
                   target.contact_email = source.contact_email, target.phone = source.phone
    WHEN NOT MATCHED THEN
        INSERT (vendor_id, vendor_name, country, contact_email, phone)
        VALUES (source.vendor_id, source.vendor_name, source.country, source.contact_email, source.phone);
    RETURN 'dim_vendor loaded';
END;
$$;

CREATE OR REPLACE PROCEDURE dwh.sp_load_dim_warehouse()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    MERGE INTO dwh.dim_warehouse AS target
    USING stage.warehouse AS source
    ON target.warehouse_id = source.wid
    WHEN MATCHED THEN
        UPDATE SET target.warehouse_name = source.warehouse_name, target.city = source.city,
                   target.state = source.state, target.country = source.country, target.capacity = source.capacity
    WHEN NOT MATCHED THEN
        INSERT (warehouse_id, warehouse_name, location, city, state, country, pincode, capacity)
        VALUES (source.wid, source.warehouse_name, source.location, source.city, source.state,
                source.country, source.pincode, source.capacity);
    RETURN 'dim_warehouse loaded';
END;
$$;

CREATE OR REPLACE PROCEDURE dwh.sp_load_dim_customer()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    MERGE INTO dwh.dim_customer AS target
    USING (
        SELECT customer_id, customer_name, email, address, country, gender
        FROM stage.customers
        QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) = 1
    ) AS source
    ON target.customer_id = source.customer_id
    WHEN MATCHED THEN
        UPDATE SET target.first_name = SPLIT_PART(source.customer_name, ' ', 1),
                   target.last_name  = SPLIT_PART(source.customer_name, ' ', 2),
                   target.email = TRIM(LOWER(source.email)), target.address = source.address,
                   target.country = source.country, target.gender = source.gender
    WHEN NOT MATCHED THEN
        INSERT (customer_id, first_name, last_name, email, address, country, gender, is_current, effective_date)
        VALUES (source.customer_id, SPLIT_PART(source.customer_name, ' ', 1), SPLIT_PART(source.customer_name, ' ', 2),
                TRIM(LOWER(source.email)), source.address, source.country, source.gender, TRUE, CURRENT_DATE());
    RETURN 'dim_customer loaded';
END;
$$;

Step 2 โ€” load the fact table (with data-quality filtering)

02_load_fact.sql
CREATE OR REPLACE PROCEDURE dwh.sp_load_fact_sales()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    MERGE INTO dwh.fact_sales AS target
    USING (
        SELECT
            s.sale_id, s.customer_id, s.product_id, p.vendor_id, p.wid AS warehouse_id, s.address_id,
            s.sale_date, s.quantity_sold, s.quantity_sold * p.price AS sales_amount
        FROM stage.sales s
        JOIN stage.products p ON s.product_id = p.product_id
        WHERE s.quantity_sold > 0                     -- drop the negative-quantity bad row
          AND s.sale_date <= CURRENT_DATE()            -- drop the future-dated bad row
    ) AS source
    ON target.sale_id = source.sale_id
    WHEN MATCHED THEN
        UPDATE SET target.quantity_sold = source.quantity_sold, target.sales_amount = source.sales_amount
    WHEN NOT MATCHED THEN
        INSERT (sale_id, customer_id, product_id, vendor_id, warehouse_id, address_id, sale_date, quantity_sold, sales_amount)
        VALUES (source.sale_id, source.customer_id, source.product_id, source.vendor_id, source.warehouse_id,
                source.address_id, source.sale_date, source.quantity_sold, source.sales_amount);
    RETURN 'fact_sales loaded: ' || SQLROWCOUNT::STRING || ' row(s) affected';
END;
$$;

Step 3 โ€” refresh the data mart

03_refresh_data_mart.sql
CREATE OR REPLACE PROCEDURE dwh.sp_refresh_data_mart()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    TRUNCATE TABLE data_mart.dm_sales_summary;
    INSERT INTO data_mart.dm_sales_summary
    SELECT sale_date, COUNT(DISTINCT sale_id), SUM(quantity_sold), SUM(sales_amount)
    FROM dwh.fact_sales GROUP BY sale_date;

    TRUNCATE TABLE data_mart.dm_customer_sales;
    INSERT INTO data_mart.dm_customer_sales
    SELECT c.customer_id, c.first_name || ' ' || c.last_name, COUNT(DISTINCT f.sale_id),
           SUM(f.quantity_sold), SUM(f.sales_amount)
    FROM dwh.fact_sales f
    JOIN dwh.dim_customer c ON f.customer_id = c.customer_id AND c.is_current = TRUE
    GROUP BY c.customer_id, c.first_name, c.last_name;

    TRUNCATE TABLE data_mart.dm_product_sales;
    INSERT INTO data_mart.dm_product_sales
    SELECT p.product_id, p.product_name, p.category, COUNT(DISTINCT f.sale_id), SUM(f.quantity_sold), SUM(f.sales_amount)
    FROM dwh.fact_sales f
    JOIN dwh.dim_product p ON f.product_id = p.product_id
    GROUP BY p.product_id, p.product_name, p.category;

    TRUNCATE TABLE data_mart.dm_top_products;
    INSERT INTO data_mart.dm_top_products
    SELECT RANK() OVER (ORDER BY SUM(f.sales_amount) DESC), p.product_name, SUM(f.sales_amount)
    FROM dwh.fact_sales f JOIN dwh.dim_product p ON f.product_id = p.product_id
    GROUP BY p.product_name
    QUALIFY RANK() OVER (ORDER BY SUM(f.sales_amount) DESC) <= 10;

    RETURN 'data_mart refreshed';
END;
$$;

Step 4 โ€” the orchestrator (what a Task actually calls)

04_run_pipeline.sql
CREATE OR REPLACE PROCEDURE dwh.sp_run_etl_pipeline()
RETURNS STRING LANGUAGE SQL AS
$$
BEGIN
    CALL dwh.sp_load_dim_vendor();
    CALL dwh.sp_load_dim_warehouse();
    CALL dwh.sp_load_dim_customer();
    CALL dwh.sp_load_dim_product();
    CALL dwh.sp_load_fact_sales();
    CALL dwh.sp_refresh_data_mart();
    RETURN 'Full retail_dwh pipeline completed at ' || CURRENT_TIMESTAMP()::STRING;
END;
$$;

-- one call runs the entire Stage -> DWH -> Data Mart pipeline
CALL dwh.sp_run_etl_pipeline();

SELECT * FROM data_mart.dm_top_products ORDER BY rank_no;
Hint
This is the procedure Section 31's root TASK should point at to fully automate the pipeline on a schedule. Order matters: dimensions load before the fact (foreign keys need to exist first), and the fact loads before the data mart (marts aggregate from the fact) โ€” the same dependency logic a task tree's AFTER clauses would encode.

What you've built

Trace it back through this guide: messy Stage data (01) โ†’ cleansed with string/date functions and QUALIFY-based dedup (10, 11) โ†’ validated against constraints you now know aren't enforced by the database (12) โ†’ joined and aggregated into dimensions (13) โ†’ upserted with MERGE and SCD logic (16) โ†’ wrapped in procedures with real control flow and error handling (17-21) โ†’ loadable from any source system (23-30) โ†’ automated with Streams and Tasks (31-32) โ†’ secured with RBAC and masking (34, 36) โ†’ and finally sitting in the right table type for the job (38). That's the whole data engineering lifecycle, in one project.