20 Snowflake Projects for Beginners and Intermediate Learners

Brolly Academy | Snowflake Projects

Build Snowflake projects step by step—from your first SQL report to customer analytics, JSON processing and data engineering workflows.

Explore 20 practical tutorials with synthetic datasets, SQL examples, expected outputs, validation checks and portfolio guidance. Start with the sales-data quality walkthrough, then choose the skill you want to build next.

20 project tutorialsSQL + sample dataPortfolio practice

Share

Snowflake Projects at Brolly Academy: practical SQL, data engineering and analytics projects

Table of Contents

Compare 20 Snowflake Project Ideas

Use this table to choose a project based on your current SQL skills and the output you want to build.

#ProjectLevelFinal deliverable
1Sales-data quality reportBeginnerClean orders, rejected records, and daily paid-order totals
2Customer repeat-purchase analysisBeginnerCustomer summary and repeat-buyer measure
3Support-ticket JSON analysisBeginnerTyped ticket records and resolution-time report
4Inventory movement reconciliationBeginnerClosing-stock report and discrepancy list
5Query efficiency investigationIntermediateEquivalent queries and performance observations
6Subscription renewal trackingBeginnerActive-subscription and upcoming-expiry report
7Invoice and payment reconciliationBeginnerOutstanding balances and payment exceptions
8Delivery promise analysisBeginnerDelivery performance and open-delivery exceptions
9Website funnel analysisIntermediateOrdered session stages and conversion counts
10Campaign spend and attributed valueBeginnerCampaign report with attribution coverage
11Product return-rate analysisIntermediateShipment-cohort return rates
12Supplier receipt trackingBeginnerPurchase-order-line reconciliation
13Course attendance analysisBeginnerAttendance report including learners with no attendance
14API reliability reportingIntermediateRequest counts, error rates, and latency percentiles
15Sensor threshold monitoringIntermediateThreshold breaches and missing-reading intervals
16Latest-state customer loadingIntermediateCustomer table with update and rerun rules
17Historical customer-address reportingIntermediateOrders matched to the address effective at purchase
18Source-to-target completeness auditIntermediateMissing, partial, and unexpected batch report
19Currency conversion and rate coverageIntermediateConverted values and missing-rate exceptions
20Analyst-ready reporting viewBeginnerReporting view, data dictionary, and optional chart

These are learning exercises. Advanced extensions later in this guide explain how to add operational requirements.

Which Snowflake Project Should You Start With?

Snowflake Projects for Beginners

Start with Project 1 if you understand SELECT, WHERE, and GROUP BY but have not built a complete data workflow.

Continue with customer purchases, inventory, invoices, or subscriptions. Focus on checking your results and explaining how missing values, duplicate records, and joins affect the answer.

Snowflake Projects for Intermediate Learners

Choose funnel analysis, customer loading, historical reporting, or batch reconciliation when you are comfortable with joins and window functions.

These projects introduce more demanding decisions about event order, update rules, effective dates, and repeated processing.

Moving Toward Advanced Projects

After validating a foundation project, add file ingestion, scheduling, monitoring, access controls, and recovery.

A working SQL query is the starting point. An operational pipeline also needs evidence that it handles failures and changing input correctly.

Plan Your Project Before You Start

MilestoneCompletion requirement
Define the problemIdentify the report user, business question, and required output
Prepare the workspaceSelect an approved role, warehouse, database, and schema
Load the inputPreserve source records and verify input counts
Transform the dataDefine keys, types, validation rules, and duplicate handling
Validate the resultCompare actual results with documented expectations
Prepare the portfolioSave scripts, outputs, decisions, and reproduction instructions

Project duration depends on your experience, account access, dataset, and extensions. Complete one reproducible build before adding more tools.

Required Tools, Account Access, and Datasets

You need access to Snowflake, a SQL worksheet in Snowsight, and permission to create the required practice objects.

Use a dedicated practice schema. Compute and storage may incur charges, so understand your account’s usage arrangements before starting.

Check your worksheet context:

SELECT
    CURRENT_ROLE(),
    CURRENT_WAREHOUSE(),
    CURRENT_DATABASE(),
    CURRENT_SCHEMA();

The first walkthrough uses temporary objects. Run it in the same session and save your scripts before disconnecting.

Python, external cloud storage, orchestration tools, and BI software are optional extensions.

Resources for Snowflake Projects With Datasets

Each project includes SQL examples, synthetic sample inputs, expected results and validation guidance. Complete Project 1 before Projects 2, 5 and 20. Run dependent temporary objects in the same worksheet session. Save each project’s:

  • Source records and dataset documentation.
  • Setup and loading scripts.
  • Transformation and validation queries.
  • Reporting queries.
  • Actual results and screenshots.
  • Assumptions and known limitations.

When using public data, document its source, usage terms, column meanings, and update frequency.

Project 1: A Snowflake End-to-End Project for Sales-Data Quality

Business question: What paid-order value did the business record each day?

Problem: The source export contains duplicate orders, an invalid date, and a negative amount.

Skills: Loading, type conversion, validation, deduplication, aggregation, and reconciliation.

Deliverables: Raw input, rejected records, clean orders, and a daily paid-order report.

Understand the Workflow

Synthetic orders

RAW input

Typed + validated

Rejects / clean orders

Daily report

This walkthrough covers loading through reporting. External extraction, scheduling, and monitoring can be added later.

Step 1: Create and Load the Dataset

Run the following in your practice schema:

CREATE OR REPLACE TEMPORARY TABLE sp_orders_raw (
    source_row INTEGER,
    order_id INTEGER,
    order_date_raw VARCHAR,
    customer_id VARCHAR,
    amount_raw VARCHAR,
    status VARCHAR
);

INSERT INTO sp_orders_raw VALUES
    (1, 1001, '2026-10-01', 'C01', '1200', 'paid'),
    (2, 1002, '2026-10-01', 'C02', '800', 'paid'),
    (3, 1003, '2026-10-02', 'C01', '650', 'refunded'),
    (4, 1004, '2026-10-02', 'C03', '2000', 'paid'),
    (5, 1004, '2026-10-02', 'C03', '2000', 'paid'),
    (6, 1005, 'not-a-date', 'C04', '900', 'paid'),
    (7, 1006, '2026-10-03', 'C05', '-50', 'paid');

Check the input count:

SELECT COUNT(*) AS raw_rows
FROM sp_orders_raw;

The expected count is seven.

For file-loading practice, save the same records as CSV and load them into a separate practice table. Use either the SQL seed or CSV as the baseline input to avoid loading both.

Step 2: Convert and Validate Values

Require an order ID, customer ID, valid date, nonnegative amount, and supported status.

CREATE OR REPLACE TEMPORARY VIEW sp_orders_typed AS
SELECT
    source_row,
    order_id,
    TRY_TO_DATE(order_date_raw, 'YYYY-MM-DD') AS order_date,
    customer_id,
    TRY_TO_DECIMAL(amount_raw, 12, 2) AS amount,
    LOWER(TRIM(status)) AS status
FROM sp_orders_raw;

CREATE OR REPLACE TEMPORARY VIEW sp_orders_checked AS
SELECT
    *,
    CASE
        WHEN order_id IS NULL THEN 'missing_order_id'
        WHEN NULLIF(TRIM(customer_id), '') IS NULL
            THEN 'missing_customer_id'
        WHEN order_date IS NULL THEN 'invalid_order_date'
        WHEN amount IS NULL THEN 'invalid_amount'
        WHEN amount < 0 THEN 'negative_amount'
        WHEN status IS NULL
            OR status NOT IN ('paid', 'refunded')
            THEN 'unsupported_status'
        ELSE NULL
    END AS rejection_reason
FROM sp_orders_typed;

This rule records the first failure encountered. You can extend it to retain multiple failure reasons.

Step 3: Retain Rejected Records

CREATE OR REPLACE TEMPORARY VIEW sp_orders_rejected AS
SELECT *
FROM sp_orders_checked
WHERE rejection_reason IS NOT NULL;

SELECT
    source_row,
    order_id,
    rejection_reason
FROM sp_orders_rejected
ORDER BY source_row;

Expected output:

source_roworder_idrejection_reason
61005invalid_order_date
71006negative_amount

Step 4: Remove Duplicate Orders

For this exercise, retain the valid record with the lowest source-row number per order ID.

CREATE OR REPLACE TEMPORARY VIEW sp_orders_clean AS
SELECT
    source_row,
    order_id,
    order_date,
    customer_id,
    amount,
    status
FROM sp_orders_checked
WHERE rejection_reason IS NULL
QUALIFY ROW_NUMBER() OVER (
    PARTITION BY order_id
    ORDER BY source_row
) = 1;

A changing source feed needs an explicit version or event-ordering policy. Document that policy before applying updates.

Step 5: Produce the Report

SELECT
    order_date,
    COUNT(*) AS paid_orders,
    SUM(amount) AS paid_order_value
FROM sp_orders_clean
WHERE status = 'paid'
GROUP BY order_date
ORDER BY order_date;

Expected output:

order_datepaid_orderspaid_order_value
2026-10-0122000.00
2026-10-0212000.00

The refunded order remains in the clean dataset but is excluded from this measure.

Step 6: Reconcile the Results

SELECT
    (SELECT COUNT(*) FROM sp_orders_raw) AS raw_rows,
    (SELECT COUNT(*) FROM sp_orders_rejected) AS rejected_rows,
    (SELECT COUNT(*) FROM sp_orders_clean) AS clean_orders,
    (
        SELECT COUNT(*)
        FROM sp_orders_checked
        WHERE rejection_reason IS NULL
    ) - (
        SELECT COUNT(*)
        FROM sp_orders_clean
    ) AS duplicate_rows_removed;

Expected counts:

Raw rowsRejected rowsClean ordersDuplicate rows removed
7241

Check key uniqueness:

SELECT order_id, COUNT(*) AS records
FROM sp_orders_clean
GROUP BY order_id
HAVING COUNT(*) > 1;

This query should return no rows.

Step 7: Test a Changed Input

Change order 1003 from refunded to paid and rerun the report. Total paid-order value should increase from 4,000 to 4,650.

Record your prediction, actual result, and explanation.

Capture actual outputs from your Snowflake workspace before presenting the project as completed. The examples in this guide do not provide verified execution evidence from a connected Snowflake account.

Projects 2–5: Customer, JSON, Inventory, and Query Practice

Project 2: Customer repeat-purchase analysis

  • Business question: which customers placed more than one paid order in this observation window, and how much paid order value did each contribute? An analyst can use this to distinguish repeat buyers from one-time buyers without confusing order count with customer count.
  • Complete Project 1 first. Create a separate customer input so that adding two purchases does not change its baseline tests. The existing refunded C01 order remains available for audit and does not qualify as a paid purchase.
CREATE OR REPLACE TEMP TABLE BA_SF_CUSTOMER_ORDERS AS
SELECT order_id, order_date, customer_id, amount, status
FROM sp_orders_clean
UNION ALL
SELECT '1007', TO_DATE('2026-10-03'), 'C01', 300, 'paid'
UNION ALL
SELECT '1008', TO_DATE('2026-10-04'), 'C02', 200, 'paid';

CREATE OR REPLACE TEMP TABLE BA_SF_CUSTOMER_SUMMARY AS
SELECT customer_id, MIN(order_date) AS first_paid_date,
       MAX(order_date) AS latest_paid_date,
       COUNT(*) AS paid_orders, SUM(amount) AS paid_order_value
FROM BA_SF_CUSTOMER_ORDERS
WHERE status = 'paid'
GROUP BY customer_id;

SELECT *, paid_orders >= 2 AS repeat_buyer
FROM BA_SF_CUSTOMER_SUMMARY ORDER BY customer_id;

SELECT COUNT(*) AS paid_customers,
       COUNT_IF(paid_orders >= 2) AS repeat_buyers,
       ROUND(100.0 * COUNT_IF(paid_orders >= 2)
             / NULLIF(COUNT(*),0),2) AS repeat_buyer_pct
FROM BA_SF_CUSTOMER_SUMMARY;
  • Expected result: C01 has 2 paid orders totalling 1500, from October 1 to October 3. C02 has 2 totalling 1000, from October 1 to October 4. C03 has 1 totalling 2000 on October 2. There are 3 paid customers and 2 repeat buyers, giving 66.67%. The customer totals reconcile to 4500 across 5 paid orders.
  • Validate the grain: the summary must contain one row per customer. Check that SUM(paid_orders) equals the number of paid input orders and SUM(paid_order_value) equals their amount total. If you later join a customer profile table, ensure it has one matching row per customer; a one-to-many join can multiply every amount.
  • What this does not prove: a customer with only one order in four days is not necessarily churned. This is a repeat-buyer share for the available window, not a retention rate or lifetime value estimate. State the window and how unknown customer IDs are handled. The starter sales rules do not reject missing customer IDs, so add that rule before applying this metric to a less controlled source.
  • Portfolio deliverable: include the customer summary, the reconciliation query and a short note explaining why refunded orders are excluded. Extend it by adding a customer with only refunded orders and show that they do not enter the paid-customer denominator.

Table grain and implementation sequence

  • One input row per unique order; one summary row per known customer with at least one paid order.
  1. Run the original sales seed and checks first. Do not insert the new orders into the original clean table.
  2. Create the separate customer-order table with orders 1007 and 1008, then inspect the five paid input orders. Confirm that the refunded row is excluded from the metric.
  3. Aggregate to customer grain before adding repeat-buyer flags. Keep the first and latest paid dates so that the observation period is visible.
  4. Compare customer-level totals with paid order-level totals and save the denominator definition with the output.

Run a baseline assertion

  • Run this after the project’s setup and transformation SQL in the same session. The expected result is PASS for the original sample only. Retain the other uniqueness, reconciliation and result-comparison checks described above; one assertion is not a complete quality framework.
SELECT IFF(COUNT(*)=3 AND SUM(paid_orders)=5
 AND SUM(paid_order_value)=4500
 AND COALESCE(COUNT_IF(paid_orders>=2),0)=2,'PASS','FAIL') AS customer_baseline
FROM BA_SF_CUSTOMER_SUMMARY;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Remove order 1007C01 becomes a one-time paid buyer; repeat-buyer count falls to 1.
Add a refunded C03 orderC03 still has only one paid order.
Duplicate 1008Order-key validation must fail before the inflated customer summary is accepted.
Add an order with missing customer IDReject or explicitly classify the unknown customer before calculating the metric.

Explain the result to a reviewer

  • Explain the difference between a repeat order and a repeat buyer. C01 contributes two paid orders but one customer to the denominator. A short observed window does not establish customer lifetime behaviour.

Project 3: Support-ticket JSON analysis

  • Business question: how long do completed support tickets take to resolve in each category, and how many records are still pending or invalid? This exercise proves that you can turn JSON fields into an auditable reporting table.
  • The five payloads below are synthetic and contain no customer information. PARSE_JSON produces a VARIANT value. Extract explicit fields and convert timestamps before calculating durations. All times in this exercise use the same assumed clock with no timezone conversion; real cross-region data needs a defined timezone policy.
CREATE OR REPLACE TEMP TABLE BA_SF_TICKET_RAW AS
SELECT PARSE_JSON(column1) AS payload FROM VALUES
 ('{"id":"T1","category":"billing","created":"2026-10-01 09:00:00","resolved":"2026-10-01 11:00:00"}'),
 ('{"id":"T2","category":"billing","created":"2026-10-01 09:00:00","resolved":"2026-10-01 13:00:00"}'),
 ('{"id":"T3","category":"technical","created":"2026-10-01 09:00:00","resolved":"2026-10-01 10:00:00"}'),
 ('{"id":"T4","category":"technical","created":"2026-10-01 09:00:00"}'),
 ('{"id":"T5","category":"billing","created":"2026-10-01 10:00:00","resolved":"2026-10-01 09:00:00"}');

CREATE OR REPLACE TEMP TABLE BA_SF_TICKET_TYPED AS
SELECT payload:id::VARCHAR AS ticket_id,
       payload:category::VARCHAR AS category,
       TRY_TO_TIMESTAMP_NTZ(payload:created::VARCHAR,
         'YYYY-MM-DD HH24:MI:SS') AS created_at,
       payload:resolved::VARCHAR AS resolved_text,
       TRY_TO_TIMESTAMP_NTZ(payload:resolved::VARCHAR,
         'YYYY-MM-DD HH24:MI:SS') AS resolved_at
FROM BA_SF_TICKET_RAW;

CREATE OR REPLACE TEMP TABLE BA_SF_TICKET_RESULTS AS
SELECT *, CASE
  WHEN created_at IS NULL THEN 'invalid'
  WHEN resolved_text IS NULL THEN 'pending'
  WHEN resolved_at IS NULL OR resolved_at < created_at THEN 'invalid'
  ELSE 'closed' END AS record_state,
  DATEDIFF('minute',created_at,resolved_at) AS resolution_minutes
FROM BA_SF_TICKET_TYPED;

SELECT record_state, COUNT(*) AS tickets
FROM BA_SF_TICKET_RESULTS GROUP BY record_state;

SELECT category, COUNT(*) AS closed_tickets,
       MEDIAN(resolution_minutes) AS median_resolution_minutes
FROM BA_SF_TICKET_RESULTS WHERE record_state = 'closed'
GROUP BY category ORDER BY category;
  • Expected result: 3 closed, 1 pending and 1 invalid ticket. Billing has two completed tickets of 120 and 240 minutes, so its median is 180 minutes. Technical has one completed ticket of 60 minutes. T4 has no resolution value and remains pending. T5 resolves before creation and is excluded from the completed-ticket metric.
  • A malformed resolution string must not become a pending ticket just because TRY_TO_TIMESTAMP returns NULL. That is why the query retains resolved_text and checks whether a value was supplied. This seed uses an absent field for an unresolved ticket; if your feed also uses JSON null, empty strings or special status values, normalise and test those representations explicitly before classification.
  • Validation and extension: the three states must sum to the raw row count. Check ticket ID uniqueness before grouping, and route missing IDs or categories using an explicit policy. Add a malformed timestamp to prove it becomes invalid, then add a missing category to decide whether to reject it or report it under Unknown. If payloads are malformed JSON rather than merely bad fields, PARSE_JSON will fail; design a separate ingestion rejection path for that case.
  • Portfolio deliverable: show a raw payload, its typed record, the invalid-ticket view and the category summary. Explain that the median covers completed tickets only. It does not describe the age of unresolved work or establish whether a service-level target was met.
  • Syntax references: PARSE_JSON, TRY_TO_TIMESTAMP, DATEDIFF and MEDIAN.

Table grain and implementation sequence

  • One payload per ticket; one classified result per ticket; one summary row per category of closed ticket.
  1. Open each payload and identify the fields that may be absent. The JSON structure and the reporting schema are not the same thing.
  2. Extract IDs and category as strings, preserve the original resolution string and separately parse timestamps. This lets you distinguish an absent resolution from an invalid supplied resolution.
  3. Apply the invalid/pending/closed classification before summarising durations. Keep invalid negative intervals out of the closed-ticket calculation.
  4. Inspect T1–T5 individually, reconcile all three states to five input rows, and then verify the category medians.

Run a baseline assertion

  • Run this after the project’s setup and transformation SQL in the same session. The expected result is PASS for the original sample only. Retain the other uniqueness, reconciliation and result-comparison checks described above; one assertion is not a complete quality framework.
SELECT IFF(COUNT(*)=5
 AND COALESCE(COUNT_IF(record_state='closed'),0)=3
 AND COALESCE(COUNT_IF(record_state='pending'),0)=1
 AND COALESCE(COUNT_IF(record_state='invalid'),0)=1,'PASS','FAIL') AS ticket_baseline
FROM BA_SF_TICKET_RESULTS;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
T4 has no resolved fieldPending; duration remains NULL.
T4 resolved is not-a-timestampInvalid rather than pending.
T5 resolution moved to 11:00It becomes a valid closed ticket with a 60-minute duration.
Repeat T1 payloadTicket-key validation should detect the duplicate before category statistics are trusted.

Explain the result to a reviewer

  • The denominator and missing-value rules matter as much as the MEDIAN function. A closed-ticket duration report should not quietly treat open tickets as zero-minute resolutions.

Project 4: Inventory movement reconciliation

  • Business question: can warehouse staff explain closing stock from opening quantities, receipts and shipments? The basic identity is closing quantity = opening quantity + receipts − shipments. It becomes unreliable if the same movement is applied twice.
  • The exercise uses one fixed reporting period and includes every listed movement in that period. Quantities are positive; movement_type determines the sign. M2 appears twice to test deduplication. There is one opening row per SKU, and a source row number provides a deterministic winner for repeated movement IDs.
CREATE OR REPLACE TEMP TABLE BA_SF_OPENING (sku VARCHAR, qty INTEGER);
INSERT INTO BA_SF_OPENING VALUES ('A',10),('B',20),('C',5);
CREATE OR REPLACE TEMP TABLE BA_SF_MOVEMENTS
 (source_row INTEGER, movement_id VARCHAR, sku VARCHAR,
  movement_type VARCHAR, qty INTEGER);
INSERT INTO BA_SF_MOVEMENTS VALUES
 (1,'M1','A','receipt',5), (2,'M2','A','shipment',3),
 (3,'M2','A','shipment',3), (4,'M3','B','shipment',4),
 (5,'M4','B','receipt',2), (6,'M5','C','shipment',7);

CREATE OR REPLACE TEMP TABLE BA_SF_MOVEMENTS_CLEAN AS
SELECT * FROM BA_SF_MOVEMENTS
WHERE qty > 0 AND movement_type IN ('receipt','shipment')
QUALIFY ROW_NUMBER() OVER
 (PARTITION BY movement_id ORDER BY source_row DESC) = 1;

CREATE OR REPLACE TEMP TABLE BA_SF_STOCK AS
SELECT o.sku, o.qty AS opening_qty,
       COALESCE(SUM(CASE WHEN m.movement_type = 'receipt'
         THEN m.qty ELSE -m.qty END),0) AS net_movement,
       o.qty + COALESCE(SUM(CASE WHEN m.movement_type = 'receipt'
         THEN m.qty ELSE -m.qty END),0) AS closing_qty
FROM BA_SF_OPENING o LEFT JOIN BA_SF_MOVEMENTS_CLEAN m
  ON o.sku = m.sku
GROUP BY o.sku, o.qty;
SELECT *, closing_qty < 0 AS investigate FROM BA_SF_STOCK ORDER BY sku;

SELECT m.* FROM BA_SF_MOVEMENTS_CLEAN m
LEFT JOIN BA_SF_OPENING o ON m.sku = o.sku
WHERE o.sku IS NULL;
-- Expected: no movements for unknown SKUs
  • Expected result: A closes at 12 (10 + 5 − 3), B at 18 (20 + 2 − 4), and C at −2 (5 − 7). Six raw movement rows become five accepted unique movements. C must be flagged for investigation; do not replace its negative balance with zero because that hides the discrepancy.
  • Reconcile to a count: add a physical-count table containing A=12, B=17 and C=−2. Join it by SKU and calculate system closing quantity minus counted quantity. B has a difference of +1; A and C have zero differences. Matching a count does not make a negative stock balance operationally valid, so keep the negative-stock alert separate from the count-difference alert.
  • The LEFT JOIN keeps products with no movements, but movements for an unknown SKU would be missing from the main stock result. The second query exposes them. Also test duplicate opening SKUs, missing movement IDs, unsupported movement types and nonpositive quantities. The small clean-table filter is not a complete rejection framework; extend the rejected-record pattern from Project 1 before claiming all input has been accounted for.
  • Portfolio deliverable: include the movement audit, closing-stock table and discrepancy list. Explain the period cutoff, how cancellations and returns would be represented, and whether late-arriving movements restate a prior balance. Do not introduce multiple warehouses or units of measure until the one-location calculation reconciles.

Table grain and implementation sequence

  • One opening row per SKU for a fixed period; one clean row per movement ID; one closing-stock row per SKU.
  1. Agree that all opening quantities refer to the same period start and all listed movements belong to that period. Do not combine snapshots from different dates.
  2. Inspect duplicate movement M2 and select its winning version before applying signs. Receipt and shipment quantities are stored positive, with direction controlled by movement type.
  3. Aggregate movement quantities before comparing closing stock with a count. Preserve SKUs that have no movements and separately report movements with no known SKU.
  4. Review negative stock and count differences as separate exception types. A matching negative count does not make a negative balance operationally reasonable.

Run a baseline assertion

  • Run this after the project’s setup and transformation SQL in the same session. The expected result is PASS for the original sample only. Retain the other uniqueness, reconciliation and result-comparison checks described above; one assertion is not a complete quality framework.
SELECT IFF(COUNT(*)=3
 AND SUM(IFF(sku='A',closing_qty,0))=12
 AND SUM(IFF(sku='B',closing_qty,0))=18
 AND SUM(IFF(sku='C',closing_qty,0))=-2,'PASS','FAIL') AS inventory_baseline
FROM BA_SF_STOCK;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Add a product D with opening 8 and no movementD closes at 8 and remains in the report.
Repeat M2 againA still closes at 12 after deduplication.
Add movement for unknown SKU ZThe orphan movement query returns it; it must not silently disappear.
Replace shipment quantity with −3Reject it under the positive-quantity rule rather than applying a double negative.

Explain the result to a reviewer

  • Explain why an order-fulfilment report cannot replace an inventory ledger. Inventory needs a signed movement calculation and a clear cutoff; supplier commitments belong to a different grain.

Project 5: Query efficiency investigation

  • Business question: does a proposed query change preserve the answer, and is there measured evidence that it improves execution? This project is a small investigation notebook, not a claim that one SQL writing style is always faster.
  • First establish a correctness baseline with the sales table. The following two formulations calculate the same daily paid order value. A CTE can improve readability, but this rewrite alone does not establish a performance benefit; the optimiser may produce equivalent plans.
CREATE OR REPLACE TEMP TABLE BA_SF_QUERY_A AS
WITH paid AS (
 SELECT order_date, amount FROM sp_orders_clean
 WHERE status = 'paid'
)
SELECT order_date, SUM(amount) AS paid_order_value
FROM paid GROUP BY order_date;

CREATE OR REPLACE TEMP TABLE BA_SF_QUERY_B AS
SELECT order_date, SUM(amount) AS paid_order_value
FROM sp_orders_clean
WHERE status = 'paid' GROUP BY order_date;

SELECT * FROM BA_SF_QUERY_A MINUS SELECT * FROM BA_SF_QUERY_B;
SELECT * FROM BA_SF_QUERY_B MINUS SELECT * FROM BA_SF_QUERY_A;
-- Both should return zero rows.
SELECT COUNT(*) FROM BA_SF_QUERY_A;
SELECT COUNT(*) FROM BA_SF_QUERY_B;
-- Both should return 2.
  • The two MINUS checks compare distinct output rows in both directions. Because this report has one row per date, also checking row counts and the grouping key makes this a suitable comparison. For outputs where duplicate rows are meaningful, use a comparison that checks multiplicity as well; set differences alone can miss duplicate-count changes.
  • Collect evidence in Snowsight: open Query History, select a completed query and inspect its details and Query Profile where available. Save the query ID, SQL text, warehouse, data size, execution context, elapsed time and relevant scan statistics. Compare repeated executions under the same conditions. Separate time spent queuing or compiling from execution when interpreting a change.
  • The seven-row seed is for correctness only. It cannot establish useful performance differences. For an actual experiment, create a larger synthetic dataset with a documented row count and distribution, run a representative filter and aggregation, and change one thing at a time. Compare the same result columns and business rules. More compute, result reuse or warehouse caching can explain a faster run without the SQL being better.
  • What to record: use a table with candidate name, query ID, run number, input rows, output rows, elapsed time, bytes scanned, partitions scanned and observations. If a statistic is unavailable, record that fact instead of inventing it. Keep screenshots tied to query IDs. A candidate that returns a different answer fails before speed is considered.
  • Portfolio deliverable: publish the experiment question, equality checks, measured observations and limitations. “Both variants returned the same result; this dataset did not establish a speed difference” is a valid conclusion. Only describe an improvement when your recorded evidence supports it. See Snowflake’s Query History and query details documentation.

Table grain and implementation sequence

  • One experiment observation per query variant and run; one result row per report date.
  1. Save the exact business question, filter and output columns. Establish result equivalence before collecting timings.
  2. Execute both report variants against the same clean input and store their outputs. Compare both set differences, row counts and grouping-key uniqueness.
  3. For performance work, move to a documented larger synthetic dataset and record warehouse, workload and cache conditions. Keep results tied to query IDs.
  4. Present repeated measured observations and a limited conclusion. If the plans are equivalent or the data is too small, report that no improvement was established.

Run a baseline assertion

  • Run this after the project’s setup and transformation SQL in the same session. The expected result is PASS for the original sample only. Retain the other uniqueness, reconciliation and result-comparison checks described above; one assertion is not a complete quality framework.
SELECT IFF((SELECT COUNT(*) FROM BA_SF_QUERY_A)=2
 AND (SELECT COUNT(*) FROM BA_SF_QUERY_B)=2
 AND (SELECT SUM(paid_order_value) FROM BA_SF_QUERY_A)=4000
 AND (SELECT SUM(paid_order_value) FROM BA_SF_QUERY_B)=4000,
 'PASS','FAIL') AS query_baseline;
-- Retain the two bidirectional MINUS checks as well.

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Remove the paid filter from one variantResult comparison fails; do not compare it as a successful optimisation.
Repeat a fast run after a slower first runInvestigate result reuse and warehouse caching before attributing a speedup.
One variant returns duplicate report datesUniqueness or multiplicity checks fail even if a distinct-row comparison misses the issue.
Change warehouse size between variantsMark the comparison as confounded and rerun under comparable conditions.

Explain the result to a reviewer

  • A readable rewrite is a maintainability improvement even when it produces the same execution plan. Claim lower runtime or lower compute consumption only when the recorded experiment supports that specific claim.

Projects 6–10: Snowflake SQL Projects for Business Analytics

Project 6: Subscription renewal tracking

  • Beginner · Skill: Date boundaries and active status
  • Business question: Which subscriptions are active on a chosen date, and which expire soon?
  • Sample data: One row per subscription, with start date and an exclusive end date. S1 runs October 1–November 1; S2 runs September 1–October 10; S3 starts October 20 and ends November 20. Use October 15 as the reporting date.
  • Build steps: Create the subscription table, agree the meaning of an end date, filter the active population and mark subscriptions ending in the next 30 days. Keep the reporting date fixed so another learner can reproduce your answer.
WITH subscriptions AS (
 SELECT column1 AS subscription_id, TO_DATE(column2) AS start_date,
        TO_DATE(column3) AS end_date
 FROM VALUES ('S1','2026-10-01','2026-11-01'),
             ('S2','2026-09-01','2026-10-10'),
             ('S3','2026-10-20','2026-11-20')
), params AS (SELECT TO_DATE('2026-10-15') AS as_of_date)
SELECT subscription_id, start_date, end_date,
       end_date <= DATEADD('day',30,as_of_date) AS expires_within_30_days
FROM subscriptions CROSS JOIN params
WHERE start_date <= as_of_date AND end_date > as_of_date;
-- S1 only; expires_within_30_days = TRUE
  • Expected result: S1 is the only active subscription on October 15 and expires within 30 days. S2 has ended and S3 has not started. This result is an operational list, not monthly recurring revenue or churn.
  • Validate and troubleshoot: Test a subscription ending exactly on October 15: it is inactive under the exclusive-end rule. Test a subscription starting that day: it is active. Reject end dates that precede start dates, and decide how open-ended contracts are represented.
  • Portfolio deliverable: An as-of subscription report, boundary-case tests and a definition of active status. Extend it with renewal events without overwriting the original contract history.

Table grain and implementation sequence

  • One input row per subscription contract; one output row per active contract on the chosen date.
  1. Treat start_date as inclusive and end_date as exclusive. Store reporting dates explicitly instead of replacing them with CURRENT_DATE in your baseline.
  2. Check for null IDs and end dates before start dates. Decide whether an absent end date means indefinite service or incomplete source data.
  3. Apply the active filter first, then calculate the upcoming-expiry flag. A future contract should not enter the active population merely because its end date is near.
  4. Compare individual IDs and flags with the baseline, then rerun at the day before, day of and day after a contract boundary.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P06_RESULT AS
WITH subscriptions AS (
 SELECT column1 AS subscription_id, TO_DATE(column2) AS start_date,
        TO_DATE(column3) AS end_date
 FROM VALUES ('S1','2026-10-01','2026-11-01'),
             ('S2','2026-09-01','2026-10-10'),
             ('S3','2026-10-20','2026-11-20')
), params AS (SELECT TO_DATE('2026-10-15') AS as_of_date)
SELECT subscription_id, start_date, end_date,
       end_date <= DATEADD('day',30,as_of_date) AS expires_within_30_days
FROM subscriptions CROSS JOIN params
WHERE start_date <= as_of_date AND end_date > as_of_date;

SELECT IFF(COUNT(*)=1 AND MIN(subscription_id)='S1'
 AND COALESCE(COUNT_IF(expires_within_30_days),0)=1,'PASS','FAIL') AS project_6_baseline
FROM BA_SF_P06_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
As-of date becomes October 10S2 is no longer active.
As-of date becomes October 20S1 and S3 are active.
S1 end date becomes October 15S1 is inactive on October 15.
Add duplicate S1 contract rowKey validation fails; the operational list must not contact the same contract twice.

Explain the result to a reviewer

  • The project answers an as-of operational question. To calculate churn, you need period-start population, qualifying cancellations and an agreed treatment of pauses and renewals; an expiry list alone is insufficient.

Project 7: Invoice and payment reconciliation

  • Beginner · Skill: Aggregation before joins
  • Business question: Which invoices remain unpaid or have an overpayment?
  • Sample data: Two invoices: I1=1000 and I2=500. Payments P1=400 and P2=300 belong to I1; P3=600 belongs to I2. Store invoice and payment amounts in the same synthetic currency.
  • Build steps: Model invoices separately from payment events. Aggregate payments to invoice grain before joining them to invoices, then calculate balance due. This prevents repeated invoice values from being summed once per payment.
WITH invoices AS (
 SELECT column1 AS invoice_id, column2::NUMBER(12,2) AS invoice_amount
 FROM VALUES ('I1',1000),('I2',500)
), payments AS (
 SELECT column1 AS payment_id, column2 AS invoice_id,
        column3::NUMBER(12,2) AS amount
 FROM VALUES ('P1','I1',400),('P2','I1',300),('P3','I2',600)
), totals AS (
 SELECT invoice_id, SUM(amount) AS paid FROM payments GROUP BY invoice_id
)
SELECT i.invoice_id, i.invoice_amount, COALESCE(t.paid,0) AS paid,
       i.invoice_amount - COALESCE(t.paid,0) AS balance_due
FROM invoices i LEFT JOIN totals t ON i.invoice_id = t.invoice_id
ORDER BY i.invoice_id;
  • Expected result: I1 has 700 paid and 300 outstanding. I2 has 600 paid and a balance of −100, indicating an overpayment under this model. Invoice value totals 1500; payments total 1300; net balance totals 200.
  • Validate and troubleshoot: Add an invoice with no payments and confirm it remains in the report. Detect duplicate payment IDs and payments whose invoice does not exist. Never label a negative balance as an ordinary unpaid amount; separate credits, refunds and allocation rules.
  • Portfolio deliverable: An exception report with unpaid, settled and overpaid categories. Explain that a positive net total can conceal an individual overpayment, so reconciliation must also occur per invoice.

Table grain and implementation sequence

  • One invoice header per invoice ID; one payment event per payment ID; one output row per invoice.
  1. Keep invoice face value in the invoice table. Do not copy and sum it across payment events.
  2. Check payment-event uniqueness and identify payments without a matching invoice before aggregating the valid payment population.
  3. Sum payments by invoice and left join those totals to all invoices. A no-payment invoice must retain its full outstanding amount.
  4. Classify positive, zero and negative balances and reconcile both per invoice and across the portfolio. Review overpayments separately from unpaid balances.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P07_RESULT AS
WITH invoices AS (
 SELECT column1 AS invoice_id, column2::NUMBER(12,2) AS invoice_amount
 FROM VALUES ('I1',1000),('I2',500)
), payments AS (
 SELECT column1 AS payment_id, column2 AS invoice_id,
        column3::NUMBER(12,2) AS amount
 FROM VALUES ('P1','I1',400),('P2','I1',300),('P3','I2',600)
), totals AS (
 SELECT invoice_id, SUM(amount) AS paid FROM payments GROUP BY invoice_id
)
SELECT i.invoice_id, i.invoice_amount, COALESCE(t.paid,0) AS paid,
       i.invoice_amount - COALESCE(t.paid,0) AS balance_due
FROM invoices i LEFT JOIN totals t ON i.invoice_id = t.invoice_id
ORDER BY i.invoice_id;

SELECT IFF(COUNT(*)=2 AND SUM(invoice_amount)=1500
 AND SUM(paid)=1300 AND SUM(balance_due)=200
 AND MIN(IFF(invoice_id='I1',balance_due,NULL))=300
 AND MIN(IFF(invoice_id='I2',balance_due,NULL))=-100,'PASS','FAIL') AS project_7_baseline
FROM BA_SF_P07_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Delete all I1 paymentsI1 remains visible with balance 1000.
Add another 300 payment to I1I1 becomes settled with balance 0.
Repeat P1Payment-key validation fails; otherwise I1 would be understated.
Add payment for unknown I9The orphan-payment list must expose it.

Explain the result to a reviewer

  • Aggregation before joining prevents row multiplication. Also distinguish a balance calculation from an ageing report: ageing needs due dates, an as-of date and allocation rules for payments or credit notes.

Project 8: Delivery promise analysis

  • Beginner · Skill: Timestamp arithmetic and denominators
  • Business question: Which delivered shipments missed their promise, and what proportion arrived on time?
  • Sample data: Three shipments in one timezone: D1 promised 12:00 and delivered 11:30, D2 promised 12:00 and delivered 12:20, D3 promised 12:00 with no delivered timestamp.
  • Build steps: Separate completed deliveries from open shipments. Calculate delay against the promise and use only completed deliveries in the on-time percentage. Open shipments need a separate as-of overdue report.
WITH shipments AS (
 SELECT column1 AS shipment_id,
        TO_TIMESTAMP_NTZ(column2) AS promised_at,
        TO_TIMESTAMP_NTZ(column3) AS delivered_at
 FROM VALUES
 ('D1','2026-10-01 12:00:00','2026-10-01 11:30:00'),
 ('D2','2026-10-01 12:00:00','2026-10-01 12:20:00'),
 ('D3','2026-10-01 12:00:00',NULL)
)
SELECT COUNT_IF(delivered_at IS NOT NULL) AS completed,
       COUNT_IF(delivered_at <= promised_at) AS on_time,
       ROUND(100.0 * COUNT_IF(delivered_at <= promised_at)
        / NULLIF(COUNT_IF(delivered_at IS NOT NULL),0),2) AS on_time_pct
FROM shipments;
  • Expected result: Two completed shipments, one on time, and an on-time percentage of 50%. D2 is 20 minutes late. D3 is open; counting it as late or on time without an as-of policy changes the meaning of the metric.
  • Validate and troubleshoot: Test delivery exactly at the promised time, which qualifies as on time here. Test missing promise values and duplicate shipment versions. Define whether the source stores local time or UTC before comparing regions.
  • Portfolio deliverable: A completed-delivery KPI plus a separate open-shipment exception list. Add a reporting-period rule based on promise date or delivery date and explain the difference.

Table grain and implementation sequence

  • One accepted current row per shipment; one aggregate output for the completed-delivery population.
  1. Validate that promise and delivery timestamps share a timezone interpretation. If deliveries arrive as status events, select the current completed version before the report.
  2. Partition open and completed shipments. An open shipment needs an as-of timestamp to decide whether it is already overdue.
  3. Calculate the completed on-time numerator using delivered_at ≤ promised_at and state the denominator explicitly.
  4. Inspect the late shipment separately with DATEDIFF, then reconcile completed and open counts to all accepted shipments.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P08_RESULT AS
WITH shipments AS (
 SELECT column1 AS shipment_id,
        TO_TIMESTAMP_NTZ(column2) AS promised_at,
        TO_TIMESTAMP_NTZ(column3) AS delivered_at
 FROM VALUES
 ('D1','2026-10-01 12:00:00','2026-10-01 11:30:00'),
 ('D2','2026-10-01 12:00:00','2026-10-01 12:20:00'),
 ('D3','2026-10-01 12:00:00',NULL)
)
SELECT COUNT_IF(delivered_at IS NOT NULL) AS completed,
       COUNT_IF(delivered_at <= promised_at) AS on_time,
       ROUND(100.0 * COUNT_IF(delivered_at <= promised_at)
        / NULLIF(COUNT_IF(delivered_at IS NOT NULL),0),2) AS on_time_pct
FROM shipments;

SELECT IFF(COUNT(*)=1 AND MIN(completed)=2 AND MIN(on_time)=1
 AND MIN(on_time_pct)=50,'PASS','FAIL') AS project_8_baseline
FROM BA_SF_P08_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
D2 delivery becomes exactly 12:00Completed on-time share becomes 100%.
D3 receives delivery at 12:05There are 3 completed shipments and only 1 on time.
D1 has no promised timestampRoute it to a missing-promise exception rather than silently counting it late.
A shipment has two source versionsApply a deterministic latest-state rule before counting shipments.

Explain the result to a reviewer

  • An on-time delivered percentage and an open-overdue count answer different questions. Keeping them separate prevents incomplete deliveries from changing a metric that claims to describe completed work.

Project 9: Website funnel analysis

  • Intermediate · Skill: Conditional aggregation and event order
  • Business question: How many sessions progressed from product view to cart and then purchase in the correct order?
  • Sample data: Three sessions: A views, carts and purchases; B only views; C carts and purchases but has no recorded product view. Events include a session ID, event type and timestamp.
  • Build steps: Roll up the earliest timestamp for each stage per session. Require stage ordering before counting conversions. Keep sessions with missing events visible as data-quality or instrumentation questions.
WITH events AS (
 SELECT column1 AS session_id, column2 AS event_name,
        TO_TIMESTAMP_NTZ(column3) AS event_at FROM VALUES
 ('A','view','2026-10-01 10:00:00'),
 ('A','cart','2026-10-01 10:01:00'),
 ('A','purchase','2026-10-01 10:03:00'),
 ('B','view','2026-10-01 11:00:00'),
 ('C','cart','2026-10-01 12:00:00'),
 ('C','purchase','2026-10-01 12:01:00')
), stages AS (
 SELECT session_id,
  MIN(IFF(event_name='view',event_at,NULL)) AS viewed_at,
  MIN(IFF(event_name='cart',event_at,NULL)) AS cart_at,
  MIN(IFF(event_name='purchase',event_at,NULL)) AS purchased_at
 FROM events GROUP BY session_id
)
SELECT COUNT_IF(viewed_at IS NOT NULL) AS viewed_sessions,
       COUNT_IF(viewed_at <= cart_at AND cart_at <= purchased_at)
         AS complete_funnels
FROM stages;
  • Expected result: Two sessions have a view and one completes the ordered funnel. The view-to-purchase funnel conversion is 1/2=50%. Session C has a purchase event but does not qualify for this particular funnel.
  • Validate and troubleshoot: Add a purchase that occurs before the cart and ensure it does not qualify. Earliest-event aggregation is intentionally simple: for repeated journeys in the same session it can miss a later valid sequence. Document that limitation rather than calling it a general attribution model.
  • Portfolio deliverable: A session-level funnel table, stage definitions and a list of incomplete journeys. Extend the model to handle repeated sequences only after defining session boundaries and duplicate-event rules.

Table grain and implementation sequence

  • One input row per event; one stage summary per session; one aggregate funnel output.
  1. Write the stage names and timestamp-order rule before loading events. Session IDs should already have a defined boundary in the source.
  2. Aggregate first occurrences by session and retain missing stages. Do not inner join stage tables in a way that hides incomplete sessions.
  3. Require the view, cart and purchase to occur in order. Count qualifying sessions rather than counting every purchase event as a separate converted session.
  4. Reconcile session-level flags to the funnel totals and inspect C as a purchase-without-view instrumentation exception.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P09_RESULT AS
WITH events AS (
 SELECT column1 AS session_id, column2 AS event_name,
        TO_TIMESTAMP_NTZ(column3) AS event_at FROM VALUES
 ('A','view','2026-10-01 10:00:00'),
 ('A','cart','2026-10-01 10:01:00'),
 ('A','purchase','2026-10-01 10:03:00'),
 ('B','view','2026-10-01 11:00:00'),
 ('C','cart','2026-10-01 12:00:00'),
 ('C','purchase','2026-10-01 12:01:00')
), stages AS (
 SELECT session_id,
  MIN(IFF(event_name='view',event_at,NULL)) AS viewed_at,
  MIN(IFF(event_name='cart',event_at,NULL)) AS cart_at,
  MIN(IFF(event_name='purchase',event_at,NULL)) AS purchased_at
 FROM events GROUP BY session_id
)
SELECT COUNT_IF(viewed_at IS NOT NULL) AS viewed_sessions,
       COUNT_IF(viewed_at <= cart_at AND cart_at <= purchased_at)
         AS complete_funnels
FROM stages;

SELECT IFF(COUNT(*)=1 AND MIN(viewed_sessions)=2 AND MIN(complete_funnels)=1,'PASS','FAIL') AS project_9_baseline
FROM BA_SF_P09_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
A purchase is earlier than its cartA no longer completes this ordered funnel.
Repeat A purchase eventThe earliest-stage baseline still counts one converted session, but event deduplication remains a separate check.
Add a view before C cartC now qualifies and the denominator increases as well.
A has a later valid journey after an invalid first cartThe first-occurrence shortcut may miss it; document or replace the algorithm.

Explain the result to a reviewer

  • Event totals and session conversion are not interchangeable. This is a simplified ordered funnel; proving general journey conversion requires a model that handles repeated sequences and sessionisation.

Project 10: Campaign spend and attributed value

  • Beginner · Skill: Metric ratios and attribution limits
  • Business question: Which campaigns produced attributed order value relative to recorded advertising spend?
  • Sample data: Campaign A spends 100 and has orders worth 150 and 50; B spends 200 and has an order worth 100; C spends 50 with no attributed order. The seed assumes one campaign attribution per order and one currency.
  • Build steps: Aggregate orders by campaign before joining spend. Retain campaigns with no orders, compute attributed value divided by spend, and keep spend distinct from revenue. Do not interpret the ratio as profit or causal lift.
WITH spend AS (
 SELECT column1 AS campaign, column2 AS spend_amount
 FROM VALUES ('A',100),('B',200),('C',50)
), orders AS (
 SELECT column1 AS order_id, column2 AS campaign, column3 AS amount
 FROM VALUES ('O1','A',150),('O2','A',50),('O3','B',100)
), attributed AS (
 SELECT campaign, SUM(amount) AS order_value FROM orders GROUP BY campaign
)
SELECT s.campaign, s.spend_amount,
       COALESCE(a.order_value,0) AS attributed_order_value,
       ROUND(COALESCE(a.order_value,0)/NULLIF(s.spend_amount,0),2)
         AS attributed_value_per_spend
FROM spend s LEFT JOIN attributed a ON s.campaign=a.campaign
ORDER BY s.campaign;
  • Expected result: A has value 200 and ratio 2.00; B has value 100 and ratio 0.50; C has value 0 and ratio 0.00. Total spend is 350 and attributed order value is 300. The combined ratio is 300/350, not the average of the three campaign ratios.
  • Validate and troubleshoot: Test zero spend, duplicate order IDs and a campaign that appears only in the orders feed. State the attribution window and refund policy. A platform-assigned campaign label does not prove that advertising caused the purchase.
  • Portfolio deliverable: A campaign report with recorded spend, attributed value, ratio and caveats. A useful extension separates unattributed orders rather than assigning them to the best-performing campaign.

Table grain and implementation sequence

  • One spend row per campaign in the fixed reporting period; one input row per attributed order; one output row per campaign.
  1. Align spend and order observation windows and use a single reporting currency. A campaign-day feed should first aggregate to the chosen campaign-period grain.
  2. Check that each order has only one assignment under this exercise’s attribution rule. Multi-touch weights require a different model.
  3. Aggregate attributed value before joining spend and preserve spend-only campaigns. Detect order-only campaigns separately.
  4. Compute campaign ratios and then calculate the overall ratio from total value divided by total spend. Never average campaign ratios without an appropriate weighting rule.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P10_RESULT AS
WITH spend AS (
 SELECT column1 AS campaign, column2 AS spend_amount
 FROM VALUES ('A',100),('B',200),('C',50)
), orders AS (
 SELECT column1 AS order_id, column2 AS campaign, column3 AS amount
 FROM VALUES ('O1','A',150),('O2','A',50),('O3','B',100)
), attributed AS (
 SELECT campaign, SUM(amount) AS order_value FROM orders GROUP BY campaign
)
SELECT s.campaign, s.spend_amount,
       COALESCE(a.order_value,0) AS attributed_order_value,
       ROUND(COALESCE(a.order_value,0)/NULLIF(s.spend_amount,0),2)
         AS attributed_value_per_spend
FROM spend s LEFT JOIN attributed a ON s.campaign=a.campaign
ORDER BY s.campaign;

SELECT IFF(COUNT(*)=3 AND SUM(spend_amount)=350
 AND SUM(attributed_order_value)=300
 AND MIN(IFF(campaign='A',attributed_value_per_spend,NULL))=2
 AND MIN(IFF(campaign='B',attributed_value_per_spend,NULL))=0.5,'PASS','FAIL') AS project_10_baseline
FROM BA_SF_P10_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
C spend becomes zeroIts ratio is NULL under the division-by-zero protection; keep the spend value visible.
Add an order to campaign D with no spend rowExpose it as missing spend coverage.
Duplicate an A orderOrder-key validation fails before the ratio is accepted.
A order is refundedApply a documented status or adjustment policy before comparing campaign value.

Explain the result to a reviewer

  • An attributed-value ratio is neither profit nor an experiment proving marketing causation. The report should make spend coverage and attribution assumptions visible alongside the ranking.

Projects 11–15: Operations and Event Analytics

Project 11: Product return-rate analysis

  • Intermediate · Skill: Distinct-unit counting and cohort windows
  • Business question: What share of shipped units were returned, and which recorded reasons appear most often?
  • Sample data: Three distinct unit IDs U1, U2 and U3 were shipped in one cohort. U1 has a return event recorded twice; U2 has one return; U3 has none. This is a unit-based exercise, not an order-based return rate.
  • Build steps: Create shipped-unit and return-event tables, deduplicate returned units, and match returns to the shipped population. Use a defined cohort and observation cutoff before reporting a rate.
WITH shipped AS (
 SELECT column1 AS unit_id FROM VALUES ('U1'),('U2'),('U3')
), returns AS (
 SELECT column1 AS event_id, column2 AS unit_id, column3 AS reason
 FROM VALUES ('R1','U1','damaged'),('R1','U1','damaged'),
             ('R2','U2','wrong_size')
), returned_units AS (SELECT DISTINCT unit_id FROM returns)
SELECT COUNT(*) AS shipped_units,
       COUNT(r.unit_id) AS returned_units,
       ROUND(100.0*COUNT(r.unit_id)/NULLIF(COUNT(*),0),2) AS return_pct
FROM shipped s LEFT JOIN returned_units r ON s.unit_id=r.unit_id;
  • Expected result: Three shipped units, two returned units and a unit return rate of 66.67%. Counting the duplicate return event as another returned unit would incorrectly produce 100%.
  • Validate and troubleshoot: Report return events that have no matching shipped unit. Verify one shipped row per unit. In real data, recently shipped cohorts have had less time to return, so compare cohorts with the same observation window. A returned item can have multiple events without being multiple units.
  • Portfolio deliverable: A cohort return-rate report and a separate reason summary after applying a documented reason-selection policy. Keep refund amount, returned quantity and return-event count as separate measures.

Table grain and implementation sequence

  • One shipped row per physical unit; multiple possible return events per unit; one aggregate unit-return output.
  1. Choose a shipment cohort and a return-observation cutoff. A unit that has not had a full return window should not be compared directly with an older cohort.
  2. Check the unit key on the shipped side. Deduplicate return events and then reduce to the returned-unit population for this metric.
  3. Left join returned units to the shipped cohort so unreturned units remain in the denominator. Identify return records outside the cohort separately.
  4. Keep the rate calculation separate from reason counts. When several reasons exist, state whether the report uses first, latest or multiple reasons per unit.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P11_RESULT AS
WITH shipped AS (
 SELECT column1 AS unit_id FROM VALUES ('U1'),('U2'),('U3')
), returns AS (
 SELECT column1 AS event_id, column2 AS unit_id, column3 AS reason
 FROM VALUES ('R1','U1','damaged'),('R1','U1','damaged'),
             ('R2','U2','wrong_size')
), returned_units AS (SELECT DISTINCT unit_id FROM returns)
SELECT COUNT(*) AS shipped_units,
       COUNT(r.unit_id) AS returned_units,
       ROUND(100.0*COUNT(r.unit_id)/NULLIF(COUNT(*),0),2) AS return_pct
FROM shipped s LEFT JOIN returned_units r ON s.unit_id=r.unit_id;

SELECT IFF(COUNT(*)=1 AND MIN(shipped_units)=3 AND MIN(returned_units)=2
 AND MIN(return_pct)=66.67,'PASS','FAIL') AS project_11_baseline
FROM BA_SF_P11_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Repeat R1 againThe unit-return share remains 66.67%.
U3 is returnedThe unit-return share becomes 100%.
Add a return for U9It does not enter this cohort denominator; the orphan report exposes it.
Duplicate U1 on the shipped sideShipped-unit uniqueness fails and can inflate both numerator and denominator.

Explain the result to a reviewer

  • A unit return rate can differ from order return rate when an order contains several units. Naming the denominator prevents apparently conflicting dashboards from being treated as calculation errors.

Project 12: Supplier receipt tracking

  • Beginner · Skill: Partial fulfilment and exception rules
  • Business question: Which purchase-order lines are under-received or over-received?
  • Sample data: Purchase order PO1 has line A for 10 units and line B for 5. Line A receives 4 then 3 units; line B receives 6. Use a composite purchase-order and line key.
  • Build steps: Aggregate receipt events at purchase-order-line grain, join to ordered quantities and classify remaining quantity. Partial receipts must not create duplicate purchase-order lines in the report.
WITH ordered AS (
 SELECT column1 AS po_id, column2 AS line_id, column3 AS ordered_qty
 FROM VALUES ('PO1','A',10),('PO1','B',5)
), receipts AS (
 SELECT column1 AS receipt_id, column2 AS po_id,
        column3 AS line_id, column4 AS qty
 FROM VALUES ('R1','PO1','A',4),('R2','PO1','A',3),('R3','PO1','B',6)
), received AS (
 SELECT po_id,line_id,SUM(qty) AS received_qty
 FROM receipts GROUP BY po_id,line_id
)
SELECT o.po_id,o.line_id,o.ordered_qty,COALESCE(r.received_qty,0) AS received_qty,
       o.ordered_qty-COALESCE(r.received_qty,0) AS remaining_qty
FROM ordered o LEFT JOIN received r
 ON o.po_id=r.po_id AND o.line_id=r.line_id ORDER BY o.line_id;
  • Expected result: Line A has received 7 and has 3 remaining. Line B has received 6 and has −1 remaining, an over-receipt. Report the excess rather than clipping the value to zero.
  • Validate and troubleshoot: Add a line with no receipt and an unknown receipt line. Test duplicate receipt IDs and two purchase orders that reuse the same line ID; joining on line ID alone must be caught. In a dated extension, include only receipts before the report cutoff.
  • Portfolio deliverable: An outstanding-receipts worklist and an over-receipt exception list. This differs from inventory reconciliation because it checks supplier fulfilment against an order commitment, not on-hand stock.

Table grain and implementation sequence

  • One commitment per purchase-order-line key; one receipt event per receipt ID; one report row per purchase-order line.
  1. Use po_id and line_id together to identify the commitment. The same line label can occur in many purchase orders.
  2. Check receipt IDs and reject receipt quantities with unsupported signs or units. Keep cancellations and supplier returns under explicit movement rules.
  3. Aggregate receipts by the composite commitment key, then join ordered quantities once.
  4. Classify remaining quantities into outstanding, exactly received and over-received. Confirm that missing receipts and unexpected receipts are both visible.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P12_RESULT AS
WITH ordered AS (
 SELECT column1 AS po_id, column2 AS line_id, column3 AS ordered_qty
 FROM VALUES ('PO1','A',10),('PO1','B',5)
), receipts AS (
 SELECT column1 AS receipt_id, column2 AS po_id,
        column3 AS line_id, column4 AS qty
 FROM VALUES ('R1','PO1','A',4),('R2','PO1','A',3),('R3','PO1','B',6)
), received AS (
 SELECT po_id,line_id,SUM(qty) AS received_qty
 FROM receipts GROUP BY po_id,line_id
)
SELECT o.po_id,o.line_id,o.ordered_qty,COALESCE(r.received_qty,0) AS received_qty,
       o.ordered_qty-COALESCE(r.received_qty,0) AS remaining_qty
FROM ordered o LEFT JOIN received r
 ON o.po_id=r.po_id AND o.line_id=r.line_id ORDER BY o.line_id;

SELECT IFF(COUNT(*)=2 AND SUM(ordered_qty)=15 AND SUM(received_qty)=13
 AND MIN(IFF(line_id='A',remaining_qty,NULL))=3
 AND MIN(IFF(line_id='B',remaining_qty,NULL))=-1,'PASS','FAIL') AS project_12_baseline
FROM BA_SF_P12_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
A receives an extra 3A becomes exactly received.
B has no receiptsB shows 5 remaining.
A different PO2 also has line AComposite-key joins keep the two commitments separate.
Repeat R1Receipt uniqueness fails; otherwise the report falsely reduces outstanding units.

Explain the result to a reviewer

  • Net outstanding units across all lines can hide an excess on one line and a shortage on another. Operational follow-up must preserve line-level exceptions.

Project 13: Course attendance analysis

  • Beginner · Skill: Deduplication and complete populations
  • Business question: How many required sessions did each enrolled learner attend?
  • Sample data: Use invented learner IDs only. L1 attends session S1 twice and S2 once, L2 attends S1, and L3 is enrolled but has no attendance record. Two required sessions make up this small course.
  • Build steps: Start with the enrolment population, deduplicate learner-session attendance, and left join counts so that learners with zero attendance remain visible. Use attendance as a learning exercise rather than as evidence of competence.
WITH enrolled AS (
 SELECT column1 AS learner_id FROM VALUES ('L1'),('L2'),('L3')
), attendance AS (
 SELECT column1 AS learner_id,column2 AS session_id
 FROM VALUES ('L1','S1'),('L1','S1'),('L1','S2'),('L2','S1')
), unique_attendance AS (SELECT DISTINCT learner_id,session_id FROM attendance),
 totals AS (
 SELECT learner_id,COUNT(*) AS attended FROM unique_attendance GROUP BY learner_id
)
SELECT e.learner_id,COALESCE(t.attended,0) AS attended,
       ROUND(100.0*COALESCE(t.attended,0)/2,2) AS attendance_pct
FROM enrolled e LEFT JOIN totals t ON e.learner_id=t.learner_id
ORDER BY e.learner_id;
  • Expected result: L1 has 2 sessions and 100%; L2 has 1 and 50%; L3 has 0 and 0%. The repeated S1 event does not give L1 more than 100%.
  • Validate and troubleshoot: Add an attendance event for a non-required session and exclude it by joining to a required-session table in the extended model. Check unenrolled learner IDs, multiple course cohorts and cancelled sessions. A hard-coded denominator is suitable only for this fixed seed.
  • Portfolio deliverable: An enrolment-complete report and a requirements table for the extension. State that attendance alone is not a certificate, assessment score or employment qualification.

Table grain and implementation sequence

  • One enrolment per learner in the fixed cohort; one accepted attendance per learner-session pair; one result per enrolled learner.
  1. Define the two required sessions and the cohort enrolment population. Use invented identifiers rather than student personal data.
  2. Deduplicate attendance by learner and session; attendance-event count is not the same as attended-session count.
  3. Aggregate accepted attendance and left join it to enrolments. L3 demonstrates why starting from attendance alone is incomplete.
  4. For the extension, store required sessions in a table and calculate the denominator from that population after excluding cancelled sessions.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P13_RESULT AS
WITH enrolled AS (
 SELECT column1 AS learner_id FROM VALUES ('L1'),('L2'),('L3')
), attendance AS (
 SELECT column1 AS learner_id,column2 AS session_id
 FROM VALUES ('L1','S1'),('L1','S1'),('L1','S2'),('L2','S1')
), unique_attendance AS (SELECT DISTINCT learner_id,session_id FROM attendance),
 totals AS (
 SELECT learner_id,COUNT(*) AS attended FROM unique_attendance GROUP BY learner_id
)
SELECT e.learner_id,COALESCE(t.attended,0) AS attended,
       ROUND(100.0*COALESCE(t.attended,0)/2,2) AS attendance_pct
FROM enrolled e LEFT JOIN totals t ON e.learner_id=t.learner_id
ORDER BY e.learner_id;

SELECT IFF(COUNT(*)=3 AND SUM(attended)=3
 AND MIN(IFF(learner_id='L1',attendance_pct,NULL))=100
 AND MIN(IFF(learner_id='L2',attendance_pct,NULL))=50
 AND MIN(IFF(learner_id='L3',attendance_pct,NULL))=0,'PASS','FAIL') AS project_13_baseline
FROM BA_SF_P13_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Repeat L1/S1 againL1 remains at 100%.
L3 attends S2L3 becomes 50%.
An S3 optional event is addedIt must not increase the required-session completion percentage.
Same learner joins another cohortInclude cohort key in both attendance and enrolment matching.

Explain the result to a reviewer

  • A complete-population left join makes zero attendance visible. The resulting percentage describes recorded participation; it does not by itself certify knowledge or completion of assessments.

Project 14: API reliability reporting

  • Intermediate · Skill: Percentiles and weighted metrics
  • Business question: Which API endpoint has failed requests or a slow latency tail?
  • Sample data: Four synthetic request logs: endpoint /orders returns 200 in 100 ms, 500 in 900 ms and 200 in 200 ms; /health returns 200 in 20 ms. Each request has a unique ID.
  • Build steps: Group by endpoint, calculate a server-error rate and compare median with the 95th percentile. A small teaching sample makes the mechanics visible but cannot establish a production service-level objective.
WITH requests AS (
 SELECT column1 AS request_id,column2 AS endpoint,
        column3 AS status_code,column4 AS latency_ms
 FROM VALUES ('Q1','/orders',200,100),('Q2','/orders',500,900),
             ('Q3','/orders',200,200),('Q4','/health',200,20)
)
SELECT endpoint,COUNT(*) AS requests,
       ROUND(100.0*COALESCE(COUNT_IF(status_code >= 500 AND status_code < 600),0)
          /COUNT(*),2) AS server_error_pct,
       MEDIAN(latency_ms) AS median_ms,
       PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_ms
FROM requests GROUP BY endpoint ORDER BY endpoint;
  • Expected result: /orders has 3 requests, a 33.33% server-error rate, median 200 ms and interpolated p95 830 ms. /health has 1 request, no server error and 20 ms for both latency measures.
  • Validate and troubleshoot: Reject negative latency and investigate duplicate request IDs. Define whether timeouts and missing responses count as errors; filtering them out can make reliability look better. Compute an overall rate from request counts, not an unweighted average of endpoint percentages.
  • Portfolio deliverable: An endpoint report with request volume and error definitions. Extend it with time buckets, a minimum sample size and an explicit reporting timezone before interpreting trends.

Table grain and implementation sequence

  • One accepted log row per request ID; one output per endpoint for a defined observation period.
  1. Decide how to classify server errors, client errors, timeouts and missing responses. The seed counts only HTTP 5xx as server errors.
  2. Check latency units and reject impossible negative values. Keep endpoint names stable so that path parameters do not create an unbounded number of groups.
  3. Calculate endpoint request volume, error rate, median and continuous p95. Show volume beside the percentiles to expose tiny samples.
  4. Investigate the slow tail using request IDs. Do not interpret a four-row sample as evidence of a production reliability commitment.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P14_RESULT AS
WITH requests AS (
 SELECT column1 AS request_id,column2 AS endpoint,
        column3 AS status_code,column4 AS latency_ms
 FROM VALUES ('Q1','/orders',200,100),('Q2','/orders',500,900),
             ('Q3','/orders',200,200),('Q4','/health',200,20)
)
SELECT endpoint,COUNT(*) AS requests,
       ROUND(100.0*COALESCE(COUNT_IF(status_code >= 500 AND status_code < 600),0)
          /COUNT(*),2) AS server_error_pct,
       MEDIAN(latency_ms) AS median_ms,
       PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_ms
FROM requests GROUP BY endpoint ORDER BY endpoint;

SELECT IFF(COUNT(*)=2
 AND MIN(IFF(endpoint='/orders',requests,NULL))=3
 AND ABS(MIN(IFF(endpoint='/orders',p95_ms,NULL))-830)<0.0001
 AND MIN(IFF(endpoint='/health',server_error_pct,NULL))=0,'PASS','FAIL') AS project_14_baseline
FROM BA_SF_P14_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
/health has no 5xx requestCOALESCE keeps its server-error percentage at zero.
A timeout has no status codeAn explicit timeout policy is needed; otherwise the 5xx metric alone misses it.
Latency arrives in seconds instead of millisecondsUnit validation must catch the mismatch.
Repeat a slow request logRequest-key validation fails before a distorted percentile is accepted.

Explain the result to a reviewer

  • A median hides tail latency and a p95 without request volume can be misleading. The continuous p95 interpolates in this seed; it is not necessarily the latency of an actual request.

Project 15: Sensor threshold monitoring

  • Intermediate · Skill: Time-series gap detection
  • Business question: When did a sensor exceed a chosen threshold, and where are expected readings missing?
  • Sample data: One synthetic sensor reports 20 at 10:00, 85 at 10:05 and 25 at 10:20. The expected cadence is every five minutes and the exercise threshold is greater than 80.
  • Build steps: Order readings within each sensor, use LAG to calculate the gap and independently flag threshold breaches. Store the threshold and cadence as exercise settings, not as universal equipment safety limits.
WITH readings AS (
 SELECT column1 AS sensor_id,TO_TIMESTAMP_NTZ(column2) AS observed_at,
        column3 AS reading FROM VALUES
 ('S1','2026-10-01 10:00:00',20),
 ('S1','2026-10-01 10:05:00',85),
 ('S1','2026-10-01 10:20:00',25)
), gaps AS (
 SELECT *,DATEDIFF('minute',LAG(observed_at) OVER
   (PARTITION BY sensor_id ORDER BY observed_at),observed_at) AS gap_minutes
 FROM readings
)
SELECT *,reading > 80 AS threshold_breach,
       gap_minutes > 5 AS missing_interval FROM gaps ORDER BY observed_at;
  • Expected result: The 10:05 reading breaches the threshold. The 10:20 reading has a 15-minute gap, implying two missing five-minute readings between observations. The first reading has no previous interval and should not be treated as a gap failure.
  • Validate and troubleshoot: Test duplicate timestamps, out-of-order arrival and more than one sensor. This compares event timestamps; it does not detect a sensor that has stopped completely after its last row. Add an expected-sensor inventory and a fixed as-of time for that separate freshness check.
  • Portfolio deliverable: A breach log plus a gap report with a documented cadence. Do not forward-fill values and then claim that the missing observations were measured.

Table grain and implementation sequence

  • One measurement per sensor and observation timestamp; one output per accepted measurement.
  1. Write down the expected five-minute cadence and the synthetic threshold. Choose an event-time timezone and sort within each sensor.
  2. Check duplicate sensor/timestamp pairs before LAG. When two readings share a timestamp, define which source version is authoritative.
  3. Calculate the time gap between adjacent observations and independently flag the measured threshold breach.
  4. For missing readings after the last event, compare a fixed reporting time with the last observation of every expected sensor; the adjacent-row query alone cannot do this.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P15_RESULT AS
WITH readings AS (
 SELECT column1 AS sensor_id,TO_TIMESTAMP_NTZ(column2) AS observed_at,
        column3 AS reading FROM VALUES
 ('S1','2026-10-01 10:00:00',20),
 ('S1','2026-10-01 10:05:00',85),
 ('S1','2026-10-01 10:20:00',25)
), gaps AS (
 SELECT *,DATEDIFF('minute',LAG(observed_at) OVER
   (PARTITION BY sensor_id ORDER BY observed_at),observed_at) AS gap_minutes
 FROM readings
)
SELECT *,reading > 80 AS threshold_breach,
       gap_minutes > 5 AS missing_interval FROM gaps ORDER BY observed_at;

SELECT IFF(COUNT(*)=3 AND COALESCE(COUNT_IF(threshold_breach),0)=1
 AND COALESCE(COUNT_IF(missing_interval),0)=1
 AND MAX(gap_minutes)=15,'PASS','FAIL') AS project_15_baseline
FROM BA_SF_P15_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Insert a reading at 10:10The later gap shrinks; one expected 10:15 reading remains missing.
Add sensor S2Its first event must not use S1 as its predecessor.
85 is replaced with 80No breach under the strict greater-than-80 rule.
S1 stops after 10:20A separate as-of freshness check is required to detect continued silence.

Explain the result to a reviewer

  • A gap is evidence that expected observations are absent, not proof of the missing physical values. Keep data absence separate from measured threshold breaches.

Projects 16–20: Snowflake Data Engineering Projects

Project 16: Latest-state customer loading

  • Intermediate · Skill: Deterministic source selection and MERGE
  • Business question: Can a daily customer feed update changed customers and insert new ones without duplicating rows on a rerun?
  • Sample data: A target starts with C1 in Hyderabad. The feed contains two C1 versions, the later one in Pune, plus new C2 in Chennai. A source sequence breaks ties after the update timestamp.
  • Build steps: Use a dedicated temporary target, select exactly one source record per customer, and apply a MERGE. Rerun the merge and compare row counts and values. This is a manual batch exercise; scheduling and change-capture features belong in their own tutorials.
CREATE OR REPLACE TEMP TABLE BA_SF_CUSTOMER_CURRENT
 (customer_id VARCHAR,city VARCHAR,updated_at TIMESTAMP_NTZ);
INSERT INTO BA_SF_CUSTOMER_CURRENT VALUES
 ('C1','Hyderabad','2026-10-01 09:00:00');
CREATE OR REPLACE TEMP TABLE BA_SF_CUSTOMER_FEED
 (source_seq INTEGER,customer_id VARCHAR,city VARCHAR,updated_at TIMESTAMP_NTZ);
INSERT INTO BA_SF_CUSTOMER_FEED VALUES
 (1,'C1','Mumbai','2026-10-02 09:00:00'),
 (2,'C1','Pune','2026-10-03 09:00:00'),
 (3,'C2','Chennai','2026-10-02 09:00:00');

MERGE INTO BA_SF_CUSTOMER_CURRENT t USING (
 SELECT * FROM BA_SF_CUSTOMER_FEED
 QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id
  ORDER BY updated_at DESC,source_seq DESC)=1
) s ON t.customer_id=s.customer_id
WHEN MATCHED AND s.updated_at > t.updated_at THEN
 UPDATE SET city=s.city,updated_at=s.updated_at
WHEN NOT MATCHED THEN
 INSERT (customer_id,city,updated_at) VALUES (s.customer_id,s.city,s.updated_at);
SELECT * FROM BA_SF_CUSTOMER_CURRENT ORDER BY customer_id;
  • Expected result: The target contains exactly two customers: C1 in Pune and C2 in Chennai. Rerunning only the MERGE leaves those values and the row count unchanged. Rerunning the whole seed script resets the experiment, so distinguish reset from rerun.
  • Validate and troubleshoot: Send an older C1 update and confirm that it does not overwrite the newer target. Reject missing keys, check target-key uniqueness and define a policy for conflicting equal-timestamp updates. This starter ignores equal timestamps; it does not resolve every source conflict.
  • Portfolio deliverable: Before/after snapshots, a rerun comparison and a source-winner policy. This demonstrates latest-state loading, not historical address tracking. Reference: Snowflake MERGE.

Table grain and implementation sequence

  • One current target row per customer ID; several feed versions per customer reduced to one deterministic source winner.
  1. Run the seed once and save the initial target. Resetting the seed is different from testing an idempotent rerun.
  2. Inspect the ranked feed and confirm that C1/Pune is the selected version before MERGE.
  3. Run MERGE, inspect both target rows and run the baseline check. Then rerun only MERGE and confirm identical values and row count.
  4. Test an older update and a conflicting equal-timestamp update. State which cases are ignored, rejected or resolved, rather than implying every source conflict is handled.

Run a baseline assertion

  • Run this after the project’s setup and transformation SQL in the same session. The expected result is PASS for the original sample only. Retain the other uniqueness, reconciliation and result-comparison checks described above; one assertion is not a complete quality framework.
SELECT IFF(COUNT(*)=2 AND COUNT(DISTINCT customer_id)=2
 AND MIN(IFF(customer_id='C1',city,NULL))='Pune'
 AND MIN(IFF(customer_id='C2',city,NULL))='Chennai',
 'PASS','FAIL') AS current_customer_baseline
FROM BA_SF_CUSTOMER_CURRENT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Rerun only MERGETwo unchanged target rows; no duplicate C2.
Feed contains C1 update from SeptemberIt must not overwrite the current newer row.
Two feed rows share timestampsource_seq selects a source winner, but equal target timestamps still need a separate conflict policy.
Target already has duplicate C1 rowsTarget-key uniqueness must fail; MERGE is not a substitute for that check.

Explain the result to a reviewer

  • Idempotent means the same batch can be applied again without an unintended additional effect. It does not mean the loader keeps address history or handles deletes unless you design those behaviours explicitly.

Project 17: Historical customer-address reporting

  • Intermediate · Skill: Effective-date joins
  • Business question: Which city did a customer belong to when an order occurred?
  • Sample data: Customer C1 lives in Hyderabad from October 1 up to October 10, then Pune from October 10 onward. Order O1 is dated October 5 and O2 October 12. Model half-open validity periods.
  • Build steps: Keep separate address versions instead of overwriting the city. Join an order timestamp to the version where valid_from is inclusive and valid_to is exclusive. This exercise focuses on querying a supplied history; it does not implement a complete slowly changing dimension loader.
WITH history AS (
 SELECT column1 AS customer_id,column2 AS city,
        TO_DATE(column3) AS valid_from,TO_DATE(column4) AS valid_to
 FROM VALUES ('C1','Hyderabad','2026-10-01','2026-10-10'),
             ('C1','Pune','2026-10-10',NULL)
), orders AS (
 SELECT column1 AS order_id,column2 AS customer_id,TO_DATE(column3) AS order_date
 FROM VALUES ('O1','C1','2026-10-05'),('O2','C1','2026-10-12')
)
SELECT o.order_id,o.order_date,h.city
FROM orders o LEFT JOIN history h
 ON o.customer_id=h.customer_id AND o.order_date >= h.valid_from
 AND (o.order_date < h.valid_to OR h.valid_to IS NULL)
ORDER BY o.order_id;
  • Expected result: O1 maps to Hyderabad and O2 to Pune. An order on October 10 maps only to Pune. Joining both orders to the current city would incorrectly rewrite the geographic context of O1.
  • Validate and troubleshoot: Test overlapping validity windows, missing history and two open-ended current rows. Each order must match at most one history row, while unmatched orders should remain visible. Use timestamps instead of dates when changes can happen within a day.
  • Portfolio deliverable: An as-of order enrichment and overlap-validation query. Explain why this business-history model differs from database Time Travel: effective dates describe the business record, not storage retention.

Table grain and implementation sequence

  • One history row per customer validity period; one enriched output row per order if history windows are valid.
  1. Define half-open date intervals and ensure that at most one row covers any given customer/date.
  2. Check for overlapping periods and multiple current rows before joining orders. A date boundary must select exactly one version.
  3. Use a left as-of join so that orders with missing history remain visible. Current-city enrichment is a different question.
  4. Count outputs per order to detect duplicate matches, then inspect dates before the first recorded address and at the change boundary.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P17_RESULT AS
WITH history AS (
 SELECT column1 AS customer_id,column2 AS city,
        TO_DATE(column3) AS valid_from,TO_DATE(column4) AS valid_to
 FROM VALUES ('C1','Hyderabad','2026-10-01','2026-10-10'),
             ('C1','Pune','2026-10-10',NULL)
), orders AS (
 SELECT column1 AS order_id,column2 AS customer_id,TO_DATE(column3) AS order_date
 FROM VALUES ('O1','C1','2026-10-05'),('O2','C1','2026-10-12')
)
SELECT o.order_id,o.order_date,h.city
FROM orders o LEFT JOIN history h
 ON o.customer_id=h.customer_id AND o.order_date >= h.valid_from
 AND (o.order_date < h.valid_to OR h.valid_to IS NULL)
ORDER BY o.order_id;

SELECT IFF(COUNT(*)=2 AND COUNT(DISTINCT order_id)=2
 AND MIN(IFF(order_id='O1',city,NULL))='Hyderabad'
 AND MIN(IFF(order_id='O2',city,NULL))='Pune','PASS','FAIL') AS project_17_baseline
FROM BA_SF_P17_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Order placed on October 10Maps to Pune under the exclusive valid_to rule.
Order placed before October 1Remains unmatched; do not silently apply the future first city.
Hyderabad valid_to becomes October 11An October 10 order would match overlapping versions; validation fails.
Two open current periods existAn order can duplicate in the join and must be quarantined for investigation.

Explain the result to a reviewer

  • The business validity period says when an address was effective. Database history says when stored data changed. Those timelines need not be the same, particularly for late corrections.

Project 18: Source-to-target completeness audit

  • Intermediate · Skill: FULL OUTER JOIN and missing-vs-zero
  • Business question: Did each daily source batch reach the reporting layer with the expected count and value?
  • Sample data: Source batch B1 has 3 rows totalling 300; B2 has 2 totalling 200. Target B1 has 3 rows and 300; B2 has 1 row and 100; unexpected target B3 has 1 row and 50.
  • Build steps: Prepare control totals per batch independently at source and target. Compare both sides with a full outer join and flag missing batches, extra batches and count/value differences. Control totals should come from a trusted source manifest, not a second count of the same target table.
WITH source_control AS (
 SELECT column1 AS batch_id,column2 AS row_count,column3 AS amount
 FROM VALUES ('B1',3,300),('B2',2,200)
), target_control AS (
 SELECT column1 AS batch_id,column2 AS row_count,column3 AS amount
 FROM VALUES ('B1',3,300),('B2',1,100),('B3',1,50)
)
SELECT COALESCE(s.batch_id,t.batch_id) AS batch_id,
       s.row_count AS source_rows,t.row_count AS target_rows,
       t.amount-s.amount AS value_difference,
       CASE WHEN s.batch_id IS NULL THEN 'unexpected target'
            WHEN t.batch_id IS NULL THEN 'missing target'
            WHEN s.row_count=t.row_count AND s.amount=t.amount THEN 'match'
            ELSE 'mismatch' END AS audit_state
FROM source_control s FULL OUTER JOIN target_control t
 ON s.batch_id=t.batch_id ORDER BY batch_id;
  • Expected result: B1 matches. B2 is a mismatch with value difference −100 and one missing target row. B3 is an unexpected target batch; its difference remains NULL because there is no source baseline.
  • Validate and troubleshoot: Add a source-only batch to test the missing-target path. Do not turn every missing value into zero before classifying absence. Equal totals also do not prove identical records: add key-level comparisons when correctness requires it.
  • Portfolio deliverable: A batch audit table with run timestamp, status and investigation notes. Show how a failed batch is detected, corrected and rechecked without pretending a successful count proves every field is right.

Table grain and implementation sequence

  • One trusted source control row and one target control row per batch; one joined audit row per batch key.
  1. Produce source totals from a source manifest or independently trusted extract. Counting the target twice is not an independent reconciliation.
  2. Make batch IDs unique on each side before the full join; otherwise duplicate controls create misleading audit rows.
  3. Classify missing-side conditions before numerical comparison. A missing source baseline should remain distinguishable from an actual zero.
  4. Investigate B2 by record keys, fix the missing record in a practice target and rerun the controls. Keep the previous failed audit as evidence of the test.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P18_RESULT AS
WITH source_control AS (
 SELECT column1 AS batch_id,column2 AS row_count,column3 AS amount
 FROM VALUES ('B1',3,300),('B2',2,200)
), target_control AS (
 SELECT column1 AS batch_id,column2 AS row_count,column3 AS amount
 FROM VALUES ('B1',3,300),('B2',1,100),('B3',1,50)
)
SELECT COALESCE(s.batch_id,t.batch_id) AS batch_id,
       s.row_count AS source_rows,t.row_count AS target_rows,
       t.amount-s.amount AS value_difference,
       CASE WHEN s.batch_id IS NULL THEN 'unexpected target'
            WHEN t.batch_id IS NULL THEN 'missing target'
            WHEN s.row_count=t.row_count AND s.amount=t.amount THEN 'match'
            ELSE 'mismatch' END AS audit_state
FROM source_control s FULL OUTER JOIN target_control t
 ON s.batch_id=t.batch_id ORDER BY batch_id;

SELECT IFF(COUNT(*)=3
 AND COALESCE(COUNT_IF(audit_state='match'),0)=1
 AND COALESCE(COUNT_IF(audit_state='mismatch'),0)=1
 AND COALESCE(COUNT_IF(audit_state='unexpected target'),0)=1
 AND MIN(IFF(batch_id='B2',value_difference,NULL))=-100,'PASS','FAIL') AS project_18_baseline
FROM BA_SF_P18_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
Add source-only B4Reported as missing target.
B2 target becomes 2 rows and 200B2 matches its control totals.
Swap two record amounts with equal net totalCount/value controls can still pass; key-level checks are required for stronger guarantees.
B3 is silently dropped from comparisonA left-only join would miss an unexpected target batch; full coverage is needed.

Explain the result to a reviewer

  • Control totals detect specified classes of incompleteness. They do not prove field-by-field equality, and a successful control should never be described as validating more than it checks.

Project 19: Currency conversion and rate coverage

  • Intermediate · Skill: Reference-data joins and missing rates
  • Business question: Which orders can be converted to a common reporting currency using an approved rate table?
  • Sample data: Three orders: O1=100 USD, O2=100 EUR and O3=100 GBP on October 1. The synthetic rate table provides USD→USD=1 and EUR→USD=1.10; GBP is deliberately missing. These are invented learning rates, not market quotes.
  • Build steps: Join by source currency and rate date, preserve orders with no rate, and calculate converted amounts only where a rate exists. Use fixed-precision numbers and specify the rounding convention.
WITH orders AS (
 SELECT column1 AS order_id,column2 AS currency,
        column3::NUMBER(12,2) AS amount,TO_DATE('2026-10-01') AS rate_date
 FROM VALUES ('O1','USD',100),('O2','EUR',100),('O3','GBP',100)
), rates AS (
 SELECT column1 AS currency,column2::NUMBER(12,6) AS usd_per_unit,
        TO_DATE('2026-10-01') AS rate_date
 FROM VALUES ('USD',1.0),('EUR',1.1)
)
SELECT o.order_id,o.currency,o.amount,r.usd_per_unit,
       ROUND(o.amount*r.usd_per_unit,2) AS usd_amount,
       IFF(r.currency IS NULL,'missing rate','converted') AS conversion_state
FROM orders o LEFT JOIN rates r
 ON o.currency=r.currency AND o.rate_date=r.rate_date
ORDER BY o.order_id;
  • Expected result: O1 converts to 100.00 USD and O2 to 110.00 USD. O3 has NULL converted value and a missing-rate status. The known converted subtotal is 210.00; it is not a complete all-order USD total.
  • Validate and troubleshoot: Test duplicate currency-date rates, zero or negative rates and an order on a date with no rate. Never silently substitute 1 for an unknown rate. If using the most recent prior business-day rate, document a maximum allowed age and test it.
  • Portfolio deliverable: A converted-order report and rate-coverage exceptions. Explain source-to-target direction and rate timing; this is an analytics exercise, not accounting or trading advice.

Table grain and implementation sequence

  • One accepted order per ID and one approved rate per currency/date/target currency; one conversion result per order.
  1. State rate direction as USD per one unit of source currency. Keep the fixed invented rate date visible.
  2. Validate that the reference rate key is unique before joining; two rates can multiply an order without raising a SQL error.
  3. Left join rates and classify rate coverage before aggregating converted values. Missing rates should not become 1 or 0.
  4. Report converted subtotal alongside converted count and missing-rate count. Add a complete-total flag so consumers do not mistake a partial sum for all orders.

Save the result and run a baseline assertion

  • A CTE exists for its statement only. The block below repeats the self-contained starter as a temporary result table so the assertion can inspect it. Run the entire CREATE statement before the SELECT check. Expected result for the unchanged sample: PASS.
-- Keep the original seed unchanged for this baseline.
-- Persist the starter result in the SAME session:
CREATE OR REPLACE TEMP TABLE BA_SF_P19_RESULT AS
WITH orders AS (
 SELECT column1 AS order_id,column2 AS currency,
        column3::NUMBER(12,2) AS amount,TO_DATE('2026-10-01') AS rate_date
 FROM VALUES ('O1','USD',100),('O2','EUR',100),('O3','GBP',100)
), rates AS (
 SELECT column1 AS currency,column2::NUMBER(12,6) AS usd_per_unit,
        TO_DATE('2026-10-01') AS rate_date
 FROM VALUES ('USD',1.0),('EUR',1.1)
)
SELECT o.order_id,o.currency,o.amount,r.usd_per_unit,
       ROUND(o.amount*r.usd_per_unit,2) AS usd_amount,
       IFF(r.currency IS NULL,'missing rate','converted') AS conversion_state
FROM orders o LEFT JOIN rates r
 ON o.currency=r.currency AND o.rate_date=r.rate_date
ORDER BY o.order_id;

SELECT IFF(COUNT(*)=3
 AND COALESCE(COUNT_IF(conversion_state='converted'),0)=2
 AND COALESCE(COUNT_IF(conversion_state='missing rate'),0)=1
 AND SUM(usd_amount)=210,'PASS','FAIL') AS project_19_baseline
FROM BA_SF_P19_RESULT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
GBP rate 1.25 is suppliedO3 converts to 125.00 and the complete total becomes 335.00.
Two EUR rates exist for the same dateRate-key validation fails before conversion.
A rate is zero or negativeReject it under the exercise’s positive-rate policy.
Order is dated October 2 with no rateIt remains a missing-rate exception unless an explicit fallback policy applies.

Explain the result to a reviewer

  • SQL SUM ignores NULL values, so a plausible subtotal can conceal incomplete conversion coverage. Report coverage with the amount and preserve the exception list.

Project 20: Analyst-ready reporting view

  • Beginner · Skill: Stable reporting contracts
  • Business question: Can an analyst consume a clearly defined daily report without repeating raw-data cleanup logic?
  • Sample data: Reuse the accepted sales orders from Project 1. The report must expose one row per paid-order date with paid order count and value, and use explicit column names.
  • Build steps: Create a temporary reporting view over the clean layer, verify its outputs, then document the grain, filter, measure definitions and refresh dependency. The contract should remain understandable when another person builds a chart.
CREATE OR REPLACE TEMP VIEW BA_SF_DAILY_PAID_REPORT AS
SELECT order_date AS paid_order_date,
       COUNT(*) AS paid_order_count,
       SUM(amount)::NUMBER(12,2) AS paid_order_value
FROM sp_orders_clean
WHERE status='paid'
GROUP BY order_date;

SELECT * FROM BA_SF_DAILY_PAID_REPORT ORDER BY paid_order_date;
SELECT SUM(paid_order_count) AS orders,SUM(paid_order_value) AS value
FROM BA_SF_DAILY_PAID_REPORT;
  • Expected result: October 1 has 2 paid orders worth 2000.00; October 2 has 1 worth 2000.00. Totals reconcile to 3 and 4000.00. A date with no paid orders is absent; absence is not automatically a zero-filled calendar row.
  • Validate and troubleshoot: Check unique report dates, explicit names and matching totals. Add a calendar join only if the consumer needs zero-filled dates. A view does not validate its upstream source and does not automatically enforce an access policy; permissions require separate design.
  • Portfolio deliverable: The view definition, a four-column data dictionary (field, type, meaning, rule), and a simple chart or table connected to the verified result. This project tests a consumable reporting interface, while Project 1 establishes upstream data quality.

Table grain and implementation sequence

  • One reporting row per paid-order date, derived from one accepted current row per order ID.
  1. Complete the upstream sales validation first. The view should expose a defined clean-layer contract, not repeat raw cleanup differently.
  2. Choose explicit column names, fixed numeric scale and a documented status filter. Avoid SELECT * in the consumer interface.
  3. Compare daily output with the baseline and check that report dates are unique. Save the data dictionary beside the view definition.
  4. Add a zero-filled calendar or chart only if the consumer needs it. Verify that the presentation uses paid_order_value and does not relabel it as net revenue.

Run a baseline assertion

  • Run this after the project’s setup and transformation SQL in the same session. The expected result is PASS for the original sample only. Retain the other uniqueness, reconciliation and result-comparison checks described above; one assertion is not a complete quality framework.
SELECT IFF(COUNT(*)=2 AND COUNT(DISTINCT paid_order_date)=2
 AND SUM(paid_order_count)=3 AND SUM(paid_order_value)=4000,
 'PASS','FAIL') AS reporting_view_baseline
FROM BA_SF_DAILY_PAID_REPORT;

Change one input and predict the result

  • Use a separate copy of the seed for each test. Restore the original input before comparing with the fixed baseline. When an extension deliberately changes the expected result, update the assertion deliberately and record why it changed.
Test changeExpected behaviour
No paid orders exist on October 3There is no row for that date in this view.
Add an accepted paid October 3 orderThe next view query includes a new date row.
Upstream table includes duplicatesUpstream validation must catch them; the view does not automatically repair them.
A chart sums a pre-aggregated report after a one-to-many joinConsumer-grain validation must detect inflated totals.

Explain the result to a reviewer

  • A stable reporting view centralises a definition, while the data dictionary tells consumers what that definition means. Neither automatically grants permissions, enforces row policies or guarantees upstream freshness.

Advanced Snowflake Project Extensions

These capstones are implementation briefs. They define scope and acceptance criteria rather than providing complete deployed pipelines.

Capstone A: Monitored Order-to-Report Pipeline

Extend Project 1 with file ingestion, incremental order processing, batch controls, and reporting.

Define event keys, version rules, and correction handling before selecting orchestration.

Demonstrate that:

  • Reprocessing a batch does not inflate totals.
  • Interrupted processing can resume with an audit trail.
  • Newer valid corrections update the intended records.
  • Older records do not overwrite newer accepted state.
  • Missing and failed batches appear in monitoring.

Retain architecture, scripts, run logs, reconciliation, recovery evidence, and actual usage observations.

Capstone B: Historical Customer and Multi-Currency Reporting

Combine historical address reporting with currency conversion.

Keep order, history, and rate keys separate. Each order should match at most one valid address and one approved rate.

Test boundary timestamps, overlapping history, duplicate rates, and missing reference data. Show coverage measures beside converted results.

Retain the data model, acceptance queries, exceptions, correction policy, and consumer report.

Troubleshoot Common Project Problems

ProblemWhat to check
Warehouse or permission errorWorksheet context and approved role access
Temporary object is missingSession continuity and script order
Date conversion returns NULLSource value and expected format
Totals are too highDuplicate keys and one-to-many joins
Records disappear after a joinJoin type and missing reference data
Rerunning a batch changes totalsSource winner rules and loading behaviour
Query becomes unexpectedly fasterCache, warehouse, workload, and result reuse
Dashboard disagrees with SQLFilters, aggregation, and refresh timing

Change one condition at a time. Record the expected behaviour, actual result, and correction.

Build Snowflake Portfolio Projects You Can Explain

A useful portfolio lets another person reproduce your work and understand why the result is trustworthy.

Organize Your Repository

snowflake-project-portfolio/
├── README.md
├── data/
├── sql/
│   ├── 01_setup_and_load.sql
│   ├── 02_transform.sql
│   ├── 03_validate.sql
│   └── 04_report.sql
├── docs/
│   ├── architecture.md
│   ├── data-dictionary.csv
│   └── decisions.md
└── evidence/
    ├── baseline-results.csv
    └── test-log.csv

Document the business problem, prerequisites, dataset source, run order, keys, row grain, rules, expected outputs, and limitations.

Identify scripts that reset objects. Exclude credentials and private records.

Include Actual Result Evidence

Capture input counts, rejected records, final reports, reconciliation checks, and at least one changed-input test.

Add screenshots or exports from your completed implementation. Explain what each item proves.

Present Snowflake Projects on Your Résumé

Describe the work you completed and the checks you performed.

Sales-data quality example:

Built a Snowflake SQL portfolio project that validated synthetic order data, retained rejected records, removed duplicate orders, and reconciled daily paid-order totals.

Customer loading example:

Implemented customer-feed loading with deterministic source selection and tests for unchanged reruns and older updates.

Use results from your own work. Avoid unsupported claims about production scale, client experience, or performance gains.

Prepare for Project Interviews

Be ready to explain the business question, table grain, duplicate policy, validation approach, and failure handling.

Discuss what you implemented independently and what would still be required for production use. For further preparation, explore Brolly Academy’s Snowflake Interview Questions.

Continue Learning With Brolly Academy

Use these projects to identify your next learning goal: SQL foundations, semi-structured data, business reporting, or data engineering.

Explore Brolly Academy’s Snowflake Training in Hyderabad for structured learning.

Share your current SQL level and the project you want to complete. Request the current project list, lab arrangements, review process, and support schedule for your selected batch.

Aim for a working project, validated results, clear documentation, and the ability to explain your decisions.

Frequently Asked Questions

Which Snowflake project is best for beginners?

Start with sales-data quality. It combines loading, conversion, validation, deduplication, and reporting in one manageable exercise.

Can I build Snowflake projects with SQL alone?

Yes. The first walkthrough uses SQL to load synthetic records, transform them, retain rejected rows, and produce a report. External tools are optional extensions.

Where can I find Snowflake projects with datasets?

The tutorials include synthetic sample inputs in their SQL examples. Projects 2, 5 and 20 reuse the clean sales dataset from Project 1. For an extension, use synthetic or permitted public data and document its source.

Does this guide include source code for every project?

All 20 projects include SQL examples on this page, with sample inputs, expected results, and checks. Projects 2, 5, and 20 reuse the clean orders from Project 1. Save the scripts in your own repository and execute the checks in your practice account.

Have the SQL examples been executed in Snowflake?

This guide does not provide verified execution evidence from a connected Snowflake account. Run the examples in your workspace and retain actual outputs before presenting them as completed work.

Are these projects free?

The article’s examples are available on this page. Snowflake compute, storage, and optional services may incur charges depending on your account.

What should a Snowflake end-to-end project include?

Include source data, loading, transformations, validation, reporting, and reproduction instructions. Operational extensions also need scheduling, monitoring, access controls, and recovery.

How many projects should I include in my portfolio?

Start with one complete project. Add another when it demonstrates a different skill, such as JSON processing, historical joins, or incremental loading.

Do these projects require Python or Power BI?

The SQL walkthrough does not require either. Add Python for a defined processing need or BI software for a reporting requirement.

Are these production-ready pipelines?

They are learning exercises and implementation briefs. Production readiness requires controls appropriate to the source systems, users, workload, and operating environment.

Does Brolly Academy provide project guidance?

Confirm the project guidance, reviews, lab access, and support included in your selected batch through the Snowflake training page.

Continue with our detailed guides to dynamic tables in Snowflake, Snowflake Cortex AI, Snowflake streams and tasks and Snowflake Time Travel for focused SQL walkthroughs, expected outputs and validation exercises.

Technical References

Use official Snowflake documentation to check syntax and platform behaviour:

Snowflake Architecture Diagram

Understand storage, compute and cloud services before extending your pipeline.

Read article →

Snowflake Interview Questions

Practise explaining SQL decisions, data quality and project results.

Read article →

Snowflake Certification

Plan the next step in your Snowflake learning journey.

Read article →

BROLLY ACADEMY • KEEP BUILDING

Turn your next Snowflake project into a working portfolio.

Explore structured learning and discuss the current syllabus, project guidance and batch options.

Explore Snowflake Training →

Continue with the Snowpipe S3 auto-ingest walkthrough and the Snowflake query optimization SQL lab.

Start with the Snowflake prerequisites and SQL readiness guide, then follow the step-by-step guide to learning Snowflake before choosing a larger project.