<?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: allenhori</title>
    <description>The latest articles on DEV Community by allenhori (@allenhori).</description>
    <link>https://dev.to/allenhori</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%2F4068288%2F60741c35-73d4-4285-8213-5d45458f8f95.jpg</url>
      <title>DEV Community: allenhori</title>
      <link>https://dev.to/allenhori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/allenhori"/>
    <language>en</language>
    <item>
      <title>zhao-dbt-plan: a microbatch cascading time-window planner for dbt</title>
      <dc:creator>allenhori</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:07:24 +0000</pubDate>
      <link>https://dev.to/allenhori/zhao-dbt-plan-a-microbatch-cascading-time-window-planner-for-dbt-8pl</link>
      <guid>https://dev.to/allenhori/zhao-dbt-plan-a-microbatch-cascading-time-window-planner-for-dbt-8pl</guid>
      <description>&lt;p&gt;A few days ago I posted about &lt;a href="https://github.com/allenhori/zhao-cli" rel="noopener noreferrer"&gt;zhao-cli&lt;/a&gt;, a breaking-change&lt;br&gt;
gate for dbt (link at the bottom if you missed it). This is the second tool in the same family, built to fix a problem I kept hitting myself. I think this one is honestly the&lt;br&gt;
bigger deal.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;dbt's &lt;code&gt;microbatch&lt;/code&gt; incremental strategy applies one flat &lt;code&gt;--event-time-start&lt;/code&gt;/&lt;code&gt;--event-time-end&lt;/code&gt; window across an entire &lt;code&gt;--select&lt;/code&gt;. That's fine until a rolling-window model reads a &lt;em&gt;wider&lt;/em&gt; span than its immediate upstream was just recomputed for, at which point dbt has no way to know it needs a wider batch too. A backfill to a daily model silently corrupts every downstream rolling aggregate that reads across the backfilled day, and dbt only ever re-triggers the model you actually selected.&lt;/p&gt;
&lt;h2&gt;
  
  
  zhao-dbt-plan: the microbatch cascading window planner
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;zhao-dbt-plan&lt;/code&gt; reads your compiled manifest, walks the DAG within whatever you &lt;code&gt;--select&lt;/code&gt;, and computes the correct, minimal per-model expanded window -- as a plan you review, never a command it runs for you. It never executes &lt;code&gt;dbt build&lt;/code&gt; or &lt;code&gt;dbt run&lt;/code&gt; on your behalf; that boundary is permanent, not a v1 scope cut. You decide how to actually run the plan -- raw dbt, Dagster, Airflow, a Databricks Asset Bundle, whatever you already use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;zhao-dbt-plan &lt;span class="nt"&gt;--select&lt;/span&gt; tag:microbatch_demo &lt;span class="nt"&gt;--event-time-start&lt;/span&gt; 2026-07-01 &lt;span class="nt"&gt;--event-time-end&lt;/span&gt; 2026-07-01 &lt;span class="nt"&gt;--pretty&lt;/span&gt;
&lt;span class="go"&gt;
[layer 0] mb_daily [2026-07-01 .. 2026-07-01]
  [layer 1] mb_rolling_7d [2026-06-28 .. 2026-07-05]
    [layer 2] mb_rolling_14d [2026-06-26 .. 2026-07-06]
      [layer 3] mb_summary [2026-06-25 .. 2026-07-07]
      [layer 3] mb_wide [2026-04-07 .. 2026-07-11]
warning: mb_wide: expanded window (96 days) exceeds max_window_expansion_days (90)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One day's backfill at the top cascades into a correctly-sized, per-model window all the way down the DAG, and it flags the one model whose expansion is wide enough to be worth a second look, instead of silently recomputing (or silently under-computing) it. &lt;code&gt;--html&lt;/code&gt; renders the same plan as an interactive report:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://htmlpreview.github.io/?https://github.com/allenhori/zhao-dbt-plan/blob/master/docs/assets/dbt-plan-demo.html" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt; · &lt;a href="https://github.com/allenhori/zhao-dbt-plan" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How this compares
&lt;/h2&gt;

&lt;p&gt;I'd rather be precise here than let anyone assume I haven't looked: this specific problem, correctly widening a downstream rolling model's window when only part of its upstream dependency was recomputed -- isn't solved by dbt's own native tooling. dbt's &lt;code&gt;lookback&lt;/code&gt; config is single-model and self-contained; it doesn't propagate anything to downstream models at all.&lt;/p&gt;

&lt;p&gt;SQLMesh &lt;em&gt;does&lt;/em&gt; handle the underlying problem, natively. &lt;code&gt;lookback&lt;/code&gt; there triggers automatic cascading restatement through the whole downstream chain, because SQLMesh owns persistent per-interval state across the DAG. That's a genuinely different, arguably more elegant mechanism&lt;br&gt;
than what &lt;code&gt;zhao-dbt-plan&lt;/code&gt; does. It's a reason to consider SQLMesh on its own merits, not a knock against it. But it only applies if you're on SQLMesh's engine; it's not something you get by adding a tool on top of dbt. I haven't found anything, official or community -- that fills this gap for dbt itself, so I can't claim it's the &lt;em&gt;only&lt;/em&gt; thing that's ever tried; I can say it's solving a real, confirmed pain point in dbt's own microbatch design that I hit myself and couldn't find an existing answer for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the plan actually apply: &lt;code&gt;zhao_utils&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;There's one gap worth being upfront about: &lt;code&gt;zhao-dbt-plan&lt;/code&gt; computes the &lt;em&gt;correct&lt;/em&gt; window, but on its own it can't make dbt's compiled SQL actually &lt;em&gt;use&lt;/em&gt; it, dbt's own microbatch &lt;code&gt;ref()&lt;/code&gt; filtering can't be overridden by a project macro (confirmed against dbt-core's real behavior, not&lt;br&gt;
assumed -- it's resolved outside normal macro dispatch, to build the dependency graph statically before Jinja even renders). So a plain &lt;code&gt;ref()&lt;/code&gt; to a widened upstream still silently gets dbt's narrow, single-batch default, no error, no warning, just a rolling-window model quietly computing on too little data.&lt;/p&gt;

&lt;p&gt;![Setup: a one-time wrapper macro in your own project, safe to blanket-replace ref with wref across your whole project since it's a no-op without meta.zhao. Effect: plain ref() reads a 1-day window and silently under-computes; wref() reads the correct 8-day window, matching the plan zhao-dbt-plan already computed]&lt;/p&gt;

&lt;p&gt;I built a small, separately-licensed (Apache-2.0), separately-repo'd package for this: &lt;br&gt;
&lt;a href="https://github.com/allenhori/zhao_dbt_utils" rel="noopener noreferrer"&gt;&lt;code&gt;zhao_utils&lt;/code&gt;&lt;/a&gt; -- &lt;code&gt;wref()&lt;/code&gt; ("windowed ref"), a&lt;br&gt;
drop-in &lt;code&gt;ref()&lt;/code&gt; replacement, plus two boundary helpers, reading the exact same &lt;code&gt;meta.zhao&lt;/code&gt; block&lt;br&gt;
the planner already does. &lt;strong&gt;Completely optional&lt;/strong&gt;. If you've already hand-rolled this pattern yourself (the documented dbt way to do a rolling-window read), you don't need it; &lt;code&gt;zhao-dbt-plan&lt;/code&gt; itself works identically either way. It's for whoever's starting that pattern fresh.&lt;/p&gt;

&lt;p&gt;Two install paths, both through GitHub, neither requiring a manually-tracked local copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;As a real dbt package&lt;/strong&gt; (&lt;code&gt;packages.yml&lt;/code&gt; + &lt;code&gt;dbt deps&lt;/code&gt;, versioned, upgradeable), macros called namespaced: &lt;code&gt;{{ zhao_utils.wref('mb_daily') }}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As a single file&lt;/strong&gt; you copy straight into your own project's &lt;code&gt;macros/&lt;/code&gt; folder, no package management at all, and calls are bare by default: &lt;code&gt;{{ wref('mb_daily') }}&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are genuinely tested, not just the primary path with a workaround bolted on: real &lt;code&gt;dbt build&lt;/code&gt; runs against DuckDB for both install modes, plus compile-verified against dbt Fusion and a real Databricks workspace. Full details, including the exact optional/validated-argument behavior, are in the package's own README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built and tested honestly
&lt;/h2&gt;

&lt;p&gt;Built and tested solo, as honestly as I could. I haven't been able to cover every setup alone, so if something doesn't fit yours, an issue or a PR is genuinely welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/allenhori/zhao-dbt-plan/master/scripts/install.sh | sh
&lt;span class="c"&gt;# or: cargo install zhao-dbt-plan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zhao-dbt-plan&lt;/code&gt; runs as a &lt;code&gt;zhao&lt;/code&gt; Addon too (&lt;code&gt;zhao dbt-plan ...&lt;/code&gt;), if you'd rather invoke it through &lt;code&gt;zhao-cli&lt;/code&gt; directly, see &lt;a href="https://github.com/allenhori/zhao-cli/tree/master/examples/hello-zhao-addon" rel="noopener noreferrer"&gt;the Addon contract&lt;/a&gt;&lt;br&gt;
if you want to build your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Licensing -- stated plainly, not buried
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;zhao-cli&lt;/code&gt; (the first tool in this family) is Apache-2.0 -- fully permissive. &lt;code&gt;zhao-dbt-plan&lt;/code&gt; is&lt;br&gt;
&lt;strong&gt;AGPLv3&lt;/strong&gt; instead -- copyleft. If you modify it or run it as part of a network service, that has&lt;br&gt;
to stay open too. Not because I'm precious about it -- I just don't want someone quietly wrapping&lt;br&gt;
it into a paid product without contributing back. Both are free to use today, either way; the&lt;br&gt;
license difference is about what happens if you modify and redistribute, not about whether you&lt;br&gt;
can use the tool.&lt;/p&gt;




&lt;p&gt;Repos: &lt;a href="https://github.com/allenhori/zhao-dbt-plan" rel="noopener noreferrer"&gt;zhao-dbt-plan&lt;/a&gt; · &lt;a href="https://github.com/allenhori/zhao_dbt_utils" rel="noopener noreferrer"&gt;zhao_dbt_utils&lt;/a&gt; · &lt;a href="https://github.com/allenhori/zhao-cli" rel="noopener noreferrer"&gt;zhao-cli&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you try this and it breaks on your setup, please open an issue, that's exactly the kind of real-world coverage I can't get building this alone.&lt;/p&gt;

</description>
      <category>dbt</category>
      <category>rust</category>
      <category>opensource</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>zhao-cli: a free, deterministic breaking-change gate for dbt</title>
      <dc:creator>allenhori</dc:creator>
      <pubDate>Sat, 08 Aug 2026 09:42:29 +0000</pubDate>
      <link>https://dev.to/allenhori/zhao-cli-a-free-deterministic-breaking-change-gate-for-dbt-aa6</link>
      <guid>https://dev.to/allenhori/zhao-cli-a-free-deterministic-breaking-change-gate-for-dbt-aa6</guid>
      <description>&lt;p&gt;I kept hitting the same problem on almost every dbt project I worked on: a PR changes a column somewhere mid-DAG, and the only way to know what it actually breaks downstream is to either read the SQL by hand across every model that might reference it, or rebuild the whole project/downstream in CI and wait.&lt;/p&gt;

&lt;p&gt;Neither scales past a DAG of any real size. So over the last few weeks, after hours, I built &lt;strong&gt;zhao-cli&lt;/strong&gt; to fix that for myself , and since it turned out to actually work, I'm putting it out there.&lt;/p&gt;

&lt;p&gt;This is a solo, weekend project. I've tested it against real dbt projects and, for the trickier calls (like whether adding a field to a &lt;code&gt;STRUCT&lt;/code&gt; column should be treated as breaking), I went and verified it against a live Databricks workspace rather than guessing. But I haven't been able to cover every adapter/warehouse combination alone -- if you hit something that doesn't match your setup, an issue or a PR is genuinely welcome. I'd rather ship something honestly labeled&lt;br&gt;
"early" than oversell it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;dbt's own &lt;code&gt;state:modified&lt;/code&gt; comparison is syntactic -- any compiled-SQL text change counts as "modified," and everything downstream is assumed affected. Teams end up either rebuilding their whole downstream cone on every PR (slow CI), or leaning on a human reviewer to catch a removed column, a narrowed type, or a loosened join by reading SQL -- something nobody reliably does across a DAG of any real size.&lt;/p&gt;
&lt;h2&gt;
  
  
  zhao-cli: the breaking-change gate
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;zhao&lt;/code&gt; parses the compiled SQL itself and computes &lt;em&gt;real&lt;/em&gt; column-level lineage between two states of your project, classifies each change against a fixed Rule catalog, and reports the exact models each change actually reaches -- never the whole DAG, never a guess.&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="nv"&gt;$ &lt;/span&gt;zhao check &lt;span class="nt"&gt;--against&lt;/span&gt; main

Changed:
  model model.jaffle_shop.stg_customers:
    - column removed: last_name

Downstream impact:
  model model.jaffle_shop.dim_customers:
    &lt;span class="o"&gt;[&lt;/span&gt;BREAKING] last_name removed from model model.jaffle_shop.stg_customers breaks reference via last_name &lt;span class="o"&gt;(&lt;/span&gt;column-removed-with-active-references&lt;span class="o"&gt;)&lt;/span&gt;

Summary: 1 model&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; changed, 1 column&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; changed, 1 breaking, 0 warning

Impacted models: dim_customers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole review, in one CI step: exactly what changed, exactly what it breaks, and exactly which models to re-validate, not a guess at the whole downstream cone.&lt;/p&gt;

&lt;p&gt;The analysis itself is entirely local: no LLM, no account, and it never reads or sends your actual data, nothing installed in your warehouse beyond what &lt;code&gt;dbt run&lt;/code&gt; already needs. The one place a network call happens is resolving a git-native Baseline (&lt;code&gt;dbt compile&lt;/code&gt;/&lt;code&gt;dbt deps&lt;/code&gt;, the same as running &lt;code&gt;dbt&lt;/code&gt; yourself) -- pass &lt;code&gt;--state&lt;/code&gt; with an already-compiled manifest to skip that entirely, for a genuinely zero-network-call run.&lt;/p&gt;

&lt;p&gt;It also exports an interactive, self-contained lineage graph, click a model or column to trace exactly what depends on it and what it depends on, search, filter, all in one offline HTML file:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fziw4s2u18cvwwl11ks0m.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fziw4s2u18cvwwl11ks0m.gif" alt="zhao lineage graph -- clicking a model, expanding columns, tracing a calculated column's real upstream source" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://htmlpreview.github.io/?https://github.com/allenhori/zhao-cli/blob/master/docs/assets/lineage-demo.html" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt; · &lt;a href="https://github.com/allenhori/zhao-cli" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How this compares
&lt;/h2&gt;

&lt;p&gt;I'd rather be precise here than let anyone assume I haven't looked:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLMesh&lt;/strong&gt; already does this well &lt;code&gt;sqlmesh plan&lt;/code&gt; uses column-level lineage to classify changes as breaking or non-breaking, natively, for free. If you're already on SQLMesh, you have this.&lt;br&gt;
&lt;code&gt;zhao-cli&lt;/code&gt; exists for the much larger population of teams already on dbt who don't want to migrate platforms just to get it. It brings the same category of protection to a dbt project as it already stands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/allenhori/zhao-cli/master/scripts/install.sh | sh
&lt;span class="c"&gt;# or: cargo install zhao-cli&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Licensing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;zhao-cli&lt;/code&gt; is Apache-2.0 -- fully permissive, use it however you want, no strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "zhao(曌)"
&lt;/h2&gt;

&lt;p&gt;Named for the character Empress Wu Zetian invented for herself: 明 (sun and moon) over 空 (sky), "illuminating everything below." Felt like an honest fit for a tool whose entire job is showing you exactly what a change touches, instead of leaving you to trace the DAG by hand.&lt;/p&gt;




&lt;p&gt;Repo: &lt;a href="https://github.com/allenhori/zhao-cli" rel="noopener noreferrer"&gt;zhao-cli&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a second tool in the same family, &lt;code&gt;zhao-dbt-plan&lt;/code&gt;, tackling a sharper dbt gap around microbatch backfills, more on that in a follow-up post in a few days.&lt;/p&gt;

&lt;p&gt;If you try this and it breaks on your setup, please open an issue, that's exactly the kind of real-world coverage I can't get building this alone.&lt;/p&gt;

</description>
      <category>dbt</category>
      <category>rust</category>
      <category>dataengineering</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
