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.
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
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
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
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
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
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
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
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
-- 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.
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.
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
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.DML
Data Manipulation Language โ INSERT, UPDATE, DELETE: the statements that change the rows inside a table you've already defined.
-- 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
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.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
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
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
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.WHERE
Row-level filtering โ comparison operators, logical operators, set membership, ranges, pattern matching, and NULL checks.
Comparison & logical operators
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
-- % = 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.ORDER BY
Sorting the final result set โ including NULL placement and pagination with LIMIT/OFFSET.
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
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.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.
-- 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).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.
-- 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
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.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.
-- 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
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.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
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
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
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
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
-- 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.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
-- 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
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
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
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
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
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
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
-- 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
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.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.
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
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
-- 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
-- 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
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
-- 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
UNION ALL unless you specifically need de-duplication โ plain UNION does real work (sorting/hashing every row) to find and remove duplicates.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
-- 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
-- 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
Subqueries in FROM, JOIN & SELECT
-- 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;
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
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
-- 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
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.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: 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
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
LISTAGG with WITHIN GROUP (ORDER BY ...) โ without it, item order in the concatenated string is unspecified and can vary between runs.MERGE (upsert)
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
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
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
-- 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
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.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.
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
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.Variables
Declaring, defaulting, and assigning local variables inside a script โ plus pulling a query result straight into one with SELECT ... INTO.
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.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
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
-- 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
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
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.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.
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
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.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.
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
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.UDF
User-Defined Functions โ SQL (single expression), Scripting (procedural), and Table (returns a whole result set, called with TABLE(...)).
SQL UDF
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
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)
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
TABLE(...) in the FROM clause, not in SELECT, since it produces a result set rather than a single scalar value.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
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
-- 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
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.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.
-- 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.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
-- 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
-- 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
STORAGE_ALLOWED_LOCATIONS, and it can be revoked instantly by disabling the IAM role โ none of which is true of the inline-credentials approach.Loading Data from GCP (Cloud Storage)
The same Storage Integration pattern as AWS, using a Google service account instead of an IAM role.
-- 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
Loading Data from Azure (Blob Storage)
Same pattern again, this time trusting an Azure AD application tied to your Snowflake account.
-- 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
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.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
-- 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
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
-- 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
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.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.
-- 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
SINGLE = TRUE, at the cost of losing that parallelism.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
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
-- 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
-- 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)
-- 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.
-- 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;
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.
-- 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
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.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.
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
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.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
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
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
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.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
-- 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
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.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.
-- 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
<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.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.
-- 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
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.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
-- 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
Table Types (Including Iceberg)
Five distinct table types, each trading off Time Travel/Fail-safe retention, cost, and storage ownership differently.
Permanent, Transient & Temporary
-- 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
-- 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.
-- 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
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)
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)
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
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)
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
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.