> ## Content Index
> Fetch the complete content index at: https://www.sfrt.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Snowflake data movement policies: the copy path gets a guardrail
- URL: https://www.sfrt.io/snowflake-data-movement-policies-the-copy-path-gets-a-guardrail/
- Published: 2026-08-20T05:40:41.000Z
- Updated: 2026-08-20T05:40:40.000Z
- Description: Masking decides what you can see. Data movement policies decide what you can take.
- Author: Martin Seifert
- Tags: Lakehouse

TL/DR: data movement policies hit general availability in Snowflake on Aug 19\. One rule per movement type sets a row budget, policies bundle rules and hang off tags, with three possible outcomes: block, alert, or allow.

---

Governance in ❄️ Snowflake had an asymmetry I kept stumbling into: Masking policies and row access policies determine whether a role may view a column. Fine, that half of the story is settled. However, once a role can read sensitive data, nothing restrains it from shipping the entire table somewhere else. I met this issue (for example) in my post on exporting Snowflake data to Excel: reads were governed, copies were not. 

[Exporting Snowflake data to ExcelGenerate XLSX files directly from Snowflake using a small Snowpark stored procedure, writing straight to a stage so stakeholders get ready-to-use Excel workbooks without any CSV exports or manual conversions![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/icon/headshot_ring-c71c03e7-222a-45f3-bc00-f927f3860431.png)sfrt.ioMartin Seifert![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/thumbnail/take_files-93911e6c-a317-41a8-a4af-7334beaae863.png)](https://www.sfrt.io/exporting-snowflake-data-to-excel/)

Data movement policies (DMP) are built to seal that gap 🥳

## The model: rules, policies, tags

The stack has three layers, modeled on the conditional governance Snowflake already ships. A (1) rule picks a single movement type and holds a `MAX_ROWS` expression. A (2) policy groups rules into a pair of lists. A (3) tag ties that construct to the data.

`MAX_ROWS` carries the weight... it returns an integer with three readings: NULL leaves a rule without effect, 0 refuses the operation, and any positive value caps how many rows that movement type may carry.

```sql
create or replace data movement rule pii_copy_hard_boundary
  type = 'COPY_INTO_EXTERNAL_STAGE'
  max_rows as () returns integer
  -> (1000);

create or replace data movement rule pii_agent_guard
  type = 'AGENT_ACCESS'
  max_rows as () returns integer
  -> (0);
```

Why would you want to allow 1000 rows of PII to be exported at all? Sounds rather arbitrary, but consider this just an example 😜

This way, one rule controls one movement type. The second rule above, which leaves agent access fully blocked, would have felt exotic a year back... today, with an MCP-style client coming out of every tool vendor, it appears not only reasonable, but rather urgent 😅

## Enforce vs. alert

Rules aggregate to a policy under one of two headings: `ENFORCE_RULES` may refuse an operation outright, `ALERT_RULES` let it complete and raise a notification. The two tiers map onto a pattern I use almost everywhere: a generous alert threshold first, a firmer ceiling above that.

```sql
create or replace data movement rule pii_copy_alert_budget
  type = 'COPY_INTO_EXTERNAL_STAGE'
  max_rows as () returns integer
  -> (500);

create or replace data movement policy pii_dmp
  enforce_rules = (pii_copy_hard_boundary, pii_agent_guard)
  alert_rules = (pii_copy_alert_budget)
  comment = 'PII guardrails: alert >= 500, block >= 1000';

alter tag pii set data movement policy pii_dmp;
```

The policy then binds to a tag at column, table, schema, or database scope, or falls back to the account as the baseline. Resolution starts at the most granular level that carries a policy and works down (or up? 🤔) toward the account. If one single query touches several policies at once, the lowest `MAX_ROWS` among them applies. Tag-based attachment is pretty neat: the tagging scheme already in place labels the sensitive domains, so the guardrail rides along with the tag rather than being welded onto every table individually.

This is an Enterprise Edition or above feature. Conditional governance is generally priced this way at Snowflake: masking and row access policies sit behind Enterprise too.

## What counts as movement

A rule's type must line up with the movement class Snowflake itself assigns to the query. Six classes exist t0day: `COPY_INTO_EXTERNAL_STAGE`, `COPY_INTO_INTERNAL_STAGE`, `AGENT_ACCESS`, `SNOWSIGHT_UI`, `UI_DOWNLOAD`, and `PROGRAMMATIC_FETCH`. Snowflake gives each statement exactly one primary type, picked in that fixed order. The consequence: a `COPY INTO` run by an agent still classifies as a `COPY_INTO_*` type, and an agent talking through a driver-like backend lands on `AGENT_ACCESS` before `PROGRAMMATIC_FETCH`.

A couple of classification quirks: Code running inside a stored procedure classifies as `PROGRAMMATIC_FETCH` even if the session that triggered it lives in Snowsight. However, `UI_DOWNLOAD` gets judged after the query finishes... crossing the threshold doesn't stop the query execution, it just disables the download control on the Snowsight results pane. Oh, and `UI_DOWNLOAD` accepts no alert rules whatsoever.

And then there is the agent angle: `AGENT_ACCESS` takes in agent and MCP-style clients. An access class that once read as an ordinary query over JDBC now produces its own visibility.

Two noteworthy limititations: Unloads that use `PARTITION BY` are blocked outright whenever DMP is live on the tables involved, no matter what a rule says. And cross-region shares fall outside DMP's reach... this is an exfiltration control, not a replication mechanism.

## Rollout

Begin alert-only with roomy budgets, find out where movement genuinely occurs, then bring in enforce rules for the risky domains and tighten gradually. Alongside that, I would encode approved workflows directly in the rule body:

```sql
create or replace data movement rule pii_copy_hard_boundary
  type = 'COPY_INTO_EXTERNAL_STAGE'
  max_rows as () returns integer
  -> (
      case
        when sys_context('SNOWFLAKE$SESSION', 'ROLE') = 'PII_TEAM_ROLE' then null
        else 500
      end
    );
```

Why express the exception as a `CASE` inside the rule and not as a separate grant? Because the carve-out is part of the movement workflow, not of the role's read privileges. The PII-team (what team is that again? 😅) keeps an uninterrupted export path, and no user holds a role that quietly dilutes DMP. `SYS_CONTEXT` additionally surfaces movement context, such as the destination stage's cloud provider and the name of the invoking agent, which lets a rule encode something like "exports to AWS may run to 1,000 rows, while every other destination smells wrong" 😉

## Watching the watchers

Both the policy inventory and observed violations surface in `ACCOUNT_USAGE`.

```sql
select query_id, movement_type, user_name, data_entities, timestamp
from snowflake.account_usage.data_movement_violations
order by timestamp desc
limit 100;
```

To set expectations about timing up front: the telemetry views can trail events by up to three hours, and violation capture is best effort. Enforcement occurs at query time, and the row that later appears in the view is an outcome, not the enforcement itself. One further trap for anyone migrating environments: cloned schemas carry over tag-to-policy bindings, yet cloned policies open with empty rule lists. Wire the rules in before trusting them, or the clone ends up with tags aimed at a policy that enforces nothing 😅

That would actually have been precisely the quiet failure DMP exists to eliminate... Oh well...

[Data movement policies | Snowflake Documentation![](https://storage.ghost.io/c/7d/94/7d942fe1-7868-4a1a-b2c9-4eb415b1a546/content/images/icon/favicon-1bbc56ec-3ebf-4bcf-afb8-a004c219b91f.png)Snowflake Documentation](https://docs.snowflake.com/en/user-guide/data-movement-policies?ref=sfrt.io)