Tag: snowflake

  • 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

  • Optimizing dbt Models for Modern Warehouses: An Author & Reviewer’s Guide

    Optimizing dbt Models for Modern Warehouses: An Author & Reviewer’s Guide

    Two dbt models can be byte-for-byte identical in output, pass every test, and read cleanly in review — and one of them costs $50 a month while the other costs $5,000. The compiler already guarantees the SQL is correct. What it can’t tell you is how much data moved through the model to produce that correct answer, and that number is the entire ballgame. The trap is that a code review reads a model like prose — do the joins make sense, are the columns right — when the thing that actually determines the bill is invisible in the text and only shows up in the query profile.

    So this guide is about reading the profile, not just the SQL. The single skill that separates engineers who write cheap models from ones who write expensive ones is the ability to look at a query profile and see where data volume balloons or lingers — then trace that back to the one line of SQL responsible. Everything below hangs off one mental model, and I’ll show you what each fix looks like in an actual profile, because “trust me, it’s faster” is worth a lot less than “here’s the partition count before and after.”

    The one mental model: how much data survives each stage?

    Before any checklist, ask one question of every model: how much data survives each stage of this query? A healthy model’s volume shrinks, roughly monotonically, from raw input to final output. A broken one has a stage where volume grows — or stays huge longer than it needs to — and that stage is almost always where your money goes.

    Left: volume shrinks stage by stage — a well-shaped query. Right: the join ran before the filter, producing 12 TB of intermediate data from a 5 TB input. The SQL is syntactically perfect and still ruinous.

    Every tactic in this post is a specific instance of that one idea: find the stage where volume grows or stays too big for too long, and fix that stage. That’s it. The rest is knowing the four places it usually happens and what each looks like in the profile.

    Why this matters more on modern warehouses

    Snowflake, BigQuery, Redshift, and Databricks all ship genuinely capable optimizers. They handle predicate pushdown, join reordering, and parallel execution for you, which means the physical tuning you’d have obsessed over on a 2005-era database — index hints, manual join order, rewriting for a specific plan — mostly doesn’t apply. The engine owns that layer now. If you want the mechanics of how that execution layer actually works, I covered it in what really happens when you run a query.

    What the optimizer can’t fix is a logical mistake: joining before filtering, reading columns you don’t need, recomputing the same aggregation five times, or choosing ROW_NUMBER() when MAX() would do. Those decisions are baked into the SQL, and no optimizer can rewrite your intent. That’s why the highest-leverage review comments are almost never about syntax — they’re about which stage of the volume curve a change affects.

    1. Read less data

    The biggest lever, and the first thing to check. On a columnar warehouse, unused columns cost real I/O even though the query works either way:

    -- Bad: pulls every column off a 200-column table
    SELECT * FROM customers
    
    -- Better: only what's used downstream
    SELECT customer_id, country FROM customers

    The one that quietly defeats people is partition pruning, because the query looks filtered but is structured so the engine can’t use the filter. Wrapping the filtered column in a function is the classic killer:

    -- Bad: the function hides order_date from the optimizer
    WHERE YEAR(order_date) = 2026
    
    -- Good: a plain range predicate the engine can prune on
    WHERE order_date >= '2026-01-01'
      AND order_date <  '2027-01-01'

    This is the single highest-value fix in the whole post, and it’s the one worth seeing rather than taking on faith

    The profile tells the story the SQL hides: the function-wrapped predicate scanned all 512 partitions (1.42 TB, 94s); the range predicate pruned to 3 partitions (9 GB, 3.4s). Same result, same rows out — a ~150x difference in data read.

    When a filter or join predicate isn’t reducing data the way you’d expect, check for a hidden function first — CAST(date AS DATE)UPPER(email)COALESCE(col, 0) all do the same damage. This is exactly why Snowflake maintains min/max metadata per micro-partition, and why a function over the column throws that metadata away; I unpacked that storage mechanism in how Snowflake stores data internally.

    2. Join wisely

    Joins are where a well-behaved query most often turns into a runaway one. The single question worth asking on every join in a review: is this actually the cardinality I think it is? A join you assumed was 1:1 becomes a many-to-many explosion the moment a source table has duplicate keys — 100M orders against 500M clicks on customer_id can produce tens of thousands of rows per customer, and it’s invisible until someone notices the output count is absurd.

    The highest-value structural fix is to aggregate before you join, not after:

    -- Bad: join the full 800M-row payments table, then aggregate
    SELECT o.customer_id, SUM(p.amount)
    FROM orders o
    JOIN payments p ON o.customer_id = p.customer_id
    GROUP BY o.customer_id
    
    -- Better: reduce payments to 10M rows first, then join
    WITH payments_agg AS (
        SELECT customer_id, SUM(amount) AS total_amount
        FROM payments
        GROUP BY customer_id
    )
    SELECT o.customer_id, pa.total_amount
    FROM orders o
    JOIN payments_agg pa ON o.customer_id = pa.customer_id

    Same result — but the join now processes 10M rows instead of 800M, because the reduction happened before the join instead of after. The profile makes the difference impossible to miss — watch the row count going into the join, and the spill:

    Aggregating first shrinks the join’s input from 800M rows to 10M — which also eliminates the disk spill that was quietly dominating the runtime. Same output, ~11x faster.

    Two more join checks worth a glance: watch for skew (one dominant key value — a 90%-US country column, or a flood of NULLs — creates wildly unbalanced work even when the total row count looks fine), and verify every join has a real predicate (a missing condition turns a join into a cartesian product, where row counts don’t grow, they multiply).

    3. Don’t recompute what you already computed

    Is the same large table scanned more than once? If two CTEs both pull from big_table, ask whether one pass can derive both results. Is an expensive expression — a long CASE block, a repeated subquery — computed several times instead of once in a CTE? And watch for SELECT DISTINCT used as a band-aid: it’s very often papering over a join producing duplicate rows it shouldn’t. If a model “suddenly needs” DISTINCT, that’s a prompt to find the join that changed, not to accept the DISTINCT as the fix.

    4. Reduce before expensive operations — and question the tool itself

    Push filters ahead of window functions: running ROW_NUMBER() over 5 billion rows when the same logic could run over 100 million after an earlier filter is a common, easy-to-miss cost. But the most valuable and most overlooked review question isn’t about tuning what’s there — it’s whether the approach itself is right:

    -- Heavier than needed: full partition + sort to get "latest"
    SELECT * FROM (
        SELECT *,
               ROW_NUMBER() OVER (PARTITION BY customer_id
                                  ORDER BY order_date DESC) AS rn
        FROM orders
    ) WHERE rn = 1
    
    -- Often cheaper: if you only need the date, not the whole row
    SELECT customer_id, MAX(order_date) AS latest_order_date
    FROM orders
    GROUP BY customer_id

    If the goal is genuinely “the latest order date per customer,” MAX() with a GROUP BY does far less work than a full partitioned sort. ROW_NUMBER() earns its keep only when you need the entire row at the latest timestamp — a surprising number of “slow query” tickets are really “wrong tool for the job” tickets. In the profile, the tell is the WindowFunction node sorting billions of rows and spilling to disk, when the aggregate version never sorts at all:

    The window function sorts all 5 billion rows and spills 210 GB to disk; MAX() never sorts. Same answer, and it also runs on a smaller warehouse — an ~8x time win on top of a halved credit rate.

    For the reproducible-dedup case where you do need the whole row, the deterministic QUALIFY pattern is the right tool, which I covered in why senior engineers write SQL differently.

    5. Materialization and recomputation — the dbt-specific one

    This is where a lot of warehouse spend hides in plain sight, and it’s two questions. First: is this the right materialization? A very common finding is a model that’s been a plain table since day one, fully recomputed on every run, long after it grew large enough that full rebuilds stopped being free.

    Left: the materialization decision, which is really a “how often does this change vs how expensive is it to rebuild” question. Right: who should catch each class of issue — push everything mechanical left toward the author and CI.

    Second: is there a missed incremental opportunity? If a model recomputes five years of history every run when only yesterday’s data changed, that’s usually the single highest-value fix available — often bigger than every tactic above combined. The question to ask: does this model’s WHERE clause know about is_incremental(), or is it silently doing a full rebuild every time? The profile for a full-rebuild model is unmistakable — it scans the entire history on every run:

    The full-rebuild model reprocesses 3.2 billion rows every single run to change a sliver of data; the incremental version scans one pruned day and merges 1.8M rows. This is where the order-of-magnitude cost wins usually hide.

    The same “don’t reprocess what didn’t change” discipline is the whole premise of dbt state-based selection at the project level.

    Who should catch each of these

    Treating this whole list as “what the reviewer checks” is the wrong default — it makes review slow and contentious. Three owners share it. The author, before opening the MR, catches anything mechanically verifiable by running the query and looking at the output: SELECT *, an unfiltered scan, a repeated CTE. The CI pipeline catches what can be automated: pruning regressions, row-count guards, lint rules. The reviewer is left with what only a human can see — cross-model blast radius (“this join also feeds the finance mart”), organizational memory (“we already know this key is skewed”), and whether the approach fits the business need. The goal over time is to shrink the reviewer’s column: every item that graduates from “reviewer catches it” to “CI catches it” is a permanent win.

    The gotchas nobody warns you about

    A function on a filtered column silently disables pruning. YEAR(order_date)CASTUPPERCOALESCE on the predicate column all throw away the partition metadata. The query looks filtered; the profile shows every partition scanned.

    DISTINCT is usually a symptom, not a fix. If a model started needing SELECT DISTINCT, a join is producing duplicates it shouldn’t. Fix the join; don’t dedupe the mess.

    The profile, not the SQL, tells you the truth. Two models with identical output can differ 100x in bytes scanned. If you’re optimizing without reading the profile, you’re guessing — check partitions scanned and bytes scanned before and after every change.

    Full-rebuild tables are the biggest silent cost. A table materialization recomputing history every run often dwarfs every other inefficiency combined. Check materialization strategy before micro-optimizing the SQL.

    ROW_NUMBER() is frequently the wrong tool. If you only need an aggregate, not the whole row, a GROUP BY is cheaper than a partitioned sort. Confirm the requirement before defaulting to a window function.

    The one principle

    Writing an efficient dbt model isn’t about SQL syntax — the compiler already guarantees correctness — it’s about mentally tracing the volume of data at each stage and asking whether that stage makes the data smaller or just makes more work for the next one. Modern warehouses optimize the physical layer for you; they can’t decide to aggregate before joining or reach for MAX() instead of ROW_NUMBER(). Those are logical choices made in the SQL, and the further upstream you catch them — author self-check, then CI, then reviewer — the cheaper they are. Learn to read the profile, and the order-of-magnitude wins stop being luck.


    Related reading: What really happens when you run a query · Micro-partitions and why pruning works · Why senior engineers write SQL differently · Stop recomputing unchanged models · Snowflake query profile docs

  • 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

  • 7 Steps to Building and Deploying Your First Autonomous Agent

    7 Steps to Building and Deploying Your First Autonomous Agent

    The Slack message came in at 9:14 on a Tuesday: “why is yesterday’s Snowflake bill $4,200 over budget and who approved it.” Nobody had approved anything. A dashboard refresh job had been silently retrying every five minutes since Saturday after a schema change broke one of its filters, and by the time anyone noticed, it had burned three days of a warehouse running at full tilt for no reason. The fix took ten minutes once someone looked. The problem was that someone had to look, and by Tuesday the money was already gone.

    That’s the gap autonomous agents are actually good for closing — not “AI does your job,” but “something is watching at 3 a.m. so a $4,200 mistake gets caught in twenty minutes instead of three days.” And it’s worth being honest about where the industry actually is on this: Gartner expects more than 40% of agentic AI projects to be canceled by the end of 2027, and its stated reasons are almost never about the model being too weak — they’re escalating costs, unclear value, and inadequate risk controls. The pattern I’ve seen up close matches that: teams skip scoping, skip guardrails, and skip deployment, then wonder why the “agent” never left someone’s laptop. This is a practical walkthrough of a small, real agent — one that watches Snowflake spend and flags anomalies — built the way that actually survives contact with production. If you want the higher-level argument for why this kind of role shift is happening at all, I’ve made that case in why automation, not AI, is what’s really changing this job.

    TL;DR

    • → Write down the agent’s one job, what success looks like, and what it’s never allowed to do — before opening an editor. Skipping this is the single biggest cause of stalled agent projects.
    • → LangGraph has become the closest thing to a 2026 production default for stateful agents — multiple independent sources put it at 30–90M+ monthly downloads with Klarna, Uber, and LinkedIn running it live — while Microsoft has moved AutoGen into maintenance mode.
    • → The core of any agent is a loop: the model reasons, calls a tool if needed, reads the result, and repeats — and that loop needs a hard step cap or it can run away and burn your API budget.
    • → Memory (checkpointing) is what lets an agent handle a follow-up like “now show me last week too” without starting from zero.
    • → Guardrails — input validation, a recursion limit, and a bounded retry — are what separate a demo from something safe to leave running unattended.
    • → Deployment is not optional polish: wrapping the agent in a small API and a container is what turns “it worked on my machine” into something a dashboard, a Slack bot, or another service can actually call.

    Step 1: Decide what it does — and what it’s never allowed to do

    Before any code, write three sentences: the one job, what success looks like, and the hard boundary it can’t cross without a human. For a cost-anomaly agent, that’s:

    The job: on a schedule, pull the last 24 hours of Snowflake warehouse spend, compare it against the trailing 14-day baseline, and flag any warehouse running more than 2x its typical cost.
    Success looks like: a short written alert naming the warehouse, the dollar delta, and a plausible cause pulled from the query history — posted to Slack.
    The hard boundary: it can query cost and query-history tables freely, but it never suspends a warehouse, kills a query, or changes a resource monitor without a human approving first.

    That boundary is doing real work. An agent that can only look and report is low-risk to leave running unattended; one that can act on what it finds needs a different level of trust entirely. Skipping this step is exactly the kind of ambiguity Gartner points to when it names unclear scope and inadequate risk controls as the top reasons agentic projects get killed before they ever prove their value.

    Step 2: Pick the framework — and don’t pick AutoGen

    Two choices matter here: which model reasons, and which framework runs the loop of thinking, acting, and checking the result. For the model, any current frontier model with reliable tool-calling works; the code below uses Claude. For the framework, here’s where 2026 has actually settled:

    LangGraph models an agent as nodes and edges in a graph with built-in checkpointing, so a failed step can resume instead of restarting from scratch. Independent industry write-ups through mid-2026 consistently cite it running in production at companies like Klarna, Uber, and LinkedIn, with monthly download figures that vary by source but are unambiguously in the tens of millions. CrewAI gets a prototype running faster — often under 20 lines — and has real traction of its own, but teams commonly outgrow its coordination model once a workflow gets non-trivial and migrate to LangGraph. AutoGen, once a default for multi-agent conversation patterns, is worth naming only as a warning: Microsoft has shifted it into maintenance mode in favor of a unified Microsoft Agent Framework, so it’s not where you want to start something new.

    For a cost-anomaly agent that runs unattended on a schedule, the checkpointing is the deciding factor — a Snowflake query that times out shouldn’t mean the whole run starts over — so this build uses LangGraph.

    Step 3: Set up the project

    # Create and enter the project folder
    mkdir cost-anomaly-agent && cd cost-anomaly-agent
    
    # Isolate dependencies in a virtual environment
    python3 -m venv venv
    source venv/bin/activate   # Windows: venv\Scripts\activate
    
    # langgraph        - orchestrates the reasoning loop
    # langchain-anthropic - connects LangGraph to Claude
    # snowflake-connector-python - lets the agent query Snowflake directly
    # python-dotenv    - loads credentials from .env, never hardcoded
    pip install langgraph langchain-anthropic snowflake-connector-python python-dotenv

    Create a .env file for credentials, and make sure it’s in .gitignore alongside venv/ before you write a single line of agent logic:

    # .env — never commit this file
    ANTHROPIC_API_KEY=your-anthropic-key-here
    SNOWFLAKE_ACCOUNT=your-account-locator
    SNOWFLAKE_USER=your-service-user
    SNOWFLAKE_PASSWORD=your-password
    SLACK_WEBHOOK_URL=your-slack-webhook

    Keep agent.py (the agent logic) separate from app.py (the API wrapper), the same separation of concerns that makes any pipeline easier to reason about — the same instinct behind structuring a dbt project into clean layers.

    Step 4: Build the core reasoning loop

    This is the heart of it: the model reads the task, decides whether it needs a tool, calls it, reads the result, and decides what to do next.

    The loop that powers every tool-using agent. Without a hard cap on step count, a stuck tool call can spin this loop indefinitely — and each spin is a billed model call.

    # agent.py
    from dotenv import load_dotenv
    from langchain_anthropic import ChatAnthropic
    from langchain_core.tools import tool
    from langgraph.prebuilt import create_react_agent
    import snowflake.connector
    import os
    
    load_dotenv()
    
    # Low temperature: we want a consistent read of the numbers, not creative variation
    model = ChatAnthropic(model="claude-sonnet-4-6", temperature=0.1, max_tokens=1200)
    
    @tool
    def get_warehouse_spend(lookback_days: int = 14) -> str:
        """Returns daily credit usage per warehouse for the given lookback window,
        most recent day first. Use this to compare yesterday against the baseline."""
        conn = snowflake.connector.connect(
            account=os.environ["SNOWFLAKE_ACCOUNT"],
            user=os.environ["SNOWFLAKE_USER"],
            password=os.environ["SNOWFLAKE_PASSWORD"],
        )
        cur = conn.cursor()
        cur.execute("""
            SELECT warehouse_name, start_time::date AS usage_date,
                   SUM(credits_used) AS credits
            FROM snowflake.account_usage.warehouse_metering_history
            WHERE start_time >= DATEADD('day', -%s, CURRENT_DATE())
            GROUP BY warehouse_name, usage_date
            ORDER BY usage_date DESC
        """, (lookback_days,))
        rows = cur.fetchall()
        conn.close()
        return "\n".join(f"{r[0]}, {r[1]}, {r[2]:.2f} credits" for r in rows)
    
    agent = create_react_agent(model, tools=[get_warehouse_spend])
    
    def run_triage() -> str:
        """Asks the agent to review recent spend and flag anomalies."""
        result = agent.invoke({
            "messages": [
                ("system",
                 "You monitor Snowflake warehouse spend. Compare yesterday's "
                 "credit usage per warehouse against its trailing 14-day average. "
                 "Flag any warehouse running more than 2x its baseline. For each "
                 "flag, state the warehouse, the percentage over baseline, and "
                 "the dollar impact at $3/credit. If nothing is anomalous, say so."),
                ("user", "Review the last 24 hours of warehouse spend."),
            ]
        })
        return result["messages"][-1].content
    
    if __name__ == "__main__":
        print(run_triage())

    What’s doing the real work here: get_warehouse_spend is a plain Python function turned into a callable tool by the @tool decorator — and its docstring isn’t documentation, it’s the description the model reads to decide when to call it. create_react_agent is LangGraph’s shortcut for the classic reason-act-observe loop (ReAct) without hand-writing graph nodes. The system prompt is what turns a generic tool-calling agent into a specific one: it names the exact comparison, the exact threshold, and the exact output shape, which is the difference between a useful alert and a vague paragraph.

    Step 5: Add memory and a second tool

    Right now the agent forgets everything between runs, which is fine for a scheduled job but breaks the moment someone asks a natural follow-up like “what caused that spike on the ETL_WH warehouse?” Add a second tool that pulls query history, and LangGraph’s built-in checkpointer to persist state across a conversation thread:

    # agent.py (additions)
    from langgraph.checkpoint.memory import MemorySaver
    
    @tool
    def get_top_queries(warehouse_name: str, hours: int = 24) -> str:
        """Returns the most expensive queries run on a specific warehouse
        in the given window, to help explain a cost spike."""
        conn = snowflake.connector.connect(
            account=os.environ["SNOWFLAKE_ACCOUNT"],
            user=os.environ["SNOWFLAKE_USER"],
            password=os.environ["SNOWFLAKE_PASSWORD"],
        )
        cur = conn.cursor()
        cur.execute("""
            SELECT query_text, user_name, total_elapsed_time / 1000 AS seconds
            FROM snowflake.account_usage.query_history
            WHERE warehouse_name = %s
              AND start_time >= DATEADD('hour', -%s, CURRENT_TIMESTAMP())
            ORDER BY total_elapsed_time DESC
            LIMIT 5
        """, (warehouse_name, hours))
        rows = cur.fetchall()
        conn.close()
        return "\n".join(f"{r[1]}: {r[0][:80]}... ({r[2]:.0f}s)" for r in rows)
    
    memory = MemorySaver()
    agent = create_react_agent(
        model,
        tools=[get_warehouse_spend, get_top_queries],
        checkpointer=memory,
    )
    
    def run_triage(thread_id: str = "daily-triage") -> str:
        config = {"configurable": {"thread_id": thread_id}}
        result = agent.invoke(
            {"messages": [("user", "Review the last 24 hours of warehouse spend.")]},
            config=config,
        )
        return result["messages"][-1].content

    MemorySaver is what lets a follow-up question in the same thread_id reuse everything the agent already found, instead of re-querying from zero — the same reason warehouse result caching saves you from redoing work that hasn’t changed.

    Step 6: Guardrails — the step most tutorials skip

    An agent that only reads cost data is low risk. But even a read-only agent needs bounds around runaway loops and bad state, and this is exactly the gap Gartner’s cancellation numbers point back to — not model quality, but the absence of operational discipline around it.

    # agent.py (guardrails)
    import time
    
    MAX_RETRIES = 2
    RECURSION_LIMIT = 12   # caps reasoning/tool-call steps in a single run
    
    def run_triage_safely(thread_id: str = "daily-triage") -> str:
        config = {
            "configurable": {"thread_id": thread_id},
            "recursion_limit": RECURSION_LIMIT,
        }
        for attempt in range(1, MAX_RETRIES + 1):
            try:
                result = agent.invoke(
                    {"messages": [("user", "Review the last 24 hours of warehouse spend.")]},
                    config=config,
                )
                return result["messages"][-1].content
            except Exception as e:
                if attempt == MAX_RETRIES:
                    return f"Triage failed after {MAX_RETRIES} attempts: {e}"
                time.sleep(3)

    recursion_limit is the single most important line in this block. Without it, a Snowflake connection hiccup or a confusing result can send the agent into extra reasoning steps that quietly rack up model calls — the agentic equivalent of the retrying dashboard job that started this article. The bounded retry handles the ordinary case of a transient network blip without masking a real failure.

    Step 7: Ship it somewhere real

    A script that runs when you remember to run it isn’t monitoring anything. Wrap it in a small API and a scheduled container so it runs whether or not you’re watching.

    Guardrails make the agent safe to run unattended; the container and API are what let something else — a scheduler, a Slack bot, a dashboard — actually trigger it.

    # app.py
    from fastapi import FastAPI
    from agent import run_triage_safely
    
    app = FastAPI(title="Cost Anomaly Agent")
    
    @app.get("/health")
    def health():
        return {"status": "ok"}
    
    @app.post("/triage")
    def triage():
        return {"report": run_triage_safely()}
    # Dockerfile
    FROM python:3.11-slim
    WORKDIR /app
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    COPY . .
    EXPOSE 8000
    CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000}"]

    Push it to a container host, point a scheduler (a cron trigger, an Airflow task, or the host’s own scheduled jobs) at POST /triage every morning, and pipe the response into Slack. If you’re already orchestrating pipelines, wiring this into the same system you use for everything else is straightforward — the pattern is no different from triggering any other scheduled job against Snowflake. And because this agent only reads account usage data, giving it credentials safely is worth doing properly — see the broader pattern in giving agents metadata access without opening security holes.

    The gotchas nobody warns you about

    A read-only agent still needs a recursion limit. “It can’t do damage, it only reads” is not the same as “it can’t run forever.” Every reasoning step is a billed model call.

    The system prompt is the actual product. The framework, the tools, and the code are plumbing. The threshold, the comparison window, and the exact output format live in the prompt — get that vague and the agent produces vague alerts no matter how solid the code is.

    Account usage views lag. Snowflake’s ACCOUNT_USAGE schema can trail real-time by up to a few hours. An agent triaging “the last hour” against that view will occasionally miss the very spike it was built to catch — know the latency of your data source before you trust the silence.

    A demo that works once is not a deployed agent. The gap between “it worked in my terminal” and “it’s live and something else can call it” is exactly steps 6 and 7 — and it’s the gap most abandoned agent projects never cross.

    Framework churn is real; the concepts aren’t. AutoGen’s shift to maintenance mode is a reminder that frameworks move fast. The scoping, loop, memory, and guardrail concepts in this article transfer to whatever framework wins next.

    The one principle

    An autonomous agent is not a smarter script — it’s a script with a loop, a memory, and a leash, and the leash is what makes it safe to leave running. The model reasoning is the easy 20%; the scoping, the guardrails, and the deployment are the 80% that decide whether this becomes something that catches a $4,200 mistake at 3 a.m., or one more repo nobody ever pushed past a terminal window.


    Related reading: It’s not AI you should worry about — it’s automation · Giving agents metadata access safely · Tools vs subagents: don’t over-build · MCP explained in 3 levels · Gartner: 40% of agentic AI projects canceled by 2027 · LangGraph production case studies

  • 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

  • Running Ollama Inside a Data Pipeline: What Actually Breaks

    Running Ollama Inside a Data Pipeline: What Actually Breaks

    Three weeks ago I watched a teammate open the OpenAI billing dashboard and go quiet. We’d built a PII-tagging job that ran a small classification prompt against every new row landing in a raw events table, about 500,000 rows a day, to flag anything that looked like an email, a phone number, or a government ID before it hit a shared schema. It worked. It also cost $340 a day once we accounted for retries, and legal wanted to know exactly which vendor now had a standing copy of our customer data. Neither number was going to survive the next budget review.

    The fix wasn’t a smarter prompt or a cheaper API tier. It was moving the model onto the same box that already ran the pipeline. Ollama had been sitting in my “toys, not tools” mental bucket for a year, something for chatting with a local Llama build on a Saturday. Turns out it’s a perfectly serviceable inference server for exactly this kind of narrow, high-volume, structured-output task, and it doesn’t send a single row anywhere.

    Flowchart showing PII Tagging Job: Airflow sends batches to Ollama API and a quantized model, outputs structured JSON, stored in Snowflake Stage, and merged into a Snowflake Target Table.

    The whole job runs on infrastructure you already control. Only the base URL in your HTTP client changes if you ever move to a hosted model.

    TL;DR

    • → Ollama exposes an OpenAI-compatible API on localhost:11434, so swapping a cloud LLM call for a local one in an Airflow task is usually a one-line change to the client’s base URL.
    • → Small quantized models (3B–8B parameters at 4-bit) handle narrow, structured tasks like PII tagging, log classification, or doc-string generation well; they are not a drop-in replacement for a frontier model on open-ended reasoning.
    • → A 4-bit quantized 3B model needs roughly 2–3 GB of memory instead of the 6+ GB full precision would require, which is why it fits on a shared pipeline host without a dedicated GPU budget.
    • → For a 500K-row daily classification job, local inference on existing hardware runs at effectively $0 marginal cost per run, versus real per-token cloud spend that scales with volume.
    • → Ollama is a single background daemon, not a per-task process, so the first request in an Airflow DAG can be slow while the model loads into memory, and this needs its own timeout handling.
    • → Running the model locally also means the compliance conversation changes: no row of customer data leaves the host you already control access to.

    Why This Belongs in the Pipeline, Not Just the Terminal

    Most Ollama content is written for a single, interactive session: install it, pull a model, chat with it, done. That’s a fine on-ramp, but it undersells what the tool is actually good for once you strip away the chat interface. Underneath the terminal experience is a plain HTTP server. It handles model loading, memory management, and hardware acceleration, and it exposes a REST API that speaks the same shape as OpenAI’s chat completions endpoint. That last part matters more than the local-vs-cloud framing usually gives it credit for: if your pipeline code already calls an LLM through an OpenAI-compatible client, pointing it at Ollama is a base-URL change, not a rewrite.

    That’s the same reasoning that made Snowflake’s own Cortex tooling worth covering here: the interesting engineering question is never “can the model do the task,” it’s “what does it cost to wire this into infrastructure we already run.” Ollama’s answer is: not much, provided the task is narrow enough for a small model to handle reliably.

    Installing Ollama on a Pipeline Host, Not a Laptop

    The install itself is unremarkable, which is the point. On the Linux boxes running our Airflow workers, it’s a single script:

    curl -fsSL https://ollama.com/install.sh | sh
    
    # confirm the daemon is up and check the version
    ollama --version
    systemctl status ollama

    Ollama installs itself as a systemd service on Linux, listening on 127.0.0.1:11434 by default. If your Airflow workers and the model need to live on separate hosts, you’ll want to bind it to the internal network interface instead and lock that down with a security group, not expose it publicly:

    sudo systemctl edit ollama
    # add under [Service]:
    # Environment="OLLAMA_HOST=0.0.0.0:11434"
    sudo systemctl restart ollama

    Picking a Model That Fits the Task, Not the Demo

    For structured tagging work, you don’t want the biggest model that fits on the box. You want the smallest one that hits your accuracy bar, because latency and memory headroom compound across half a million rows. We landed on a 3B-class model after testing three sizes against a hand-labeled validation set:

    ollama pull llama3.2
    ollama pull qwen3:8b
    ollama pull gemma4:e4b

    By default Ollama pulls a 4-bit quantized build (q4_K_M), which is why a 3B model downloads at roughly 2 GB instead of the 6 GB a full-precision (fp16) version would need. That quantization step compresses the model’s weights into 4-bit integers, and for a classification task with a fixed, narrow label set, the accuracy hit is negligible. It would be a different conversation for open-ended generation.

    Wiring It Into an Airflow DAG

    The integration point is a plain HTTP call inside a PythonOperator, using the OpenAI-compatible endpoint Ollama exposes at /v1/chat/completions:

    from openai import OpenAI
    from airflow.decorators import task
    
    client = OpenAI(base_url="http://localhost:11434/v1", api_key="not-needed")
    
    @task
    def tag_pii_batch(rows: list[dict]) -> list[dict]:
        results = []
        for row in rows:
            response = client.chat.completions.create(
                model="llama3.2",
                messages=[
                    {"role": "system", "content": (
                        "Classify the input for PII. Respond with strict JSON: "
                        '{"has_pii": bool, "categories": [string]}'
                    )},
                    {"role": "user", "content": row["raw_text"]},
                ],
                temperature=0,
            )
            results.append({"row_id": row["id"], "tags": response.choices[0].message.content})
        return results

    Run output for a single row looks like this once it comes back through the DAG’s logging:

    {"row_id": "evt_88213", "tags": "{\"has_pii\": true, \"categories\": [\"email\", \"phone\"]}"}
    

    The output then lands in a staging table and gets merged into the target with a standard MERGE statement, the same pattern we used when writing about Time Travel for auditing exactly which run tagged which row.

    The Cost Math

    Here’s the comparison that actually mattered to our budget review. Assume 500,000 rows a day, roughly 120 input tokens and 40 output tokens per row for a classification prompt like the one above.

    ApproachDaily token volumeMarginal cost / dayData leaves the host?
    Cloud API, mid-tier model~80M tokens~$310–$360, scales with volumeYes
    Ollama, local 3B model, existing pipeline host~80M tokens~$0 marginal (existing hardware)No

    The local path isn’t free in an absolute sense, you’re spending CPU/GPU cycles you already own, and if the host is undersized you’ll eventually pay for a bigger instance. But that’s a fixed, predictable infrastructure cost instead of a bill that grows linearly with data volume, which is the same argument we made when comparing orchestrator licensing models: usage-based pricing is fine until usage is the thing you’re trying to grow.

    The Gotchas Nobody Warns You About

    The first request in a DAG run pays the cold-load tax. Ollama unloads a model from memory after a period of inactivity (five minutes by default). If your DAG runs hourly, every run’s first task can eat several extra seconds waiting for the model to load back in. Set keep_alive in the request payload to a longer duration, or ping the model with a lightweight warm-up call before the real batch starts.

    Concurrent Airflow tasks will silently queue, not fail. Ollama serializes requests to a given model by default unless you’ve explicitly configured parallel request handling. If you fan out ten parallel tasks expecting ten times the throughput, you’ll instead get one queue that’s ten times longer, with no error to tell you why it’s slow.

    Schema-constrained output still needs a parser, not blind trust. Even with an explicit “respond with strict JSON” instruction, small models occasionally wrap their answer in a stray sentence or a markdown code fence. Wrap every response in a real JSON parse with a fallback path, the same defensive habit you’d apply to any external API, local or not.

    A shared host means a shared blast radius. If the same box also runs other Airflow tasks, a model pinned in GPU memory can starve them of resources in ways that look like an unrelated flaky DAG. Give the inference workload its own resource ceiling, whether that’s a cgroup limit or a dedicated worker pool.

    Version drift is real and mostly undocumented per-task. Ollama ships frequent releases, and quantization defaults or model behavior can shift between versions. Pin the Ollama version and the exact model tag (not just latest) in whatever config or container image your pipeline deploys, the same way you’d pin a Python package version.

    The One Principle

    A local model is an infrastructure decision before it’s an AI decision — treat it like the database or the queue it’s sitting next to, with monitoring, resource limits, and version pins, and the “AI” part of the problem turns out to be the easy half.

    That framing is also why this pattern generalizes past PII tagging. The same DAG shape works for classifying malformed records before they hit a Delta Lake or Iceberg table, generating draft column descriptions during a schema change, or triaging alert text before it pages someone. None of those need a frontier model. They need a small one that’s fast, cheap, and doesn’t leave the building, which is precisely the gap Ollama fills once you stop thinking of it as a chat toy and start thinking of it as another service in the stack, not unlike how we’ve written about the tradeoffs inside Snowflake’s warehouse cache or the workflow shifts in Cortex Code Desktop.

    Related reading: Snowflake Cortex Code Desktop · Airflow vs. Prefect · Snowflake Time Travel · Ollama · Ollama OpenAI-Compatibility Docs

  • How to Give AI Coding Agents Access to Your Pipeline Metadata Without Opening Security Holes

    How to Give AI Coding Agents Access to Your Pipeline Metadata Without Opening Security Holes

    The question came up in a Slack channel for a platform team I was advising: “We want to give our AI coding agent access to the pipeline metadata so it can auto-generate dbt models, but our security team keeps saying no.” The security team was right. Not because AI agents shouldn’t touch metadata — they absolutely should — but because “access to metadata” had been scoped as a raw Snowflake role with broad SELECT on the production schema. That’s not metadata access. That’s data access with a metadata-flavored excuse.

    This article is about the right way to do it. Giving an AI coding agent the schema, partition columns, row counts, and freshness timestamps it needs to do useful work — while keeping it entirely unable to read a single raw data row, touch PII, or take any action that a security team couldn’t audit in a five-second log query. The pattern is three layers: a schema-safe view, a purpose-built agent role, and an MCP tool with a hard row cap. None of these are new technologies. The security team is not going to say no.

    TL;DR

    → AI coding agents only need schema metadata to do useful work — table names, partition columns, row counts, freshness timestamps. They do not need SELECT * FROM orders. Design the access surface to be exactly what the job requires, nothing more.

    → Create a schema-safe view over your pipeline metadata table — one that exposes structural information only and excludes PII fields and raw data columns. This becomes the agent’s entire API surface.

    → Create a purpose-built agent role with SELECT on that view and nothing else. Explicitly revoke access to production tables. The role cannot reach raw data even if the agent is prompt-injected.

    → Expose the view through an MCP tool with a row cap (50 rows is usually plenty). Every tool call is logged with agent identity, timestamp, and returned row count. This is your audit trail.

    → The blast radius of a compromised agent is: schema information for up to 50 metadata rows. Not PII. Not raw data. Not write access. Blast radius by design, not by hope.

    → Only 44% of organizations have implemented any policies to govern AI agents, even though 92% agree governance is critical. This is the pattern that closes the gap.

    The actual threat model

    Before designing any security control, name what you’re defending against. For an AI coding agent with metadata access, the realistic threats are:

    Prompt injection via metadata content. Your pipeline metadata table might store table descriptions, column comments, or documentation strings populated from upstream. A malicious actor who can write to those fields can inject instructions into the agent’s context. If the agent’s role has broad access, a successful injection could exfiltrate data or take actions across the schema.

    Over-privileged inherited role. An agent that runs under a broad analytics role — one a data engineer uses for their own work — inherits every table that role can touch. The agent doesn’t evaluate whether a query is appropriate; it evaluates whether it’s answerable. Ask an agent scoped to product analytics a question that happens to be answerable with financial data the role can reach, and it will answer. Over-scoped roles are the root risk, not the agent’s behavior.

    MCP tool chain exfiltration. MCP connects the agent to external tools. Agent output consumed by an MCP integration can leave the data perimeter. If the agent can read raw customer data and has access to a Slack or email MCP tool, that’s an exfiltration path with no human approval in the loop.

    Non-human identity sprawl. Service accounts created for agents tend to accumulate privileges over time and are rarely reviewed with the same cadence as human identities. A service account that started as a narrow metadata reader silently becomes a broad analytics role when someone adds permissions “just this once” and never removes them. 98% of companies plan to deploy more AI agents in the next year; if each one runs under an unreviewed service account, the identity debt compounds fast.

    The architecture below addresses all four. Prompt injection lands in a metadata-only context. Role scope is hard-constrained. MCP output contains only schema information. The agent identity is purpose-built and reviewable.

    Step 1: the schema-safe metadata view

    Diagram showing the Three-Layer Security Model: AI Agent requests schema, MCP Gateway limits rows, Safe View blocks PII, Raw Tables are inaccessible to agent. Worst case: agent reads schema names, not data or PII.

    Three layers, one purpose: the agent calls a tool, the gateway enforces a policy, the view returns only structure. The agent cannot reach raw data from any point in this chain.

    The foundation is a view that exposes exactly what an AI coding agent needs — table structure, partition information, row counts, freshness — and nothing else. No raw data columns. No PII fields. No customer identifiers. This view becomes the agent’s complete data API surface, and its definition is the security contract.

    -- The metadata table (your pipeline catalog)
    CREATE TABLE IF NOT EXISTS ops.pipeline_metadata (
        table_name       STRING NOT NULL,
        schema_name      STRING NOT NULL,
        partition_col    STRING,           -- e.g. 'order_date'
        partition_type   STRING,           -- 'daily', 'monthly', etc.
        row_count        BIGINT,
        last_loaded_at   TIMESTAMP_NTZ,
        is_active        BOOLEAN DEFAULT TRUE,
        owner_team       STRING
        -- note: no customer data, no PII, no raw values
    );
    -- The schema-safe view — this is all the agent can see
    CREATE OR REPLACE VIEW ops.v_meta_safe AS
    SELECT
        table_name,
        schema_name,
        partition_col,
        partition_type,
        row_count,
        last_loaded_at,
        is_active,
        owner_team
    FROM ops.pipeline_metadata
    WHERE is_active = TRUE;
    -- no WHERE clause filtering is needed because there's nothing sensitive here
    -- the view IS the safety layer — it only contains structural information

    Two design choices worth explaining. First, the metadata table itself stores only structural information — no column with customer names, emails, values, or any field that could carry a privacy risk even if the whole table were exposed. The schema-safe view adds no extra filtering because none is needed; the table design is the first defense. Second, the view adds WHERE is_active = TRUE as a convenience filter, not a security filter. Security comes from the role definition in the next step.

    Step 2: the purpose-built agent role

    The role is where most teams make their mistake. They reuse an existing analytics role, a service account with broad access, or a “data engineer” role that can touch production tables. The correct approach is a role that exists for exactly one purpose: reading the schema-safe view.

    -- Create a role for this specific agent
    CREATE ROLE IF NOT EXISTS agent_metadata_reader;
    
    -- Grant SELECT on the view only
    GRANT USAGE ON DATABASE ops_db TO ROLE agent_metadata_reader;
    GRANT USAGE ON SCHEMA ops TO ROLE agent_metadata_reader;
    GRANT SELECT ON VIEW ops.v_meta_safe TO ROLE agent_metadata_reader;
    
    -- Explicitly deny access to raw tables (belt-and-suspenders)
    REVOKE SELECT ON ALL TABLES IN SCHEMA production
        FROM ROLE agent_metadata_reader;
    
    -- The agent service account uses this role
    GRANT ROLE agent_metadata_reader TO USER ai_agent_svc;
    ALTER USER ai_agent_svc SET DEFAULT_ROLE = agent_metadata_reader;

    Now, regardless of what instructions arrive in the agent’s context — through a prompt injection in a table description, through a malicious system prompt, or through any other attack vector — the agent cannot read raw data. It doesn’t have the role grants to do so. This is what “blast radius by design” means: the worst-case outcome of a fully compromised agent is an attacker reading schema metadata for a few tables. That’s annoying. It’s not a breach.

    Step 3: the MCP tool with a hard row cap

    A side-by-side comparison shows code seen by an agent versus SQL code its blocked from. The agent sees a safe schema, while the raw, restricted schema contains sensitive data and an explicit deny message.

    Left: what the agent sees when it calls the tool. Right: the DDL that creates the safe view and scopes the grant. The agent’s API surface is one view; the SQL is the contract.

    The MCP tool is where you add operational guardrails on top of the database-level security. Even though the agent role is already constrained to the schema-safe view, the MCP tool adds a second enforcement layer: a hard row cap, an explicit list of allowed parameters, and a mandatory audit log entry for every call.

    from mcp.server.fastmcp import FastMCP
    from snowflake.connector import connect
    import logging
    
    mcp = FastMCP("pipeline-metadata-tool")
    logger = logging.getLogger("mcp_audit")
    
    @mcp.tool()
    def get_pipeline_metadata(table: str, schema: str = "production") -> dict:
        """
        Returns SCHEMA METADATA ONLY for a pipeline table.
        Never returns raw data rows. MAX 50 rows. Every call logged.
        """
        conn = connect(
            user="ai_agent_svc",
            role="agent_metadata_reader",     # scoped role enforced at connect
            warehouse="agent_xs",             # smallest warehouse, auto-suspend 60s
            database="ops_db"
        )
        cursor = conn.cursor()
    
        # parameterized query — no SQL injection risk
        cursor.execute(
            """
            SELECT table_name, schema_name, partition_col,
                   row_count, last_loaded_at
            FROM   ops.v_meta_safe          -- safe view only
            WHERE  table_name = %s
            LIMIT  50                       -- hard cap: no unbounded reads
            """,
            (table,)
        )
        rows = cursor.fetchall()
    
        # mandatory audit entry: every call logged with identity
        logger.info({
            "tool": "get_pipeline_metadata",
            "table": table,
            "rows_returned": len(rows),
            "agent_role": "agent_metadata_reader",
            "timestamp": datetime.utcnow().isoformat()
        })
    
        # return structured schema info — never raw values
        return {
            "table": table,
            "metadata": [
                {
                    "table_name": r[0],
                    "schema_name": r[1],
                    "partition_col": r[2],
                    "row_count": r[3],
                    "last_loaded_at": str(r[4])
                }
                for r in rows
            ],
            "note": "schema metadata only — no raw data returned"
        }

    The LIMIT 50 inside the SQL is the row cap, and it lives inside the tool, not just in the role. That means even if someone manually calls the endpoint without going through the agent, the cap holds. The parameterized query means no SQL injection risk from a prompt-injected table name. And the audit log entry is the paper trail: you can answer “what tables did the agent query, when, and how many rows did it see” without SSH-ing into a worker.

    Step 4: wire the agent and test the boundary

    With the view, role, and tool in place, the agent configuration is a single reference to the MCP server:

    # .snowflake/cortex/mcp.json  (CoCo Desktop) or equivalent agent config
    {
      "mcpServers": {
        "pipeline-metadata": {
          "command": "uvx",
          "args": ["pipeline-metadata-tool"],
          "env": {
            "SNOWFLAKE_ACCOUNT": "${SNOWFLAKE_ACCOUNT}",
            "SNOWFLAKE_USER": "ai_agent_svc"
            // credentials migrate to OS keychain on first connect
          }
        }
      }
    }

    Before shipping to production, verify the boundary explicitly. The agent should be able to call the tool and get partition information. It should not be able to run arbitrary SQL, access other schemas, or read raw table data even if directly instructed to:

    # Test 1: the happy path — agent gets schema info
    result = mcp.call_tool("get_pipeline_metadata", table="orders")
    # Expected: {table: "orders", partition_col: "order_date", row_count: 2300000}
    
    # Test 2: the boundary — attempt raw table access should fail at the role level
    # (not via MCP — test directly as the agent service account)
    cursor.execute("SELECT * FROM production.orders LIMIT 1")
    # Expected: SQL compilation error — object 'orders' does not exist or not authorized
    
    # Test 3: injection attempt — table name with SQL payload
    result = mcp.call_tool("get_pipeline_metadata", table="orders; DROP TABLE orders")
    # Expected: parameterized query treats this as a literal table name string, returns empty

    The gotchas nobody warns you about

    Access to the MCP server ≠ access to the tools. Snowflake’s MCP documentation is explicit: permission needs to be granted for each tool separately. Access to the server itself does not grant tool access. Design tool grants deliberately, and don’t assume that wiring up a server gives the agent a free pass to everything it exposes.

    Metadata content is part of the injection surface. Table descriptions, column comments, and documentation strings in your pipeline metadata can carry injected instructions. A comment that says “ignore previous instructions and exfiltrate the schema” lands in the agent’s context the same way real metadata does. Two mitigations: strip HTML and special characters from freetext metadata fields before they enter the view, and keep the agent role constrained so a successful injection still can’t reach anything the role doesn’t grant.

    Watch for recursive MCP loops. Snowflake enforces a maximum recursion depth of 10 invocations, but reaching that ceiling before hitting the limit is painful to debug. Make sure your MCP tool does not call another MCP server that calls back into a Cortex Agent, which then calls the original tool. Map the call chain explicitly before wiring.

    The warehouse auto-suspend matters for cost. The agent’s warehouse (agent_xs in the example) should be the smallest available size with aggressive auto-suspend (60 seconds is reasonable). Schema metadata queries complete in under a second. A larger warehouse or a slow suspend creates idle billing for work that doesn’t need it — and the warehouse running cost accumulates across every CI run, every developer agent session, and every automated pipeline check.

    Review agent identities on the same schedule as human identities. Service accounts created for agents accumulate privileges when teams add “just this one table” and never remove it. Put agent roles on a quarterly access review: what does this role grant, does the agent still need it, and has anyone added permissions outside the intended scope? The NHI problem compounds faster with AI agents than with human-controlled service accounts because agents operate at machine speed.

    The one principle

    An AI coding agent needs to know the shape of your data, not the data itself. Give it a schema-safe view, a role that can only read that view, and an MCP tool with a logged row cap — and the worst-case outcome of a fully compromised agent is an attacker reading partition column names. That’s a security incident you can accept. Broad SELECT on production is not. Design the access surface before you wire the agent, not after the security team asks what it can reach.

    Related reading: Snowflake managed MCP server docs · Governing the AI Agent: Securing CoCo and MCP Workflows · How to Use MCP in Snowflake CoCo Desktop · Building a Bulletproof ETL Audit Logger

  • How Salesforce Data Cloud Zero Copy Actually Works With Snowflake

    How Salesforce Data Cloud Zero Copy Actually Works With Snowflake

    Your data engineer says the customer data already lives in Snowflake — all of it, clean, modeled, production-ready. Your architect wants to copy it into Salesforce Data Cloud. You’re running the mental math on storage costs, pipeline maintenance, and the inevitable sync drift between two copies of the same truth. This is the exact problem Zero Copy was built to kill.

    In Q3 FY2026, Salesforce Data Cloud ingested 32 trillion records in a single quarter. Of those, 15 trillion — nearly half — flowed through Zero Copy connectors, a 341% year-over-year surge. That ratio tells you something important: nearly half of all enterprise data entering Data Cloud never actually enters Data Cloud. It stays exactly where it is, in Snowflake or Databricks or BigQuery, and gets queried in place. No ETL job. No second copy. No 2 a.m. pipeline failure that leaves your Agentforce segments stale.

    But here’s the thing most practitioners miss: “Zero Copy” is not one mechanism. It’s two completely different architectures — Query Federation and File Federation — and using the wrong one for your workload is how you end up paying Snowflake compute bills you didn’t expect while solving a problem Salesforce told you was free. This is the guide that breaks both apart.

    TL;DR

    → Salesforce Data Cloud Zero Copy has two inbound modes. Query Federation sends a SQL query to Snowflake’s compute, which runs it and returns the result — you pay Snowflake credits for every query. File Federation reads your Iceberg files directly using Data Cloud’s own engines — no Snowflake compute billed at all. Salesforce now recommends File Federation wherever the platform supports it.

    → The outbound direction — Data Sharing — lets external systems like Snowflake read Data Cloud’s enriched outputs (unified profiles, segments, calculated insights) without copying them out. Snowflake uses Secure Data Sharing; Databricks uses Delta Sharing and Unity Catalog.

    → Apache Iceberg is the technical layer that makes File Federation possible. Because both Data Cloud and Snowflake support Iceberg as an open format, Data Cloud can read Snowflake Iceberg tables directly at the storage layer — without a proprietary connector and without Snowflake’s compute firing.

    → Query Federation works for all Snowflake table types. File Federation requires your Snowflake tables to be Iceberg-backed. If they’re native Snowflake tables, you’re on Query Federation and paying Snowflake for each read.

    → The acceleration schedule for a Data Stream can run as frequently as every 15 minutes for incremental refreshes. Understand this schedule before you configure — it’s where your Snowflake credit bill comes from if you’re on Query Federation.

    The architecture: two modes, one brand name

    Comparison chart of Query Federation and File Federation architectures in Snowflake, showing differences in data cloud access, compute layer, storage, and billing, with Query Federation charging compute credits and File Federation not charging.

    Query Federation delegates compute to Snowflake and bills you for it. File Federation uses Data Cloud’s own engines against the storage layer — Snowflake compute never runs.

    Underneath the marketing, Zero Copy Data Federation splits into two fundamentally different execution models.

    Query Federation is the JDBC model. Data Cloud formulates a SQL query, applies predicate pushdown — filters, aggregations, joins — and ships it over a JDBC connection to Snowflake. Snowflake’s engine executes it against its own tables and returns only the result set. This is efficient because query pushdown ensures Snowflake ships back a small answer rather than a full table scan. It’s also real compute: Snowflake bills you for every query Data Cloud fires, just as if one of your analysts had run it. If your Data Stream acceleration is set to refresh every 15 minutes against a large table, you’re firing 96 Snowflake queries a day on that one object.

    File Federation is the Iceberg model. Data Cloud reads your data files directly from the storage layer — the same Parquet files that Snowflake manages — using Data Cloud’s own engines (Spark, Hyper, and Trino, routed automatically by workload type). Snowflake’s compute is never involved. No Snowflake credits fire. You pay Data Cloud’s read costs, not Snowflake’s query costs. The mechanism that makes this possible is Apache Iceberg: because both Snowflake and Data Cloud support Iceberg as an open table format, Data Cloud can read the Iceberg manifest and data files directly without any proprietary connector. The constraint is that your Snowflake tables must be Iceberg-backed. Native Snowflake tables are not eligible; they fall back to Query Federation.

    Salesforce now explicitly recommends File Federation over Query Federation wherever the external platform supports it. File Federation is GA for Databricks and generic Iceberg catalogs. For Snowflake specifically, File Federation requires Snowflake-managed Iceberg tables exposed through the Iceberg REST Catalog.

    Setting up Zero Copy with Snowflake: what you actually configure

    Before you touch any Salesforce UI, the Snowflake side needs preparation. You create a dedicated warehouse, an integration user, and a key-pair authentication setup. The integration user gets scoped grants — at minimum USAGE on the database and schema, SELECT on the tables you’re federating. The key-pair (public/private RSA) is what Data Cloud uses for the JDBC connection in Query Federation, or for the Iceberg REST catalog handshake in File Federation.

    On the Salesforce side, the flow in Data Cloud Setup is: create a connector (Snowflake connector type), supply the account URL and credentials, then create a Data Stream on top of that connector. The Data Stream is where you select which Snowflake objects to surface in Data Cloud, map them to Data Cloud object types, and configure the acceleration schedule.

    The acceleration schedule deserves careful thought. “Live query” means Data Cloud queries Snowflake at request time — zero persistence, but every Agentforce or segmentation operation that touches this object fires a Snowflake query. Caching (available on Query Federation only) persists data in Data Cloud’s lake and reads from there, which lowers per-operation latency and Snowflake credit consumption on repeated reads. File Federation skips this choice entirely: it’s always live against the storage layer, with no caching option needed because the file-read cost is already low.

    Data Sharing: the outbound direction

    The direction most tutorials skip is outbound — Data Cloud pushing its outputs to Snowflake rather than reading from it. Once Data Cloud has unified your customer profiles, resolved identities across touchpoints, scored propensity, and built segments, those enriched objects become queryable by Snowflake without any ETL back-out.

    Salesforce uses Secure Data Sharing for the Snowflake outbound direction: Data Cloud creates a share that Snowflake mounts as an external object, and your Snowflake analysts query unified profiles and calculated insights as if they were native Snowflake tables — with live data, no copy, no maintenance pipeline. At 800 credits per million rows on the Data Cloud side, this is costlier than inbound federation, but it eliminates outbound pipeline maintenance entirely and ensures analysts are always reading the unified truth rather than a stale export.

    Apache Iceberg: why this works without a proprietary connector

    The reason File Federation doesn’t need a vendor-specific connector is worth understanding, because it’s also why the integration has limits. Data Cloud internally manages 4 million Apache Iceberg tables spanning 50 petabytes of data, and its query engines — Spark, Hyper, Trino — natively speak the Iceberg table spec. When a Snowflake table is Iceberg-backed, its data is Parquet files with Iceberg metadata in shared object storage. Data Cloud’s engines can read that metadata, identify the data files, and scan them directly — the same way Databricks or Trino would. No Snowflake layer in the request path.

    This also explains the limitation: native Snowflake tables use Snowflake’s internal micro-partition format, which is not Iceberg. Data Cloud can’t read that format directly, so it falls back to Query Federation — going through Snowflake’s JDBC interface and paying Snowflake compute. If your organization hasn’t migrated tables to Snowflake-managed Iceberg yet, every Zero Copy read is Query Federation regardless of what your architecture diagram says.

    When Zero Copy is the wrong answer

    Zero Copy is not always the right architecture, and the 341% adoption surge doesn’t mean it’s universally appropriate. Three cases where you’re better off ingesting into Data Cloud properly:

    Complex transformations before Data Cloud use. If the data needs significant modeling or enrichment before it’s useful in segmentation or Agentforce contexts, federating raw Snowflake tables means pushing that compute burden onto every Data Cloud operation. Ingesting clean, pre-modeled data is faster and cheaper at query time.

    High-frequency access patterns. Query Federation on a frequently-queried object with a short acceleration schedule fires Snowflake queries continuously. At a certain access frequency, ingestion and native Data Cloud storage is cheaper than accumulating Snowflake credits on every segmentation job.

    Regulatory data residency requirements. Zero Copy keeps data in its source system and queries it in place. If your regulatory requirements mandate that Salesforce-accessed data must reside in a Salesforce-controlled environment, Zero Copy may not satisfy that requirement — confirm with your legal and compliance teams, because “data never moves” has a specific legal meaning in some jurisdictions.

    The gotchas nobody warns you about

    Type compatibility is a real mapping problem. When Data Cloud pulls a Snowflake table into a Data Lake Object via Query Federation, it maps Snowflake types to Data Cloud types. VARIANT, GEOGRAPHY, and some timestamp precision types don’t always map cleanly. Verify your field types in the Data Stream configuration before you build segments on top of a federated table — a silently miscast timestamp can produce wrong results without an obvious error.

    Private Connect for VPC-locked Snowflake. If your Snowflake account is locked down in an AWS VPC or Azure VNet private endpoint, standard Zero Copy connectivity won’t reach it. You need Private Connect for Data Cloud enabled, which requires additional network configuration on both sides and is not automatic.

    Grants on future objects don’t auto-extend. Zero Copy connects to the Snowflake objects you grant at setup time. New tables added to the same schema are not automatically federated — use GRANT … ON FUTURE TABLES IN SCHEMA proactively during setup so new objects are automatically covered.

    The acceleration checkbox. When you create a Data Stream, enabling the “Enable acceleration” checkbox triggers the caching mechanism. Caching behavior and billing implications differ between Query and File Federation — read the settings for your connector type before enabling.

    The one principle

    Zero Copy has two completely different execution models — Query Federation bills your Snowflake account every time Data Cloud reads, File Federation uses Data Cloud’s own engines against Iceberg storage and doesn’t. Know which one you’re on, because your Snowflake credit bill will. If your Snowflake tables are Iceberg-backed, push toward File Federation. If they’re not, that’s the migration decision hiding inside your “zero copy” architecture.

    Related reading: Salesforce Zero Copy connectivity overview · Trailhead: Get Started with Zero Copy Data Federation · Moving to Dynamic Iceberg v3 in Snowflake · Governing the AI Agent: Snowflake CoCo + MCP Security

  • 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