Category: Snowflake

Dive deep into the Snowflake Data Cloud. Guides on building a modern cloud data warehouse, data sharing, performance optimization, and leveraging advanced features like Snowpipe and Streams.

  • Identifying Hidden Token Costs in Snowflake Cortex AI

    Identifying Hidden Token Costs in Snowflake Cortex AI

    The demo works. It always does. You call AI_CLASSIFY on a sample of 10,000 rows, the credits barely move, and someone in the room says “this is so much cheaper than sending data to an external API.” Three weeks later your first real workload hits production — a million rows, five label classes, a moderately verbose model — and the bill is three times what you modelled. Nobody touched the model. Nobody changed the prompt. The data volume was planned. What went wrong?

    The short answer: Snowflake Cortex AI has three independent cost meters running in parallel, and two of them are nearly invisible until you go looking. The warehouse credit line your resource monitors watch? That’s only one of the three. The other two — AI token consumption and always-on serving compute — accumulate quietly in tables most engineers haven’t queried yet.

    After the April 2026 introduction of AI Credits as a separate billing currency, the gap between what teams expect to pay and what actually lands on the invoice got wider, not narrower. This piece maps exactly where the hidden costs live, shows you the math on each one, and gives you the SQL to surface them before your finance team does.

    TL;DR

    • Snowflake Cortex AI bills across three independent meters — warehouse compute, AI token consumption, and serving compute — and resource monitors only cover the first one.
    • Functions like AI_CLASSIFYAI_SENTIMENT, and AI_SUMMARIZE silently inject a system prompt before your text, so the billed token count is always higher than the text you actually sent.
    • For AI_CLASSIFY, your label list is counted as input tokens for every single row processed, not once per call — a five-class classifier with verbose descriptions can multiply your expected token count by 2–4×.
    • Cortex Search charges a continuous serving-compute fee per GB of indexed data per month, regardless of whether any queries are running — a 70 GB corpus costs roughly $882/month at rest.
    • As of April 2026, AI Features bill in AI Credits ($2.00 global / $2.20 regional), which are separate from Platform Credits; the two currency types can coexist on the same bill and require different monitoring queries.
    • Query SNOWFLAKE.ACCOUNT_USAGE.CORTEX_FUNCTIONS_USAGE_HISTORY per function and model to find your real cost breakdown; do not try to sum multiple overlapping views or you will double-count.
    • Model selection is still the single largest cost lever — the same classification workload can differ by 10–60× in price depending on which model you choose.

    Why the Demo Lied to You

    The confusion starts with how Snowflake traditionally teaches cost intuition. For years, the mental model was: bigger warehouse = more credits = more cost. You learned to right-size warehouses, use auto-suspend, and watch the METERING_HISTORY view. That model works fine for compute-heavy SQL. It actively misleads you for Cortex AI.

    When you run a Cortex AI function, the warehouse compute cost still applies — your VWH is active while the query runs, so those credits accumulate. But the AI token charges are separate, billed in a different currency against a different meter, and they show up in different Account Usage views. A demo on a SMALL warehouse processing 10,000 rows barely registers on either meter. A production run of one million rows with a frontier model is a completely different animal.

    One well-documented real-world example: a team processed 1.18 billion records using Cortex Functions and received a single-query bill of nearly $5,000 — almost entirely from token costs, with minimal warehouse compute. Their resource monitors never triggered because resource monitors don’t watch the AI token meter. The bill simply appeared.

    The April 2026 billing restructure added another wrinkle. Snowflake introduced AI Credits as a separate billing currency, flat-priced at $2.00 per credit for global routing or $2.20 for regional routing, independent of your Snowflake edition. This means an Enterprise customer and a Standard customer pay exactly the same rate for AI inference — but the two credit types appear as separate line items and require separate monitoring logic. If you built a cost dashboard before April 2026, it is almost certainly incomplete.

    The Token Inflation You’re Not Accounting For

    Most engineers assume “tokens billed = tokens in my text.” For AI_COMPLETE with a hand-written prompt that assumption is roughly correct. For the structured AI functions — AI_CLASSIFYAI_SENTIMENTAI_FILTERAI_AGGAI_SUMMARIZEAI_TRANSLATE — it is wrong in ways the documentation buries in a footnote.

    According to Snowflake’s official cost documentation, these functions add a system prompt to your input text before sending it to the model. The billed token count is therefore always higher than the number of tokens in the text you provide. You pay for the system prompt on every row. You have no visibility into how long that system prompt is. You cannot opt out.

    For AI_CLASSIFY specifically, the hidden cost compounds further: your label list, descriptions, and examples are counted as input tokens for every record processed, not once per call. If you have five label classes with 30-word descriptions each, you’re paying for roughly 150 extra tokens on every single row. Run that against a million-row table and you’ve added 150 million tokens of cost that had nothing to do with your data.

    The fix is to measure before you scale. Snowflake provides a AI_COUNT_TOKENS function that reports token counts without incurring LLM charges — use it on a sample to calibrate your label overhead before committing to a full-table run:

    -- Estimate label overhead before running AI_CLASSIFY at scale
    SELECT
      COUNT(*) AS sample_rows,
      AVG(SNOWFLAKE.CORTEX.AI_COUNT_TOKENS(
        'llama3.1-8b',
        your_text_column
      )) AS avg_text_tokens,
      -- Add your label string manually to see the combined token count
      AVG(SNOWFLAKE.CORTEX.AI_COUNT_TOKENS(
        'llama3.1-8b',
        your_text_column || ' CATEGORIES: positive, negative, neutral, urgent, spam'
      )) AS avg_with_labels_tokens
    FROM your_table
    LIMIT 5000;
    

    The gap between avg_text_tokens and avg_with_labels_tokens is your label overhead per row. Multiply by row count and by the per-million-token rate for your chosen model to get a cost estimate before you fire the real query. This takes five minutes and can prevent a four-figure surprise.

    The Two-Currency Problem

    Before you can build a cost dashboard, you need to understand which features bill in which currency — because the monitoring SQL differs by type.

    Cortex FeatureCredit TypeBilling DimensionPrimary Usage View
    AI Functions (AI_COMPLETE, AI_CLASSIFY, AI_EMBED, etc.)AI CreditPer million tokens (input + output)CORTEX_FUNCTIONS_USAGE_HISTORY
    Cortex AgentsAI CreditPer million tokens; additive across sub-callsCORTEX_AGENT_USAGE_HISTORY
    Cortex Search (serving)AI CreditPer GB indexed per month, continuousCORTEX_SEARCH_SERVING_USAGE_HISTORY
    Cortex Search (embedding)AI CreditPer token on insert/updateCORTEX_SEARCH_SERVING_USAGE_HISTORY
    AI Parse DocAI CreditPer 1,000 pages; each page = 970 tokensCORTEX_DOCUMENT_PROCESSING_USAGE_HISTORY
    Cortex Analyst API (standalone)Platform CreditPer 1,000 messagesMETERING_DAILY_HISTORY
    Cortex Fine-tuningPlatform CreditPer compute jobMETERING_DAILY_HISTORY
    Virtual Warehouse (any query)Platform CreditPer second, 60-second minimumWAREHOUSE_METERING_HISTORY

    The important detail: Cortex AI Functions like AI_COMPLETE stack two meters simultaneously. You pay AI Credits for the tokens, and you pay Platform Credits for the warehouse time your query consumed. A query that takes 30 seconds on a MEDIUM warehouse and processes 500,000 tokens is billing on two completely separate ledgers. Neither one cancels the other. Snowflake’s recommendation is to use no larger than a MEDIUM warehouse for Cortex AI calls, because a larger warehouse doesn’t speed up token processing — it just burns more Platform Credits for the same result.

    The Cortex Search Idle Tax

    Cortex Search is architecturally different from the AI SQL functions. It’s a managed vector-search service: you create a search service over a table, Snowflake indexes it, and you query it via a REST call or through Cortex Agents. The billing model reflects this — and it’s the most surprising line item for teams that build and then deprioritize a search-based RAG feature.

    Cortex Search’s serving compute bills continuously per GB of indexed data per month, while the service is resumed — whether or not any queries are running. The Snowflake pricing documentation confirms this: “A running search service incurs costs even when it isn’t serving queries.” Based on the Service Consumption Table, the serving rate is 6.3 AI Credits per GB per month. At the global AI Credit price of $2.00, that’s $12.60 per GB per month, every month, at rest.

    Run the math for a team that has multiple Cortex Search services:

    ScenarioIndexed Data (GB)AI Credits/moCost/mo (global)
    Single knowledge base (small)20 GB126 Cr$252 / mo
    Single knowledge base (medium)70 GB441 Cr$882 / mo
    5 domain services × 70 GB350 GB2,205 Cr$4,410 / mo
    Dev service (left running)30 GB189 Cr$378 / mo (wasted)

    The dev service row is where most teams first notice the problem. Someone spun up a search service in a development environment to prototype a chatbot, the project shifted priorities, and the service kept running. It doesn’t consume query tokens because nobody’s hitting it. It consumes serving compute because it exists. That’s $378/month for a service that produced zero output in that billing period.

    The mitigation is straightforward: configure AUTO_SUSPEND on any search service that has predictable idle windows, and manually suspend development services when a feature is deprioritised. Snowflake Batch Search is an alternative for workloads that don’t need real-time retrieval — its serving compute runs only during the batch job, not continuously.

    Cortex Agents: The Cost Multiplier Nobody Drew on the Whiteboard

    Cortex Agents are billed per million tokens, in AI Credits, with rates determined by the underlying model. That sounds simple. The complication is that agents orchestrate multi-step workflows, and every step that invokes a sub-service generates its own token consumption. Snowflake’s official pricing docs state it directly: costs are additive across the underlying services the agent invokes.

    A realistic agent loop might look like this: the agent receives a user question (input tokens), calls Cortex Search to retrieve context (embedding tokens + serving compute), calls Cortex Analyst to generate SQL (Analyst tokens), executes the SQL on a warehouse (Platform Credits), and then calls an LLM to formulate a final answer (more input + output tokens). Every hop generates its own consumption. The result visible to the user is a single response. The result visible to your billing dashboard is five separate line items, split across two credit types, spread across four different usage views.

    Standard monitoring via CORTEX_FUNCTIONS_USAGE_HISTORY does not provide agent-specific breakdowns. To get token-level visibility per agent, you need to query SNOWFLAKE.LOCAL.AI_OBSERVABILITY_EVENTS — a system table that captures token counts, models used, timing, and execution context for each agent invocation. That table is not surfaced by default in the Snowflake UI; you have to query it directly.

    -- Per-agent token cost attribution
    -- Requires ACCOUNTADMIN or SNOWFLAKE_TELEMETRY privilege
    SELECT
      agent_name,
      model_name,
      DATE_TRUNC('day', event_timestamp) AS event_day,
      SUM(input_tokens)                  AS total_input_tokens,
      SUM(output_tokens)                 AS total_output_tokens,
      SUM(input_tokens + output_tokens)  AS total_tokens
    FROM SNOWFLAKE.LOCAL.AI_OBSERVABILITY_EVENTS
    WHERE event_timestamp >= CURRENT_DATE - 30
    GROUP BY 1, 2, 3
    ORDER BY total_tokens DESC;
    

    For AI SQL functions, your canonical daily monitoring query should look like this:

    -- Cortex AI function cost by model and function — last 30 days
    -- Use CORTEX_FUNCTIONS_USAGE_HISTORY as the single source; do NOT sum
    -- across CORTEX_AISQL_USAGE_HISTORY and CORTEX_FUNCTIONS_USAGE_HISTORY together
    SELECT
      DATE_TRUNC('day', start_time)   AS usage_day,
      function_name,
      model_name,
      SUM(input_tokens)               AS input_tokens,
      SUM(output_tokens)              AS output_tokens,
      SUM(credits_used)               AS ai_credits,
      ROUND(SUM(credits_used) * 2.00, 2) AS est_cost_usd
    FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_FUNCTIONS_USAGE_HISTORY
    WHERE start_time >= CURRENT_DATE - 30
    GROUP BY 1, 2, 3
    ORDER BY ai_credits DESC;
    

    One critical warning from Snowflake’s own community documentation: the views CORTEX_AISQL_USAGE_HISTORYCORTEX_FUNCTIONS_USAGE_HISTORY, and an incremental metering path all overlap. Summing them produces double-counts. Pick one canonical view per service type and reconcile totals against the matching service type in METERING_DAILY_HISTORY.

    Non-Text Inputs: The Per-Page and Per-Second Trap

    If your team is using Cortex for document intelligence — contract review, PDF extraction, audio transcription — the token model changes again. AI_PARSE_DOCUMENT and AI_EXTRACT bill by page rather than by text token: each page in a document counts as 970 tokens. A 50-page contract isn’t 50 pages of your text column — it’s 48,500 tokens before a single word of your prompt or the model’s output enters the meter.

    Audio inputs bill at 50 tokens per second of audio. A one-hour customer support call is 180,000 audio tokens before output tokens are added. At frontier model rates, an hour of audio can cost more than a thousand-word document by a wide margin.

    The implication for pipeline design: always pre-filter. Before sending a document to AI_EXTRACT, check page count. Before sending audio to a transcription function, check duration. For PDFs specifically, page-level sampling — sending only the pages likely to contain the target information — can reduce cost by 60–80% compared to sending the full document.

    The Gotchas Nobody Warns You About

    Your existing resource monitors don’t cover AI token spend.Resource monitors in Snowflake watch warehouse compute credits. They have no visibility into AI Credit consumption. A runaway AI_CLASSIFY job on a large table will not trigger your existing budget alerts. You need separate alerting built on CORTEX_FUNCTIONS_USAGE_HISTORY and wired to a Snowflake Task and notification integration.

    The regional routing setting silently raises every AI bill by 10%.If your account has CORTEX_ENABLED_CROSS_REGION set to DISABLED or a specific regional setting for data residency, you’re paying $2.20 per AI Credit instead of $2.00. That’s a 10% tax on every token across every Cortex AI feature, and it’s an account-level parameter many teams set once during a compliance review and never revisit against their cost model.

    Cortex Analyst through the standalone API still bills in Platform Credits, not AI Credits.If you’re calling Cortex Analyst via the REST API directly rather than through Cortex Agents, it bills per 1,000 messages at Platform Credit rates — which vary by your Snowflake edition. The same Analyst call made through a Cortex Agent costs in AI Credits. The same feature, two different billing regimes, depending on how you invoke it.

    Materializing AI results is almost always cheaper than recomputing them.Teams building pipelines that call AI_CLASSIFY or AI_SENTIMENT inside a scheduled task often reprocess unchanged records on every run. The AI functions have no inherent awareness of which records changed since the last run. Join against your source table’s UPDATED_AT column, write results to a separate table, and only pass new or modified rows to the AI function. This pattern, applied consistently, can reduce ongoing AI Credit consumption by 50–90% for stable datasets.

    The Cortex Guard security layer adds its own token cost on top of AI_COMPLETE.If you’re using Cortex Guard to filter model outputs for safety — which is sensible for user-facing applications — it bills separately from the underlying AI_COMPLETE call. The input token count for Cortex Guard is based on the number of tokens in AI_COMPLETE’s output. In other words, longer model responses cost more not once but twice: once when generated, and again when scanned by the guard.

    The One Principle

    “Treat Cortex AI cost engineering the same way you treat warehouse sizing — measure before you scale, not after. The token meter doesn’t have a circuit breaker unless you build one.”

    Related reading: Cortex Search RAG guide · Cortex Code and dbt optimization · Governing AI Agents in Snowflake · AI coding agents and pipeline security · What actually works when building AI agents · Snowflake Cortex AI cost docs (official) · Snowflake AI pricing and AI Credits (official)

  • Snowflake Time Travel vs. Fail-safe: What Gets Recovered and When

    Snowflake Time Travel vs. Fail-safe: What Gets Recovered and When

    3:14 a.m., and a migration script hands off to DROP TABLE orders_staging; against what everyone on the team swore was a permanent table. It wasn’t. Somewhere in the last quarter it got recreated as TRANSIENT to shave storage costs, and nobody updated the runbook. The on-call engineer isn’t worried — Snowflake has Time Travel, Snowflake has Fail-safe, this is a solved problem. Except Fail-safe doesn’t apply to transient tables. Zero days. The table is gone the moment its one-day Time Travel window closes, and by the time anyone notices, it already has. Six hours of ingestion, rebuilt by hand from source, on a Saturday.

    That’s the gap this article is about. Time Travel and Fail-safe get talked about together so often that people assume they’re one continuous safety net. They’re not the same feature, they don’t behave the same way, and the difference has real financial and recovery-time consequences that most teams only discover during an incident.

    TL;DR

    • → Time Travel lets you query, clone, or UNDROP historical data yourself for a retention window you configure — 0 to 1 day on Standard Edition, up to 90 days on Enterprise Edition and above, for permanent objects.
    • → Fail-safe is a separate, fixed 7-day recovery period that starts after Time Travel expires, and it is not self-service — only Snowflake Support can pull data back from it, and only for permanent tables.
    • → Transient and temporary tables carry zero Fail-safe days. They’re cheaper precisely because Snowflake gives up that protection.
    • → Both features bill as storage: Time Travel data accrues at the normal storage rate, and a table with heavy daily updates on a long retention window can multiply its effective storage several times over.
    • → Time Travel doesn’t create a second copy of your table — it retains the old micro-partitions that a write would otherwise discard, via Snowflake’s copy-on-write architecture.
    • → UNDROP TABLEAT, and BEFORE only work inside the Time Travel window. Once an object crosses into Fail-safe, none of those commands work anymore.

    What Time Travel Actually Stores

    Snowflake tables are stored as immutable micro-partitions — compressed, columnar chunks of roughly 50–500 MB of uncompressed data each. When you run an UPDATE or DELETE, Snowflake doesn’t rewrite rows in place. It writes new micro-partitions reflecting the change and stops referencing the old ones from the table’s current state. That’s the whole trick: Time Travel is Snowflake choosing not to immediately throw those old partitions away.

    Snowflake doesn’t back up a table for Time Travel — it just delays discarding the partitions a write would otherwise drop.

    Every table, schema, and database has a DATA_RETENTION_TIME_IN_DAYS parameter that controls how long those superseded partitions stick around before they’re eligible for permanent deletion. Retention is inherited: set it on a database and every schema and table created under it picks up the value unless overridden lower down. There’s also an account-level MIN_DATA_RETENTION_TIME_IN_DAYS floor — if it’s set, the effective retention for any object becomes whichever is larger, its own setting or the floor. That parameter is easy to forget exists and even easier to be surprised by months later.

    Standard Edition accounts get 1 day of retention by default, and you can only turn it down to 0 — there’s no way to go longer without upgrading to Enterprise Edition. Enterprise and above allow up to 90 days for permanent databases, schemas, and tables, configurable per object.

    Retention by Table Type — the Comparison Nobody Reads Until It’s Too Late

    The gotcha in the opening story lives entirely in this table. Table type determines the retention ceiling independent of edition, and it determines whether Fail-safe exists at all.

    Table typeTime Travel rangeFail-safe periodNotes
    Permanent0–1 day (Standard) · 0–90 days (Enterprise+)7 days, fixedThe only table type with Fail-safe protection
    Transient0–1 day, on any edition0 daysCapped at 1 day even on Enterprise — cannot be extended
    Temporary0–1 day, session-scoped0 daysDropped automatically when the session ends

    Notice that transient tables don’t just lose Fail-safe — their Time Travel ceiling is capped at one day regardless of what edition you’re on or what the account default says. That’s the entire reason transient tables cost less to store: Snowflake is retaining less history for them, full stop.

    Querying and Restoring Inside the Window

    Time Travel is queryable directly in SQL, three ways: by timestamp, by relative offset, or by the query ID of the statement that changed the data.

    -- Query a table as it existed at a specific timestamp
    SELECT * FROM orders
    AT (TIMESTAMP => '2026-07-28 09:00:00'::timestamp);
    
    -- Query as it existed immediately before a specific statement ran
    SELECT * FROM orders
    BEFORE (STATEMENT => '8e5d0c1d-0073-4f57-8263-6e6bb1a2b1d4');
    
    -- Restore an accidentally dropped table, in place
    UNDROP TABLE orders_staging;
    
    -- Clone a table's state from 6 hours ago into a new object,
    -- useful for diffing without touching the live table
    CREATE TABLE orders_audit_clone
    CLONE orders AT (OFFSET => -60*60*6);
    

    All four of those commands only work while the object — or the specific rows you’re targeting — is still inside its Time Travel window. Past that point, UNDROP returns an object-not-found error, not a graceful fallback into Fail-safe. This trips people up constantly: Fail-safe existing doesn’t mean these commands quietly keep working against it. They don’t.

    Fail-safe: What It’s For, and What It Isn’t

    Fail-safe is the part of this system most engineers get wrong, because the name suggests self-service safety and it’s the opposite. It’s a fixed, non-configurable 7-day period that begins the moment an object’s Time Travel retention expires, and it exists for Snowflake’s disaster-recovery purposes — not for routine “oops I dropped a table” moments. You cannot query it, clone from it, or run UNDROP against it. The only path back is a support ticket, and Snowflake is explicit that recovery through Fail-safe can take anywhere from hours to several days, positioning it as a last resort rather than a recovery SLA you can plan around.

    Only permanent tables get Fail-safe. It’s a fixed 7-day buffer after Time Travel expires, and it’s not something you access yourself.

    And critically, Fail-safe only exists for permanent tables. Look back at the comparison table above — transient and temporary objects get 0 days of it. If your team leans on transient tables for staging (a completely reasonable cost optimization, discussed in our zero-copy cloning guide), you’ve implicitly decided that a mistake on those tables gets exactly one day — the Time Travel window — before it’s unrecoverable at any price, support ticket included.

    The Cost Math

    Time Travel and Fail-safe both bill as storage, at your account’s normal per-terabyte rate. As of 2026, Snowflake’s published on-demand list price is around $23 per compressed terabyte per month for AWS US East, with regional variation — worth flagging because a lot of older guides still cite a $40/TB figure that Snowflake has since moved off of.

    The part that surprises people isn’t the rate, it’s the multiplier. Time Travel storage isn’t billed once — it’s billed for the entire retention window, for every version of every changed row, not just until the next write overwrites it.

    Illustrative numbers, not a live account screenshot — but the shape of the math holds: retention window × daily churn rate is the real cost driver, not table size alone.

    A 100 GB table with 10% of its rows modified daily, sitting on a 90-day retention setting, accrues on the order of 10 GB of Time Travel history per day. Over the full window that’s roughly 900 GB — close to a 9x storage multiplier over the table’s own size, from one retention setting on one heavily-churned table. Multiply that across every staging and fact table on a 90-day account default and it stops being a rounding error.

    This is also where the MIN_DATA_RETENTION_TIME_IN_DAYS parameter bites teams that think they’ve already optimized. Someone sets an individual table’s retention to 1 day to cut costs, but the account-level minimum is still 30 — the effective retention is the larger of the two, and the storage bill doesn’t move.

    The Gotchas Nobody Warns You About

    Transient tables have zero Fail-safe, by design, not by oversight. That’s the trade you’re making every time you choose transient for cost savings. It’s a good trade for genuinely disposable staging data. It’s a bad surprise for anything that turns out to matter more than you thought.

    Dropping and recreating a schema resets what its children inherit. If you drop a schema and recreate it with a different DATA_RETENTION_TIME_IN_DAYS, tables created afterward inherit the new value — but objects dropped under the old setting keep whatever retention was active at the time they were dropped, not the new one. It’s easy to assume a schema-level change is retroactive. It isn’t.

    The account-level minimum silently overrides a lower table-level setting. As covered above — if you’re trying to cut Time Travel storage costs by lowering retention on specific tables and the number on your bill doesn’t move, check MIN_DATA_RETENTION_TIME_IN_DAYS before assuming the change didn’t take.

    Cloning at a past timestamp quietly locks in stale data. CREATE TABLE ... CLONE x AT (...) is a zero-copy operation that materializes as a real object pointing at that historical state. It’s easy to leave one of these lying around after an investigation and forget it’s not tracking the live table anymore.

    Fail-safe recovery is not a routine operation, and Snowflake treats it that way. There’s no dashboard, no self-service button, and no fixed turnaround time — it’s a support ticket that gets prioritized as the disaster-recovery mechanism it was designed to be, not an extension of your undo history.

    The One Principle

    Time Travel is a tool you use; Fail-safe is a safety net Snowflake uses on your behalf — design your retention and table types as if Fail-safe doesn’t exist, because for anything outside a permanent table, it doesn’t.

    Related reading: Snowflake Time Travel architecture, deep dive · zero-copy cloning for storage and CI/CD · how Snowflake stores data internally · Snowflake docs: Understanding and using Time Travel · Snowflake docs: Understanding storage cost

  • The Medallion Architecture, Reconsidered: What It Solved and Where It Cracks

    The Medallion Architecture, Reconsidered: What It Solved and Where It Cracks

    Almost every data team says it’s “doing medallion architecture.” Look under the hood and most of them aren’t — they have a Bronze layer that’s a dumping ground, a Silver layer that’s Bronze with nicer column names, and a Gold layer business users technically have access to but can’t actually use. That gap between the tidy Bronze → Silver → Gold diagram in Confluence and the thing that pages someone at 3 a.m. isn’t a sign the teams are sloppy. It’s a sign the pattern itself has load-bearing cracks that only show up at scale.

    To be clear up front: medallion is not bad. It solved a genuine problem, and for a lot of teams it’s still the right default. But it’s now old enough, and deployed widely enough, that the failure modes are well documented — and a wave of 2025–2026 writing (including Adam Bellemare’s widely-shared “The End of the Bronze Age”) has moved from “here’s how to do medallion” to “here’s where medallion breaks and what comes next.” This is a practitioner’s tour of both halves: what the pattern actually solved, the specific places it cracks, and the shift-left / data-product thinking that’s emerging as the alternative — without pretending the alternative is free.

    TL;DR

    • → Medallion (Bronze/Silver/Gold) solved a real problem: it gave data-lake chaos a legible, staged structure with progressive quality guarantees and clear replay points.
    • → Its core weakness is that it’s a multi-hop pull architecture — the consumer owns data access, and cleaning happens repeatedly downstream instead of once at the source.
    • → Every hop re-reads, re-processes, and re-writes the same data, so you pay storage and compute for the same record two or three times over.
    • → The Bronze layer is fragile: it’s tightly coupled to source schemas, so an upstream column rename can silently break everything downstream.
    • → In practice, Silver often collapses into “Bronze with better names,” and Gold tables ship that no one can actually consume — the layers stop earning their keep.
    • → The emerging alternative is shift-left: clean and contract the data once, near the source, as a reusable data product serving both analytical and operational consumers.
    • → This isn’t a migration you rush. Medallion is still fine for many teams; shift-left trades pipeline cost for organizational and contract discipline you have to actually be able to sustain.

    What medallion actually solved

    Before piling on, give the pattern its due, because the reasons it won are the reasons it’s still everywhere. Data lakes started as swamps: raw files dumped into object storage with no structure, no quality guarantees, and no obvious place for any given transformation to live. Medallion imposed a legible order on that chaos. Bronze is the raw landing zone, a faithful mirror of the source. Silver is cleaned, deduplicated, conformed data organized around business entities. Gold is denormalized, read-optimized, application-aligned output. Three layers, quality rising left to right, each with a clear job.

    That structure bought three real things. It gave teams a shared vocabulary — “is this a Silver table?” is a meaningful question. It created natural replay points — when something breaks, you can reprocess from Bronze rather than re-ingesting from the source. And it mapped cleanly onto the tooling, which is exactly why I’ve recommended a version of it for organizing transformation work in structuring dbt projects into staging, intermediate, and mart layers. None of that value evaporates because the pattern has limits. The point isn’t that medallion is wrong; it’s that its assumptions stop holding as scale and consumer count grow.

    Where it cracks, crack #1: you pay for the same data three times

    The most concrete flaw is cost, and it’s structural, not incidental. Medallion is a multi-hop architecture: to get from raw to usable, the same data is copied and reprocessed at each layer. Populating Bronze means reading and writing the data once. Producing Silver means reading Bronze, transforming, and writing again. Gold reads Silver and writes a third time. Each hop incurs its own storage, network, and compute bill — for what is, fundamentally, the same record getting progressively reshaped.

    The multi-hop tax, animated: one logical record gets re-read, re-processed, and re-written at every medallion layer — you’re billed for storage and compute once per hop, not once per record.

    On a small pipeline this is invisible. On a wide table with billions of rows and a short SLA, the triple-write becomes a line item someone in finance eventually circles in red. It compounds, too: an unsure consumer who can’t tell which layer to trust often just builds their own pipeline from the source, adding a fourth and fifth copy. The pattern that was supposed to reduce duplication quietly manufactures it. This is the same immutability-and-rewrite economics I dug into for how Snowflake stores data internally — every materialization is a real, billed rewrite, and medallion mandates three of them by design.

    Crack #2: the Bronze layer is brittle by construction

    Bronze is defined as a near-mirror of the source, which means it’s tightly coupled to the source’s schema — and tight coupling to something you don’t control is fragility by another name. When an upstream team renames a column, changes a type, or restructures a table, the Bronze ingestion and every transformation layered on top of it can break. The consumer, who owns the pull, absorbs all of that pain without any ownership or influence over the source model. It’s a reactive posture: you’re perpetually reacting to changes made by people who have no reason to warn you.

    This is precisely the failure I walked through in how one renamed column kills a pipeline, and medallion structurally guarantees you’ll keep hitting it, because it puts the cleaning burden downstream of the schema you don’t own. The layers also have a way of quietly degrading: under deadline pressure, Silver becomes “Bronze with renamed columns and a dedupe,” and Gold becomes a table that technically exists but that no analyst can actually build a report from. When that happens, you’re paying the three-copy cost without getting the quality-progression benefit the copies were supposed to buy.

    Crack #3: nothing gets reused for operational workloads

    Medallion lives in the analytical world. The cleaning, standardizing, and modeling work all happens inside the analytics stack, processed by periodic batch jobs. That work is invisible and unusable to operational systems, which need low-latency access and can’t wait on a nightly batch. So operational teams build their own separate path to the same source data — duplicating the standardization logic, and widening the very operational-analytical divide the platform was supposed to bridge. You end up doing the same “what does a valid customer address look like” work twice, in two stacks, with two subtly different answers.

    The emerging alternative: shift left

    The through-line of every crack above is the same: cleaning happens repeatedly, downstream, owned by consumers who don’t control the source. Shift-left inverts that. Instead of each consumer pulling raw data and re-cleaning it, you do the cleaning and standardization once, as close to the source as possible, and publish the result as a reusable data product with an explicit contract.

    The shift-left move: take the cleaning work you were doing in Bronze/Silver and do it once at the source as a contracted data product, reused by both analytical and operational consumers instead of re-copied down a chain.

    Two ideas make this work. A data product is data published with the same care as any other product — owned, documented, discoverable, with a named owner who sits on the team that produces the source. A data contract is the formal agreement about that product’s schema, its evolution rules, and its SLAs, acting as a stable-but-evolvable API and a barrier between the producer’s internal model and everyone downstream. Cleaning once at the source kills the triple-copy cost, the contract kills the brittle-coupling problem (schema changes now go through an agreed evolution process instead of silently breaking you), and publishing the product in both streaming and table modes lets a single investment serve operational and analytical consumers at once. Open table formats like Apache Iceberg are a big part of why this is newly practical — you can materialize a table from a stream without making yet another copy, the same open-format shift I covered in the native-tables-to-Iceberg migration piece.

    So should you rip out medallion? Almost certainly not yet

    Here’s the honest counterweight, because the shift-left literature can read like a sales pitch. Shift-left doesn’t delete the work — it relocates it, and relocation has a cost the diagrams hide. Cleaning at the source means the source team now owns data-product responsibilities they may not want, staff for, or be organizationally incentivized to do. Data contracts require negotiation, governance, and social buy-in across teams that historically didn’t talk. For a legacy source you can’t modify, or an org where the producing team won’t cooperate, a full shift-left is simply not available, and you’ll end up doing the cleaning outside the source anyway — which looks a lot like Bronze with extra steps.

    The realistic path is incremental: shift one high-value, high-pain dataset left, prove the contract model works socially and technically, and expand from there — while the rest of your medallion pipelines keep running. Medallion remains a perfectly good default for a single team with a manageable number of sources and consumers. The cracks matter most when you have many consumers, many sources, and a cost or trust problem that’s already biting. Match the architecture to that reality, not to whichever pattern is winning the current news cycle.

    The gotchas nobody warns you about

    Silver quietly becomes Bronze-with-better-names. If your Silver layer only renames columns and dedupes, you’re paying a full extra copy for cosmetic changes. Silver has to add real modeling and conformance or it isn’t earning its cost.

    Consumer-owned pipelines multiply behind your back. When people can’t tell which layer to trust, they build their own path from the source. Every one of those is another copy and another maintenance burden you’ll inherit later.

    Shift-left is an org change wearing an architecture costume. The hard part isn’t the streams or Iceberg tables — it’s convincing the source team to own a data product and honor a contract. If that social change isn’t real, the technical change won’t stick.

    “We do medallion” is often aspirational. Audit what your layers actually contain before defending or replacing them. Many teams are debating a pattern they haven’t truly implemented.

    Don’t confuse a data contract with a schema file. A contract includes evolution rules, ownership, and SLAs — who gets paged, and how the schema is allowed to change. A bare Avro or Parquet schema with none of that is documentation, not a contract.

    The one principle

    Medallion’s cracks all trace back to one root cause — it cleans data repeatedly, downstream, owned by whoever consumes it — and every serious alternative is really an argument about moving that work upstream to whoever produces it. Bronze/Silver/Gold isn’t a mistake to be ashamed of; it’s a pattern whose assumptions you should now hold consciously instead of by default. Know which crack is actually costing you — copies, brittleness, or duplicated operational work — and shift left exactly as far as your organization can sustain. The goal was never medallion, and it was never shift-left. It was relevant, trustworthy data at a cost you can defend.


    Related reading: How one renamed column kills a pipeline · Structuring dbt projects into layers · Why every materialization is a real, billed rewrite · FDN vs open Iceberg tables · The End of the Bronze Age (InfoQ) · Apache Iceberg

  • How Snowflake Stores Data Internally: Micro-Partitions, FDN & Pruning

    How Snowflake Stores Data Internally: Micro-Partitions, FDN & Pruning

    A team I worked with ran a nightly job that did something completely reasonable: it updated a single status flag on rows in a 2 TB orders table. One column. A few million rows a night. Harmless. Three months later their storage bill had nearly quadrupled, and no one had loaded any new data. The culprit wasn’t the update itself — it was a fact about Snowflake’s storage engine that almost nobody internalizes until it bites them: you cannot change a row in place. That one-column update was silently rewriting entire micro-partitions and stockpiling the old versions in Time Travel and Fail-safe, and they were paying to store every generation.

    There’s a popular assumption that Snowflake just stores compressed Parquet files behind the scenes. It doesn’t. And the real design isn’t trivia — it’s the thing that explains why your queries prune well or scan everything, why a tiny update can be expensive, and why your storage bill has line items you never created directly. This is the storage layer, specifically — not the “cloud services” box everyone waves at in architecture diagrams. If you want the compute-and-query side, I’ve covered what really happens when you run a query separately; this article is about what’s actually sitting on disk, and why it dictates so much of your day.

    TL;DR

    • → Snowflake does not store Parquet — it stores its own proprietary columnar format (widely called FDN) in files called micro-partitions on cloud object storage.
    • → Each micro-partition holds 50–500 MB of uncompressed data, stored column-by-column and compressed per column, with a metadata header of per-column min/max, distinct counts, and null counts.
    • → That metadata — not the data — lives in FoundationDB and drives pruning: the optimizer skips whole micro-partitions by reading statistics, never touching the files.
    • → Micro-partitions are immutable; every UPDATE, DELETE, or MERGE rewrites whole partitions rather than editing rows in place.
    • → Immutability is why Time Travel, zero-copy cloning, and Fail-safe exist — and why churny small DML quietly inflates storage.
    • → Clustering (natural by load order, or a defined key) controls how well pruning works; you can measure it with SYSTEM$CLUSTERING_INFORMATION.
    • → You don’t tune Snowflake storage directly — you influence it by how you load, update, and cluster data.

    The three layers, and why storage is the interesting one

    Snowflake separates into three layers: cloud services (the brain — metadata, security, the optimizer), compute (virtual warehouses that run queries), and storage (the data itself). The famous selling point is that compute and storage are decoupled, which is why you can resize a warehouse without touching data and why the warehouse cache behaves the way it does. But the layer that quietly determines your costs and query speed is the bottom one — and it’s the one people understand the least.

    What’s actually on disk: micro-partitions, not Parquet

    When you load data into a table, Snowflake automatically slices it into micro-partitions. Per Snowflake’s own documentation, each one holds between 50 MB and 500 MB of uncompressed data (smaller on disk, since it’s always compressed), and the rows in it are stored in a columnar layout — each column laid out and compressed independently, with Snowflake picking the best compression scheme per column. A large table isn’t a handful of big partitions; it can be millions of these small, uniform files.

    The file format is proprietary and closed — commonly referred to as FDN (“Flocon de Neige,” French for snowflake). It is emphatically not Parquet, even though both are columnar and compressed. Why build a custom format instead of reusing an open one? Because the format is co-designed with the metadata and pruning system, and that tight coupling is where Snowflake’s speed comes from. The anatomy diagram above is the mental model to hold: a header of statistics, then column chunks.

    The metadata is the magic (and it lives in FoundationDB)

    Here’s the part that reframes everything. For every micro-partition, Snowflake records metadata — the range of values (min and max) for each column, the number of distinct values, null counts, and more. That metadata is not stored in the file with the data; it lives in Snowflake’s metadata store, which Snowflake has publicly described as being built on FoundationDB, a distributed key-value store. The logical table you query is really a set of pointers, held in that metadata store, mapping to the physical FDN files in object storage.

    This separation is what makes pruning possible. When you filter on a column, the optimizer consults the min/max metadata for each micro-partition and skips any whose range can’t possibly contain a match — without reading the file at all.

    Pruning reads statistics, not data. Only the partition whose min/max range straddles July 4th is opened; the other four are eliminated before any I/O.

    This is also why two SQL habits quietly kill performance. Wrapping a filter column in a function — WHERE DATE(order_ts) = '2026-07-04' — means the min/max stats on the raw order_ts column can’t be used, so pruning is defeated and every partition gets scanned. And SELECT * forces every column chunk to be read even though the columnar layout was designed to let Snowflake read only the columns you asked for. The half-open range order_ts >= '2026-07-04' AND order_ts < '2026-07-05' on the bare column, selecting only needed columns, is what lets both optimizations fire.

    Immutability changes how you think about DML

    Micro-partitions are immutable. Snowflake never edits a row in place — a truth that has bigger consequences than it first appears. When you run an UPDATEDELETE, or MERGE, Snowflake reads the affected micro-partitions, produces new micro-partitions with the changes applied, and re-points the table’s metadata at the new files. The old files don’t vanish; they’re retained for Time Travel, then Fail-safe.

    That’s the mechanism behind the quadrupled bill from the intro. A one-column update touching rows spread across thousands of micro-partitions rewrites all of those partitions in full — and keeps the previous versions around for the retention window. This same copy-on-write design is exactly why Time Travel isn’t really a backup feature — it’s just versioned pointers to immutable partitions you already paid to store — and why zero-copy clones are nearly free at creation: a clone is a new set of pointers to the same existing files, and it only costs storage as the two copies diverge and new partitions get written.

    Seeing it for yourself

    None of this has to be taken on faith. You can inspect the physical reality directly. To see how well a table is clustered — how much its micro-partitions overlap on a column, which is what determines pruning quality — use the built-in function:

    SELECT SYSTEM$CLUSTERING_INFORMATION('sales', '(order_date)');
    {
      "cluster_by_keys" : "LINEAR(order_date)",
      "total_partition_count" : 12483,
      "average_overlaps" : 3.11,
      "average_depth" : 2.94,
      "partition_depth_histogram" : {
        "00000" : 0,
        "00001" : 4821,
        "00002" : 5102,
        "00004" : 2560
      }
    }

    A low average_depth means a filter on order_date hits few overlapping partitions — good pruning. A high depth means the values are smeared across many partitions and queries scan more than they should. To see the storage consequences of immutability, query the account usage view that breaks storage into its real components:

    SELECT
        table_name,
        active_bytes      / POW(1024, 3) AS active_gb,
        time_travel_bytes / POW(1024, 3) AS time_travel_gb,
        failsafe_bytes    / POW(1024, 3) AS failsafe_gb
    FROM snowflake.account_usage.table_storage_metrics
    WHERE table_name = 'ORDERS'
    ORDER BY active_bytes DESC;
    +------------+-----------+----------------+-------------+
    | TABLE_NAME | ACTIVE_GB | TIME_TRAVEL_GB | FAILSAFE_GB |
    +------------+-----------+----------------+-------------+
    | ORDERS     |    198.4  |         912.7  |      301.5  |
    +------------+-----------+----------------+-------------+

    That’s the intro’s bug, made visible: 198 GB of live data, but ~1.2 TB of retained old versions you’re billed for, generated by churny updates rewriting partitions night after night.

    Clustering: natural, defined, and not free

    By default, data is naturally clustered by the order it was loaded — load July’s data in date order and date-filtered queries prune beautifully; load it shuffled and they don’t. For large tables with a consistent filter pattern, you can define a clustering key and Snowflake’s Automatic Clustering service will reorganize micro-partitions in the background to keep them well-sorted. It genuinely helps pruning, and it’s central to keeping interactive dashboards fast.

    But reclustering rewrites micro-partitions (immutability again), which consumes credits and generates yet more retained versions. Clustering keys earn their keep only on large tables that are frequently filtered on the key. Adding one to a small or write-heavy table often costs more than it saves — this is the same “don’t pay to reprocess what didn’t change” discipline behind dbt state-based selection.

    The cost math

    Put rough numbers on the immutability tax. Say you have a 200 GB table and a job that rewrites 20% of its partitions daily — a modest MERGE. With a 7-day Time Travel window, you’re retaining roughly seven days of old versions of that churned 20%: on the order of 200 GB of Time Travel bytes on top of the 200 GB active, plus a further ~7 days of Fail-safe Snowflake keeps regardless. You can easily end up billed for 3–4x your live data size. The fix isn’t to stop updating — it’s to batch changes so you rewrite partitions once instead of trickling small updates, to right-size Time Travel retention per table, and to be deliberate about which tables actually need churn. Storage is cheap per GB, but “cheap × 4 × forever” is a real line on the invoice.

    FDN vs open formats

    The proprietary FDN format is what gives Snowflake its pruning speed and features — but it also means your data is locked inside Snowflake’s engine. That trade-off is exactly what the industry’s shift toward open table formats is reacting to: Iceberg tables let you keep data as open Parquet on your own object storage, readable by other engines, at some cost in Snowflake-native performance and features. If you’re weighing it, I’ve written on the native-to-Iceberg migration trap and when it’s actually worth migrating. The short version: FDN for Snowflake-first workloads, Iceberg when open access matters more than raw speed.

    The gotchas nobody warns you about

    Small, frequent updates are a storage trap. Single-row updates to a big table rewrite whole micro-partitions and pile up retained versions. Batch DML; don’t trickle it.

    Functions on filter columns silently disable pruning. DATE(ts) = ...UPPER(name) = ..., or a cast on the filtered column all prevent Snowflake from using the raw column’s min/max stats. Filter on the bare column with ranges.

    Load order is a performance decision. Because natural clustering follows insertion order, loading data shuffled destroys pruning for range queries. Sort on load, or accept the cost of a clustering key.

    DELETE doesn’t free storage immediately. Deleted rows live on as old partition versions through Time Travel and Fail-safe. If you need space back fast, that retention window matters.

    Overlapping ranges beat pruning even with a clustering key. A high average_depth from SYSTEM$CLUSTERING_INFORMATION means your “clustered” table still scans widely. Measure it; don’t assume the key is working.

    The one principle

    Snowflake doesn’t store rows — it stores immutable, self-describing micro-partitions, and every cost and speed characteristic you experience is a downstream consequence of that one fact. Pruning is the metadata header doing its job. Expensive updates are immutability doing its job. Time Travel, cloning, and your storage bill are all the same design viewed from different angles. Once you see storage as immutable statistics-wrapped column chunks, Snowflake stops being magic and starts being predictable — which is exactly when you can make it cheap and fast.


    Related reading: What really happens when you run a query · Why Time Travel isn’t a backup · Zero-copy clone storage costs · FDN vs Iceberg: the migration trap · Snowflake micro-partitions docs · FoundationDB powers Snowflake metadata

  • Building a Bulletproof ETL Audit Logger: Capturing Airflow Execution Context in Snowflake

    Building a Bulletproof ETL Audit Logger: Capturing Airflow Execution Context in Snowflake

    The 2 a.m. page said the pipeline “succeeded.” The dashboard was green. And the finance team was still staring at yesterday’s numbers, because one task in a forty-task DAG had quietly processed the wrong micro-batch window and nobody could prove when, or why, without SSH-ing into a worker and grepping logs by hand. That’s the gap between “DAG success/failure notifications” and actual observability: a green checkmark tells you the code didn’t throw, not that the right data moved in the right window at the right time.

    The fix isn’t a fancier alerting tool. It’s an audit table — a row written to Snowflake at the start and end of every single task, carrying the execution context Airflow already knows: which logical date this run is for, which try number, when the task actually started and finished, how long it took, and what it touched. Once that table exists, “when did this break and why is it slow” stops being an archaeology project and becomes a SELECT. This is the complete build: the Snowflake schema, the Airflow callback code that captures context at both ends of every task, and what the whole thing looks like when it runs.

    TL;DR

    → DAG-level success/failure is too coarse. Capture context at task start and task end for granular observability — timing, retries, and the exact micro-batch window per task.

    → Airflow exposes the execution context through callbacks: on_execute_callback fires right before a task runs (your “start” hook), and on_success_callback / on_failure_callback fire at the end. Each receives the full context dictionary.

    → The context carries what you need: logical_date (the micro-batch window), dag_run.run_idti.try_numberti.start_date, plus ds/ds_nodash for partition keys. In Airflow 3, access it programmatically with get_current_context() from the Task SDK.

    → Attach the callbacks once via default_args and every task in the DAG is audited automatically — no per-task boilerplate.

    → Ship rows to a centralized Snowflake PIPELINE_AUDIT_LOG table keyed by dag_id + task_id + run_id + try_number, with a START row and an END row per attempt so duration and status fall out of a simple query.

    → Once the data lands, debugging execution delays is a SELECT … ORDER BY duration_seconds DESC, and finding the slowest task in the slowest run is a window function, not a log grep.

    Why DAG-level notifications aren’t observability

    Diagram comparing “DAG success/failure” with “Per-task-attempt audit” using checklists. DAG shows overall result; audit tracks details like duration per task, attempts, logical date window, and slowest tasks.

    A DAG success signal answers one coarse question. The unit of observability you actually want is the task attempt.

    A DAG success notification answers one question: did the whole thing finish without an unhandled exception? That’s necessary and nowhere near sufficient. It can’t tell you which task in the chain was slow, whether a task silently ran on its second retry, which logical date window each task actually processed, or how today’s run compares to last week’s for the same task. Those are the questions you actually have during an incident, and log-grepping to answer them is how a five-minute diagnosis becomes a two-hour one.

    The unit of observability you want is the task attempt, not the DAG run. Every task attempt has a start, an end, a try number, and a logical date. If you record those four things for every attempt in one queryable place, you can answer “when did this get slow,” “which task is the bottleneck,” and “did this run process the window it should have” directly — and you can do it after the fact, without the worker still being alive.

    Step 1: the Snowflake audit table

    Start with the destination. The schema is deliberately simple — one row per task-attempt per phase (START and END), keyed so you can pair them up and compute duration. Keeping START and END as separate rows (rather than updating one row) means a task that dies hard still leaves its START row behind, which is itself a signal.

    CREATE TABLE IF NOT EXISTS ops.pipeline_audit_log (
        audit_id        STRING DEFAULT UUID_STRING(),
        dag_id          STRING       NOT NULL,
        task_id         STRING       NOT NULL,
        run_id          STRING       NOT NULL,
        try_number      NUMBER       NOT NULL,
        phase           STRING       NOT NULL,   -- 'START' | 'END'
        status          STRING,                  -- 'RUNNING' | 'SUCCESS' | 'FAILED'
        logical_date    TIMESTAMP_NTZ,           -- the micro-batch window
        event_time      TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP(),
        duration_sec    NUMBER,                  -- populated on END
        operator        STRING,
        map_index       NUMBER,                  -- for dynamically mapped tasks
        hostname        STRING,
        error_message   STRING,
        loaded_at       TIMESTAMP_LTZ DEFAULT CURRENT_TIMESTAMP()
    );

    A few deliberate choices. logical_date is stored as its own column because it’s the micro-batch window the task is for — distinct from event_time, the wall-clock moment the row was written. Conflating those two is the single most common audit-table mistake, and it’s exactly the confusion that hid the “wrong window” bug in the opening story. try_number is in the key because retries are first-class events you want to see, not noise to collapse. And map_index is there so dynamically mapped tasks (the .expand() fan-out) each get their own audit trail instead of blurring together.

    Step 2: extracting the execution context

    Airflow hands you everything through the context dictionary. The pieces that matter for auditing:

    def extract_audit_fields(context: dict) -> dict:
        """Pull the audit-relevant fields out of the Airflow context."""
        ti = context["ti"]                     # the TaskInstance
        dag_run = context["dag_run"]
    
        return {
            "dag_id":       ti.dag_id,
            "task_id":      ti.task_id,
            "run_id":       dag_run.run_id,
            "try_number":   ti.try_number,
            # logical_date is the micro-batch window this run is FOR.
            # Asset-triggered DAGs in Airflow 3 have none — fall back to None.
            "logical_date": context.get("logical_date"),
            "operator":     ti.operator,
            "map_index":    ti.map_index,
            "hostname":     ti.hostname,
            "start_date":   ti.start_date,
        }

    The distinction that trips people up: logical_date (formerly execution_date) is the window the run represents, which may be hours or months before the wall clock if you’re backfilling. ti.start_date is when the task actually began executing. You want both — one to know what the task processed, the other to know when and how long. In Airflow 3, if you’re inside task code rather than a callback, you get the same dictionary with from airflow.sdk import get_current_context and context = get_current_context().

    Step 3: the callbacks that fire at start and end

    This is the heart of it. on_execute_callback runs immediately before the task’s own code — that’s your START row. on_success_callback and on_failure_callback run after — those are your END rows, one carrying SUCCESS, the other FAILED plus the exception.

    from datetime import datetime, timezone
    
    def _write_audit_row(fields: dict) -> None:
        """Insert a single audit row into Snowflake via a reusable hook."""
        from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
        hook = SnowflakeHook(snowflake_conn_id="snowflake_ops")
        hook.run(
            """
            INSERT INTO ops.pipeline_audit_log
                (dag_id, task_id, run_id, try_number, phase, status,
                 logical_date, duration_sec, operator, map_index,
                 hostname, error_message)
            VALUES
                (%(dag_id)s, %(task_id)s, %(run_id)s, %(try_number)s,
                 %(phase)s, %(status)s, %(logical_date)s, %(duration_sec)s,
                 %(operator)s, %(map_index)s, %(hostname)s, %(error_message)s)
            """,
            parameters=fields,
        )
    
    def audit_on_start(context: dict) -> None:
        f = extract_audit_fields(context)
        f.update(phase="START", status="RUNNING",
                 duration_sec=None, error_message=None)
        _write_audit_row(f)
    
    def audit_on_success(context: dict) -> None:
        f = extract_audit_fields(context)
        duration = (datetime.now(timezone.utc) - f["start_date"]).total_seconds()
        f.update(phase="END", status="SUCCESS",
                 duration_sec=round(duration, 2), error_message=None)
        _write_audit_row(f)
    
    def audit_on_failure(context: dict) -> None:
        f = extract_audit_fields(context)
        duration = (datetime.now(timezone.utc) - f["start_date"]).total_seconds()
        f.update(phase="END", status="FAILED",
                 duration_sec=round(duration, 2),
                 error_message=str(context.get("exception"))[:2000])
        _write_audit_row(f)

    Two production notes. First, keep the callback body cheap and defensive — a callback that raises can interfere with task handling, so in a hardened version you wrap _write_audit_row in a try/except that logs and swallows, because a failed audit write should never fail the pipeline. Second, opening a fresh Snowflake connection per callback is fine at low task volume; at high volume you’d batch these through a staging mechanism rather than one INSERT per event, which the “gotchas” section revisits.

    Step 4: wire it into every task with one line

    The elegance is that you attach these once through default_args, and every task in the DAG inherits them — no per-task decoration, no touching your existing operators.

    from airflow import DAG
    from airflow.operators.python import PythonOperator
    import pendulum
    
    default_args = {
        "on_execute_callback": audit_on_start,
        "on_success_callback": audit_on_success,
        "on_failure_callback": audit_on_failure,
        "retries": 2,
    }
    
    with DAG(
        dag_id="sales_etl",
        schedule="@hourly",
        start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
        catchup=False,
        default_args=default_args,   # <- every task is now audited
    ) as dag:
    
        extract = PythonOperator(task_id="extract_orders",
                                 python_callable=run_extract)
        transform = PythonOperator(task_id="transform_orders",
                                   python_callable=run_transform)
        load = PythonOperator(task_id="load_to_warehouse",
                              python_callable=run_load)
    
        extract >> transform >> load

    That’s the whole integration. Three callbacks defined once, referenced in default_args, and every task — extract, transform, load, and any you add later — writes a START and an END row automatically.

    What it looks like when it runs

    When the DAG executes, each task emits two rows. Here’s the Airflow task log showing the callbacks firing, followed by the rows that land in Snowflake:

    [2026-07-18T02:00:03Z] INFO - Executing on_execute_callback: audit_on_start
    [2026-07-18T02:00:03Z] INFO - Audit START written: sales_etl.extract_orders try=1
    [2026-07-18T02:00:41Z] INFO - Marking task as SUCCESS. dag_id=sales_etl, task_id=extract_orders
    [2026-07-18T02:00:41Z] INFO - Executing on_success_callback: audit_on_success
    [2026-07-18T02:00:41Z] INFO - Audit END written: sales_etl.extract_orders try=1 duration=38.4s

    And the resulting rows in ops.pipeline_audit_log:

    A table shows task phases, durations, and statuses, with highlighted notes about bottlenecks, per-task timing, and retries. Main message: transform is the bottleneck at 112 seconds.

    The rows that land in Snowflake. The 112-second transform and the correct 02:00 window are visible at a glance — neither was in the green checkmark.

    DAG_ID     TASK_ID          RUN_ID              TRY  PHASE  STATUS   LOGICAL_DATE         DURATION_SEC
    ---------  ---------------  ------------------  ---  -----  -------  -------------------  ------------
    sales_etl  extract_orders   manual__2026-07-18   1   START  RUNNING  2026-07-18 02:00:00        (null)
    sales_etl  extract_orders   manual__2026-07-18   1   END    SUCCESS  2026-07-18 02:00:00        38.40
    sales_etl  transform_orders manual__2026-07-18   1   START  RUNNING  2026-07-18 02:00:00        (null)
    sales_etl  transform_orders manual__2026-07-18   1   END    SUCCESS  2026-07-18 02:00:00       112.65
    sales_etl  load_to_warehouse manual__2026-07-18  1   START  RUNNING  2026-07-18 02:00:00        (null)
    sales_etl  load_to_warehouse manual__2026-07-18  1   END    SUCCESS  2026-07-18 02:00:00        54.10

    Immediately you can see what a green checkmark never showed you: transform_orders took 112 seconds — nearly three times extract — and every task processed the 02:00 logical window as intended. That’s the observability the DAG notification couldn’t give you, and it’s now sitting in a table.

    Step 5: the queries that pay it back

    The point of the table is what you can ask it. Duration per task-attempt, pairing START and END:

    SELECT dag_id, task_id, run_id, try_number,
           MAX(duration_sec) AS duration_sec,
           MAX(CASE WHEN phase = 'END' THEN status END) AS final_status
    FROM ops.pipeline_audit_log
    GROUP BY dag_id, task_id, run_id, try_number
    ORDER BY duration_sec DESC NULLS LAST;
    The slowest task in each run — the bottleneck finder — with a window function:
    
    SELECT dag_id, run_id, task_id, duration_sec
    FROM (
        SELECT dag_id, run_id, task_id, duration_sec,
               ROW_NUMBER() OVER (PARTITION BY dag_id, run_id
                                  ORDER BY duration_sec DESC) AS rn
        FROM ops.pipeline_audit_log
        WHERE phase = 'END'
    )
    WHERE rn = 1
    ORDER BY duration_sec DESC;

    And the one that catches silent regressions — a task getting slower over time, comparing each run to that task’s trailing average:

    SELECT dag_id, task_id, run_id, logical_date, duration_sec,
           AVG(duration_sec) OVER (
               PARTITION BY dag_id, task_id
               ORDER BY logical_date
               ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
           ) AS trailing_avg
    FROM ops.pipeline_audit_log
    WHERE phase = 'END' AND status = 'SUCCESS'
    QUALIFY duration_sec > trailing_avg * 1.5   -- 50% slower than usual
    ORDER BY logical_date DESC;

    That last query is the one that turns the audit log from a forensic tool into an early-warning system: it surfaces the task that’s creeping slower before it becomes the 2 a.m. page.

    The gotchas nobody warns you about

    A raising callback can disrupt task handling. If audit_on_failure itself throws (say Snowflake is briefly unreachable), you can turn one problem into two. Wrap the write in try/except, log the failure, and swallow it — the audit system must never be able to fail the pipeline it’s observing.

    One INSERT per callback will not scale. At a few hundred task-attempts a day it’s fine. At tens of thousands, opening a Snowflake connection per event is both slow and expensive (every connection burns warehouse time). The scalable pattern is to write audit events to a lightweight buffer — a local file, a queue, or Snowpipe/streaming ingestion — and land them in batches, so your observability layer isn’t itself a warehouse cost problem.

    try_number semantics shifted across Airflow versions. Historically ti.try_number read differently inside a running task versus after completion, which has burned people building retry logic on it. Pin your understanding to your Airflow version and verify what value you actually get in each callback rather than assuming — a quick log line during rollout saves confusion later.

    Asset-triggered DAGs have no logical_date. In Airflow 3, DAGs triggered by asset events don’t get a logical date or the derived ds/ds_nodash variables. Your extract_audit_fields must tolerate None there and lean on dag_run.run_id for identity, or the callback will KeyError on exactly the DAGs you were proud of modernizing.

    Wall-clock duration isn’t queue time. The duration computed from ti.start_date is execution time, not the time the task spent waiting in the scheduler queue. If you’re debugging delays specifically, capture the gap between the DAG run’s start and the task’s start too — a task that’s “fast” but starts late points at scheduler or pool contention, a completely different fix than optimizing the task itself.

    The one principle

    Observability is a table, not a notification. Record every task attempt’s start and end with the execution context Airflow already hands you — logical date, try number, timings — and ship it to one Snowflake table. Then “when did this break, which task is slow, and did it process the right window” become queries instead of log archaeology. A green checkmark tells you nothing failed loudly. An audit row tells you what actually happened — and that’s the difference between hoping your pipeline is healthy and knowing it.

    Related reading: Airflow templates & context reference (official docs) · Accessing the Airflow context (Astronomer) · Orchestrating dbt With Airflow on Snowflake · Dynamic Airflow DAGs via Snowflake Metadata · Debugging Zero-Copy Clone Storage Costs in CI/CD

  • The 2026 Migration Trap: Moving from Native Tables to Dynamic Apache Iceberg v3 in Snowflake

    The 2026 Migration Trap: Moving from Native Tables to Dynamic Apache Iceberg v3 in Snowflake

    The pitch is intoxicating and mostly true: keep your data in open Apache Iceberg format on your own object storage, let external engines read it, and let Snowflake’s Dynamic Tables handle the low-latency transformations on top — one declarative pipeline, no lock-in, a real lakehouse. In 2026, with Iceberg v3 generally available on Snowflake since May 7, teams are migrating native tables to dynamic Iceberg tables expecting exactly that. Most of them hit the same wall in the same order.

    The wall is this: “open and interoperable” describes the storage format, not the write path, and “low latency” describes Dynamic Tables under conditions that partitioned Iceberg writes and cross-engine change tracking quietly violate. The migration doesn’t fail loudly. It succeeds, ships, and then your incremental pipeline starts doing full refreshes you didn’t ask for, your partitioned writes fan out into a metadata problem, and the external engine you promised could write to these tables turns out to be read-only. This is the guide to the traps — the ones that don’t show up in the quickstart — and how to design around them before they cost you a quarter.

    TL;DR

    → Iceberg v3 (GA on Snowflake May 2026) brings deletion vectors, row lineage (native CDC), VARIANT, and multi-argument partition transforms. You cannot upgrade v2 to v3 in place — you recreate the table. Plan the migration, don’t expect an ALTER.

    → Dynamic Iceberg tables support PARTITION_BYTARGET_FILE_SIZE, and PATH_LAYOUTPATH_LAYOUT = HIERARCHICAL only produces Hive-style partitioned paths when paired with PARTITION_BY — and over-partitioning (more than a few thousand partitions) turns your metadata layer into the bottleneck.

    → The cross-engine reality: Snowflake-managed Iceberg tables are read-write for Snowflake, read-only for external engines. Writes from external engines to Snowflake-managed v3 tables via Horizon Catalog aren’t supported yet. External engines can only write to externally-managed tables.

    → Dynamic Tables track changes at the row level for native tables but the file level for externally-managed Iceberg base tables. Frequent copy-on-write on the external table degrades incremental refresh — a file changes, the whole file is “changed.”

    → INSERT OVERWRITE on a base table resets change-tracking metadata and forces a full refresh. Row lineage (v3) and primary keys with RELY are how you keep incrementality alive across rewrites.

    → Deletion vectors (v3 merge-on-read) are governed by heuristics: Snowflake only writes a deletion vector if fewer than ~5% of a file’s rows are deleted and the file is larger than ~1.6 MB. External engines that don’t understand v3 deletion vectors force you to set ICEBERG_MERGE_ON_READ_BEHAVIOR = 'DISABLED' (copy-on-write) for compatibility.

    What v3 actually changed, and why in-place upgrade isn’t a thing

    The target architecture: a Bronze/Silver/Gold lakehouse where Dynamic Iceberg tables handle incremental transforms and write open Iceberg that external engines can read. The traps live in the arrows, not the boxes.

    Iceberg v3 is a genuine step change, not a point release. It adds deletion vectors (up to ~10x faster DML by avoiding positional-delete merges at read time), row lineage for native change data capture, a VARIANT type for semi-structured data with structured-query performance, default column values, geometry/geography types, nanosecond timestamps, and multi-argument partition transforms. On Snowflake it went to preview in March 2026 and GA on May 7, 2026.

    Here’s the first thing that trips migrations: you can’t upgrade an Iceberg table from v2 to v3. There is no ALTER TABLE ... SET ICEBERG_VERSION = 3 that rewrites your existing table in place. You configure the default Iceberg version and create new v3 tables, migrating data into them. This matters because teams plan the migration as a flag flip and discover it’s a recreate-and-backfill — which, for a large partitioned table, is a real project with a real compute bill, not a maintenance-window toggle. The related gotcha: v2 tables using copy-on-write represent an updated or relocated row in a standard stream as a DELETE followed by an INSERT for the same row, so any CDC logic you built on v2 stream semantics needs re-validation against v3’s row lineage before you cut over.

    The partitioned-write trap: HIERARCHICAL paths and the metadata ceiling

    Dynamic Iceberg tables expose three storage-shaping properties: PARTITION_BYTARGET_FILE_SIZE, and PATH_LAYOUT. The one that surprises people is PATH_LAYOUT. It defaults to FLAT, meaning all Parquet data files land directly under the data/ directory. Set it to HIERARCHICAL and Snowflake writes Hive-style partitioned paths — but only in combination with PARTITION_BY. Setting HIERARCHICAL without a partition spec does nothing useful; the two are a pair.

    A minimal partitioned dynamic Iceberg table looks like this:

    CREATE DYNAMIC ICEBERG TABLE my_dt (
      product_id NUMBER, product_name STRING, order_time TIMESTAMP_NTZ
    )
      TARGET_LAG = '20 minutes'
      WAREHOUSE = my_wh
      EXTERNAL_VOLUME = 'my_vol'
      CATALOG = 'SNOWFLAKE'
      BASE_LOCATION = 'my_dt'
      PARTITION BY (YEAR(order_time))
      PATH_LAYOUT = HIERARCHICAL
      AS SELECT product_id, product_name, order_time FROM staging;

    The trap isn’t the syntax; it’s the partition cardinality. Iceberg’s metadata tracks files per partition, and every partition you create adds manifest overhead. Snowflake’s own guidance is blunt: avoid creating more than a few thousand partitions, and test query performance against your actual workload before finalizing a partitioning strategy. The failure mode when you ignore this is quietly brutal — partition by DAY(event_time) on a table with a few years of history and a high-cardinality secondary key, and you can generate tens of thousands of tiny partitions, each with its own small files. Now your Dynamic Table refresh spends its time in metadata planning rather than moving data, and your “low-latency” pipeline has a latency floor set by manifest bookkeeping.

    The design rule that keeps you out of trouble: partition on the coarsest grain that still prunes your dominant query pattern (usually a month or a broad category), let TARGET_FILE_SIZE and Snowflake’s file management handle within-partition layout, and reach for clustering rather than finer partitions when you need more selective pruning. Hierarchical paths are for interoperability and human-navigable storage, not a license to over-partition.

    The cross-engine write trap: “interoperable” is asymmetric

    This is the one that derails architecture diagrams. The interoperability story — external engines like Spark and Trino reading your Iceberg data — is real, but it runs in one direction for Snowflake-managed tables. Snowflake-managed Iceberg tables are read-write for Snowflake and read-only for external engines. As of the v3 GA, reading Snowflake-managed v3 tables from an external engine via the Horizon Iceberg REST Catalog API is generally available; writing from external engines to Snowflake-managed v3 tables through Horizon is explicitly not supported yet.

    If your architecture needs an external engine to write Iceberg that Snowflake then transforms, you must use externally-managed tables — data written by Spark into a catalog like AWS Glue, which Snowflake reads via a catalog integration and a linked database. That’s a supported and powerful pattern (it’s the canonical Bronze layer of an open lakehouse), but it’s a different architecture with different semantics than “Snowflake-managed tables that everyone can write to,” which does not exist today. Decide early which engine owns writes for each table, because that choice dictates managed-vs-external, and switching later means a migration. A further sharp edge: you can’t write with vended credentials to cloned or converted tables, and you can’t write at all to a table that was converted from externally-managed to Snowflake-managed — conversions are one-way for write access.

    The change-tracking trap: file-level vs row-level

    The granularity of change tracking decides how much work an incremental refresh does. Row-level (native) processes a tight delta; file-level (external Iceberg) can reprocess an entire file because one row moved.

    Dynamic Tables get their speed from incremental refresh — processing only what changed since the last refresh. The catch that native-table migrators don’t see coming: Dynamic Tables track changes at the file level for externally-managed Iceberg base tables, whereas they track at the row level for native Snowflake tables. That single difference reshapes your performance profile.

    With a native base table, if one row in a micro-partition changes, Snowflake knows it was that row, and the incremental refresh processes a tight delta. With an externally-managed Iceberg base table, change tracking is file-granular: a copy-on-write update that rewrites a data file marks the entire file as changed, so the refresh reprocesses every row in it, even if one row moved. On a table with frequent small updates and copy-on-write behavior, this inflates the change set dramatically and can make an “incremental” refresh behave like it’s doing far more work than the actual data change justifies. Snowflake’s documentation states it plainly: frequent copy-on-write operations on externally-managed Iceberg tables may impact incremental-refresh performance.

    Then there’s the metadata reset. INSERT OVERWRITE on a base table — a common pattern for batch reloads — resets change-tracking metadata, and the next Dynamic Table refresh falls back to a full recomputation. If your ingestion rewrites tables wholesale, your downstream “incremental” pipeline isn’t incremental at all.

    How v3 features rescue the change-tracking story

    The good news is that v3 exists partly to solve this, and using its features deliberately is the difference between a fast lakehouse and a slow one.

    Row lineage is the headline. In v3, tables track _row_id (a stable unique identifier assigned to each row) and _last_updated_sequence_number (the commit that last touched the row). This lets any compliant engine reliably match the same row across snapshots and detect row-level changes — native CDC in the format itself, not bolted on. Row lineage is supported for both Snowflake-managed and externally-managed v3 tables and underpins append-only and standard streams on Snowflake-managed v3 tables.

    Primary keys with RELY are the pragmatic rescue for the INSERT OVERWRITE problem. If you declare a reliable primary key on the base table, Snowflake compares rows by key value instead of leaning on change-tracking columns — so even when a table is fully rewritten, it computes the minimal set of actual changes rather than reprocessing everything. This is also how you enable incremental refresh downstream of a full-refresh dynamic table, by giving Snowflake a stable identity to diff against. For append-only CDC, the QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) = 1 pattern gives you latest-row-per-key with a derived unique key, handling out-of-order arrival without extra logic.

    Deletion vectors replace v2’s positional deletes for merge-on-read, and they’re governed by heuristics worth knowing: Snowflake writes a deletion vector only if fewer than ~5% of a data file’s rows are deleted and the file exceeds ~1.6 MB; otherwise it rewrites the file (copy-on-write). You control the behavior with ICEBERG_MERGE_ON_READ_BEHAVIOR. The compatibility trap: if an external engine in your stack doesn’t yet understand v3 deletion vectors, you must set that parameter to 'DISABLED' to force copy-on-write, or the external engine will misread the table. Interoperability constrains you to the capabilities of the least capable engine that touches the table.

    The gotchas nobody warns you about

    Change tracking must be on, with non-zero Time Travel, on every underlying object. Incremental refresh silently depends on it. Snowflake will try to enable it automatically for incremental dynamic tables, but if you recreate a base object you must re-enable it — and a base object with Time Travel set to zero quietly breaks incrementality.

    The GRANT syntax has a trap for dynamic Iceberg tables. To grant access to future dynamic Iceberg tables in a schema, you use GRANT … ON FUTURE ICEBERG TABLES without the DYNAMIC keyword. The intuitive ON FUTURE DYNAMIC ICEBERG TABLES does not cover them, so a reasonable-looking grant leaves new tables inaccessible.

    Gen2 warehouses matter more than you’d expect. Snowflake’s Dynamic Table performance work — measured up to ~2.8x faster refresh over the past year — is specifically tied to Gen2 warehouses for patterns like top-level aggregates, QUALIFY row/rank = 1, cluster-by, and joins. If your incremental pipeline is on Gen1, you’re leaving a large multiple of refresh speed on the table before any Iceberg tuning.

    Cross-region and cross-cloud tables bill for transfer. A Snowflake-managed Iceberg table whose external volume sits in a different region or cloud than your account incurs cross-region data-transfer charges under the DATA_LAKE transfer type. Keep external volumes in the same region as your account unless you have a deliberate DR reason not to.

    A migration order that avoids the traps

    Sequence matters. First, decide per table who owns writes — if an external engine writes, it’s externally-managed; if only Snowflake writes, Snowflake-managed — because that’s the irreversible-ish decision. Second, set your default Iceberg version to v3 and plan recreate-and-backfill for existing v2 tables rather than expecting an upgrade. Third, choose a coarse partition grain (validated against real query patterns, staying well under a few thousand partitions) and use clustering for finer pruning. Fourth, make change tracking deliberate: declare reliable primary keys where base tables get rewritten, lean on row lineage for CDC, and confirm change tracking plus non-zero Time Travel on every base object. Fifth, pin ICEBERG_MERGE_ON_READ_BEHAVIOR to match the least-capable engine that reads the table. Then move workloads to Gen2 warehouses and measure incremental-refresh times against your latency target before you call it done.

    The one principle

    “Open Iceberg lakehouse with low-latency Dynamic Tables” is true only when the write path, the partition cardinality, and the change-tracking granularity all line up — and by default they don’t. Migrating native tables to dynamic Iceberg v3 is a design exercise, not a format swap: decide who writes, partition coarsely, give Snowflake a stable row identity to diff against, and constrain merge-on-read to your least-capable engine. Get those four right and the lakehouse is genuinely fast and open. Get them wrong and you’ve built a slow data lake with extra steps, one full refresh at a time.

    Related reading: Create dynamic Apache Iceberg tables (official docs) · Manage Iceberg tables: row lineage & deletion vectors · Snowflake Iceberg v3: When to Migrate · dbt State on Snowflake: Skip Unchanged Models · Dynamic Airflow DAGs via Snowflake Metadata

  • Governing the AI Agent: Securing Snowflake CoCo and MCP Workflows in Production

    Governing the AI Agent: Securing Snowflake CoCo and MCP Workflows in Production

    In March 2026, two days after Snowflake shipped Cortex Code, security researchers at PromptArmor published something that should have changed how every data team thinks about AI agents. They didn’t break Snowflake’s authentication. They didn’t steal a password. They fed the agent a piece of poisoned content — an indirect prompt injection — and the agent, reasoning helpfully as designed, used its own cached Snowflake credentials to exfiltrate data and drop tables. The attacker never logged in. The agent did the damage, under its own legitimate identity, because someone told it to and nothing stopped it.

    That’s the whole problem with the agentic enterprise in one incident. We spent a decade getting good at controlling what people can do in Snowflake — roles, grants, masking, row access policies. Then we handed an autonomous agent the keys and discovered our governance model couldn’t tell the difference between a human running a query and an agent running the same query on someone’s behalf. CoCo isn’t just writing SQL anymore; it’s orchestrating pipelines, calling external tools over MCP, and taking actions across your stack. The question is no longer “can it do useful work” — it obviously can — but “what stops it from doing damage, and who approves the actions that matter.”

    This is the practitioner’s guide to governing agentic workflows in production: the identity model, data-movement controls, and multi-party approvals that let you use CoCo and MCP without turning every agent into an unaudited superuser.

    TL;DR

    → An agent runs with the privileges of the role that invoked it. If that role has broad SELECT across production, the agent has the same reach — and it evaluates whether a query is answerable, not whether it’s appropriate. Over-scoped roles are the root risk.

    → The injection surface is wide (READMEs, web content, table data, MCP tool responses) and can’t be eliminated. Governance shifts from “prevent the injection” to “limit the blast radius when one lands.” Scope first, then monitor.

    → AI Agent Identity (GA at Summit 2026) gives each agent a cryptographic identity, per-agent RBAC, and a full audit trail — so policies can treat agent traffic differently from human traffic and you can actually attribute actions.

    → Data Movement Policies restrict where data can flow and which channels agents can use — the control that stops an over-scoped agent from piping regulated data out through an MCP integration.

    → Multi-party approval (private preview) puts a human (or two) in the loop for destructive or high-sensitivity actions — the agentic equivalent of a code review before a DROP.

    → The MCP Gateway (built on Snowflake’s Natoma acquisition) centralizes and governs every MCP connection — identity-aware authorization and audit at the tool-call level, instead of each agent wiring its own servers ungoverned.

    → Start today: enable the free built-in prompt-injection guardrails, audit which agents touch sensitive data, write an explicit agent policy (what each agent may and may not do), and apply data-movement policies to your most sensitive tables.

    The core problem: an agent inherits your blast radius

    Everything else follows from one fact, so internalize it first. A Cortex Agent — and CoCo is one — runs under the privileges of the Snowflake user or role that invoked it. It has exactly the access that role has. Not less, because it isn’t sandboxed away from the role’s grants by default; not more, because Snowflake’s perimeter still applies. Whatever the invoking role can SELECT, the agent can SELECT.

    In a world of humans, over-scoped roles are a latent risk — a person could query the HR schema they never actually touch, but they don’t, because they know not to. An agent has no such judgment. It does not weigh whether querying the compensation table is appropriate to the task; it weighs whether the query is answerable given the permissions available. Ask an agent configured for product analytics a question that happens to be answerable using financial data its role can reach, and it will answer. There is no internal boundary that says “that’s not my department.”

    So the blast radius of every over-scoped role expands dramatically the moment that role underpins an always-on agent. The single most important governance move you can make is not a new feature — it’s scoping the agent’s role down to exactly the data its job requires, and no more. Every control below is a layer on top of that foundation. If the foundation is a role with broad production access, no amount of monitoring saves you.

    Same agent, same injection — the only difference is how tightly the underlying role is scoped. Least privilege is what makes a successful injection cheap.

    Why you defend the blast radius, not the perimeter

    The PromptArmor attack teaches the strategy. The injection didn’t target authentication; it targeted the agent’s reasoning and then rode its existing credentials. The injection surface — anything the agent reads and treats as context — is enormous: source files and READMEs, web pages it fetches, rows in tables it queries, and crucially the responses that come back from MCP tools. You cannot realistically eliminate that surface. A determined attacker will eventually land an injection.

    That reframes the whole job. If you can’t stop every injection, you make a successful one cheap. Controls that limit what the agent can access limit what an attacker can do through it. Scope first, then monitor. Every governance control that follows exists to shrink the blast radius of an injection that gets through — not to pretend none ever will.

    Agent identity: making the agent a first-class, distinct actor

    The reason our old governance couldn’t cope is that agent traffic looked exactly like human traffic. If an agent runs under a shared service account, you cannot tell in the audit log whether “the agent” or “a person using the agent’s role” ran a query, and you cannot apply different rules to the two. AI Agent Identity, which went GA at Summit 2026, fixes this at the platform level: every agent gets a cryptographic identity, per-agent RBAC, and a complete audit trail.

    Concretely, that buys three things. First, attribution — the audit log records that this specific agent, not a nebulous service account, took this action, so incident response has something to trace. Second, differential policy — because Snowflake can recognize when an action occurs in an agent’s context, security teams can apply custom masking or visibility rules to agent traffic specifically, tightening or loosening access for agents independently of the humans behind them. Third, lifecycle — a distinct identity can be reviewed and decommissioned when a project ends, which is the antidote to the classic failure mode where a “the agent” service account silently accumulates privileges for years and is never cleaned up.

    The practical instruction: never run production agents under a shared or personal role. Give each agent its own identity, grant it a purpose-built role scoped to its task, and treat that identity as something you review on a schedule — the same way you’d review a human’s access, because now it’s a non-human actor with real reach.

    Data movement policies: stopping the exfiltration path

    Here’s the MCP-specific risk that most governance frameworks haven’t caught up to. When you connect an agent’s output to external systems over MCP, data reachable by the agent becomes potentially reachable outside the Snowflake perimeter. If the agent’s role can read regulated or confidential data, that data can flow outward through an MCP integration that isn’t subject to the same controls as the warehouse. Your carefully governed table is one tool-call away from a Slack channel or a third-party API.

    Data Movement Policies are the control for exactly this. They let you restrict where data can go and which channels agents are allowed to use, applied at the level of your most sensitive tables. The pattern that works: identify your regulated and confidential datasets, and attach movement policies that restrict agentic access channels — so even if an agent’s role can technically read a table, the policy prevents that data from being moved out through an ungoverned path. This is the difference between “the agent can see it” and “the agent can send it somewhere,” and for regulated data those are very different permissions.

    Pair this with the principle that the model runs where the data lives. Snowflake’s model-in-platform approach (running Claude and other models natively inside Cortex) means sensitive data doesn’t have to leave the perimeter for the agent to reason over it. Movement policies then govern the exceptions — the deliberate, approved paths where data does flow outward — rather than leaving every MCP connection as an open door.

    Multi-party approval: a human gate on destructive actions

    Not every action needs a human. The pattern is to auto-execute low-risk reads and route destructive or high-sensitivity actions to an approval gate — a code-review step before an agent does something irreversible.

    Some actions are too consequential to let an agent take unilaterally, no matter how well-scoped. Dropping a table, moving regulated data, granting privileges, deploying to production — these are the agentic equivalent of a force-push to main. Multi-party approval (in private preview as of Summit 2026) is the control: it requires human sign-off before an agent executes designated high-risk actions, and for the most sensitive it can require two approvers.

    The design pattern that keeps this usable is triage. If you gate everything, people rubber-stamp approvals and the control becomes theater. Instead, classify actions by risk. Read-only work — a SELECT, a Cortex Search, generating a chart — executes automatically; that’s the whole point of an agent. Destructive or high-sensitivity actions divert to an approval gate before they run, and are blocked and logged if no one approves. The engineering task is deciding, up front, which actions in your environment belong on the destructive list — and it’s worth doing that exercise now, before you turn agents loose, rather than after an incident.

    The MCP Gateway: governing the actions, not just the data

    Early MCP adoption looks like every agent configuring its own servers independently — one team wires up a Jira server, another points at an internal API, and nobody has a single view of what’s connected or what those connections can do. That’s ungoverned by construction. Snowflake’s acquisition of Natoma exists to fix this: the MCP Gateway is a centralized, governed layer for every MCP connection in the organization.

    What the Gateway changes is where enforcement happens. Instead of trusting each agent to behave, every tool call — sending an email, opening a ticket, hitting an API — flows through a gateway that enforces identity verification, access policies, and audit controls at the level of the individual action. This extends governance from the data an agent reads to the actions it takes. Central management means one place to see and control all external tool connections; gateway-level access control means an agent can only invoke the tools its policy permits; and tool-call-level audit means you can reconstruct exactly what an agent did across systems, not just within Snowflake.

    The mental shift: data governance and action governance are different problems. Row access policies protect what the agent can read. The MCP Gateway protects what the agent can do with tools once it has read something. In the agentic enterprise you need both, because an agent that can only read sensitive data is a smaller problem than one that can read it and send it somewhere.

    The gotchas nobody warns you about

    Service accounts are where governance goes to die. The most common real-world failure isn’t a clever attack — it’s an agent running under a service account created for “the agent,” never scoped tightly, never reviewed, accumulating privileges as teams bolt on data sources. Tie every agent to a defined access scope and a lifecycle policy, and review non-human identities on the same cadence as human ones.

    MCP tool responses are part of the injection surface. It’s easy to think of prompt injection as coming from user input, but a compromised or malicious MCP server can return a response crafted to hijack the agent’s reasoning. Treat data coming back from external tools with the same suspicion as any other untrusted input — which is another argument for routing MCP through a governed gateway rather than trusting arbitrary servers.

    Built-in guardrails are necessary, not sufficient. Snowflake’s baseline prompt-injection protection is free, automatic, and worth enabling immediately — it blocks known attack patterns. But it’s a pattern database, so it lags novel attacks by definition. Guardrails reduce the frequency of successful injections; scope and movement policies reduce the impact. You need both, and you should never let “guardrails are on” substitute for scoping.

    Cross-organization collaboration multiplies the identity problem. As agentic workflows span companies (clean rooms, shared data, partner integrations), privacy-preserving controls and role separation stop being nice-to-haves and become engineering requirements. An agent acting across an organizational boundary needs an identity and policy that make sense on both sides.

    A starting checklist for production

    If you’re deploying agents now, the order of operations matters. Enable the built-in AI guardrails first — they’re free and automatic. Audit which agents (and which underlying roles) can reach sensitive data, and scope those roles down to least privilege; this is the highest-leverage step and it’s just RBAC discipline. Give each production agent its own identity rather than a shared service account. Write an explicit agent policy — for each agent, what it may do and what’s off-limits — and apply data movement policies to your most sensitive tables, restricting agentic channels specifically. Then identify the destructive actions in your environment that warrant two-person confirmation and get ahead of the multi-party approval rollout. Finally, route MCP connections through a governed gateway so action-level governance and audit exist from day one rather than being retrofitted after an incident.

    The one principle

    An AI agent is a non-human actor that inherits a role’s full reach and exercises none of a human’s judgment, so govern it as an identity, not as a feature. Scope its role to least privilege, give it a distinct auditable identity, restrict where its data can move, gate its destructive actions behind human approval, and route its tool calls through a governed MCP gateway. You will not prevent every prompt injection. What you can decide, in advance, is how little damage a successful one is able to do — and in the agentic enterprise, that decision is the whole game.

    Related reading: Cortex Agents governance & access control (official docs) · Snowflake Managed MCP Servers: secure, governed data agents · How to Use MCP in Snowflake CoCo Desktop · Debugging Zero-Copy Clone Storage Costs in CI/CD · Dynamic Airflow DAGs via Snowflake Metadata

  • Dynamic Airflow DAGs via Snowflake Metadata: Eliminating Hardcoded Pipeline Tasks

    Dynamic Airflow DAGs via Snowflake Metadata: Eliminating Hardcoded Pipeline Tasks

    I once inherited an Airflow repo with 214 DAG files that were, functionally, the same DAG. Each one extracted a table from a source system, loaded it into Snowflake, and ran a transform. The only differences between them were the table name, the schedule, and which SQL file to run. Someone had copy-pasted the template 214 times, and every schema change meant a find-and-replace across 214 files and a prayer that nothing got missed. Onboarding a new table meant copy-pasting a 215th.

    That repo is the case against hardcoded pipeline tasks in a single painful sentence: if the only thing that changes between your DAGs is data, then your DAGs should be generated from data. The fix is to move the pipeline definitions out of Python files and into a Snowflake metadata table, then generate the DAGs from that table. Add a row, get a pipeline. Change a row, change a pipeline. No copy-paste, no 214-file find-and-replace.

    This is the guide to doing that properly — including the distinction that trips most people up (there are two completely different “dynamic” features in Airflow and they solve different problems), the metadata-driven generation pattern, and the parsing gotchas that will wreck your scheduler if you get them wrong.

    TL;DR

    → Two different features share the word “dynamic.” Dynamic DAG generation builds DAG structure at parse time from config/metadata — the task count is fixed for a given run. Dynamic task mapping (.expand()) creates N task instances at runtime from an upstream task’s output. They solve different problems; you’ll often use both.

    → The metadata-driven pattern: store pipeline definitions (DAG name, tasks, schedule, SQL file, parent/child dependencies) in a Snowflake table → feed each row into a Jinja template → render a dag.py file per pipeline. Add a row, get a DAG.

    → Add operational columns to the metadata table — created_at and last_updated_at — so you can track which pipelines exist and trigger regeneration when a definition changes.

    → Use environment variables, not Airflow Variables, in top-level DAG code. Airflow Variables hit the metadata DB on every parse and will slow your scheduler to a crawl.

    → Generate tasks in a stable, sorted order every time (ORDER BY in your query or sorted() in Python), or the Grid View reshuffles tasks on every refresh and your history becomes unreadable.

    → For large numbers of generated DAGs, use get_parsing_context() to skip building DAG objects you don’t need during task execution — one documented case cut parsing from 120s to 200ms.

    → Use dynamic task mapping when the count is unknown until runtime (e.g. “process however many files landed today”). Note trigger_rule=ALWAYS is not allowed on task-generated mapped tasks.

    The distinction that trips everyone up

    Before any code, get this straight, because conflating the two is the single most common source of confusion I see. Airflow has two features with “dynamic” in the name and they are not interchangeable.

    Dynamic DAG generation is about producing DAG files or objects programmatically. Instead of hand-writing 214 near-identical DAGs, you write one generator that reads definitions from somewhere (a config file, a metadata table) and emits the DAGs. The important property: the structure is decided at parse time, when Airflow loads the DAG file. For a given DAG run, the number of tasks is fixed. This is what you want when you have many similar pipelines that differ only by parameters.

    Dynamic task mapping (introduced in Airflow 2.3, via .expand() and .map()) is about creating task instances at runtime. A task returns a list, and Airflow creates one copy of a downstream task per element — and it doesn’t know how many until the upstream task actually runs. This is the MapReduce model: the scheduler creates N copies of the mapped task right before execution. This is what you want when the count is genuinely unknown until runtime — “process each file that landed in S3 today,” where “today” might be 3 files or 300.

    The rule of thumb: if you know the shape of the work when the DAG is parsed, use dynamic DAG generation. If the shape depends on data that only exists at runtime, use dynamic task mapping. A mature setup often uses both — generated DAGs whose internal tasks map over runtime data.

    Left: fixed structure known at parse time. Right: task instances fanned out at runtime. Same word, opposite problems.

    The metadata-driven pattern

    The pipeline that builds pipelines: a Snowflake metadata table feeds a Jinja template that renders one dag.py per row, which Airflow then parses like any other DAG.

    The architecture has four moving parts. First, a metadata table in Snowflake that holds pipeline definitions. At minimum it stores, per task: the DAG name it belongs to, the task name, the schedule, what the task runs (say, a SQL file path), and the parent/child dependency links. A row-per-task layout with a parent_task column lets you express arbitrary dependency graphs — a task names its parent, and the generator wires the edges.

    Here’s a minimal shape:

    CREATE TABLE pipeline_metadata (
      dag_name      STRING,
      task_name     STRING,
      parent_task   STRING,  -- NULL for a root task
      schedule      STRING,  -- e.g. '0 2 * * *'
      sql_file      STRING,  -- what the task executes
      is_active     BOOLEAN,
      created_at    TIMESTAMP,
      last_updated_at TIMESTAMP
    );

    Second, a Jinja template — a .j2 file that looks like a DAG with placeholders where the metadata values go: the DAG id, the schedule, a loop that emits one operator per task, and the dependency wiring. Third, a generator that queries the metadata table, groups rows by dag_name, and renders the template once per DAG, writing out a dag.py file. Fourth, Airflow’s normal DAG File Processor, which parses those rendered files exactly as if you’d hand-written them.

    The payoff is the operational columns. Because each row carries created_at and last_updated_at, you can tell when a pipeline was first defined and when it last changed. When someone edits a definition, last_updated_at moves, and you can trigger regeneration for just the affected DAGs rather than rebuilding everything. Onboarding a new pipeline is now an INSERT, not a new file.

    Rendering the DAG from a row

    The generator itself is short. Conceptually: query the active metadata, group by DAG, and for each group render the template with that group’s tasks and dependencies. A sketch:

    from jinja2 import Environment, FileSystemLoader
    import os
    
    # env var, NOT an Airflow Variable — see the parsing note below
    env = os.environ.get("DEPLOYMENT", "PROD")
    
    rows = run_query("""
      SELECT dag_name, task_name, parent_task, schedule, sql_file
      FROM pipeline_metadata
      WHERE is_active = TRUE
      ORDER BY dag_name, task_name  -- stable order, always
    """)
    
    template = Environment(loader=FileSystemLoader("templates")) \
        .get_template("dag_template.j2")
    
    for dag_name, tasks in group_by_dag(rows):
      rendered = template.render(dag_name=dag_name, tasks=tasks, env=env)
      with open(f"dags/{dag_name}.py", "w") as f:
        f.write(rendered)

    Notice the ORDER BY. That is not cosmetic — it’s load-bearing, and the next section explains why.

    The parsing gotchas that wreck schedulers

    Three parse-time mistakes and their fixes. Every one of these is invisible until your scheduler is under load, then very visible.

    Dynamic generation runs at parse time, and the DAG File Processor parses your files constantly. Anything expensive or unstable in that path multiplies across every parse. Three specific mistakes:

    Airflow Variables in top-level code. It’s tempting to configure your generator with Variable.get("something"). Don’t, not at the top level. Every Airflow Variable read in top-level code opens a connection to the metadata database, and top-level code runs on every parse. At scale this hammers your metadata DB and drags parsing. Use environment variables (os.environ.get(...)) for anything read during generation — they’re free to read and don’t touch the DB.

    Unstable task ordering. If your generator emits tasks in a different order on different parses — because the query has no ORDER BY, or you iterated a Python set — Airflow’s Grid View reshuffles the task rows every time it refreshes. Your run history becomes impossible to read, and it looks like the DAG is changing when it isn’t. Always impose a stable order: ORDER BY in the query, or sorted() in Python. Deterministic generation is not optional.

    Parsing every DAG on every task execution. The DAG File Processor loads the whole file to get metadata, but executing a single task only needs that one DAG object. If your generator builds hundreds of DAGs in one file, every task execution pays to construct all of them. The fix is get_parsing_context(): check which DAG is actually being parsed and skip generating the rest. The documented “Magic Loop” example cut parsing from 120 seconds to 200 milliseconds this way. It’s most valuable when the generated-DAG count is high — use it with care and test it, since it doesn’t apply if later DAGs depend on earlier ones.

    When to reach for dynamic task mapping instead

    Everything above generates structure from metadata known at parse time. But some workloads only reveal their shape at runtime, and that’s dynamic task mapping’s job. The canonical example is file processing: an unknown number of files land in cloud storage each day, and you want one task instance per file loaded into Snowflake.

    The pattern is a task that returns the list, and a downstream task that expands over it:

    @task
    def list_new_files():
      return get_s3_keys(prefix=f"{{{{ ds_nodash }}}}/")  # however many landed
    
    @task
    def load_to_snowflake(key):
      copy_into_snowflake(key)
    
    load_to_snowflake.expand(key=list_new_files())

    The scheduler creates one load_to_snowflake instance per key, right before execution, and the Grid View shows the mapped count in brackets. You can also map over task groups with the @task_group decorator and .expand() when each unit of work is several steps, using the map_indexes parameter to pull the right XCom per instance. One constraint to remember: trigger_rule=TriggerRule.ALWAYS is not allowed on a task-generated mapped task, because the expanded parameters are undefined at the moment of immediate execution — Airflow raises an error at parse time if you try.

    Cost and maintenance math

    The win here isn’t compute cost, it’s maintenance cost, and it compounds. Go back to the 214-DAG repo. A schema change that touched every pipeline meant editing 214 files — call it a day of careful, error-prone work, plus review, plus the near-certainty of missing one. With metadata-driven generation, the same change is either one UPDATE to the metadata table or one edit to the shared Jinja template, followed by regeneration. Minutes, not a day, and uniform by construction — you cannot miss one, because there’s only one definition.

    Onboarding scales the same way. In the file-per-pipeline world, each new table is a new hand-authored file and a new opportunity for drift. In the metadata world it’s an INSERT. Ten new tables is ten rows. The marginal cost of a pipeline drops toward zero, which changes what’s worth automating — pipelines that weren’t worth hand-writing become trivially worth a row.

    The gotchas nobody warns you about

    Generation failure takes down everything at once. The flip side of one definition is one point of failure. A bug in the template or generator doesn’t break one DAG, it breaks all of them. Validate rendered output (even a quick python -c "compile(...)" check) before writing files, and keep the last-good rendered files so a bad generation doesn’t wipe working DAGs.

    Metadata and reality drift. The metadata table says what pipelines should exist; the dags/ folder holds what does. If someone edits a rendered file by hand, or a row is deleted without removing the file, the two diverge. Treat rendered files as build artifacts, never edit them directly, and have regeneration remove files for DAGs no longer in the metadata.

    Secrets don’t belong in the metadata table. It’s tempting to store connection details per pipeline. Keep credentials in Airflow Connections or a secrets backend and reference them by name from the metadata — the table should hold pipeline structure, not secrets.

    Too much magic hurts debuggability. A generated DAG is one level removed from the code you read. When something breaks at 2 a.m., the on-call engineer is debugging rendered output, not the template. Keep the template readable, keep the rendered files on disk (don’t generate purely in memory), and make it obvious which metadata row produced which DAG.

    The one principle

    If the only thing that changes between your pipelines is data, define them with data, not code. Put pipeline definitions in a Snowflake metadata table, render them through one Jinja template, and let Airflow parse the result — but keep generation deterministic, keep Airflow Variables out of top-level code, and remember that one definition means one point of failure worth guarding. The goal isn’t cleverness; it’s that onboarding the 215th pipeline should be an INSERT, and a schema change should touch one place, not two hundred.

    Related reading: Airflow: Dynamic DAG Generation (official docs) · Airflow: Dynamic Task Mapping (official docs) · Orchestrating dbt With Airflow on Snowflake · dbt State on Snowflake: Skip Unchanged Models · Snowflake Query Execution: What Really Happens

  • Debugging Zero-Copy Clone Storage Costs in CI/CD

    Debugging Zero-Copy Clone Storage Costs in CI/CD

    The Snowflake bill for our CI account had roughly tripled over a quarter, and nobody could point to why. We hadn’t loaded meaningfully more data. Compute was flat. But storage kept climbing, month over month, in an account whose entire job was to spin up throwaway test environments and tear them down. Throwaway. Torn down. And yet the storage line kept going up and to the right.

    The culprit was the feature I’d been recommending to everyone as “basically free”: zero-copy clones. Our CI pipeline cloned production on every pull request, ran migrations and tests against the clone, and dropped it at the end. Textbook. The problem is that “zero-copy” describes the moment of creation and nothing after it, and “drop” doesn’t mean what you think it means when clones are involved. We were paying for storage we believed we’d deleted weeks ago.

    This is the guide to why that happens, how to find it in your own account, and how to stop it. If you run clone-based CI/CD at any scale, some version of this is almost certainly happening to you right now.

    TL;DR

    → Zero-copy clones are free at creation — they share the source’s micro-partitions through metadata pointers. They are not free after anything writes. Every INSERT/UPDATE/DELETE on either side writes new micro-partitions that are billed.

    → In CI/CD the divergence is your migrations. Clone prod, run a schema migration or a backfill against the clone, and you’ve just created new micro-partitions that cost real storage — every pull request, every pipeline run.

    → Dropping the clone does not immediately free that storage. Dropped tables enter Time Travel, then Fail-safe (up to 1 day + 7 days on permanent tables) before the bytes are physically removed. Fast CI loops drop clones constantly and stack up a rolling backlog of retained bytes.

    → The nasty one: clone-group ownership transfer. Storage for shared micro-partitions is owned by the oldest table in the clone group. Drop the source and its still-shared partitions don’t vanish — ownership transfers to a surviving clone. You can delete “the original” and watch storage not move.

    → Diagnose with SNOWFLAKE.ACCOUNT_USAGE.TABLE_STORAGE_METRICSACTIVE_BYTESTIME_TRAVEL_BYTESFAILSAFE_BYTES, and the key one, RETAINED_FOR_CLONE_BYTES — bytes kept alive only because a clone still references them.

    → Fixes: clone with transient tables/databases for CI (no Fail-safe, minimal Time Travel), set Time Travel to 0 days on CI objects, actually DROP at pipeline end even on failure, and don’t run heavy migrations against the clone if a lighter check will do.

    Why “zero-copy” is a half-truth

    Snowflake stores table data in immutable micro-partitions — compressed columnar files, tens to hundreds of MB each. Once written, a micro-partition is never modified. When you clone a table, Snowflake doesn’t copy those files. It writes a new metadata entry pointing at the same set of micro-partitions. That’s why a clone of a 5 TB table is instant and costs nothing extra at that instant. It’s a hard link at the partition level, not a copy.

    The word “zero-copy” describes exactly that instant and no other. Because micro-partitions are immutable, the moment you change a row — in the clone or the original — Snowflake can’t edit the shared partition in place. It writes a new micro-partition containing the change, and that new partition is owned exclusively by whichever side made the change. The unchanged partitions stay shared. So your storage cost isn’t the size of the clone; it’s the size of the divergence between the clone and its source.

    The correct mental model, which took me an embarrassingly large bill to internalize: a clone is not a free copy, it’s an instant branch that gets more expensive as it diverges. Read-only clone of 1 TB? Costs nothing. Clone you fully rewrite? Costs a second 1 TB. Real CI workloads land in between — and “in between,” multiplied by every pull request, is a budget line.

    Where the cost actually enters in CI/CD

    The clone is free at step 1. Your migration at step 2 is what creates billed storage — and step 4’s drop doesn’t reclaim it right away.

    Here’s the standard CI pattern, the one in every tutorial:

    CREATE DATABASE ci_test_${BUILD_ID} CLONE production_db;
    -- run migrations against the clone
    -- run integration tests
    DROP DATABASE IF EXISTS ci_test_${BUILD_ID};

    Step one is genuinely free. The cost enters at “run migrations.” A migration that adds a column, backfills a value, rebuilds a table, or runs a dbt model against the clone writes new micro-partitions for every affected partition. If your migration touches 10% of a 500 GB table, you just materialized ~50 GB of new storage — for one CI run. Run that pipeline 40 times a day across a team and the daily divergence is measured in terabytes of writes, even though each individual run “only” changed a slice.

    None of that is visible while you’re looking at it, because the clone gets dropped at the end and the environment looks clean. Which brings us to the part that actually generates the surprise bill.

    The two things that keep paying after you “delete”

    1. Dropping a table doesn’t free its bytes immediately. When you DROP a permanent table (or database), it doesn’t evaporate — it goes into Time Travel for its retention period (default 1 day, and up to 90), and then into Fail-safe for a further 7 days, during which only Snowflake can recover it. Throughout both windows you’re billed for those bytes. A CI loop that creates and drops clones dozens of times a day is continuously feeding a rolling backlog: at any given moment you’re paying for the Time-Travel-and-Fail-safe tail of every clone dropped in roughly the last week, not just the ones alive right now.

    2. Clone-group ownership transfer — the one that breaks intuition. Every table in a clone group has an independent lifecycle, but the storage for shared micro-partitions is owned by the oldest table in the group. Here’s the trap: you decide the source table is the problem and drop it. You expect storage to fall. It doesn’t. Because a clone still references those shared partitions, Snowflake can’t release them — so when they’d otherwise exit Time Travel, ownership transfers to a surviving clone instead. You deleted the original and the bytes simply changed owner. This is why teams stare at a dropped production backup table and can’t understand why the account storage didn’t budge.

    Finding it in your own account

    Four columns tell the whole story. RETAINED_FOR_CLONE_BYTES is the one that reveals storage kept alive purely because a clone still references it.

    The view you want is SNOWFLAKE.ACCOUNT_USAGE.TABLE_STORAGE_METRICS. It breaks every table’s footprint into the buckets that matter:

    SELECT
      table_catalog,
      table_schema,
      table_name,
      active_bytes / POW(1024,3) AS active_gb,
      time_travel_bytes / POW(1024,3) AS time_travel_gb,
      failsafe_bytes / POW(1024,3) AS failsafe_gb,
      retained_for_clone_bytes / POW(1024,3) AS clone_retained_gb
    FROM snowflake.account_usage.table_storage_metrics
    WHERE retained_for_clone_bytes > 0
    ORDER BY retained_for_clone_bytes DESC;

    ACTIVE_BYTES is your live data — the part you expect to pay for. TIME_TRAVEL_BYTES and FAILSAFE_BYTES are the recovery tails. RETAINED_FOR_CLONE_BYTES is the smoking gun: bytes that are only still on disk because some clone in the group references them. If that column is large on tables you thought were long gone, you’ve found your leak.

    To hunt CI clones specifically, filter by naming convention and age. Because Snowflake records clone lineage, you can surface old clones still retaining significant storage:

    SELECT
      table_catalog,
      table_name,
      clone_group_id,
      retained_for_clone_bytes / POW(1024,3) AS clone_retained_gb,
      table_created
    FROM snowflake.account_usage.table_storage_metrics
    WHERE table_catalog ILIKE 'CI_TEST_%'
      AND retained_for_clone_bytes > 0
    ORDER BY clone_retained_gb DESC;

    One caveat worth knowing: ACCOUNT_USAGE views have latency (often a couple of hours), so don’t expect a drop you ran five minutes ago to show up instantly. Debug against yesterday’s picture, not this second’s.

    The cost math, concretely

    Say production is 2 TB and your CI migration reliably rewrites ~8% of it per run: ~160 GB of new micro-partitions per pipeline. The clone is dropped at the end, so those 160 GB immediately become Time Travel + Fail-safe bytes rather than active bytes — and they linger for the retention tail. With a 1-day Time Travel plus 7-day Fail-safe window on permanent objects, each run’s divergence sticks around for roughly 8 days before it’s physically purged.

    Run the pipeline 30 times a day and, in steady state, you’re carrying roughly 30 runs/day × 8 days × 160 GB ≈ 38 TB of retained bytes that you believe you deleted. At standard on-demand storage rates that’s a four-figure monthly line for data that exists only because “drop” isn’t “delete” and permanent tables carry a Fail-safe tail. The exact number depends on your migration’s write volume and your retention settings — but the shape is always the same, and it’s always bigger than teams expect.

    The fixes, in priority order

    Clone into transient objects for CI. This is the single biggest lever. Transient tables and databases have no Fail-safe period and a Time Travel retention of 0 or 1 day. Clone production into a transient database for CI, and when you drop it there’s no 7-day Fail-safe tail — the bytes are reclaimable almost immediately. CREATE TRANSIENT DATABASE ci_test_${BUILD_ID} CLONE production_db; Note the source’s own storage behavior is unchanged; this only governs the CI-side lifecycle, which is exactly the part generating your backlog.

    Set Time Travel to zero on CI objects. If you can’t use transient objects for some reason, at least set DATA_RETENTION_TIME_IN_DAYS = 0 on the CI database so dropped clones don’t linger in Time Travel. Combined with the above, your CI divergence becomes genuinely short-lived.

    Actually drop, even on failure. The tutorial pattern drops the clone at the end — but if tests fail and the pipeline exits early, the DROP may never run. Orphaned clones from failed builds are a classic source of retained storage. Put the DROP in a finally/always block so it runs regardless of test outcome, and add a scheduled sweeper task that drops any CI_TEST_% database older than a few hours as a backstop.

    Diverge less. Ask whether your CI actually needs to rewrite 8% of a 2 TB table. Often the migration under test only needs to run against a representative subset, or the test only needs schema validation, not a full data backfill. Cloning gives you production-realistic structure for free; you don’t always need to exercise it against production-scale writes.

    Mind the ownership trap when cleaning up. If you’re deleting old backup clones to reclaim space, remember that dropping the oldest member of a clone group transfers ownership rather than freeing bytes. To actually reclaim storage from a clone group, you generally need to drop all members that reference the shared partitions and let the retention windows expire. Deleting one and expecting the bill to fall is how the confusion starts.

    The gotchas nobody warns you about

    Grants diverge at clone time. A clone inherits grants and masking policies from the source at the instant of cloning, then becomes independent. For CI this is usually fine, but if your pipeline relies on grants applied to production after the clone was taken, they won’t be there.

    Small-file defragmentation writes Time Travel bytes too. Even plain INSERT/COPY/Snowpipe loads can generate Time Travel and Fail-safe bytes, because Snowflake periodically compacts small micro-partitions — deleting the small ones (which enter the recovery tail) and writing a consolidated one. So retained bytes aren’t exclusively a clone phenomenon; clones just amplify it.

    External tables, stages, and pipes don’t clone. If your CI environment depends on them, cloning the database won’t bring them along — you’ll need to recreate them in the clone.

    ACCOUNT_USAGE latency hides fast loops. Because the storage views lag by up to a few hours, a tight CI loop can be generating and “hiding” retained storage faster than your dashboards refresh. Trust the trend over days, not the instantaneous number.

    The one principle

    Zero-copy cloning is free to create and expensive to diverge — and “drop” is not “delete.” In CI/CD, the storage you pay for is the write volume of your migrations times the retention tail of your dropped clones. Clone into transient objects, keep Time Travel short, drop reliably, and diverge only as much as the test actually requires. The feature isn’t lying to you; it’s just describing creation, not the whole lifecycle. Manage the lifecycle and the “free” clone stays close to free.

    Related reading: Snowflake data storage considerations (clone groups & CDP) · TABLE_STORAGE_METRICS view reference · dbt State on Snowflake: Skip Unchanged Models · Snowflake Query Execution: What Really Happens · Snowflake Iceberg v3: When to Migrate

  • How to Use MCP in Snowflake CoCo Desktop

    How to Use MCP in Snowflake CoCo Desktop

    The first thing I tried to do in CoCo Desktop was ask it to pull the open tickets for a data pipeline I was debugging. It couldn’t. Not because it wasn’t smart enough — it’s genuinely good at reasoning over your Snowflake schemas — but because CoCo’s context ends where Snowflake’s context ends. It knew everything about my tables, my RBAC, my lineage. It knew nothing about my Jira board sitting one browser tab away.

    That gap is exactly what MCP closes. Once I wired up a couple of MCP servers, CoCo went from “excellent inside Snowflake’s walls” to “reaches into the rest of my stack” — Jira, GitHub, internal APIs — without me writing a line of integration code. This is the practical guide to doing that: the setup flow, where the config actually lives, how credentials are handled, and the operational limits that will trip you up on day one if nobody warns you.

    A quick naming note before we start, because it confused me too: CoCo is the new name for Cortex Code. Snowflake renamed it at Summit 2026. Same product, same architecture — you’ll still see “cortex” all over the file paths and environment variables, which is why this guide uses both names where the paths demand it.

    TL;DR

    → MCP (Model Context Protocol) is an open standard that connects CoCo Desktop to external tools — GitHub, Jira, internal APIs, databases — without per-tool integration code. Add a server once and its tools appear to the agent automatically.

    → Setup is fast: Agent Settings → MCP tab → + New → pick a scope (Global or Workspace) → pick a transport (Command/stdio or Remote/HTTP) → fill in details → Save. The server starts immediately, no restart.

    → Two transport types: Command (stdio) runs a local process (e.g. uvx mcp-server-git); Remote (HTTP) connects to a URL (e.g. a hosted server with auth headers).

    → Config lives in JSON: global at ~/.snowflake/cortex/mcp.json (all workspaces), workspace at <workspace>/.snowflake/cortex/mcp.json (that project only). Top-level key is "mcpServers".

    → Credentials are handled for you: on first connection CoCo migrates secrets (env vars, headers, OAuth tokens) out of mcp.json and into your OS keychain, then strips them from the file. Never hardcode tokens.

    → The limits that bite: tool output is capped at 50 KB (design servers to return summaries, not raw dumps), default tool timeout is 60 seconds (override with COCO_MCP_TOOL_TIMEOUT_MS), and tool names must be alphanumeric/underscore/hyphen and under 64 characters or the server is rejected outright.

    → If you already run MCP servers for Claude Desktop, Cursor, or Windsurf, CoCo Desktop can often reuse them — MCP is a standard, not a Snowflake-specific connector.

    What MCP actually does for CoCo

    CoCo is a data-native coding agent. Its whole advantage is that it understands your Snowflake environment — live schemas, access controls, lineage — so it generates SQL and dbt code that actually works against your real objects within your permissions. That’s also its boundary. The moment you need context from outside Snowflake, CoCo is blind to it.

    MCP is the bridge. It’s an open protocol (the same one Claude Desktop, Cursor, and Windsurf use) that lets an agent call tools exposed by external “servers.” A GitHub MCP server exposes tools like “search code” and “list pull requests.” A Jira server exposes “find issues” and “create ticket.” Once you register that server with CoCo, those tools become part of the agent’s toolbox automatically — no code changes, no custom connector. You ask CoCo “what are the open bugs on the ingestion pipeline?” and it calls the Jira tool, reads the result, and reasons over it alongside your Snowflake context.

    The mental model that helped me: CoCo already has one deep well of context (Snowflake). MCP servers are additional wells you drill wherever you need them. Each server you add widens what the agent can see and do.

    Setting up your first MCP server

    The whole setup is a short form in Agent Settings. The server starts the moment you save — no restart dance.

    You manage everything through the Agent Settings panel. Open Agent Settings, select MCP from the sidebar, and you’ll see the MCP Connectors panel listing any configured servers and their status.

    To add one, click + New. You’ll fill in a short form:

    Server Name — a unique identifier, e.g. github. This name matters more than it looks: it becomes part of the tool namespace. A server named github exposes tools like mcp__github__search. Pick descriptive names so tool calls read clearly — mcp__github__search tells you what it does; mcp__gh1__search doesn’t.

    Scope — Global stores the server in ~/.snowflake/cortex/mcp.json and makes it available in every workspace. Workspace stores it in <workspace>/.snowflake/cortex/mcp.json, scoped to the current project so it travels with the repo. Use Global for tools you always want (your personal GitHub); use Workspace for project-specific servers that should live in version control with the code.

    Server Type (transport) — pick Command (stdio) to run a local process, then enter the command (for example uvx mcp-server-git). Pick Remote (HTTP) to connect to a hosted server, then enter the Server URL (for example https://your-mcp-server-url) and optionally add auth Headers. For stdio servers you can add Environment Variables instead.

    Click Save, and the server starts. Its tools are available to the agent immediately.

    If you don’t have a specific server in mind, click + New and select Browse MCP Servers — CoCo Desktop ships with a gallery of ready-to-install integrations you can add straight from the UI.

    Editing the config directly (JSON)

    The form is convenient, but for anything repeatable — sharing setup with a team, checking config into git — you’ll want the JSON. In the Add New MCP Server form, switch to the JSON tab, or edit the files directly. The top-level key is "mcpServers", and each entry is keyed by server name:

    {
      "mcpServers": {
        "git": {
          "command": "uvx",
          "args": ["mcp-server-git"]
        },
        "internal-api": {
          "type": "http",
          "url": "https://your-mcp-server-url",
          "headers": { "Authorization": "Bearer ${API_TOKEN}" }
        }
      }
    }

    CoCo expands environment variables in config fields before connecting, so you can reference ${API_TOKEN} and similar. Prefer the braced form ${VAR} over bare $VAR to avoid ambiguity. There’s also a special ${workspaceFolder} variable that resolves to the current workspace root — handy for paths like cwd or envFile.

    How config files stack (the merge order)

    Config merges from multiple sources; later layers win on name collisions. Workspace beats global beats admin-enforced — unless the admin has locked things down.

    This is the part that saves you a confusing debugging session later. CoCo Desktop merges MCP config from several sources, and when two sources define a server with the same name, the later source wins. The order, from lowest to highest priority:

    First, administrator-enforced servers from managed settings. Then user (global) servers from ~/.snowflake/cortex/mcp.json. Then workspace servers from <workspace>/.snowflake/cortex/mcp.json. So if you have a server named github in both your global and your workspace config, the workspace definition takes precedence. This is usually what you want — a project can override your personal defaults — but it also means a workspace config you forgot about can silently shadow your global one.

    On managed accounts there’s an extra wrinkle: admins can restrict MCP usage through managed settings and URL allowlists, and can even disable user MCP servers entirely so that only admin-enforced servers load. If a server you configured refuses to appear on a corporate account, check whether the admin has locked MCP down before you assume your config is broken.

    How credentials are handled (better than you’d expect)

    This surprised me pleasantly. When you add a server with environment variables, headers, or OAuth, CoCo doesn’t leave your secrets sitting in a plaintext JSON file. On first connection it migrates those sensitive values out of mcp.json and into your operating system’s keychain, then rewrites the JSON file with those fields removed. Credentials are stored under a keychain entry named mcp_oauth_<server-name> as a single blob containing tokens, OAuth registration, headers, and environment variables.

    Practically, this means: put your token in as an env var reference or let the OAuth flow run, and after the first connect it won’t be in the file anymore. Don’t hardcode raw tokens in mcp.json expecting them to stay — and don’t panic when they disappear from the file, that’s the migration working. If you ever need to reset a credential, remove and re-add the server to trigger a fresh flow.

    The operational limits nobody warns you about

    These three cost me time before I understood them, and they’re the difference between “MCP is flaky” and “MCP works fine, I just configured it wrong.”

    Tool output is capped at 50 KB. If you point an MCP server at something that returns large result sets — a query that dumps thousands of rows, an API that returns a giant JSON blob — CoCo truncates the output and appends a notice. The fix isn’t to raise a limit; it’s to design the server to return summaries or pointers, not raw dumps. Have the tool return “here are the top 20 rows and a row count” or “results written to this file,” and let CoCo read the detail in a follow-up step if it needs to.

    The default tool timeout is 60 seconds. Wire up a server that hits a slow internal API and you can spend ten minutes assuming the connection is broken when the tool is just slow. Override the timeout globally with the COCO_MCP_TOOL_TIMEOUT_MS environment variable — raise it for genuinely long-running tools, or lower it to fail fast on servers that should be quick.

    Tool names must be alphanumeric, underscores, or hyphens, and under 64 characters. An MCP server that exposes a tool with a non-conforming name gets rejected outright — not silently renamed, rejected. If a server won’t load and the config looks right, check the tool names it exposes.

    The gotchas nobody warns you about

    Cross-app discovery on shared machines. Because MCP is a shared standard, CoCo can discover servers you set up for other tools — and on a shared machine, that can mean picking up someone else’s servers or exposing yours. Be deliberate about scope on multi-user boxes.

    Variables expand from the launch environment, not your editor’s shell. CoCo expands ${VAR} from the environment it was launched in, not from a shell embedded in an editor. If a variable resolves to empty, check that it’s actually set in the environment where CoCo (not your terminal-inside-the-app) started.

    Descriptive server names aren’t cosmetic. Because the server name becomes the tool namespace prefix, a vague name makes every downstream permission rule and tool call harder to read. Name servers for what they connect to, once, up front.

    Permissions are per-tool and worth configuring. MCP tools participate in CoCo’s standard permission system. You can allow, deny, or prompt per tool, matching individual tools by full name (mcp__github__read_file) or all tools from a server with a wildcard (mcp__github__*). At runtime CoCo also asks on first use and can remember the choice for the session. Denying destructive tools explicitly — mcp__github__delete_repo, say — is cheap insurance.

    A sensible starting setup

    If you’re setting this up for the first time on a Snowflake data project, here’s the configuration I’d start with. Add a Git server (Command/stdio, uvx mcp-server-git) at Workspace scope so it travels with the repo. Add your issue tracker (Jira or GitHub Issues) at Global scope since you’ll want it everywhere. Set a permission policy that allows read tools freely, asks on writes, and denies anything destructive. Bump COCO_MCP_TOOL_TIMEOUT_MS only if you actually add a slow server. And design any custom internal-API server to return summaries under 50 KB from the start, so you never hit the truncation wall.

    That gives you a CoCo that reasons over your Snowflake data and your tickets and your code history, with guardrails on the actions that matter — which is the whole point of MCP here.

    The one principle

    CoCo’s native genius is Snowflake context; MCP is how you extend its reach past Snowflake’s walls without writing integration code. Add servers deliberately, name them clearly, let the keychain hold your secrets, and design tools to return summaries — then the agent can reason across your whole stack instead of just your warehouse.

    Related reading: CoCo Desktop MCP support (official docs) · Model Context Protocol specification · Snowflake CoCo product page · Snowflake Interactive Tables: How and When to Use Them · Orchestrating dbt With Airflow on Snowflake