<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Laura Chicovis </title>
    <description>The latest articles on DEV Community by Laura Chicovis  (@laura_cristinachicovisd).</description>
    <link>https://dev.to/laura_cristinachicovisd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081810%2F86dee10b-34bd-4ddd-bf84-ad526f124b30.jpg</url>
      <title>DEV Community: Laura Chicovis </title>
      <link>https://dev.to/laura_cristinachicovisd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/laura_cristinachicovisd"/>
    <language>en</language>
    <item>
      <title>Open or closed model? There is a third option</title>
      <dc:creator>Laura Chicovis </dc:creator>
      <pubDate>Wed, 09 Sep 2026 21:01:30 +0000</pubDate>
      <link>https://dev.to/laura_cristinachicovisd/open-or-closed-model-there-is-a-third-option-4ojd</link>
      <guid>https://dev.to/laura_cristinachicovisd/open-or-closed-model-there-is-a-third-option-4ojd</guid>
      <description>&lt;p&gt;Open or closed? The question shows up in every architecture review, and it usually gets settled with a benchmark chart and a price-per-token comparison. But it often gives the wrong answer, because the two options on the table are not the two you have.&lt;/p&gt;

&lt;p&gt;There is a third sitting between them, and for most workloads it wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the binary is badly posed
&lt;/h2&gt;

&lt;p&gt;The usual reasoning runs like this. An H100 rents for a few dollars an hour, while a frontier API charges a markup per token on that same hardware. So you run the weights yourself and keep the margin someone else is taking.&lt;/p&gt;

&lt;p&gt;The catch is that "using an open model" and "operating an open model" got collapsed into one choice. So separate them, and the third option appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three shapes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Closed frontier API.&lt;/strong&gt; Variable cost, zero operations, no access to the weights. Roughly $2.50 to $15 per million tokens (packet.ai, jul/2026).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted open-weight API.&lt;/strong&gt; Variable cost, zero operations, and the same weights you would run. Roughly $0.07 to $0.90 per million tokens for Llama 4 and DeepSeek V4 class models (packet.ai, jul/2026).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted open weights.&lt;/strong&gt; Fixed monthly cost, full control, and an operational surface you now own. Llama 4 70B on an H100 at batch=8 comes to about $0.18 per million output tokens (packet.ai, jul/2026).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rows two and three run the same model, in the same order of magnitude. So the saving that justifies buying GPUs is not the one against the frontier API, it is the one against a provider already serving those weights, and there it mostly disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that decides it is utilization
&lt;/h2&gt;

&lt;p&gt;That $0.18 figure assumes the GPU runs at 85% utilization with vLLM continuous batching, against 30 to 40% for naive serving (packet.ai, jul/2026). Meanwhile, average GPU utilization across production Kubernetes fleets sits at 5%, and the best cluster in that dataset, a 136-node H200 deployment, reached 49% (Cast AI, State of Kubernetes Optimization Report 2026).&lt;/p&gt;

&lt;p&gt;That gap is the whole argument. A rented GPU bills the same idle as saturated, so cost per token scales inversely with utilization. Drop from 85% to 5% and the same setup costs roughly seventeen times more per token.&lt;/p&gt;

&lt;p&gt;Then add operations. Version upgrades, OOM debugging, monitoring and capacity planning run 10 to 20 hours a month, between $1,500 and $4,000 that never shows up in the GPU bill (packet.ai, jul/2026).&lt;/p&gt;

&lt;p&gt;Which is why published break-even points disagree so much. Against managed frontier APIs, self-hosting on reserved capacity breaks even around 2 to 5 million tokens a day over 12 months (packet.ai, jul/2026). Against a hosted endpoint running the same weights, it moves out to 50 million or more (howaiworks, jul/2026).&lt;/p&gt;

&lt;p&gt;And none of this is unique to inference. It is the same trap as provisioned capacity in a data platform, easier to see in &lt;a href="https://bixtech.ai/microsoft-fabric-cost-optimization/?utm_source=dev.to&amp;amp;utm_medium=guestpost&amp;amp;utm_campaign=okr_backlinks_q3_2026"&gt;Microsoft Fabric capacity units&lt;/a&gt;, where the meter runs on what you reserved and not on what you used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on your own numbers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;HOURS_PER_MONTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;730&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;monthly_self_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gpu_hourly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_gpus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ops_multiplier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fixed. Billed by the hour, never by the token.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gpu_hourly&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;HOURS_PER_MONTH&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_gpus&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ops_multiplier&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;breakeven_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gpu_hourly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_gpus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ops_multiplier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price_per_mtok&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Monthly token volume where self-hosting matches the API bill.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monthly_self_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gpu_hourly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_gpus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ops_multiplier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;price_per_mtok&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;monthly_capacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens_per_sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_gpus&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Ceiling. Use sustained throughput measured at your real batch size.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tokens_per_sec&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_gpus&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;HOURS_PER_MONTH&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;breakeven_tokens&lt;/code&gt; twice, once against your frontier API price and once against a hosted endpoint serving the model you intend to run. Then check both against &lt;code&gt;monthly_capacity&lt;/code&gt;, because a break-even you cannot reach is not a break-even.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Below your volume and inside capacity.&lt;/strong&gt; Self-hosting has a case, so load test the throughput figure before anyone reserves an instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Above capacity.&lt;/strong&gt; Reaching it means adding GPUs, which raises the fixed cost, which pushes break-even further out. At that price the lines never meet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Far above your volume, measured against the hosted endpoint.&lt;/strong&gt; The open model wins and running it yourself does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the math does not settle
&lt;/h2&gt;

&lt;p&gt;Cost is loud, but compliance is louder. Data residency, contractual confidentiality, a no-training guarantee or a hard latency floor can close the question before the spreadsheet opens. That bites hardest in Europe, where routing inference through US platforms raises transfer questions under Chapter V of the GDPR (Lyceum Technology, ago/2026). If the weights have to stay inside your network, break-even is beside the point.&lt;/p&gt;

&lt;p&gt;Although it cuts the other way too. Self-hosting to satisfy a requirement that a zero-retention agreement already covers buys permanent operational load for a guarantee you had on paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, three options or one routing rule?
&lt;/h2&gt;

&lt;p&gt;In practice it lands as a split. A small open model takes the high-volume, well-shaped work, so classification, extraction and routing, while the frontier API stays reserved for reasoning it cannot handle. Cost drops because the cheap path absorbs the traffic, and quality holds because the expensive path catches the hard cases.&lt;/p&gt;

&lt;p&gt;Which turns open versus closed into a routing decision made per workload, rather than an architecture decision made once and defended for two years.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to run generative AI on SQL tables with Snowflake Cortex</title>
      <dc:creator>Laura Chicovis </dc:creator>
      <pubDate>Thu, 03 Sep 2026 02:21:09 +0000</pubDate>
      <link>https://dev.to/laura_cristinachicovisd/how-to-run-generative-ai-on-sql-tables-with-snowflake-cortex-5gh6</link>
      <guid>https://dev.to/laura_cristinachicovisd/how-to-run-generative-ai-on-sql-tables-with-snowflake-cortex-5gh6</guid>
      <description>&lt;p&gt;The question that decides whether Cortex belongs in your stack is not what it can do. It is what it costs once the table has millions of rows instead of five.&lt;/p&gt;

&lt;p&gt;That question exists because the model call is an ordinary SQL function. It sits inside a &lt;code&gt;SELECT&lt;/code&gt;, composes with &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt; and &lt;code&gt;GROUP BY&lt;/code&gt;, and runs once per row. So this walkthrough goes in that order. Access first, then the functions on a small slice, then the credit consumption before anything scales up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Snowflake account on Standard edition or above, in a region where Cortex is available.&lt;/li&gt;
&lt;li&gt;A running warehouse. The AI functions are billed on the tokens they process, and the query around them still consumes warehouse credits, so you pay for both.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ACCOUNTADMIN&lt;/code&gt; once, to grant access.&lt;/li&gt;
&lt;li&gt;A table with a text column. The examples use &lt;code&gt;support.tickets&lt;/code&gt;, with &lt;code&gt;ticket_id&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt; and &lt;code&gt;region&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Granting access
&lt;/h2&gt;

&lt;p&gt;Cortex access lives in a database role called &lt;code&gt;SNOWFLAKE.CORTEX_USER&lt;/code&gt;. It cannot be granted to a user directly, only to a role, which is the first thing that trips people up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ACCOUNTADMIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;ai_analyst&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;SNOWFLAKE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CORTEX_USER&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_analyst&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;ai_wh&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_analyst&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_analyst&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;my_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two habits are worth adopting right away. The first is to put AI work on its own warehouse, so the credits appear separated in your billing without any extra tagging. The second is to grant the database role to a purpose-built role instead of something broad like &lt;code&gt;ANALYST&lt;/code&gt;, because revoking access later is the only real spending control you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first call
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AI_COMPLETE&lt;/code&gt; is the general-purpose function. It takes a model name and a prompt, and returns text. Since model availability changes by region and by release, start by listing what your account can actually call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;CORTEX&lt;/span&gt; &lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="n"&gt;MODELS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then use one of those names below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_analyst&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="n"&gt;WAREHOUSE&lt;/span&gt; &lt;span class="n"&gt;ai_wh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;AI_COMPLETE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'claude-4-sonnet'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- replace with a model from the list above&lt;/span&gt;
  &lt;span class="s1"&gt;'Summarize this support ticket in one sentence: '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tickets&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;LIMIT 5&lt;/code&gt;. Without it, the statement runs one model call per row, and a table with two million tickets will happily oblige. Every AI function here behaves the same way, so develop against a &lt;code&gt;LIMIT&lt;/code&gt; and remove it only when the prompt is settled.&lt;/p&gt;

&lt;p&gt;If you find older material online using &lt;code&gt;SNOWFLAKE.CORTEX.COMPLETE&lt;/code&gt;, that is the previous generation. &lt;code&gt;AI_COMPLETE&lt;/code&gt; is the updated version, and the same rename ran across the family, so &lt;code&gt;SENTIMENT&lt;/code&gt; became &lt;code&gt;AI_SENTIMENT&lt;/code&gt; and &lt;code&gt;CLASSIFY_TEXT&lt;/code&gt; became &lt;code&gt;AI_CLASSIFY&lt;/code&gt;. Copying a 2024 tutorial gets you working but deprecated syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The functions that replace a pipeline
&lt;/h2&gt;

&lt;p&gt;Free-text prompting is the least interesting part of Cortex. The task-specific functions are where the SQL actually gets shorter, because they return typed values you can group and aggregate, instead of prose you would then have to parse.&lt;/p&gt;

&lt;p&gt;Classification into your own categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AI_CLASSIFY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'feature request'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'churn risk'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filtering in natural language, inside the &lt;code&gt;WHERE&lt;/code&gt; clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tickets&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;AI_FILTER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'This message describes a customer threatening to cancel: '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, aggregation across rows, which is the function that removes the most code. &lt;code&gt;AI_AGG&lt;/code&gt; reads an entire column against a single prompt and is not bound by the model context window, so there is no chunking loop to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AI_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'What are the three most repeated complaints in these tickets?'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;themes&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tickets&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DATEADD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading the bill
&lt;/h2&gt;

&lt;p&gt;Do this on day one rather than after the invoice arrives. Every call is recorded in the Account Usage schema. Run &lt;code&gt;SELECT *&lt;/code&gt; against the view once to see its current columns, since it has changed shape more than once, then aggregate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;function_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_credits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SNOWFLAKE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACCOUNT_USAGE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CORTEX_AI_FUNCTIONS_USAGE_HISTORY&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DATEADD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap here: &lt;code&gt;CORTEX_FUNCTIONS_USAGE_HISTORY&lt;/code&gt;, the view most existing tutorials point at, is no longer updated. Use &lt;code&gt;CORTEX_AI_FUNCTIONS_USAGE_HISTORY&lt;/code&gt; for full coverage, or &lt;code&gt;CORTEX_AISQL_USAGE_HISTORY&lt;/code&gt;. Data can take a few hours to appear, so an empty result right after your first query is expected rather than a permissions problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that bite later
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Preview status.&lt;/strong&gt; Several of these functions, &lt;code&gt;AI_FILTER&lt;/code&gt; and &lt;code&gt;AI_AGG&lt;/code&gt; among them, are marked as preview in Snowflake's documentation, and preview means the signature can change under you. So check the current status of every function you depend on before it reaches a scheduled task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model and region availability.&lt;/strong&gt; When the model you want is missing from that &lt;code&gt;SHOW CORTEX BASE MODELS&lt;/code&gt; list, the account needs cross-region inference, which is an &lt;code&gt;ACCOUNTADMIN&lt;/code&gt; decision made once for the whole account through &lt;code&gt;CORTEX_ENABLED_CROSS_REGION&lt;/code&gt;, never per user or per session. The real question there is compliance rather than cost, so if your data cannot leave a jurisdiction, that parameter is the conversation to have before writing any SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost scales with rows, not with queries.&lt;/strong&gt; A warehouse costs the same whether the query touches ten rows or ten million. An AI function does not. The mental model you built tuning SQL stops applying here, and the discipline that replaces it is simple: filter before the function call, never after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-determinism.&lt;/strong&gt; The same prompt on the same row can return different text on different days. If a downstream table depends on the output, materialize the result and version it, rather than calling the function inside a view that recomputes on every read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit of putting the model in SQL
&lt;/h2&gt;

&lt;p&gt;Inference now sits next to your data, under the same access controls and the same query engine. That covers a large class of work that used to justify a separate service.&lt;/p&gt;

&lt;p&gt;Meaning is the part that does not come with it. The model can classify a ticket, but it has no idea what your company counts as an active customer, which revenue definition finance signed off on, or which of four &lt;code&gt;customer_id&lt;/code&gt; columns is the governed one. Those definitions live in a semantic layer, and this blueprint on &lt;a href="https://bixtech.ai/aifirst-data-architecture-a-practical-blueprint-for-the-future-of-enterprise-intelligence/?utm_source=hashnode&amp;amp;utm_campaign=backlinks" rel="noopener noreferrer"&gt;the semantic layer as a single source of business meaning&lt;/a&gt; covers how that layer sits between raw tables and anything that answers questions.&lt;/p&gt;

&lt;p&gt;Skip it and Cortex still works. You just get fluent answers that quietly disagree with the finance report, produced faster than before.&lt;/p&gt;

</description>
      <category>snowflake</category>
      <category>sql</category>
      <category>ai</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>How to integrate Apache Airflow with OpenLineage for end-to-end traceability</title>
      <dc:creator>Laura Chicovis </dc:creator>
      <pubDate>Mon, 31 Aug 2026 23:13:01 +0000</pubDate>
      <link>https://dev.to/laura_cristinachicovisd/how-to-integrate-apache-airflow-with-openlineage-for-end-to-end-traceability-15ca</link>
      <guid>https://dev.to/laura_cristinachicovisd/how-to-integrate-apache-airflow-with-openlineage-for-end-to-end-traceability-15ca</guid>
      <description>&lt;h1&gt;
  
  
  How to integrate Apache Airflow with OpenLineage for end-to-end traceability
&lt;/h1&gt;

&lt;p&gt;By the end of this walkthrough, every DAG run in your Airflow instance emits structured lineage events that name the exact tables each task read and wrote, and you can open a graph and answer "which upstream job produced this number" without grepping a single scheduler log.&lt;/p&gt;

&lt;p&gt;That is the whole promise. No manual documentation, no lineage spreadsheet that goes stale in three weeks. The orchestrator reports what it actually did, while it is doing it.&lt;/p&gt;

&lt;p&gt;The setup itself is short. What follows is ordered to surface the three failures that usually show up first, before they cost you an afternoon of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites and versions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Apache Airflow 2.11.0 or later, or any Airflow 3.x release. That is the minimum version supported by the current provider distribution.&lt;/li&gt;
&lt;li&gt;Python 3.9 to 3.12.&lt;/li&gt;
&lt;li&gt;Docker, to run a lineage backend locally.&lt;/li&gt;
&lt;li&gt;A Postgres connection in Airflow (&lt;code&gt;postgres_default&lt;/code&gt;) if you want to reproduce the SQL example exactly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two packages that matter: the &lt;a href="https://airflow.apache.org/docs/apache-airflow-providers-openlineage/stable/index.html" rel="noopener noreferrer"&gt;Airflow OpenLineage provider&lt;/a&gt; extracts Airflow metadata and turns it into events, and &lt;code&gt;openlineage-python&lt;/code&gt; transmits them. The client can be upgraded independently of the provider, which is useful when you need a transport fix without touching your Airflow version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the event model, before you install anything
&lt;/h2&gt;

&lt;p&gt;OpenLineage has three objects and one extension mechanism. Skipping this part is why most first integrations produce an empty graph.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Job&lt;/strong&gt;: something that runs. Your DAG is a job, and each task is also a job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run&lt;/strong&gt;: one execution of a job, with a unique run ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset&lt;/strong&gt;: something read or written. Identified by a &lt;code&gt;namespace&lt;/code&gt; and a &lt;code&gt;name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facet&lt;/strong&gt;: an atomic block of metadata attached to any of the above. Schema, SQL text, column-level lineage, run state, and your own custom fields all arrive as facets, and the &lt;a href="https://openlineage.io/docs/" rel="noopener noreferrer"&gt;OpenLineage specification&lt;/a&gt; lists the standard ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Events fire on state transitions: &lt;code&gt;START&lt;/code&gt;, &lt;code&gt;RUNNING&lt;/code&gt;, &lt;code&gt;COMPLETE&lt;/code&gt;, &lt;code&gt;FAIL&lt;/code&gt;, &lt;code&gt;ABORT&lt;/code&gt;, &lt;code&gt;OTHER&lt;/code&gt;. Lineage is reconstructed downstream by joining datasets across runs, which means dataset identity is the thing that makes or breaks the graph. More on that later, because it is the most common source of a graph with nodes and no edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: install the provider
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;apache-airflow-providers-openlineage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Official Airflow Docker images may already ship it. Check before adding it to your requirements file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;airflow providers list | &lt;span class="nb"&gt;grep &lt;/span&gt;openlineage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is emitted yet. The provider stays silent until it knows where to send events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: point it at a transport
&lt;/h2&gt;

&lt;p&gt;Start with the console transport. It writes events to the task logs, costs nothing to run, and tells you immediately whether extraction works at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AIRFLOW__OPENLINEAGE__TRANSPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"type": "console"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you see events in the logs, move to a real backend. &lt;a href="https://github.com/MarquezProject/marquez" rel="noopener noreferrer"&gt;Marquez&lt;/a&gt; is one option here, and any OpenLineage-compatible backend works. It is the reference implementation of the standard, which makes it the fastest way to get a lineage UI running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/MarquezProject/marquez
&lt;span class="nb"&gt;cd &lt;/span&gt;marquez
./docker/up.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API listens on port 5000, the admin interface on 5001, and the web UI on 3000. On macOS, port 5000 is reserved by the operating system, so run &lt;code&gt;./docker/up.sh --api-port 9000&lt;/code&gt; and adjust the URL below.&lt;/p&gt;

&lt;p&gt;Now switch the transport:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AIRFLOW__OPENLINEAGE__TRANSPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"type": "http", "url": "http://localhost:5000", "endpoint": "api/v1/lineage"}'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AIRFLOW__OPENLINEAGE__NAMESPACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'airflow-local'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same thing in &lt;code&gt;airflow.cfg&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[openlineage]&lt;/span&gt;
&lt;span class="py"&gt;transport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;{"type": "http", "url": "http://localhost:5000", "endpoint": "api/v1/lineage"}&lt;/span&gt;
&lt;span class="py"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;airflow-local&lt;/span&gt;
&lt;span class="py"&gt;disabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the namespace deliberately. It logically separates producers, so a staging Airflow and a production Airflow do not merge into one graph and lie to you. If you leave it unset, everything lands in &lt;code&gt;default&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For anything beyond local, do not put credentials in &lt;code&gt;airflow.cfg&lt;/code&gt;. The provider accepts a Generic Airflow connection ID holding the transport config, including auth, in the connection extra.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: run a DAG that actually produces lineage
&lt;/h2&gt;

&lt;p&gt;SQL operators are the best place to start, because the provider parses the query and derives inputs, outputs, and column-level relationships without you writing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# dags/openlineage_demo.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DAG&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.providers.common.sql.operators.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SQLExecuteQueryOperator&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dag_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openlineage_demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@daily&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;catchup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;build_daily_orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SQLExecuteQueryOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build_daily_orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;conn_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres_default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            CREATE TABLE IF NOT EXISTS analytics.daily_orders AS
            SELECT
                o.order_date,
                c.region,
                COUNT(*)      AS order_count,
                SUM(o.amount) AS revenue
            FROM raw.orders o
            JOIN raw.customers c ON c.customer_id = o.customer_id
            GROUP BY o.order_date, c.region;
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trigger it. The resulting event should list &lt;code&gt;raw.orders&lt;/code&gt; and &lt;code&gt;raw.customers&lt;/code&gt; as inputs and &lt;code&gt;analytics.daily_orders&lt;/code&gt; as the output, with a column-level facet mapping &lt;code&gt;revenue&lt;/code&gt; back to &lt;code&gt;o.amount&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: verify it worked
&lt;/h2&gt;

&lt;p&gt;Three checks, in this order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at the graph.&lt;/strong&gt; Open &lt;code&gt;http://localhost:3000&lt;/code&gt;, find the namespace you configured, and confirm the two source tables connect to the output table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check which tasks are even reporting.&lt;/strong&gt; This is the diagnostic that is easiest to miss. The &lt;code&gt;DagRun&lt;/code&gt; &lt;code&gt;START&lt;/code&gt; event carries an &lt;code&gt;AirflowJobFacet&lt;/code&gt; listing every task in the DAG, each with an &lt;code&gt;emits_ol_events&lt;/code&gt; boolean. That tells you ahead of time which operators will stay invisible, instead of leaving you to guess why half the graph is missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Understand the silence.&lt;/strong&gt; An &lt;code&gt;EmptyOperator&lt;/code&gt; emits nothing by default, because Airflow does not schedule it the way it schedules real work. Add an &lt;code&gt;on_execute&lt;/code&gt; or &lt;code&gt;on_success&lt;/code&gt; callback, or a task outlet, if you need it represented. When task-level detail does not matter, the &lt;code&gt;DagRun&lt;/code&gt; &lt;code&gt;COMPLETE&lt;/code&gt; event carries an &lt;code&gt;AirflowStateRunFacet&lt;/code&gt; with the state of every task in the run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: cover the operators that report nothing
&lt;/h2&gt;

&lt;p&gt;Automatic extraction covers SQL operators and many provider operators. Your own operators report nothing until you tell them what they touch.&lt;/p&gt;

&lt;p&gt;For operators you own, implement the OpenLineage methods directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseOperator&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;S3ToWarehouseOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseOperator&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source_bucket&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source_key&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_table&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# your copy logic
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_openlineage_facets_on_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task_instance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# import locally: top-level Airflow imports here can be cyclical
&lt;/span&gt;        &lt;span class="c1"&gt;# and make extraction fail silently
&lt;/span&gt;        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.providers.common.compat.openlineage.facet&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.providers.openlineage.extractors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OperatorLineage&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OperatorLineage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="nc"&gt;Dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_bucket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="nc"&gt;Dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres://warehouse:5432&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules worth internalizing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You must implement at least one of &lt;code&gt;get_openlineage_facets_on_start()&lt;/code&gt; or &lt;code&gt;get_openlineage_facets_on_complete(ti)&lt;/code&gt;. If &lt;code&gt;on_complete&lt;/code&gt; is missing, the provider falls back to &lt;code&gt;on_start&lt;/code&gt;. There is also &lt;code&gt;get_openlineage_facets_on_failure(ti)&lt;/code&gt;, which by default reuses the &lt;code&gt;on_complete&lt;/code&gt; logic.&lt;/li&gt;
&lt;li&gt;Prefer &lt;code&gt;on_complete&lt;/code&gt; whenever the real dataset names are only resolved during &lt;code&gt;execute&lt;/code&gt;. Reporting a wildcard path on start and never correcting it produces a confident, wrong graph.&lt;/li&gt;
&lt;li&gt;Import OpenLineage objects inside the method, never at module level. The listener is instantiated when the worker starts, so a top-level Airflow import can become circular and kill extraction without an obvious error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For third-party operators you cannot modify, write a custom extractor and register it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AIRFLOW__OPENLINEAGE__EXTRACTORS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'plugins.extractors.MyCustomExtractor'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: attach your own context
&lt;/h2&gt;

&lt;p&gt;Since provider version 1.10.0, you can inject arbitrary run facets without touching operator code. Write a function that accepts the task instance and returns a facet dictionary, then register the import paths, separated by semicolons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AIRFLOW__OPENLINEAGE__CUSTOM_RUN_FACETS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'plugins.ol_facets.ownership_facet'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is how you get team, cost center, or change-ticket ID onto every event, which turns "who owns this broken pipeline" from a Slack thread into a filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks in production
&lt;/h2&gt;

&lt;p&gt;Four settings and one habit account for most of the pain.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;include_full_task_info&lt;/code&gt;&lt;/strong&gt;: tempting, and expensive. With it on, all serializable task parameters go into the event. Depending on what you pass to your tasks, single events can reach megabytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;execution_timeout&lt;/code&gt;&lt;/strong&gt;: cap how long extraction may run so a slow lineage call never becomes a pipeline incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dag_state_change_process_pool_size&lt;/code&gt;&lt;/strong&gt;: processes the scheduler uses to handle DAG state changes asynchronously. Worth tuning on busy instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;emission_policy&lt;/code&gt;&lt;/strong&gt;: the current way to control what gets emitted. The older &lt;code&gt;selective_enable&lt;/code&gt; and &lt;code&gt;disable_source_code&lt;/code&gt; flags are deprecated in its favor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naming discipline&lt;/strong&gt;: lineage joins on dataset identity. If one job writes &lt;code&gt;analytics.daily_orders&lt;/code&gt; and another reads &lt;code&gt;ANALYTICS.DAILY_ORDERS&lt;/code&gt;, you get two nodes and no edge. Fix the convention (&lt;code&gt;database.schema.table&lt;/code&gt;, environment-scoped namespaces) before you scale, because rewriting identities after the fact means reprocessing history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where lineage stops being the answer
&lt;/h2&gt;

&lt;p&gt;What you have now is operational lineage: what ran, what it read, what it wrote, and whether it failed. That is enough to trace an incident backward and to run impact analysis before a schema change.&lt;/p&gt;

&lt;p&gt;It is not enough to answer who owns a dataset, whether it is certified, what "active customer" means in business terms, or where PII flows. Those live in a metadata platform, and OpenLineage events are the input to it rather than a replacement for it. If that is the direction you are heading, this breakdown of &lt;a href="https://bixtech.ai/datahub-and-openlineage-a-modern-blueprint-for-data-governance-and-end-to-end-lineage/?utm_source=hashnode&amp;amp;utm_campaign=backlinks" rel="noopener noreferrer"&gt;data governance and end-to-end lineage with a metadata catalog&lt;/a&gt; covers how the collection layer and the governance layer divide the work.&lt;/p&gt;

&lt;p&gt;The order matters, though. Emit first, catalog second. A catalog fed by hand ages exactly as fast as the documentation you were trying to replace.&lt;/p&gt;

</description>
      <category>airflow</category>
      <category>dataengineering</category>
      <category>openlineage</category>
      <category>datalineage</category>
    </item>
    <item>
      <title>Partitioning, clustering, and BI Engine: measuring what each one saves in BigQuery</title>
      <dc:creator>Laura Chicovis </dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:18:52 +0000</pubDate>
      <link>https://dev.to/laura_cristinachicovisd/partitioning-clustering-and-bi-engine-measuring-what-each-one-saves-in-bigquery-2m59</link>
      <guid>https://dev.to/laura_cristinachicovisd/partitioning-clustering-and-bi-engine-measuring-what-each-one-saves-in-bigquery-2m59</guid>
      <description>&lt;p&gt;By the end of this walkthrough you will have two versions of the same table, a repeatable way to price any query before running it, and a query that tells you exactly how many bytes each version billed you. No estimates, no vendor benchmark, just the numbers your own project reports.&lt;/p&gt;

&lt;p&gt;I keep running into the same situation: someone turns on partitioning, the bill does not move, and the conclusion becomes "partitioning does not work here". Usually partitioning worked fine and the queries were never written to use it. The only way to settle that is to measure both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud project with billing enabled and the BigQuery API on.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;bq&lt;/code&gt; CLI from the Google Cloud SDK, or the BigQuery console if you prefer clicking. Every snippet below is standard GoogleSQL, so both work.&lt;/li&gt;
&lt;li&gt;Permissions to create datasets and tables, plus &lt;code&gt;bigquery.jobs.list&lt;/code&gt; if you want the billing history query at the end.&lt;/li&gt;
&lt;li&gt;Around 5 to 10 GiB of scanning budget. On-demand pricing gives you the first 1 TiB per month free and charges $6.25 per TiB after that in the US multi region (Google Cloud, BigQuery pricing documentation), so this exercise costs cents, not dollars.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One warning before you start: the queries that build the optimized table are themselves billed. Dry run them first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bq mk &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;US &lt;span class="nt"&gt;--dataset&lt;/span&gt; bq_cost_lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Build the baseline
&lt;/h2&gt;

&lt;p&gt;I use the public Stack Overflow dataset because everyone can reproduce it. This copies a slice of it into a plain table with no partitioning and no clustering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`bq_cost_lab.questions_plain`&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;creation_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;owner_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;view_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tags&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`bigquery-public-data.stackoverflow.posts_questions`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;creation_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2018-01-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what I did not copy. The &lt;code&gt;body&lt;/code&gt; column holds the full HTML of every question and it is the single most expensive column in that table. BigQuery charges according to the data processed in the columns you select, even when you set an explicit LIMIT (Google Cloud, BigQuery pricing documentation), so column selection is the cheapest optimization available and it costs nothing to implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Price the query before you run it
&lt;/h2&gt;

&lt;p&gt;The dry run flag returns the byte estimate without executing anything and without charging you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bq query &lt;span class="nt"&gt;--use_legacy_sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--dry_run&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s1"&gt;'SELECT owner_user_id, COUNT(*) AS questions
 FROM `bq_cost_lab.questions_plain`
 WHERE DATE(creation_date) BETWEEN "2018-03-01" AND "2018-03-07"
 GROUP BY owner_user_id'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write down the number it gives you. That is your baseline: &lt;code&gt;[CONFIRMAR: rodar o dry run e anotar o valor observado]&lt;/code&gt; bytes. Divide it by 1,099,511,627,776 to get TiB, then multiply by 6.25 for the dollar figure. The date filter in that query saves you nothing yet, because a plain table has no partitions to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Partition on the column you actually filter by
&lt;/h2&gt;

&lt;p&gt;Partitioning splits the table into physical blocks, and a qualifying filter on the partitioning column lets BigQuery scan the matching partitions and skip the rest, a process the documentation calls pruning (Google Cloud, introduction to partitioned tables).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`bq_cost_lab.questions_tuned`&lt;/span&gt;
&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creation_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;owner_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;OPTIONS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;require_partition_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`bq_cost_lab.questions_plain`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions are packed into those four lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily granularity, not hourly.&lt;/strong&gt; A partitioned table is capped at 10,000 partitions &lt;code&gt;[CONFIRMAR: conferir o número atual na página Quotas and limits, o limite subiu de 4.000 em 2024]&lt;/code&gt; (Google Cloud, BigQuery quotas and limits). Daily partitions give you 27 years of runway. Hourly partitions give you 416 days, and hitting that ceiling in production is a migration, not a config change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;require_partition_filter = TRUE&lt;/code&gt;.&lt;/strong&gt; This rejects any query that does not filter on the partitioning column (Google Cloud, managing partitioned tables). It is one line, it is reversible with an ALTER statement, and it is the difference between a table that saves money and a table that saves money until the first person forgets the WHERE clause. Turn it on unless you have a specific reason not to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The partition filter has to be a constant expression.&lt;/strong&gt; Filtering on a column that is not the partition key prunes nothing, and neither does a filter whose value BigQuery cannot resolve before execution. This is where most disappointed partitioning stories end.&lt;/p&gt;

&lt;p&gt;Partitioning also has a storage side that rarely gets mentioned. Long term storage drops the rate by roughly half after 90 days without modification, and each partition of a partitioned table is evaluated separately for that discount (Google Cloud, BigQuery pricing documentation). On a plain table, one late arriving row resets the timer for everything. On a partitioned table, it resets one day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Cluster in the order you filter
&lt;/h2&gt;

&lt;p&gt;Clustering sorts storage blocks by the values in the clustered columns, and queries that filter or aggregate on those columns scan only the relevant blocks instead of the full table or partition (Google Cloud, introduction to clustered tables).&lt;/p&gt;

&lt;p&gt;You get up to four clustering columns, and the order determines the sort order, so the most frequently filtered column goes first (Google Cloud, creating clustered tables). In my snippet, &lt;code&gt;owner_user_id&lt;/code&gt; comes before &lt;code&gt;score&lt;/code&gt; because equality filters on user are common and selective, while score usually shows up as a range filter on top of that. Reverse them and the same query prunes worse.&lt;/p&gt;

&lt;p&gt;Now the part that surprises people. Run a dry run against the clustered table and compare it to the previous one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# replace 12345 with an owner_user_id that exists in your slice&lt;/span&gt;
bq query &lt;span class="nt"&gt;--use_legacy_sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--dry_run&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s1"&gt;'SELECT owner_user_id, COUNT(*) AS questions
 FROM `bq_cost_lab.questions_tuned`
 WHERE DATE(creation_date) BETWEEN "2018-03-01" AND "2018-03-07"
   AND owner_user_id = 12345
 GROUP BY owner_user_id'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The estimate drops because of partition pruning, but it does not reflect the clustering at all. When you query a clustered table you do not get an accurate cost estimate before execution, because the number of storage blocks to scan is not known until the query runs, and the final cost is based on the blocks actually scanned (Google Cloud, introduction to clustered tables). The dry run on a clustered table is an upper bound. Treating it as the answer is how teams conclude that clustering did nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Verify with billed bytes, not estimates
&lt;/h2&gt;

&lt;p&gt;Run both versions for real, then ask the metadata what happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;creation_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cache_hit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total_bytes_processed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total_bytes_billed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_bytes_billed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;POW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;approx_usd&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`region-us`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFORMATION_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JOBS_BY_PROJECT&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;creation_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMP_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;HOUR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;statement_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SELECT'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;creation_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;total_bytes_billed&lt;/code&gt; is the number that reaches your invoice. Two details make it diverge from the estimate in ways worth knowing: charges are rounded up with a minimum of 10 MB per table referenced and 10 MB per query, and you are not charged for queries that fail or that return cached results (Google Cloud, BigQuery pricing documentation). If &lt;code&gt;cache_hit&lt;/code&gt; is true, you measured nothing. Change a literal and run it again.&lt;/p&gt;

&lt;p&gt;Record the pair. Baseline billed bytes: &lt;code&gt;[CONFIRMAR: valor observado]&lt;/code&gt;. Tuned billed bytes: &lt;code&gt;[CONFIRMAR: valor observado]&lt;/code&gt;. That ratio is the only savings figure I would put in a report.&lt;/p&gt;

&lt;p&gt;This is the same argument the &lt;a href="https://bixtech.ai/what-is-observability/?utm_source=devto&amp;amp;utm_medium=guestpost&amp;amp;utm_campaign=okr_backlinks_q3_2026" rel="noopener noreferrer"&gt;observability&lt;/a&gt; people have been making for years with logs, metrics, and traces. Cost is one more signal your platform emits. If nobody queries it, nobody knows what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Decide whether BI Engine earns its keep
&lt;/h2&gt;

&lt;p&gt;BI Engine caches table data in memory to accelerate SQL. When it accelerates a query under on-demand pricing, the stage that reads table data is charged for zero scanned bytes (Google Cloud, BigQuery pricing documentation). That sounds like free money until you look at how it bills: $0.0416 per GiB hour, charged per project where you reserved capacity, whether or not anyone runs a query.&lt;/p&gt;

&lt;p&gt;So the math is a subscription against a metered service. A 10 GiB reservation runs about $0.42 per hour, close to $304 per month at 730 hours. At $6.25 per TiB, that is the equivalent of roughly 48 TiB of scanning. If the dashboards hitting that reservation do not scan near that volume, the reservation loses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- reserve capacity, then confirm queries are actually being accelerated&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;bi_engine_statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bi_engine_mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total_bytes_billed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`region-us`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFORMATION_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JOBS_BY_PROJECT&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;creation_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMP_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;bi_engine_statistics&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;creation_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;bi_engine_mode&lt;/code&gt; comes back as DISABLED or PARTIAL for most of your traffic, you are paying for memory that is not accelerating anything. Two things commonly cause that: the working set does not fit the reservation, or the queries use operations the accelerator does not cover.&lt;/p&gt;

&lt;p&gt;One detail that changes the calculation for larger shops: BigQuery editions commitments bundle BI Engine capacity at no extra cost, starting at 5 GiB for 100 slots and scaling to 100 GiB at 2,000 slots (Google Cloud, BigQuery pricing documentation). If you already hold a commitment, some of that capacity may be sitting unclaimed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;p&gt;Partitioning, clustering, and BI Engine all reduce bytes scanned. None of them touch the two costs I see grow fastest.&lt;/p&gt;

&lt;p&gt;The first is storage on tables nobody queries. Active logical storage runs $23.55 per TiB per month in the US multi region (Google Cloud, BigQuery pricing documentation), and partition expiration is the cheapest cleanup available. The second is repeated scanning of the same aggregate by twelve dashboards, which is a materialized view problem rather than a partitioning one.&lt;/p&gt;

&lt;p&gt;There is a third one that no table setting solves. If the pipeline writes duplicates and someone rebuilds the table twice a week to fix them, you are paying for the rework, not for the analytics. That is a &lt;a href="https://bixtech.ai/what-is-data-quality/?utm_source=devto&amp;amp;utm_medium=guestpost&amp;amp;utm_campaign=okr_backlinks_q3_2026" rel="noopener noreferrer"&gt;data quality&lt;/a&gt; problem, and it gets solved upstream, not in the DDL.&lt;/p&gt;

&lt;p&gt;The habit that has saved me the most is boring: dry run first, check &lt;code&gt;total_bytes_billed&lt;/code&gt; after, and never accept a savings number that nobody measured. Everything above is just the mechanism.&lt;/p&gt;

&lt;p&gt;If you want the follow up, tell me in the comments whether materialized views or slot reservations are the harder call on your side. &lt;/p&gt;

</description>
      <category>bigquery</category>
      <category>dataengineering</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
