🚀   Portable does more than just ELT. Explore Our AI Orchestration Capabilities 

Preserving Snowflake Column Tags with Portable: The Privileges You Actually Need

Sergio
CTO

When you connected Snowflake as a Portable destination, you gave us a role, a warehouse, a database, and a schema, and that role had everything a sync needs: it can create tables, stage files, and load data all day without complaint.

Then someone on your data governance team attaches a tag to a column, the kind that drives a tag-based masking policy, so analysts see ***-**-**** instead of a real SSN. Your next full refresh runs, the load succeeds, and the masking is quietly gone. No error, no warning. The column that was masked yesterday returns raw values today.

Portable has a setting for exactly this: Persist column tags on a full refresh sync, under the Policy Tags section of your Snowflake destination's settings. But flipping the switch isn't enough, because reattaching tags requires privileges that a normal load role doesn't have. And the tag itself usually lives in a governance schema your role can't see. This post explains what to grant, where, and why.

Why Full Refreshes Drop Column Tags

This isn't a bug, in Snowflake or in Portable. A full refresh exists to make your table converge to the source's current shape: columns added upstream appear, dropped columns disappear, type changes apply. To do that, Portable recreates the table with CREATE OR REPLACE TABLE.

A replaced table is a brand-new object. The column tag associations from the old table don't carry across, so the new table's columns come back untagged. The tag object itself still exists, and any tag-based masking policy attached to it is untouched, but with nothing on the new columns pointing at the tag, the policy no longer applies to them. The protection isn't deleted so much as orphaned.

So when the preserve toggle is on, Portable does the work for you:

  1. Capture. Before the refresh, we read which columns carry which tags, and the value set on each.
  2. Reattach. After the load, we run ALTER TABLE ... ALTER COLUMN ... SET TAG to put each tag back.

One honest difference from how this works on BigQuery: on Snowflake the reattach happens after the load completes, not as part of it. There's a brief window, between the table being replaced and the SET TAG statements running, where the refreshed table holds data with no tags on it, and therefore no tag-based masking. The window is short and within the same run, but if your threat model can't tolerate it at all, schedule full refreshes into a maintenance hour. The capture step needs nothing special. The reattach step is where privileges get interesting.

Two Privileges, and a Role Hierarchy in the Way

Reattaching a tag touches two things, and Snowflake checks your role for both:

  1. Reading the tags that were there before. Portable lists them through INFORMATION_SCHEMA.TAG_REFERENCES, which only returns tags your role is allowed to see.
  2. Setting the tags back on the columns. To run SET TAG, your role needs either the APPLY TAG privilege on the account, or the APPLY privilege on that specific tag plus ownership of the table.

That second clause matters for least privilege. Portable owns every table it creates, so the scoped path, APPLY on just the tags you use, is enough. You don't have to hand a load role the account-wide APPLY TAG.

The part that trips people up is the same shape as on BigQuery, just expressed in Snowflake's RBAC. The tag is usually defined in a central governance schema, owned by a security or platform team, not in the schema Portable loads into. Your role can write tables in its own schema all day and still be unable to see, let alone apply, a tag that lives next door. Visibility into the tag is a separate grant from anything your load role already has.

And there's a wrinkle BigQuery doesn't have: effective privileges in Snowflake come through a role hierarchy. The privilege you need might be granted directly to Portable's role, or to a role granted to it, two or three levels up. "Does this role have APPLY TAG?" is rarely a one-line answer, which is exactly why it's easy to get wrong and hard to debug.

What to Grant

Assume Portable connects as the role PORTABLE_LOADER. To let it read the existing tags, grant visibility into them, either narrowly or broadly:

-- narrow: the role can see tags defined in the governance schema
GRANT USAGE ON DATABASE governance TO ROLE portable_loader;
GRANT USAGE ON SCHEMA governance.tags TO ROLE portable_loader;

-- broad: account-wide governance visibility
GRANT DATABASE ROLE SNOWFLAKE.GOVERNANCE_VIEWER TO ROLE portable_loader;

To let it reattach the tags, pick one of the two paths Snowflake allows:

-- scoped (least privilege): APPLY on each tag Portable needs to reattach.
-- Portable owns the tables it creates, which satisfies the ownership half.
GRANT APPLY ON TAG governance.tags.pii_type TO ROLE portable_loader;

-- broad: one grant covers every tag, account-wide
GRANT APPLY TAG ON ACCOUNT TO ROLE portable_loader;

Just as important, what Portable does not need:

  • CREATE TAG, or ownership of the tags. Portable sets existing tags on columns. It never creates, alters, or drops a tag object. Leave tag authorship with your governance team.
  • The ability to read masked data. Reattaching a tag never requires seeing the values the masking policy protects. If a tool asks for that to manage tags, it's an over-grant. Push back, including on us.

A subtlety worth knowing before you grant anything: privileges only count if they reach the role Portable actually connects as. If you set a default secondary role or grant APPLY TAG to a role that isn't in PORTABLE_LOADER's hierarchy, the runtime SET TAG still fails. Grant to the connecting role, or to a role inside its chain.

Verifying the Setup Before a 3 AM Surprise

Portable runs these checks for you. When you enable the preserve toggle, we probe tag-read access against a real table in the destination and walk the role hierarchy looking for APPLY TAG, and a Verify permissions button in the same section re-runs them anytime. One limitation to know: that walk confirms the account-level APPLY TAG. If you took the scoped APPLY ON TAG path instead, the check honestly reports that it couldn't confirm the privilege rather than guessing, so a "couldn't verify" there isn't the same as a failure.

To check by hand, connect as the role Portable uses, not as ACCOUNTADMIN, or you'll verify the wrong principal:

USE ROLE portable_loader;

See what the role can actually do with tags. SHOW GRANTS is the honest tool here, and "honest" is doing some work, because it shows grants made directly to this role, not what it inherits through the hierarchy:

SHOW GRANTS TO ROLE portable_loader;

Look for APPLY TAG on ACCOUNT, or APPLY on the specific tags. If you don't see them here, check the roles this one inherits from before concluding they're missing:

-- roles granted TO portable_loader; repeat SHOW GRANTS on each to walk the chain
SHOW GRANTS TO ROLE portable_loader;  -- rows where "granted_on" = ROLE are inherited roles

Then confirm the role can actually read tags, the same call Portable makes before a refresh. Point it at one of your own tagged tables:

SELECT *
FROM TABLE(
  governance.INFORMATION_SCHEMA.TAG_REFERENCES('analytics.public.customers', 'TABLE')
);

An "insufficient privileges" error, or an empty result on a table you know is tagged, means the role can't see the tags. That's your missing read grant, identified before any sync runs.

If Tags Still Aren't Sticking

The toggle is on, you've made the grants, and a full refresh still drops or fails to reattach tags. The likely culprits, roughly in order:

  1. The privilege went to the wrong role. It was granted to a role Portable doesn't connect as, or one outside its hierarchy. Re-grant to the connecting role.
  2. Scoped APPLY without ownership. The APPLY ON TAG path also requires owning the table. Portable owns tables it created, but if a table was created out-of-band by another role, the reattach on it will fail. Either grant APPLY TAG on the account, or make sure Portable's role owns the tables.
  3. The role can't see the tag. No USAGE on the governance schema and no GOVERNANCE_VIEWER, so TAG_REFERENCES returns nothing and there's nothing to reattach.
  4. A new tag in a schema the role can't reach. Governance added a tag in a database your role has no USAGE on. Visibility is per-tag-location, not global, unless you used GOVERNANCE_VIEWER.

When a reattach does fail, Portable records it with the run, per table and per tag, so if you contact support we can tell you exactly which column couldn't be re-tagged and why.

Checklist

  • Column tag preservation is toggled on under your Snowflake destination's settings in Portable.
  • The role Portable connects as can read tags, via USAGE on the tag's schema or the SNOWFLAKE.GOVERNANCE_VIEWER database role.
  • That role can apply tags, via APPLY TAG on the account or APPLY on each specific tag.
  • If you used scoped APPLY ON TAG, Portable's role owns the tables it loads into (it does for tables it creates).
  • The grants landed on the role Portable actually connects as, or one inside its hierarchy, not a sibling role.
  • You verified with SHOW GRANTS as that role and a TAG_REFERENCES query against a tagged table, instead of inferring from role names.

One thing to keep in mind about Snowflake specifically: because the reattach runs after the load rather than as part of it, the run history is the authoritative record of whether your tags made it back on a given sync. If a governance audit needs proof, that's where it lives, per table and per tag.

Using BigQuery instead? Tag preservation works there too, with a different permission model (bigquery.tables.setCategory on the table and datacatalog.taxonomies.get on a taxonomy that often lives in another project). We wrote that one up separately, in our guide to BigQuery policy tag permissions.

Questions, or a privilege error that doesn't match anything above? Reach out. We've debugged this more times than we'd like to admit.