<?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: IT-Craftsman</title>
    <description>The latest articles on DEV Community by IT-Craftsman (@itcraftsman).</description>
    <link>https://dev.to/itcraftsman</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%2F2553368%2Fb5285653-2bf1-48ee-a9c3-1100ef8c6485.jpg</url>
      <title>DEV Community: IT-Craftsman</title>
      <link>https://dev.to/itcraftsman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/itcraftsman"/>
    <language>en</language>
    <item>
      <title>5 SQL window-function traps that show up in data engineering interviews (with runnable DuckDB examples)</title>
      <dc:creator>IT-Craftsman</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:21:49 +0000</pubDate>
      <link>https://dev.to/itcraftsman/5-sql-window-function-traps-that-show-up-in-data-engineering-interviews-with-runnable-duckdb-4gdd</link>
      <guid>https://dev.to/itcraftsman/5-sql-window-function-traps-that-show-up-in-data-engineering-interviews-with-runnable-duckdb-4gdd</guid>
      <description>&lt;p&gt;Window functions are the most common SQL topic in data engineering interviews, and most candidates know the syntax. The follow-up questions are where people stumble. Here are five traps, each with a tiny example you can paste into DuckDB (DuckDB CLI: &lt;code&gt;curl https://install.duckdb.org | sh&lt;/code&gt; or &lt;code&gt;brew install duckdb&lt;/code&gt;, then run &lt;code&gt;duckdb&lt;/code&gt;; or Python: &lt;code&gt;pip install duckdb&lt;/code&gt;, then &lt;code&gt;duckdb.sql("&amp;lt;query&amp;gt;").show()&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Setup:&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;VALUES&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="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&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="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-02'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-12'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. The default frame is RANGE, not ROWS
&lt;/h2&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&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;default_frame&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
                         &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&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;rows_frame&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;order_id&lt;/th&gt;
&lt;th&gt;order_date&lt;/th&gt;
&lt;th&gt;amount&lt;/th&gt;
&lt;th&gt;default_frame&lt;/th&gt;
&lt;th&gt;rows_frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-01-01&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2026-01-01&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2026-01-02&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2026-01-12&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With an &lt;code&gt;ORDER BY&lt;/code&gt; and no frame, the frame is &lt;code&gt;RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW&lt;/code&gt;: rows with the same &lt;code&gt;order_date&lt;/code&gt; are peers and are all included. &lt;strong&gt;Always write the frame.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ROW_NUMBER without a tie-breaker is non-deterministic
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date)&lt;/code&gt; must number orders 1 and 2 (same date) in &lt;em&gt;some&lt;/em&gt; order, and nothing says which one gets number 1. Reruns can return different rows. Add a unique tie-breaker:&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="n"&gt;QUALIFY&lt;/span&gt; &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If ties are &lt;em&gt;meaningful&lt;/em&gt; ("top 3 including ties"), use &lt;code&gt;RANK&lt;/code&gt; or &lt;code&gt;DENSE_RANK&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. LAST_VALUE returns the current row
&lt;/h2&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;LAST_VALUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&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;looks_wrong&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;LAST_VALUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
            &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;FOLLOWING&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;actual_last&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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;order_id&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;looks_wrong&lt;/code&gt; is just each row's own amount (10, 5, 20, 7), because the default frame ends at the current row. &lt;code&gt;actual_last&lt;/code&gt; is 7 everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. WHERE runs before window functions
&lt;/h2&gt;

&lt;p&gt;"Show the running total, but only display January 2nd onwards":&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;-- wrong: rows before Jan 2 are removed BEFORE the sum is computed&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
                         &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&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;running&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-02'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- 20, 27&lt;/span&gt;

&lt;span class="c1"&gt;-- right: compute first, filter after&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="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_date&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
                           &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&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;running&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-02'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;-- 35, 42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logical order: &lt;code&gt;FROM → WHERE → GROUP BY → HAVING → window → QUALIFY → SELECT → ORDER BY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "Last 7 rows" is not "last 7 days"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW&lt;/code&gt; counts rows. On sparse data (no orders between Jan 2 and Jan 12) that window reaches back weeks. Use a time-based frame where your engine supports it:&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_date&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;
            &lt;span class="k"&gt;RANGE&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="n"&gt;DAYS&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&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;last_7_days&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;-- 15, 15, 35, 7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or build a date spine (one row per day, &lt;code&gt;COALESCE&lt;/code&gt; missing days to 0) and then use &lt;code&gt;ROWS&lt;/code&gt;. BigQuery, for example, only allows numeric &lt;code&gt;RANGE&lt;/code&gt; offsets, so you would order by &lt;code&gt;UNIX_DATE(order_date)&lt;/code&gt; there.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Bonus: a test fixture that catches a missing PARTITION BY
&lt;/h2&gt;

&lt;p&gt;(Thanks to Emily Woods for suggesting this in the comments.) With a single customer, a missing &lt;code&gt;PARTITION BY customer_id&lt;/code&gt; is invisible: every row is in the same partition anyway. Add a second customer with an order on the same date and the wrong query fails visibly:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
            &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&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;no_partition&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
            &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&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;per_customer&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;order_id&lt;/th&gt;
&lt;th&gt;customer_id&lt;/th&gt;
&lt;th&gt;no_partition&lt;/th&gt;
&lt;th&gt;per_customer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;115&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Customer 2's 100 bleeds into customer 1's running total. That's a nice way to turn these snippets into a small regression set: one fixture per trap, each designed so the &lt;em&gt;wrong&lt;/em&gt; query visibly fails. (Run this after the other examples; it changes the table.)&lt;/p&gt;




&lt;p&gt;The likely follow-up questions in an interview: &lt;em&gt;"How does this scale in Spark?"&lt;/em&gt; (a window with &lt;code&gt;ORDER BY&lt;/code&gt; sorts each partition; one huge customer causes skew) and &lt;em&gt;"How would you do the 'latest record per key' incrementally?"&lt;/em&gt; (deduplicate the batch, then &lt;code&gt;MERGE&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;These come from the &lt;em&gt;Data Engineering Interview Kit 2026&lt;/em&gt;, which has 80 questions with trade-offs, common mistakes and follow-ups, plus a SQL practice set whose solutions are all executed in DuckDB. &lt;strong&gt;Free sample (20 questions): &lt;a href="https://payhip.com/b/18A5n" rel="noopener noreferrer"&gt;https://payhip.com/b/18A5n&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;About me: I work as an independent data engineer and DevOps freelancer (IT-Craftsman) and have delivered several data engineering and DevOps projects for large companies. I also work for a technology consulting firm, where I conduct hiring interviews for data engineers and DevOps engineers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All SQL in this post was executed in DuckDB.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>dataengineering</category>
      <category>duckdb</category>
      <category>interview</category>
    </item>
    <item>
      <title>Airflow 3 quietly changed what your cron schedule means</title>
      <dc:creator>IT-Craftsman</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:18:23 +0000</pubDate>
      <link>https://dev.to/itcraftsman/airflow-3-quietly-changed-what-your-cron-schedule-means-2okg</link>
      <guid>https://dev.to/itcraftsman/airflow-3-quietly-changed-what-your-cron-schedule-means-2okg</guid>
      <description>&lt;p&gt;If you upgrade a DAG like this from Airflow 2 to Airflow 3 without changing a line, it can start processing a &lt;strong&gt;different day&lt;/strong&gt; than before:&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="nd"&gt;@dag&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;0 0 * * *&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="n"&gt;pendulum&lt;/span&gt;&lt;span class="p"&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;10&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;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UTC&lt;/span&gt;&lt;span class="sh"&gt;"&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;daily_orders&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nd"&gt;@task&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;load_orders_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# which day is ds?
&lt;/span&gt;    &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is broken. The &lt;em&gt;default timetable&lt;/em&gt; changed. Here is what is going on and how to make your DAGs independent of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Airflow 2: a run covers an interval and starts after it ends
&lt;/h2&gt;

&lt;p&gt;In Airflow 2, &lt;code&gt;[scheduler] create_cron_data_intervals&lt;/code&gt; defaults to &lt;code&gt;True&lt;/code&gt;, so a bare cron string becomes a &lt;code&gt;CronDataIntervalTimetable&lt;/code&gt;. Each run &lt;strong&gt;covers a data interval&lt;/strong&gt; and is started &lt;strong&gt;after the interval ends&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;logical_date&lt;/th&gt;
&lt;th&gt;data interval&lt;/th&gt;
&lt;th&gt;actually starts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Oct 1 run"&lt;/td&gt;
&lt;td&gt;2026-10-01 00:00&lt;/td&gt;
&lt;td&gt;[Oct 1 00:00, Oct 2 00:00)&lt;/td&gt;
&lt;td&gt;shortly after Oct 2 00:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;logical_date&lt;/code&gt; (and therefore &lt;code&gt;ds&lt;/code&gt;) equals &lt;code&gt;data_interval_start&lt;/code&gt;: the day being processed. This confused a generation of engineers ("why does the Oct 1 run start on Oct 2?"), but it made "process yesterday" DAGs work naturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Airflow 3: a run is triggered at the cron time
&lt;/h2&gt;

&lt;p&gt;In Airflow 3 the default is &lt;code&gt;create_cron_data_intervals = False&lt;/code&gt;, so a bare cron string becomes a &lt;strong&gt;&lt;code&gt;CronTriggerTimetable&lt;/code&gt;&lt;/strong&gt;. The run starts &lt;em&gt;at&lt;/em&gt; the cron time, &lt;code&gt;logical_date&lt;/code&gt; is the trigger time, and &lt;code&gt;data_interval_start&lt;/code&gt; and &lt;code&gt;data_interval_end&lt;/code&gt; are normally the same moment unless you configure an interval.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;logical_date&lt;/th&gt;
&lt;th&gt;data interval&lt;/th&gt;
&lt;th&gt;actually starts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;run at Oct 2 00:00&lt;/td&gt;
&lt;td&gt;2026-10-02 00:00&lt;/td&gt;
&lt;td&gt;&lt;a href="https://dev.toempty"&gt;Oct 2 00:00, Oct 2 00:00&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Oct 2 00:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the run that starts at midnight on October 2nd has &lt;code&gt;ds = 2026-10-02&lt;/code&gt; in Airflow 3, but processed &lt;code&gt;2026-10-01&lt;/code&gt; in Airflow 2. If your task does &lt;code&gt;WHERE order_date = '{{ ds }}'&lt;/code&gt;, it now loads the &lt;em&gt;current&lt;/em&gt; (incomplete) day.&lt;/p&gt;

&lt;p&gt;The official upgrade guide calls this out: if you rely on &lt;code&gt;data_interval_start&lt;/code&gt;/&lt;code&gt;data_interval_end&lt;/code&gt; or on &lt;code&gt;ds&lt;/code&gt;/&lt;code&gt;ts&lt;/code&gt;, either set &lt;code&gt;create_cron_data_intervals=True&lt;/code&gt; before upgrading or pin an explicit timetable. (Another change while you are there: &lt;code&gt;catchup&lt;/code&gt; now defaults to &lt;code&gt;False&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop depending on the default
&lt;/h2&gt;

&lt;p&gt;Pin the timetable in the DAG and give it an explicit interval. Then the DAG behaves the same regardless of the global config:&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;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pendulum&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.sdk&lt;/span&gt; &lt;span class="kn"&gt;import&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;task&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.timetables.trigger&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CronTriggerTimetable&lt;/span&gt;

&lt;span class="nd"&gt;@dag&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="nc"&gt;CronTriggerTimetable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 0 * * *&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UTC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&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;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pendulum&lt;/span&gt;&lt;span class="p"&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;10&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;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UTC&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;def&lt;/span&gt; &lt;span class="nf"&gt;daily_orders&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nd"&gt;@task&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_interval_start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_interval_end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# run at 2026-10-02 00:00 gets [2026-10-01 00:00, 2026-10-02 00:00)
&lt;/span&gt;        &lt;span class="nf"&gt;load_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data_interval_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data_interval_end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;daily_orders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: even with &lt;code&gt;interval=&lt;/code&gt;, &lt;code&gt;logical_date&lt;/code&gt; (and &lt;code&gt;ds&lt;/code&gt;) is the trigger time (Oct 2), so template on &lt;code&gt;data_interval_start&lt;/code&gt;/&lt;code&gt;data_interval_end&lt;/code&gt;, not on &lt;code&gt;ds&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And make the task read &lt;strong&gt;the window it is given&lt;/strong&gt;, never "now":&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;# bad: depends on when the task happens to run (retries, backfills, catchup all break)
&lt;/span&gt;&lt;span class="nf"&gt;load_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# good: depends only on the run
&lt;/span&gt;&lt;span class="nf"&gt;load_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data_interval_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data_interval_end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A checklist before you upgrade
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; your DAGs for &lt;code&gt;ds&lt;/code&gt;, &lt;code&gt;ds_nodash&lt;/code&gt;, &lt;code&gt;ts&lt;/code&gt;, &lt;code&gt;logical_date&lt;/code&gt;, &lt;code&gt;execution_date&lt;/code&gt; (removed in Airflow 3), &lt;code&gt;data_interval_start&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For each DAG with a bare cron string, decide: interval semantics or trigger semantics? Pin it explicitly.&lt;/li&gt;
&lt;li&gt;Check manual triggers: Airflow 3's upgrade guide warns not to assume a manually triggered run's &lt;code&gt;data_interval&lt;/code&gt; equals the &lt;code&gt;logical_date&lt;/code&gt; you typed in.&lt;/li&gt;
&lt;li&gt;Make tasks idempotent (delete-and-replace the run's window, or &lt;code&gt;MERGE&lt;/code&gt; by key), so a run that processed the wrong day can simply be rerun.&lt;/li&gt;
&lt;li&gt;Add a test that imports every DAG and asserts the timetable class you expect.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;This is a condensed version of one question from the &lt;em&gt;Data Engineering Interview Kit 2026&lt;/em&gt;. Interviewers love it because it tests whether you understand &lt;em&gt;why&lt;/em&gt; idempotent, interval-based tasks matter. The free sample with 20 full questions (answers, trade-offs, common mistakes, follow-ups) is here: &lt;strong&gt;&lt;a href="https://payhip.com/b/18A5n" rel="noopener noreferrer"&gt;https://payhip.com/b/18A5n&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;About me: I work as an independent data engineer and DevOps freelancer (IT-Craftsman) and have delivered several data engineering and DevOps projects for large companies. I also work for a technology consulting firm, where I conduct hiring interviews for data engineers and DevOps engineers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Facts checked against the Airflow 3 documentation ("Upgrading to Airflow 3", "Timetables").&lt;/em&gt;&lt;/p&gt;

</description>
      <category>airflow</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>etl</category>
    </item>
    <item>
      <title>The easiest way to monitor complex Kubernetes Deployments (using Harbor as an example)</title>
      <dc:creator>IT-Craftsman</dc:creator>
      <pubDate>Thu, 01 Oct 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/itcraftsman/the-easiest-way-to-monitor-complex-kubernetes-deployments-using-harbor-as-an-example-4f7f</link>
      <guid>https://dev.to/itcraftsman/the-easiest-way-to-monitor-complex-kubernetes-deployments-using-harbor-as-an-example-4f7f</guid>
      <description>&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%2Ffp3qix6nylbd758279k5.png" 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%2Ffp3qix6nylbd758279k5.png" alt="AliveSignal Kubernetes Container Monitoring" width="720" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you’ve ever deployed &lt;a href="https://goharbor.io/" rel="noopener noreferrer"&gt;Harbor Container Registry&lt;/a&gt; on Kubernetes, you know it’s an incredibly powerful tool. But you also know that it has &lt;em&gt;a lot&lt;/em&gt; of moving parts. A standard Harbor deployment consists of multiple Deployments and StatefulSets: &lt;code&gt;harbor-core&lt;/code&gt;, &lt;code&gt;harbor-jobservice&lt;/code&gt;, &lt;code&gt;harbor-portal&lt;/code&gt;, &lt;code&gt;registry&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;, and a database.&lt;/p&gt;

&lt;p&gt;When everything is running smoothly, Harbor is a joy to use. But what happens when the &lt;code&gt;harbor-jobservice&lt;/code&gt; quietly crashes? Your images stop being scanned for vulnerabilities, replication policies fail, and you might not notice until a developer complains hours later.&lt;/p&gt;

&lt;p&gt;You need a way to monitor pod availability. &lt;/p&gt;

&lt;p&gt;The industry standard is to deploy Prometheus, set up Grafana, write PromQL queries, configure Alertmanager, and maintain all of this infrastructure. If you just want to answer the simple question: &lt;em&gt;"Are my Harbor pods actually running?"&lt;/em&gt;, this feels like massive overkill.&lt;/p&gt;

&lt;p&gt;I wanted a simpler solution. I didn't want to install more heavy monitoring agents inside my cluster just to check if my pods were alive.&lt;/p&gt;

&lt;p&gt;That’s why I used &lt;strong&gt;&lt;a href="https://alivesignal.itcraftsman.de" rel="noopener noreferrer"&gt;AliveSignal&lt;/a&gt;&lt;/strong&gt; to monitor the entire Harbor namespace using a zero-agent, heartbeat-based approach. The best part? It took me exactly 60 seconds. Here is how you can do it too.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Push vs. Pull Paradigm
&lt;/h3&gt;

&lt;p&gt;Traditional monitoring systems (like Prometheus) use a &lt;strong&gt;Pull&lt;/strong&gt; model. A central server scrapes metrics from your pods. This requires your pods to expose metric endpoints and often requires a dedicated agent running on your cluster nodes.&lt;/p&gt;

&lt;p&gt;AliveSignal flips this into a &lt;strong&gt;Push&lt;/strong&gt; model (Heartbeat monitoring). Instead of constantly asking the cluster &lt;em&gt;"Are you okay?"&lt;/em&gt;, we run a tiny, lightweight, concurrent CronJob inside the cluster that simply pings AliveSignal every minute: &lt;em&gt;"I'm okay!"&lt;/em&gt;. &lt;/p&gt;

&lt;p&gt;If AliveSignal doesn't receive the ping, it alerts you immediately via Email or Webhook. &lt;/p&gt;




&lt;h3&gt;
  
  
  Step-by-Step: Automating Harbor Health Checks
&lt;/h3&gt;

&lt;p&gt;Usually, setting up individual heartbeat monitors for 7 different deployments is tedious. But AliveSignal has a feature called &lt;strong&gt;Kubernetes Auto-Discovery&lt;/strong&gt; that makes this feel like magic. &lt;/p&gt;

&lt;p&gt;Here is the exact workflow.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1: Export your Harbor Namespace
&lt;/h4&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%2Fl0vh3sh5e2tscc8ndqpl.png" 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%2Fl0vh3sh5e2tscc8ndqpl.png" alt="AliveSignal Kubernetes Auto-Discovery" width="720" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First, we tell Kubernetes to give us the current state of all Deployments and StatefulSets in the Harbor namespace, formatted as JSON.&lt;/p&gt;

&lt;p&gt;Run this command in your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy,sts &lt;span class="nt"&gt;-n&lt;/span&gt; harbor &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; harbor.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Make sure to replace &lt;code&gt;-n harbor&lt;/code&gt; with your actual namespace if it differs).&lt;/em&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2: Upload to AliveSignal
&lt;/h4&gt;

&lt;p&gt;Log into your &lt;a href="https://alivesignal.com" rel="noopener noreferrer"&gt;AliveSignal Dashboard&lt;/a&gt;. Click on &lt;strong&gt;"K8s Auto-Discovery"&lt;/strong&gt; and upload the &lt;code&gt;harbor.json&lt;/code&gt; file you just generated.&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%2F90emi4yqv39py68uqpu3.png" 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%2F90emi4yqv39py68uqpu3.png" alt="AliveSignal Kubernetes Auto-Discovery success" width="720" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instantly, AliveSignal parses the JSON and detects every single component of your Harbor registry. It automatically creates a separate monitor for &lt;code&gt;harbor-core&lt;/code&gt;, &lt;code&gt;harbor-jobservice&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;, and so on. You don't have to type a single monitor name.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 3: Apply the Zero-Agent Manifest
&lt;/h4&gt;

&lt;p&gt;Once the monitors are created, AliveSignal generates a custom Kubernetes manifest file for you: &lt;code&gt;monitoring.yaml&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;This file contains a lightweight Kubernetes &lt;code&gt;CronJob&lt;/code&gt;. It doesn't require any special permissions (no heavy RBAC, no ClusterRoles) and doesn't run as a DaemonSet. It simply uses &lt;code&gt;curl&lt;/code&gt; to concurrently ping the unique URLs for each of your newly created monitors based on pod availability.&lt;/p&gt;

&lt;p&gt;Download the file and apply it to your cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; monitoring.yaml &lt;span class="nt"&gt;-n&lt;/span&gt; harbor
serviceaccount/dms-monitor-sa created
role.rbac.authorization.k8s.io/dms-monitor-role created
rolebinding.rbac.authorization.k8s.io/dms-monitor-rolebinding created
configmap/dms-monitor-config created
cronjob.batch/dms-monitor-cronjob created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That’s it. You are done.&lt;/strong&gt;&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%2Fzpbsz3fi68zayzegx6wy.png" 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%2Fzpbsz3fi68zayzegx6wy.png" alt="AliveSignal Dashboard for Harbor Kubernetes Resources" width="720" height="480"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  The Result
&lt;/h3&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%2Fcrzoc1v1esmm07f4x498.png" 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%2Fcrzoc1v1esmm07f4x498.png" alt="AliveSignal Dashboard for Harbor Kubernetes Resources Status ‘Up’" width="720" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Within 5 minutes, your AliveSignal dashboard will light up green. You now have deep visibility into your Harbor infrastructure without having installed a single heavy agent. &lt;/p&gt;

&lt;p&gt;If the Kubernetes scheduler fails to start your &lt;code&gt;trivy&lt;/code&gt; scanner pod, or if your &lt;code&gt;harbor-core&lt;/code&gt; pod gets stuck in a &lt;code&gt;CrashLoopBackOff&lt;/code&gt;, the internal CronJob will detect the missing pod, stop sending the ping for that specific component, and AliveSignal will alert you before your users notice.&lt;/p&gt;

&lt;h4&gt;
  
  
  Bonus: Instant Public Status Pages
&lt;/h4&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%2F7cbrvolz9scb4kv2aghu.png" 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%2F7cbrvolz9scb4kv2aghu.png" alt="AliveSignal Public Status Page" width="720" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because you are running an infrastructure tool that your whole engineering team relies on, transparency is key. &lt;/p&gt;

&lt;p&gt;With AliveSignal, you can take those newly created Harbor monitors and instantly publish them to a &lt;strong&gt;Public Status Page&lt;/strong&gt;. Out-of-the-box, it supports custom domains and Dark Mode. You can link this Status Page in your internal developer documentation, so your team always knows if the Container Registry is healthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Monitoring doesn't always have to mean maintaining complex metric stacks. For many critical workloads, simple heartbeat monitoring is not just easier to set up—it's more reliable, because it exists completely outside of the infrastructure it is monitoring.&lt;/p&gt;

&lt;p&gt;If you want to secure your Harbor Registry, Apache Airflow, or any other complex Kubernetes workload in under a minute, give &lt;strong&gt;&lt;a href="https://alivesignal.itcraftsman.de" rel="noopener noreferrer"&gt;AliveSignal&lt;/a&gt;&lt;/strong&gt; a try. It’s built for developers who want peace of mind, without the maintenance overhead.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>monitoring</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
