Report Snowflake data quality scores and trends in Power BI
The short answer
Run your checks as Snowflake data metric functions with expectations. Save each day's pass or fail in a history table. Expose two views: one row per check per day, and one score per table per day. Point Power BI at those two views. We ran the Snowflake side on November 5, 2026. We did not run Power BI.
What you will have at the end
One SQL file, snowflake_dq_scores_for_power_bi.sql, that builds four things in Snowflake: data quality checks on your tables, a history table with a procedure that records each day's results, a task that calls the procedure daily, and two views. It also builds DQ_CAPTURE_ISSUES, which lists each table the procedure could not score, and why. One view, DQ_CHECK_DAILY, has a row for each table, check and day. The other, DQ_SCORE_DAILY, has a row for each table and day with its score. Power BI should read the views like any Snowflake table; we did not test that.
The score is the share of a table's checks that were met that day, as a percentage: two checks met out of three is 66.7. It counts checks, not rows. The checks are data metric functions with expectations (read 2026-11-05). A data metric function counts something about a column; an expectation says what the count must be. Data quality monitoring requires Enterprise Edition (Snowflake docs, read 2026-11-05). Your role needs the privileges named at the top of the SQL file.
This guide covers Snowflake only. The same pattern applies to Databricks, but we did not test it.
Why keep your own history
Snowflake stores data metric function results in the view SNOWFLAKE.LOCAL.DATA_QUALITY_MONITORING_RESULTS (read 2026-11-05). Its documented columns hold the measured value (value) and the time it was measured (measurement_time), not a pass or fail verdict for an expectation. Its documented columns do not include the expectation, so we store the verdict ourselves. Reading it needs the SNOWFLAKE.DATA_QUALITY_MONITORING_VIEWER or SNOWFLAKE.DATA_QUALITY_MONITORING_ADMIN application role (same page). The function SYSTEM$EVALUATE_DATA_QUALITY_EXPECTATIONS does return a verdict: its expectation_violated column is TRUE when an expectation was violated (read 2026-11-05). It reports the state right now, so a daily trend needs something to save it. The procedure in step 2 saves it.
Step 1. Attach the checks to your tables
Each check counts what breaks a rule: rows with a NULL, rows with a non-positive price, or a duplicate key. An expectation of VALUE = 0 means the check was met. The five checks use three data metric functions: two built in, NULL_COUNT and DUPLICATE_COUNT, and one of ours, RULE_PRICE_POSITIVE, which counts prices that are missing or not above zero. All data metric functions on a table follow the same schedule (Snowflake docs, read 2026-11-05). Snowflake runs them every hour unless you set a schedule, and bills data metric functions for their scheduled runs (Snowflake docs, read 2026-11-05). The procedure in step 2 does not read those scheduled results: SYSTEM$EVALUATE_DATA_QUALITY_EXPECTATIONS runs the checks when it is called (Snowflake docs, read 2026-11-05). So the file sets each table's schedule to once a day, at 05:00 UTC. The procedure does not wait for that run. In run 1, the procedure got results in the same session that attached the checks, so no scheduled run had happened yet.
-- 3. The checks. Each counts what BREAKS the rule (rows, or duplicated values), so 0 means met, and
-- each is attached with an expectation that the count is 0. Three functions: two built in
-- and one of your own.
-- Snowflake also runs a table's checks on its own schedule (every hour unless you set one) and
-- bills each scheduled run. The procedure in step 5 does not read those runs: it runs the checks
-- itself when it is called. So each table's schedule is set to once a day.
CREATE OR REPLACE DATA METRIC FUNCTION RULE_PRICE_POSITIVE(arg_t TABLE(arg_c NUMBER))
RETURNS NUMBER AS
$$ SELECT COUNT(*) FROM arg_t WHERE arg_c IS NULL OR arg_c <= 0 $$;
ALTER TABLE ORDERS SET DATA_METRIC_SCHEDULE = 'USING CRON 0 5 * * * UTC';
ALTER TABLE ORDERS ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.NULL_COUNT ON (O_CUSTKEY)
EXPECTATION no_null_customer (VALUE = 0);
ALTER TABLE ORDERS ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.DUPLICATE_COUNT ON (O_ORDERKEY)
EXPECTATION no_duplicate_order (VALUE = 0);
ALTER TABLE ORDERS ADD DATA METRIC FUNCTION RULE_PRICE_POSITIVE ON (O_TOTALPRICE)
EXPECTATION price_positive (VALUE = 0);
ALTER TABLE CUSTOMER SET DATA_METRIC_SCHEDULE = 'USING CRON 0 5 * * * UTC';
ALTER TABLE CUSTOMER ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.NULL_COUNT ON (C_NATIONKEY)
EXPECTATION no_null_nation (VALUE = 0);
ALTER TABLE CUSTOMER ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.DUPLICATE_COUNT ON (C_CUSTKEY)
EXPECTATION no_duplicate_customer (VALUE = 0);
The numbers in the code comments are the numbers in the SQL file, not the headings on this page.
Step 2. Save each day's results with a procedure
One small table lists which tables are scored. A second table keeps one row per table, check and day. The procedure reads each listed table's expectations through SYSTEM$EVALUATE_DATA_QUALITY_EXPECTATIONS, which wants the table's fully qualified name as a string (Snowflake docs, read 2026-11-05), and stores the answers. A second capture on the same day replaces that day's rows for each table it captures. Run 3, below, captures twice on one day. The procedure stamps every capture in UTC, with CONVERT_TIMEZONE (Snowflake docs, read 2026-11-05), so a day is a UTC day whatever the session's time zone.
A listed table the procedure cannot score gets a row in DQ_CAPTURE_ISSUES instead of a score: a table that returns no expectations, or a table whose capture fails, for example because the table was dropped or renamed or the role lost access to it. The row holds the time, the table and the reason, and the procedure moves on to the next table. The procedure takes the tables in name order. The procedure raises no error for these tables, because a task whose runs keep failing is suspended: by default after 10 failed runs in a row (SUSPEND_TASK_AFTER_NUM_FAILURES, Snowflake docs, read 2026-11-05), and then no table would get a daily score. The one error the procedure raises on purpose is for a capture that scores no registered table at all, for example when every registered table was dropped. The procedure records every table in DQ_CAPTURE_ISSUES first, then fails, so the task's run fails where you can see it. By default, 10 such runs in a row suspend the task. With no table registered, the call returns 'captured 0 tables' and does not fail; we did not test that case. Without the issues table, a table whose checks were all removed would quietly disappear from the report. The procedure replaces a table's rows for the day only when that table's checks returned results, so a table that lost its checks keeps its earlier capture for that day. The function also skips suspended data metric functions by default (SKIP_SUSPENDED_DMF defaults to TRUE, same page). A table goes into DQ_CAPTURE_ISSUES for missing checks only when every check on it is missing. If one check of several is suspended or removed, the table does not go into DQ_CAPTURE_ISSUES, and its score is calculated over fewer checks. We did not test that case.
-- 4. Which tables are scored, where the daily results are kept, and where a table that could not
-- be scored is recorded. DQ_TABLES has one row per table: its fully qualified name. The history
-- keeps one row per table, check and day. DQ_CAPTURE_ISSUES keeps one row per table the
-- procedure could not score, each time it could not, with the reason. CAPTURED_AT is a time in
-- UTC, whatever the session's time zone.
CREATE TABLE IF NOT EXISTS DQ_TABLES (TABLE_FQN VARCHAR);
CREATE TABLE IF NOT EXISTS DQ_CHECK_HISTORY (
CAPTURED_AT TIMESTAMP_NTZ, TABLE_FQN VARCHAR, EXPECTATION_NAME VARCHAR,
VALUE NUMBER, EXPECTATION_VIOLATED VARCHAR);
CREATE TABLE IF NOT EXISTS DQ_CAPTURE_ISSUES (
CAPTURED_AT TIMESTAMP_NTZ, TABLE_FQN VARCHAR, ISSUE VARCHAR);
-- Register your own with a statement like this (one per table):
-- INSERT INTO DQ_TABLES VALUES ('MY_DB.MY_SCHEMA.MY_TABLE');
-- 5. The procedure that records today's results. For every registered table, in name order, it asks
-- Snowflake which expectations are violated right now and stores them. A second capture on the
-- same day replaces that day's rows for the table, but only when the new capture returned results.
-- A table it cannot score gets a row in DQ_CAPTURE_ISSUES instead: one with no expectations, or
-- one whose capture fails, for example because the table was dropped or renamed or the role lost
-- access. Then the procedure moves on to the next table. It raises no error for these, so one
-- such table does not fail the task's run. The one error it raises on purpose is for a capture
-- that scores no registered table at all: it records every table first, then fails, so the
-- task's run fails where you can see it. Every capture is stamped in UTC, whatever the
-- session's time zone, so the same-day replace works on the UTC date. AS_OF is only for
-- backfilling a demo, as a UTC time; the task passes NULL, which means now.
CREATE OR REPLACE PROCEDURE CAPTURE_DQ_SCORES(AS_OF TIMESTAMP_NTZ)
RETURNS VARCHAR
LANGUAGE SQL
AS
$$
DECLARE
stamp TIMESTAMP_NTZ DEFAULT COALESCE(:AS_OF, CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())::TIMESTAMP_NTZ);
tables_done NUMBER DEFAULT 0;
issues NUMBER DEFAULT 0;
n NUMBER;
nothing_scored EXCEPTION (-20003, 'No registered table could be scored. Each one is recorded in DQ_CAPTURE_ISSUES.');
c1 CURSOR FOR SELECT TABLE_FQN FROM DQ_TABLES ORDER BY UPPER(TABLE_FQN);
BEGIN
CREATE OR REPLACE TEMPORARY TABLE DQ_CAPTURE_NOW LIKE DQ_CHECK_HISTORY;
FOR rec IN c1 DO
LET fqn VARCHAR := rec.TABLE_FQN;
BEGIN
DELETE FROM DQ_CAPTURE_NOW;
INSERT INTO DQ_CAPTURE_NOW
SELECT :stamp, UPPER(:fqn), EXPECTATION_NAME, VALUE, EXPECTATION_VIOLATED
FROM TABLE(SYSTEM$EVALUATE_DATA_QUALITY_EXPECTATIONS(REF_ENTITY_NAME => :fqn));
n := SQLROWCOUNT;
IF (n = 0) THEN
INSERT INTO DQ_CAPTURE_ISSUES
VALUES (:stamp, UPPER(:fqn), 'No expectations, so no score');
issues := issues + 1;
ELSE
DELETE FROM DQ_CHECK_HISTORY
WHERE UPPER(TABLE_FQN) = UPPER(:fqn) AND CAPTURED_AT::DATE = :stamp::DATE;
INSERT INTO DQ_CHECK_HISTORY SELECT * FROM DQ_CAPTURE_NOW;
tables_done := tables_done + 1;
END IF;
EXCEPTION
WHEN OTHER THEN
INSERT INTO DQ_CAPTURE_ISSUES
VALUES (:stamp, UPPER(:fqn), 'Capture failed: ' || :SQLERRM);
issues := issues + 1;
END;
END FOR;
DROP TABLE DQ_CAPTURE_NOW;
IF (tables_done = 0 AND issues > 0) THEN
RAISE nothing_scored;
END IF;
RETURN 'captured ' || tables_done || ' tables'
|| IFF(issues > 0, ', ' || issues || ' recorded in DQ_CAPTURE_ISSUES', '');
END;
$$;
Step 3. Run the procedure every day with a task
A new task starts suspended and runs on its schedule only after ALTER TASK ... RESUME (read 2026-11-05). Running a task needs the account-level EXECUTE TASK privilege on the owner's role (same page). The schedule below is a cron expression with a time zone, here 06:00 UTC. The file creates the task and leaves it suspended.
We did not run the task. The sandbox role we tested with has no EXECUTE TASK privilege, so EXECUTE TASK failed in an earlier run and we called the procedure directly instead, with the date left empty, which is what the task passes.
-- 6. The task that runs the procedure once a day. A new task starts suspended: RESUME it to start
-- the schedule. The file leaves it suspended.
CREATE OR REPLACE TASK DQ_CAPTURE_TASK
WAREHOUSE = CONTENT_LOOP_WH
SCHEDULE = 'USING CRON 0 6 * * * UTC'
AS CALL CAPTURE_DQ_SCORES(NULL);
-- The role that resumes it needs the account-level EXECUTE TASK privilege.
-- ALTER TASK DQ_CAPTURE_TASK RESUME;
Step 4. Publish the two views
DQ_CHECK_DAILY says, for each table, check and day, how many values or rows broke the rule and whether the check was met. ROWS_BREAKING_RULE is the number the check returned. For DUPLICATE_COUNT, our test returned 1 for one key used twice; we did not test a key used three times. DQ_SCORE_DAILY turns that into a score per table per day. Its last column says how many points the score moved since that table's previous captured day. That is not always yesterday: if a day was missed, the comparison skips to the last day that was captured. Both views carry TABLE_FQN, the table's fully qualified name, and DQ_SCORE_DAILY scores by it, so two tables with the same name in different schemas get separate scores. TABLE_NAME, the last part of that name, is a short label.
-- 8. The two views Power BI reads. DQ_CHECK_DAILY has one row per table, check and day, with PASSED
-- TRUE or FALSE. DQ_SCORE_DAILY has one row per table and day: the score, and how many points it
-- moved since that table's previous captured day, which may not be yesterday if a day was missed
-- (NULL on its first day). Both views keep each table's fully qualified name, TABLE_FQN, and
-- DQ_SCORE_DAILY groups by it, so two tables with the same name in different schemas get
-- separate scores.
-- TABLE_NAME is the last part of that name, a short label for the report. DQ_CHECK_DAILY also
-- keeps CAPTURED_AT, the time of the capture in UTC, which the report uses to find the latest
-- capture. SCORE_DAY is the UTC date of CAPTURED_AT.
CREATE OR REPLACE VIEW DQ_CHECK_DAILY AS
SELECT TABLE_FQN, SPLIT_PART(TABLE_FQN, '.', 3) AS TABLE_NAME, CAPTURED_AT, CAPTURED_AT::DATE AS SCORE_DAY,
EXPECTATION_NAME, VALUE AS ROWS_BREAKING_RULE,
EXPECTATION_VIOLATED = 'FALSE' AS PASSED
FROM DQ_CHECK_HISTORY;
CREATE OR REPLACE VIEW DQ_SCORE_DAILY AS
WITH per_day AS (
SELECT TABLE_FQN, TABLE_NAME, SCORE_DAY, COUNT(*) AS CHECKS, COUNT_IF(PASSED) AS CHECKS_PASSED,
ROUND(100 * COUNT_IF(PASSED) / COUNT(*), 1) AS SCORE_PCT
FROM DQ_CHECK_DAILY GROUP BY TABLE_FQN, TABLE_NAME, SCORE_DAY)
SELECT TABLE_FQN, TABLE_NAME, SCORE_DAY, CHECKS, CHECKS_PASSED, SCORE_PCT,
SCORE_PCT - LAG(SCORE_PCT) OVER (PARTITION BY TABLE_FQN ORDER BY SCORE_DAY) AS POINTS_VS_PREVIOUS_CAPTURE
FROM per_day;
Step 5. Point Power BI at the two views
This step is not something we ran. We tested the Snowflake side only, and we did not open Power BI. What follows is what Microsoft's connector page says (Power Query Snowflake connector, read 2026-11-05).
- In Power BI Desktop, choose Get Data, then Database, then Snowflake.
- Enter your Snowflake server and warehouse. Both are required. In the advanced options you can name the role the report uses; that role must be available to the user.
- Sign in. The page lists Microsoft Entra ID as the recommended method, and says Entra single sign-on supports DirectQuery only.
- In the navigator, select
DQ_SCORE_DAILY,DQ_CHECK_DAILYandDQ_CAPTURE_ISSUESfrom the database and schema you created them in. Choose Import or DirectQuery; the connector supports both.
Then the report is two visuals, and a third for the tables that got no score. A line chart of SCORE_PCT by SCORE_DAY, one line per TABLE_FQN, is the trend. TABLE_NAME makes a shorter label, but it can repeat across schemas. A table of DQ_CHECK_DAILY rows where PASSED is false, for the latest day, is what to fix. The third visual is a table of the latest capture's rows in DQ_CAPTURE_ISSUES: the rows whose CAPTURED_AT is the latest CAPTURED_AT in DQ_CHECK_DAILY or DQ_CAPTURE_ISSUES. Every row one capture writes has the same CAPTURED_AT. The first query below returns those rows, and run 4, below, ran it on Snowflake; we did not run it in Power BI. The rows name each table the latest capture could not score, and why. No rows means the latest capture scored every registered table. Show the latest CAPTURED_AT, with its date, next to that visual; CAPTURED_AT is a UTC time. The first query below shows only the time, and returns no rows when the latest capture had no issues. The second query returns the latest capture and whether it was made today, by the UTC date; run 5, below, ran it. If the latest capture's UTC date is not today's UTC date (CAPTURED_TODAY_UTC is FALSE), no capture has recorded anything today, and every score is from an earlier day. Between 00:00 and 06:00 UTC, FALSE is expected when the latest capture is from the previous UTC day, because the task runs at 06:00 UTC; in Los Angeles, 00:00 to 06:00 UTC is 16:00 to 22:00 in winter. After 06:00 UTC, FALSE means the task did not run, the task's run failed before it recorded any table, or no table is registered in DQ_TABLES. Use a role that can read only these two views and DQ_CAPTURE_ISSUES. Do not add scores across days or across tables: each score is already a percentage for one table on one day.
SELECT TABLE_FQN, TO_CHAR(CAPTURED_AT, 'HH24:MI') AS CAPTURED_AT, REPLACE(ISSUE, CHR(10), ' ') AS ISSUE
FROM DQ_CAPTURE_ISSUES
WHERE CAPTURED_AT = (SELECT MAX(CAPTURED_AT) FROM (
SELECT CAPTURED_AT FROM DQ_CHECK_DAILY UNION ALL SELECT CAPTURED_AT FROM DQ_CAPTURE_ISSUES))
ORDER BY TABLE_FQN;
SQL: the staleness query, whether the latest capture was made today, by the UTC date
SELECT TO_CHAR(MAX(CAPTURED_AT), 'YYYY-MM-DD HH24:MI') AS LATEST_CAPTURE_UTC,
MAX(CAPTURED_AT)::DATE = CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())::DATE AS CAPTURED_TODAY_UTC
FROM (SELECT CAPTURED_AT FROM DQ_CHECK_DAILY UNION ALL SELECT CAPTURED_AT FROM DQ_CAPTURE_ISSUES);
Step 6. Run it against planted errors, then against clean data
The test tables start as 1,000 orders and 1,000 customers copied from the TPC-H sample data. For day one we planted three errors in the orders: a NULL customer, a non-positive price and an order key used twice. In this run DUPLICATE_COUNT reports 1 for that, one key value that is duplicated. We captured the results as if on 2026-11-02, by passing the procedure that date.
TABLE_NAME | SCORE_DAY | EXPECTATION_NAME | ROWS_BREAKING_RULE | PASSED -----------+------------+-----------------------+--------------------+------- CUSTOMER | 2026-11-02 | NO_DUPLICATE_CUSTOMER | 0 | True CUSTOMER | 2026-11-02 | NO_NULL_NATION | 0 | True ORDERS | 2026-11-02 | NO_DUPLICATE_ORDER | 1 | False ORDERS | 2026-11-02 | NO_NULL_CUSTOMER | 1 | False ORDERS | 2026-11-02 | PRICE_POSITIVE | 1 | False
For day two (2026-11-03, again passed as a date) we removed the duplicate and planted a NULL nation key in the customers. Then we removed every planted row and captured with the date left empty, as the task would, which recorded 2026-11-05. The score view then showed the trend. Because days one and two were backfilled with a date, the demo has a gap between 2026-11-03 and 2026-11-05, and the last column compares across it.
Output from our run 2: the score fell and rose with the data, and the clean run scored 100.0TABLE_NAME | SCORE_DAY | CHECKS | CHECKS_PASSED | SCORE_PCT | POINTS_VS_PREVIOUS_CAPTURE -----------+------------+--------+---------------+-----------+--------------------------- CUSTOMER | 2026-11-02 | 2 | 2 | 100.0 | NULL CUSTOMER | 2026-11-03 | 2 | 1 | 50.0 | -50.0 CUSTOMER | 2026-11-05 | 2 | 2 | 100.0 | 50.0 ORDERS | 2026-11-02 | 3 | 0 | 0.0 | NULL ORDERS | 2026-11-03 | 3 | 1 | 33.3 | 33.3 ORDERS | 2026-11-05 | 3 | 3 | 100.0 | 66.7
The orders table went from 0.0 to 33.3 to 100.0 as its errors were removed. The customers table dropped to 50.0 when the NULL nation key appeared and returned to 100.0 when it was removed. The clean run is the last row for each table. What this proves and what it does not: it proves the checks, the procedure and the two views produce a score and a trend from Snowflake. It does not prove the daily schedule, because we did not run the task, and it does not prove anything about Power BI.
Run 3 tests four cases: a registered table with no checks, a registered table that does not exist, a table that loses its checks after an earlier capture the same day, and two tables with the same name. It ran steps 0 to 8 of the file, then the SQL below. A second table named ORDERS, in another schema, gets one check. The procedure captures once as if at 06:00 UTC on 2026-11-04, by being passed that time, as the 2026-11-02 and 2026-11-03 captures were passed a date. Then four things change: a NULL customer is planted in the first orders table; a table that does not exist, A_DROPPED_TABLE, and a table with no checks, A_NO_CHECKS, are registered, both first in name order; and every check is removed from the customers table. The procedure captures again, as if at 18:00 UTC the same day.
-- Run 3. A second table named ORDERS, in another schema, with one check. Register it.
CREATE OR REPLACE SCHEMA DATA_QUALITY_SCORES_IN_POWER_BI_2;
CREATE TABLE ORDERS AS SELECT * FROM DATA_QUALITY_SCORES_IN_POWER_BI.ORDERS;
ALTER TABLE ORDERS SET DATA_METRIC_SCHEDULE = 'USING CRON 0 5 * * * UTC';
ALTER TABLE ORDERS ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.NULL_COUNT ON (O_CUSTKEY)
EXPECTATION no_null_customer (VALUE = 0);
USE SCHEMA DATA_QUALITY_SCORES_IN_POWER_BI;
INSERT INTO DQ_TABLES VALUES ('CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI_2.ORDERS');
-- The first capture of the day, passed 06:00. Every registered table has checks.
CALL CAPTURE_DQ_SCORES('2026-11-04 06:00:00'::TIMESTAMP_NTZ);
-- Then four things change: a NULL customer is planted in ORDERS; a table that does not exist,
-- A_DROPPED_TABLE, is registered; a table with no checks, A_NO_CHECKS, is registered; and every
-- check is removed from CUSTOMER, which already has a capture for the day. Both new names come
-- before every other table in name order.
INSERT INTO ORDERS VALUES (900000001, NULL, 'F', 10.00);
INSERT INTO DQ_TABLES VALUES ('CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.A_DROPPED_TABLE');
CREATE TABLE A_NO_CHECKS (ID NUMBER);
INSERT INTO DQ_TABLES VALUES ('CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.A_NO_CHECKS');
ALTER TABLE CUSTOMER DROP DATA METRIC FUNCTION SNOWFLAKE.CORE.NULL_COUNT ON (C_NATIONKEY);
ALTER TABLE CUSTOMER DROP DATA METRIC FUNCTION SNOWFLAKE.CORE.DUPLICATE_COUNT ON (C_CUSTKEY);
-- The second capture of the day, passed 18:00. It must succeed: if it failed, a task run that called it would fail.
CALL CAPTURE_DQ_SCORES('2026-11-04 18:00:00'::TIMESTAMP_NTZ);
SELECT TABLE_FQN, TO_CHAR(CAPTURED_AT, 'HH24:MI') AS CAPTURED_AT, REPLACE(ISSUE, CHR(10), ' ') AS ISSUE
FROM DQ_CAPTURE_ISSUES ORDER BY TABLE_FQN;
SELECT h.TABLE_FQN, TO_CHAR(h.CAPTURED_AT, 'HH24:MI') AS CAPTURED_AT, s.CHECKS, s.CHECKS_PASSED, s.SCORE_PCT
FROM (SELECT DISTINCT TABLE_FQN, CAPTURED_AT FROM DQ_CHECK_HISTORY) h
JOIN DQ_SCORE_DAILY s ON s.TABLE_FQN = h.TABLE_FQN AND s.SCORE_DAY = h.CAPTURED_AT::DATE
ORDER BY h.TABLE_FQN;
-- The daily task from step 6: how many failed runs in a row suspend it.
SHOW PARAMETERS LIKE 'SUSPEND_TASK_AFTER_NUM_FAILURES' IN TASK DQ_CAPTURE_TASK;
DROP SCHEMA DATA_QUALITY_SCORES_IN_POWER_BI_2;
DROP SCHEMA DATA_QUALITY_SCORES_IN_POWER_BI;
Output from our run 3: the second capture succeeded
CAPTURE_DQ_SCORES -------------------------------------------------- captured 2 tables, 3 recorded in DQ_CAPTURE_ISSUESOutput from our run 3: the tables it could not score
TABLE_FQN | CAPTURED_AT | ISSUE ---------------------------------------------------------------------+-------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.A_DROPPED_TABLE | 18:00 | Capture failed: SQL compilation error: Table 'CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.A_DROPPED_TABLE' does not exist or not authorized. This executable runs with owner's rights. The owner role CONTENT_LOOP_ROLE must have at least one privilege granted on TABLE CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.A_DROPPED_TABLE. CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.A_NO_CHECKS | 18:00 | No expectations, so no score CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.CUSTOMER | 18:00 | No expectations, so no scoreOutput from our run 3: the scores it saved
TABLE_FQN | CAPTURED_AT | CHECKS | CHECKS_PASSED | SCORE_PCT --------------------------------------------------------------+-------------+--------+---------------+---------- CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.CUSTOMER | 06:00 | 2 | 2 | 100.0 CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.ORDERS | 18:00 | 3 | 2 | 66.7 CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI_2.ORDERS | 18:00 | 1 | 1 | 100.0
The second capture succeeded, so a task run making the same call would not have failed. It recorded the three tables it could not score in DQ_CAPTURE_ISSUES, with Snowflake's error message for the table that does not exist, and went on to capture both orders tables, although A_DROPPED_TABLE and A_NO_CHECKS come first in name order. The first orders table was captured again at 18:00 and scored 66.7 with its planted NULL. The customers table, which had lost its checks, kept its 06:00 capture. The second orders table has its own row, 100.0 on one check, and is not added into the first orders table's score.
key | value | default | level | description | type --------------------------------+-------+---------+-------+------------------------------------------------------------------------------------------------------------+------- SUSPEND_TASK_AFTER_NUM_FAILURES | 10 | 10 | | How many times a task must fail in a row before it is automatically suspended. 0 disables auto-suspending. | NUMBER
Run 4 tests the one error the procedure raises on purpose: a capture that can score no registered table. It ran steps 0 to 8 of the file and captured once as if at 06:00 UTC on 2026-11-04. Then the orders table was dropped and every check was removed from the customers table. The procedure was then called again as if at 18:00 UTC, as a separate statement, the way a task makes its one call.
SQL: run 4, after steps 0 to 8 of the file-- Run 4. The first capture of the day, passed 06:00, scores both tables.
CALL CAPTURE_DQ_SCORES('2026-11-04 06:00:00'::TIMESTAMP_NTZ);
-- Then no registered table can be scored: ORDERS is dropped, and every check is removed from CUSTOMER.
DROP TABLE ORDERS;
ALTER TABLE CUSTOMER DROP DATA METRIC FUNCTION SNOWFLAKE.CORE.NULL_COUNT ON (C_NATIONKEY);
ALTER TABLE CUSTOMER DROP DATA METRIC FUNCTION SNOWFLAKE.CORE.DUPLICATE_COUNT ON (C_CUSTKEY);
-- Run 4, second capture, passed 18:00, run as a separate statement, the way a task makes its one call. It must fail.
CALL CAPTURE_DQ_SCORES('2026-11-04 18:00:00'::TIMESTAMP_NTZ);
-- Run 4, after the failed call. The rows the issues visual shows: the issues of the latest capture.
-- They were recorded before the call failed, and the failure did not remove them.
SELECT TABLE_FQN, TO_CHAR(CAPTURED_AT, 'HH24:MI') AS CAPTURED_AT, REPLACE(ISSUE, CHR(10), ' ') AS ISSUE
FROM DQ_CAPTURE_ISSUES
WHERE CAPTURED_AT = (SELECT MAX(CAPTURED_AT) FROM (
SELECT CAPTURED_AT FROM DQ_CHECK_DAILY UNION ALL SELECT CAPTURED_AT FROM DQ_CAPTURE_ISSUES))
ORDER BY TABLE_FQN;
-- The scores are still those of the 06:00 capture.
SELECT TABLE_NAME, SCORE_DAY, CHECKS, CHECKS_PASSED, SCORE_PCT FROM DQ_SCORE_DAILY ORDER BY TABLE_FQN;
Output from our run 4: the second capture failed
ERROR: ------ (P0001): Uncaught exception of type 'NOTHING_SCORED' on line 38 at position 4 : No registered table could be scored. Each one is recorded in DQ_CAPTURE_ISSUES. (request_id=40c5c7de-1487-4ce3-ad18-2f2589018204, sfqid=01c835ff-0720-c177-0000-067104633132)Output from our run 4: the issues the failed capture recorded
TABLE_FQN | CAPTURED_AT | ISSUE --------------------------------------------------------------+-------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.CUSTOMER | 18:00 | No expectations, so no score CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.ORDERS | 18:00 | Capture failed: SQL compilation error: Table 'CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.ORDERS' does not exist or not authorized. This executable runs with owner's rights. The owner role CONTENT_LOOP_ROLE must have at least one privilege granted on TABLE CONTENT_LOOP_SANDBOX.DATA_QUALITY_SCORES_IN_POWER_BI.ORDERS.Output from our run 4: the scores, still from the 06:00 capture
TABLE_NAME | SCORE_DAY | CHECKS | CHECKS_PASSED | SCORE_PCT -----------+------------+--------+---------------+---------- CUSTOMER | 2026-11-04 | 2 | 2 | 100.0 ORDERS | 2026-11-04 | 3 | 3 | 100.0
The second capture failed with the procedure's error, so a task run making the same call would fail and count toward suspending the task. The two issue rows that capture recorded before it failed are still there, and the query for the third visual returns them. The scores are still those of the 06:00 capture, 100.0 for both tables. Without the issues visual and the latest CAPTURED_AT, a report would show those scores as current.
Run 5 tests the time zone. It ran steps 0 to 8 of the file, then set the session to America/Los_Angeles, Snowflake's default time zone (Snowflake docs, read 2026-11-05). It captured with a stamp of 06:00 UTC today, the time the daily task runs, then ran the staleness query, then captured again with the date left empty, as the task does.
SQL: run 5, after steps 0 to 8 of the file-- Run 5. Set the session to Snowflake's default time zone, America/Los_Angeles.
ALTER SESSION SET TIMEZONE = 'America/Los_Angeles';
-- A capture stamped 06:00 UTC today, the time the daily task runs.
SET TODAY_0600_UTC = DATEADD(HOUR, 6, CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())::DATE::TIMESTAMP_NTZ);
CALL CAPTURE_DQ_SCORES($TODAY_0600_UTC);
-- The capture counts for the UTC date, although 06:00 UTC is the evening before in Los Angeles.
SELECT DISTINCT TO_CHAR(CAPTURED_AT, 'YYYY-MM-DD HH24:MI') AS CAPTURED_AT_UTC, SCORE_DAY,
TO_CHAR(CONVERT_TIMEZONE('UTC', 'America/Los_Angeles', CAPTURED_AT), 'YYYY-MM-DD HH24:MI') AS SAME_TIME_IN_LOS_ANGELES
FROM DQ_CHECK_DAILY;
-- The staleness check: was the latest capture made today, by the UTC date?
SELECT TO_CHAR(MAX(CAPTURED_AT), 'YYYY-MM-DD HH24:MI') AS LATEST_CAPTURE_UTC,
MAX(CAPTURED_AT)::DATE = CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP())::DATE AS CAPTURED_TODAY_UTC
FROM (SELECT CAPTURED_AT FROM DQ_CHECK_DAILY UNION ALL SELECT CAPTURED_AT FROM DQ_CAPTURE_ISSUES);
-- A second capture now, the way the task calls it, replaces the 06:00 rows of the same UTC day.
CALL CAPTURE_DQ_SCORES(NULL);
SELECT TABLE_NAME, SCORE_DAY, COUNT(DISTINCT CAPTURED_AT) AS CAPTURES_KEPT,
TO_CHAR(MAX(CAPTURED_AT), 'HH24:MI') AS KEPT_CAPTURE_UTC, COUNT(*) AS CHECKS
FROM DQ_CHECK_DAILY GROUP BY TABLE_FQN, TABLE_NAME, SCORE_DAY ORDER BY TABLE_FQN;
DROP SCHEMA DATA_QUALITY_SCORES_IN_POWER_BI;
Output from our run 5: the 06:00 UTC capture counts for its UTC date
CAPTURED_AT_UTC | SCORE_DAY | SAME_TIME_IN_LOS_ANGELES -----------------+------------+------------------------- 2026-11-05 06:00 | 2026-11-05 | 2026-11-04 22:00Output from our run 5: the staleness query treats it as today
LATEST_CAPTURE_UTC | CAPTURED_TODAY_UTC -------------------+------------------- 2026-11-05 06:00 | TrueOutput from our run 5: the second capture replaced the first on the same UTC day
TABLE_NAME | SCORE_DAY | CAPTURES_KEPT | KEPT_CAPTURE_UTC | CHECKS -----------+------------+---------------+------------------+------- CUSTOMER | 2026-11-05 | 1 | 16:00 | 2 ORDERS | 2026-11-05 | 1 | 16:00 | 3
The 06:00 UTC capture counts for 2026-11-05, its UTC date, although in Los Angeles that moment was 22:00 on 2026-11-04. The staleness query reports it as made today. The second capture replaced the 06:00 rows, because both captures fall on the same UTC day: each table keeps one capture for 2026-11-05.
Tested on
Tested on
- Platform
- Snowflake
- Edition
- Enterprise Edition
- Date
- 2026-11-05
- Dataset
- SNOWFLAKE_SAMPLE_DATA, TPCH_SF1 (
ORDERSandCUSTOMER, each limited to 1,000 rows) - Not run
- Power BI, and the daily task
- Code
- snowflake_dq_scores_for_power_bi.sql
Run it on your own tables
The step numbers in this section are the numbers in the SQL file's comments, not the headings on this page.
To run this on your own tables, delete every step the file marks TEST ONLY (numbered 0, 2, 7, 9, 10 and 11). Keep step 1 and the USE DATABASE line, and change them to the database and schema where you want the history and the two views kept. In step 3, replace the attach statements with your own tables, columns and rules, each with an expectation (VALUE = 0), and rename or rewrite RULE_PRICE_POSITIVE to your own rule. Each SET DATA_METRIC_SCHEDULE line sets a table's schedule to once a day; change ORDERS and CUSTOMER in those lines to your own tables. If something else reads a table's scheduled results, an alert for example, keep that table's existing schedule and delete that table's SET DATA_METRIC_SCHEDULE line. After step 4, run one INSERT INTO DQ_TABLES for each table, with the table's fully qualified name; the lookup ignores case. In step 6, replace CONTENT_LOOP_WH with your warehouse. Run the file, then run CALL CAPTURE_DQ_SCORES(NULL) once; the task does the same each day once you resume it.
We ran these instructions on a different schema in the same sandbox, with two tables named claims and policies, written in lower case in the SQL, a NULL member id, and one planted error per check. We registered the tables with lower-case names in DQ_TABLES. The score view showed the claims table at 0.0 with three failed checks and the policies table at 100.0. We then registered a table with no checks and a table that does not exist, both first in name order, and called the procedure again with the date left empty, as the task does. The call succeeded, captured the claims and policies tables, and recorded the other two tables in DQ_CAPTURE_ISSUES. The policies table also holds a NULL holder name that no check covers, so it still scored 100.0: a score only reflects the checks you attach. We did not test table names written in quotation marks.
Download: snowflake_dq_scores_for_power_bi.sql · Get the full guide (PDF)
Get the full guide as a PDF
We'll email you a link to it. Please use your work email.
Thanks, . We're sending the link to now. If it isn't there in a few minutes, check your spam or junk folder, or email info@datameaning.com.
When this is not enough
- The score counts checks, not rows. A check with one broken row and a check with a million broken rows each count as one failed check.
DQ_CHECK_DAILYcarries the number each check returned for that reason. - A table that cannot be scored is only recorded. A table with no checks, or a table whose capture fails, gets a row in
DQ_CAPTURE_ISSUESand no score from that capture. The procedure call still succeeds when at least one other table is scored, so a task run would show no failure. Someone has to readDQ_CAPTURE_ISSUES, or show it in the report. A table that lost its checks after an earlier capture on the same day still shows that earlier capture inDQ_CHECK_DAILY, and its earlier score inDQ_SCORE_DAILY. - A capture that scores no table fails the run. If no registered table can be scored, the procedure records every table in
DQ_CAPTURE_ISSUESand then raises an error, so the task's run fails. By default 10 such runs in a row suspend the task (Snowflake docs, read 2026-11-05). Run 4 shows the call failing and the issue rows kept. After you fix the tables, resume a suspended task withALTER TASK DQ_CAPTURE_TASK RESUME. - Errors outside a table's capture still fail the run. An error outside one table's capture fails the task's run, for example if the procedure's owner role loses access to
DQ_TABLES, which the procedure reads before it captures any table. By default 10 failed runs in a row suspend the task (Snowflake docs, read 2026-11-05). We did not test an error outside a table's capture, and we did not run the task. - It is one snapshot a day. A check that failed and was fixed between two captures does not appear.
- The score is per table, not per rule. The two views score each table. To follow one rule over time, filter
DQ_CHECK_DAILYby itsEXPECTATION_NAME. - Days are UTC days. The procedure stamps every capture in UTC, and
SCORE_DAYis the UTC date of that stamp, whatever the session's time zone. In Snowflake's default time zone, America/Los_Angeles, the 06:00 UTC capture falls on the evening before; run 5 shows it counting for the UTC date. ReadCAPTURED_ATandSCORE_DAYas UTC, and do not convert them to local time in the report. TABLE_NAMEis only a label.DQ_SCORE_DAILYscores byTABLE_FQN, so two tables with the same name in different schemas get separate scores, but both show the sameTABLE_NAME. Label the report byTABLE_FQNwhen names repeat.- Every check weighs the same. A failed check on a regulatory report and one on a spare column move the score by the same amount.
- Nobody is told. The report shows a failure; it does not email the steward. Our guide on emailing the data steward when a Snowflake check fails covers that.
- We measured no cost. Data metric functions, the task and the report's queries all use compute on your account.
- Snowflake only. We did not test Databricks, and we did not run Power BI.
Native Data Quality turns approved rules into scores and trends in Power BI
Native Data Quality turns your approved data quality rules into checks that run inside your data platform. The results stay in the platform. When a rule fails, the steward of that rule gets an email.
Native Data Quality includes a report on every rule: its score, its trend, and which tables and columns the rules cover. It also includes scores and trends in Power BI. The SQL in this guide scores each table; the Native Data Quality report covers each rule.