Most Snowflake accounts we are brought into have solid pipelines and shaky governance. Masking is applied column by column by whoever remembered, row filtering lives inside secure views that nobody dares refactor, and "is this data still correct?" is answered by a Slack message. Snowflake Horizon — the governance and compliance surface that sits over the platform — has enough primitives now to replace all of that with a small amount of code you can keep in Git.
This tutorial builds a working governance layer on a fictional ACME.SALES schema. Everything below is plain SQL, so it drops straight into dbt, Terraform, Schemachange, or your CI job of choice.
The pieces, and why you use each one
| Need | Primitive |
|---|---|
| Label sensitive columns once, centrally | Object tags (plus classification for discovery) |
| Redact column values by role | Masking policy, attached to a tag rather than to columns |
| Restrict which rows a role can see | Row access policy + mapping table |
| Allow analysis but block row-level snooping | Aggregation policy / projection policy |
| Continuously check data quality | Data metric functions (DMFs) |
| Prove who touched what | ACCOUNT_USAGE.ACCESS_HISTORY, Trust Center |
The design rule that makes this maintainable: policies attach to tags, tags attach to columns. Adding a new PII column then becomes a one-line tag statement instead of a policy change.
1. Roles and a tag taxonomy
Keep governance objects in their own schema owned by a dedicated role. Do not let the ETL role own policies.
USE ROLE ACCOUNTADMIN;
CREATE ROLE IF NOT EXISTS GOVERNANCE_ADMIN;
GRANT CREATE TAG, CREATE MASKING POLICY, CREATE ROW ACCESS POLICY
ON SCHEMA ACME.GOVERNANCE TO ROLE GOVERNANCE_ADMIN;
GRANT APPLY MASKING POLICY, APPLY ROW ACCESS POLICY, APPLY TAG
ON ACCOUNT TO ROLE GOVERNANCE_ADMIN;
USE ROLE GOVERNANCE_ADMIN;
CREATE SCHEMA IF NOT EXISTS ACME.GOVERNANCE;
CREATE OR REPLACE TAG ACME.GOVERNANCE.DATA_SENSITIVITY
ALLOWED_VALUES 'public', 'internal', 'pii_email', 'pii_name', 'pii_national_id', 'financial'
COMMENT = 'Drives tag-based masking. One value per column.';
A constrained ALLOWED_VALUES list is the whole game. Free-text tags rot within a quarter.
Tag the columns:
ALTER TABLE ACME.SALES.CUSTOMER MODIFY COLUMN EMAIL
SET TAG ACME.GOVERNANCE.DATA_SENSITIVITY = 'pii_email';
ALTER TABLE ACME.SALES.CUSTOMER MODIFY COLUMN FULL_NAME
SET TAG ACME.GOVERNANCE.DATA_SENSITIVITY = 'pii_name';
ALTER TABLE ACME.SALES.CUSTOMER MODIFY COLUMN TAX_ID
SET TAG ACME.GOVERNANCE.DATA_SENSITIVITY = 'pii_national_id';
Don't hand-hunt for candidates. Let Snowflake's sensitive data classification propose them, then review:
SELECT SYSTEM$CLASSIFY('ACME.SALES.CUSTOMER', {'auto_tag': false});
SELECT * FROM TABLE(
INFORMATION_SCHEMA.TAG_REFERENCES_ALL_COLUMNS('ACME.SALES.CUSTOMER', 'table')
);
Classification is a suggestion engine, not an authority. We review its output with the data owner before tagging anything in production.
2. One masking policy per data type, attached to the tag
Masking policies are typed, so you need one per return type — not one per column.
CREATE OR REPLACE MASKING POLICY ACME.GOVERNANCE.MASK_EMAIL AS (val STRING)
RETURNS STRING ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
WHEN IS_ROLE_IN_SESSION('ANALYST')
THEN REGEXP_REPLACE(val, '^[^@]+', '****') -- keep the domain
ELSE '***MASKED***'
END;
CREATE OR REPLACE MASKING POLICY ACME.GOVERNANCE.MASK_STRING AS (val STRING)
RETURNS STRING ->
CASE WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val ELSE '***MASKED***' END;
Use IS_ROLE_IN_SESSION() rather than CURRENT_ROLE(): it respects secondary roles and role inheritance, which is what people actually expect when they activate a role hierarchy.
Now bind policies to tag values — this is the tag-based masking step, and it is where the maintenance saving comes from:
ALTER TAG ACME.GOVERNANCE.DATA_SENSITIVITY SET
MASKING POLICY ACME.GOVERNANCE.MASK_EMAIL,
MASKING POLICY ACME.GOVERNANCE.MASK_STRING;
One masking policy per return type can be attached to a tag; Snowflake applies the matching one based on the column's data type. If different tag values need different logic for the same type (say pii_name versus pii_national_id), branch inside the policy on the tag itself:
CREATE OR REPLACE MASKING POLICY ACME.GOVERNANCE.MASK_STRING AS (val STRING)
RETURNS STRING ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN val
WHEN SYSTEM$GET_TAG_ON_CURRENT_COLUMN('ACME.GOVERNANCE.DATA_SENSITIVITY') = 'pii_national_id'
THEN '***RESTRICTED***'
ELSE '***MASKED***'
END;
Test it the boring way — as each role:
USE ROLE ANALYST; SELECT EMAIL, FULL_NAME FROM ACME.SALES.CUSTOMER LIMIT 3;
USE ROLE PII_READER; SELECT EMAIL, FULL_NAME FROM ACME.SALES.CUSTOMER LIMIT 3;
3. Row access policies with a mapping table
Hard-coding regions into a policy body guarantees a change request every time sales reorganises. Use a mapping table instead.
CREATE OR REPLACE TABLE ACME.GOVERNANCE.ROLE_REGION_MAP (
ROLE_NAME STRING,
REGION STRING
);
INSERT INTO ACME.GOVERNANCE.ROLE_REGION_MAP VALUES
('SALES_EMEA','EMEA'), ('SALES_AMER','AMER'), ('SALES_APAC','APAC');
CREATE OR REPLACE ROW ACCESS POLICY ACME.GOVERNANCE.RAP_REGION
AS (region STRING) RETURNS BOOLEAN ->
IS_ROLE_IN_SESSION('SALES_GLOBAL')
OR EXISTS (
SELECT 1 FROM ACME.GOVERNANCE.ROLE_REGION_MAP m
WHERE m.REGION = region
AND IS_ROLE_IN_SESSION(m.ROLE_NAME)
);
ALTER TABLE ACME.SALES.ORDERS
ADD ROW ACCESS POLICY ACME.GOVERNANCE.RAP_REGION ON (REGION);
Three things to know before you ship this:
- Performance. The policy body runs as a correlated predicate on every query. Keep the mapping table tiny, avoid joins to large tables inside the policy, and let Snowflake cache the result set. A mapping table with millions of rows and a
LIKEpredicate is the classic way to make a warehouse look broken. - The policy column must exist. If a fact table lacks
REGION, either denormalise it in or apply the policy to a dimension-joined secure view. - Deletes and updates are filtered too. Row access policies apply to DML, so an ETL role that must see everything needs to be in the bypass branch or the merge will silently skip rows.
4. Aggregation and projection policies: the middle ground
Often the answer to "can analysts use this table?" is "yes, in aggregate." That used to mean building pre-aggregated views. Now it is a policy.
-- Force queries to return groups of at least 25 rows
CREATE OR REPLACE AGGREGATION POLICY ACME.GOVERNANCE.AGG_MIN_25
AS () RETURNS AGGREGATION_CONSTRAINT ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN NO_AGGREGATION_CONSTRAINT()
ELSE AGGREGATION_CONSTRAINT(MIN_GROUP_SIZE => 25)
END;
ALTER TABLE ACME.SALES.CUSTOMER
SET AGGREGATION POLICY ACME.GOVERNANCE.AGG_MIN_25;
-- Let a column be filtered/joined on but never SELECTed
CREATE OR REPLACE PROJECTION POLICY ACME.GOVERNANCE.NO_PROJECT
AS () RETURNS PROJECTION_CONSTRAINT ->
CASE
WHEN IS_ROLE_IN_SESSION('PII_READER') THEN PROJECTION_CONSTRAINT(ALLOW => true)
ELSE PROJECTION_CONSTRAINT(ALLOW => false)
END;
ALTER TABLE ACME.SALES.CUSTOMER MODIFY COLUMN TAX_ID
SET PROJECTION POLICY ACME.GOVERNANCE.NO_PROJECT;
This combination is what makes internal data sharing and clean-room-style collaboration practical: the counterparty can join and count, but cannot enumerate.
5. Data metric functions: quality checks that run themselves
Governance is not only access control. DMFs let Snowflake schedule quality checks against tables and log the results, so freshness and null-rate breaches are visible without an external framework.
-- Built-in metrics
ALTER TABLE ACME.SALES.ORDERS SET DATA_METRIC_SCHEDULE = '15 MINUTE';
ALTER TABLE ACME.SALES.ORDERS
ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.NULL_COUNT ON (CUSTOMER_ID);
ALTER TABLE ACME.SALES.ORDERS
ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.FRESHNESS ON (LOADED_AT);
ALTER TABLE ACME.SALES.ORDERS
ADD DATA METRIC FUNCTION SNOWFLAKE.CORE.DUPLICATE_COUNT ON (ORDER_ID);
-- A custom one: orders with a negative amount
CREATE OR REPLACE DATA METRIC FUNCTION ACME.GOVERNANCE.NEGATIVE_AMOUNTS(
arg_t TABLE(arg_c NUMBER)
) RETURNS NUMBER AS
$$
SELECT COUNT(*) FROM arg_t WHERE arg_c < 0
$$;
ALTER TABLE ACME.SALES.ORDERS
ADD DATA METRIC FUNCTION ACME.GOVERNANCE.NEGATIVE_AMOUNTS ON (AMOUNT);
Results land in SNOWFLAKE.LOCAL.DATA_QUALITY_MONITORING_RESULTS. Alert on them rather than eyeballing a dashboard:
SELECT measurement_time, table_name, metric_name, value
FROM SNOWFLAKE.LOCAL.DATA_QUALITY_MONITORING_RESULTS
WHERE measurement_time > DATEADD('day', -1, CURRENT_TIMESTAMP())
AND value > 0
ORDER BY measurement_time DESC;
Cost note: every DMF evaluation is compute on serverless warehouses. A 1-minute schedule on fifty tables is a real line item. Start at 15 minutes or TRIGGER_ON_CHANGES for the tables that matter and leave the rest daily.
6. Verify, then keep verifying
Governance that is never audited is decoration. Three queries we run on every engagement:
-- Which columns are tagged but have no policy in effect?
SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.TAG_REFERENCES
WHERE TAG_NAME = 'DATA_SENSITIVITY';
SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.POLICY_REFERENCES
WHERE POLICY_KIND IN ('MASKING POLICY','ROW ACCESS POLICY');
-- Who actually read a sensitive column in the last 30 days?
SELECT ah.user_name, ah.query_start_time, f.value:"objectName"::STRING AS obj
FROM SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY ah,
LATERAL FLATTEN(input => ah.base_objects_accessed) f
WHERE f.value:"objectName"::STRING = 'ACME.SALES.CUSTOMER'
AND ah.query_start_time > DATEADD('day', -30, CURRENT_TIMESTAMP())
ORDER BY ah.query_start_time DESC;
Pair that with the Trust Center scanner packages for security posture (over-privileged roles, users without MFA, stale keys) and you have both halves of Horizon covered: who can see data, and whether the account is configured safely.
Deployment pattern that survives contact with reality
- Governance objects live in one schema, in one Git repo, applied by CI with a dedicated role. Never click these into existence in the UI.
- Order matters: tags → policies → tag/policy attachments → table attachments. Make the scripts idempotent (
CREATE OR REPLACEfor policies,SET TAGis naturally idempotent). - Replacing a policy that is attached to hundreds of columns is instant because attachment is by reference — this is the payoff of the tag-based approach.
- Test with a role-impersonation test suite: for each (role, table) pair, assert row counts and masked values. Ten minutes of tests prevents the incident where an
ACCOUNTADMIN-owned view quietly bypasses everything. - Watch for bypass paths: secure views owned by a privileged role,
COPY INTOunloads to stages, shares created before policies existed, and cloned tables (clones inherit policies, but a clone into a schema without the tag definitions can behave unexpectedly).
Common failure modes we get called in to fix
- Policies applied to views instead of base tables. Someone finds the base table and the controls evaporate. Protect the table; views inherit.
CURRENT_ROLE()logic. Breaks the moment secondary roles are enabled. UseIS_ROLE_IN_SESSION().- Row access policy joins to a big table. Query times triple and nobody connects it to the policy. Keep mapping tables small and clustered.
- Tags with free-text values.
pii,PII,Pii-email— and now masking silently misses columns. UseALLOWED_VALUES. - No ETL bypass branch. Merges start missing rows. Every row access policy needs an explicit, documented bypass role.
Where to start on Monday
Run classification on your top ten tables, agree a six-value tag taxonomy with the data owners, ship two masking policies and one row access policy through CI, and add freshness plus null-count DMFs on the tables your executive dashboards depend on. That is a week of work and it removes the majority of audit findings we see.
If you would rather not spend that week discovering the edge cases yourself, our Snowflake architects do exactly this build-out — taxonomy, policy code, CI wiring, and an audit query pack you keep. Get in touch with your schema list and compliance drivers and we will scope it.