<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[IT-Craftsman Data Engineering]]></title><description><![CDATA[IT-Craftsman Data Engineering]]></description><link>https://itcraftsman.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 19:25:30 GMT</lastBuildDate><atom:link href="https://itcraftsman.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[5 SQL window-function traps that show up in data engineering interviews (with runnable DuckDB examples)]]></title><description><![CDATA[Default frames, ties, LAST_VALUE, filters before windows and sparse moving averages. Five small traps that separate knows SQL from writes correct…]]></description><link>https://itcraftsman.hashnode.dev/sql-window-function-interview-traps-duckdb</link><guid isPermaLink="true">https://itcraftsman.hashnode.dev/sql-window-function-interview-traps-duckdb</guid><category><![CDATA[SQL]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[duckDB]]></category><category><![CDATA[interview]]></category><dc:creator><![CDATA[IT-Craftsman]]></dc:creator><pubDate>Mon, 12 Oct 2026 17:31:10 GMT</pubDate><content:encoded><![CDATA[<p>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: <code>curl https://install.duckdb.org | sh</code> or <code>brew install duckdb</code>, then run <code>duckdb</code>; or Python: <code>pip install duckdb</code>, then <code>duckdb.sql("&lt;query&gt;").show()</code>).</p>
<p>Setup:</p>
<pre><code class="language-sql">CREATE TABLE orders AS SELECT * FROM (VALUES
  (1, 1, DATE '2026-01-01', 10),
  (2, 1, DATE '2026-01-01',  5),
  (3, 1, DATE '2026-01-02', 20),
  (4, 1, DATE '2026-01-12',  7)
) t(order_id, customer_id, order_date, amount);
</code></pre>
<h2>1. The default frame is RANGE, not ROWS</h2>
<pre><code class="language-sql">SELECT order_id, order_date, amount,
       SUM(amount) OVER (ORDER BY order_date) AS default_frame,
       SUM(amount) OVER (ORDER BY order_date, order_id
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_frame
FROM orders ORDER BY order_id;
</code></pre>
<table>
<thead>
<tr>
<th>order_id</th>
<th>order_date</th>
<th>amount</th>
<th>default_frame</th>
<th>rows_frame</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2026-01-01</td>
<td>10</td>
<td>15</td>
<td>10</td>
</tr>
<tr>
<td>2</td>
<td>2026-01-01</td>
<td>5</td>
<td>15</td>
<td>15</td>
</tr>
<tr>
<td>3</td>
<td>2026-01-02</td>
<td>20</td>
<td>35</td>
<td>35</td>
</tr>
<tr>
<td>4</td>
<td>2026-01-12</td>
<td>7</td>
<td>42</td>
<td>42</td>
</tr>
</tbody></table>
<p>With an <code>ORDER BY</code> and no frame, the frame is <code>RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW</code>: rows with the same <code>order_date</code> are peers and are all included. <strong>Always write the frame.</strong></p>
<h2>2. ROW_NUMBER without a tie-breaker is non-deterministic</h2>
<p><code>ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date)</code> must number orders 1 and 2 (same date) in <em>some</em> order, and nothing says which one gets number 1. Reruns can return different rows. Add a unique tie-breaker:</p>
<pre><code class="language-sql">SELECT * FROM orders
QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id
                           ORDER BY order_date, order_id) = 1;
</code></pre>
<p>If ties are <em>meaningful</em> ("top 3 including ties"), use <code>RANK</code> or <code>DENSE_RANK</code> instead.</p>
<h2>3. LAST_VALUE returns the current row</h2>
<pre><code class="language-sql">SELECT order_id,
       LAST_VALUE(amount) OVER (ORDER BY order_date, order_id) AS looks_wrong,
       LAST_VALUE(amount) OVER (ORDER BY order_date, order_id
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS actual_last
FROM orders ORDER BY order_id;
</code></pre>
<p><code>looks_wrong</code> is just each row's own amount (10, 5, 20, 7), because the default frame ends at the current row. <code>actual_last</code> is 7 everywhere.</p>
<h2>4. WHERE runs before window functions</h2>
<p>"Show the running total, but only display January 2nd onwards":</p>
<pre><code class="language-sql">-- wrong: rows before Jan 2 are removed BEFORE the sum is computed
SELECT order_id,
       SUM(amount) OVER (ORDER BY order_date, order_id
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM orders WHERE order_date &gt;= DATE '2026-01-02';      -- 20, 27

-- right: compute first, filter after
SELECT * FROM (
  SELECT order_id, order_date,
         SUM(amount) OVER (ORDER BY order_date, order_id
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
  FROM orders
) WHERE order_date &gt;= DATE '2026-01-02';                  -- 35, 42
</code></pre>
<p>Logical order: <code>FROM → WHERE → GROUP BY → HAVING → window → QUALIFY → SELECT → ORDER BY</code>.</p>
<h2>5. "Last 7 rows" is not "last 7 days"</h2>
<p><code>ROWS BETWEEN 6 PRECEDING AND CURRENT ROW</code> 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:</p>
<pre><code class="language-sql">SELECT order_id, order_date,
       SUM(amount) OVER (ORDER BY order_date
            RANGE BETWEEN INTERVAL 6 DAYS PRECEDING AND CURRENT ROW) AS last_7_days
FROM orders ORDER BY order_id;     -- 15, 15, 35, 7
</code></pre>
<p>or build a date spine (one row per day, <code>COALESCE</code> missing days to 0) and then use <code>ROWS</code>. BigQuery, for example, only allows numeric <code>RANGE</code> offsets, so you would order by <code>UNIX_DATE(order_date)</code> there.</p>
<h2>6. Bonus: a test fixture that catches a missing PARTITION BY</h2>
<p>(Thanks to Emily Woods for suggesting this in the comments.) With a single customer, a missing <code>PARTITION BY customer_id</code> 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:</p>
<pre><code class="language-sql">INSERT INTO orders VALUES (5, 2, DATE '2026-01-01', 100);

SELECT order_id, customer_id,
       SUM(amount) OVER (ORDER BY order_date, order_id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS no_partition,
       SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date, order_id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS per_customer
FROM orders ORDER BY order_id;
</code></pre>
<table>
<thead>
<tr>
<th>order_id</th>
<th>customer_id</th>
<th>no_partition</th>
<th>per_customer</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1</td>
<td>10</td>
<td>10</td>
</tr>
<tr>
<td>2</td>
<td>1</td>
<td>15</td>
<td>15</td>
</tr>
<tr>
<td>3</td>
<td>1</td>
<td>135</td>
<td>35</td>
</tr>
<tr>
<td>4</td>
<td>1</td>
<td>142</td>
<td>42</td>
</tr>
<tr>
<td>5</td>
<td>2</td>
<td>115</td>
<td>100</td>
</tr>
</tbody></table>
<p>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 <em>wrong</em> query visibly fails. (Run this after the other examples; it changes the table.)</p>
<hr />
<p>The likely follow-up questions in an interview: <em>"How does this scale in Spark?"</em> (a window with <code>ORDER BY</code> sorts each partition; one huge customer causes skew) and <em>"How would you do the 'latest record per key' incrementally?"</em> (deduplicate the batch, then <code>MERGE</code>).</p>
<p>These come from the <em>Data Engineering Interview Kit 2026</em>, which has 80 questions with trade-offs, common mistakes and follow-ups, plus a SQL practice set whose solutions are all executed in DuckDB. <strong>Free sample (20 questions): <a href="https://payhip.com/b/18A5n">https://payhip.com/b/18A5n</a></strong></p>
<p><em>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.</em></p>
<p><em>All SQL in this post was executed in DuckDB.</em></p>
]]></content:encoded></item><item><title><![CDATA[Airflow 3 quietly changed what your cron schedule means]]></title><description><![CDATA[In Airflow 3 a bare cron string no longer creates data intervals by default. Here is what changed, why your ds can shift by a day, and how to make your…]]></description><link>https://itcraftsman.hashnode.dev/airflow-3-cron-schedule-data-intervals</link><guid isPermaLink="true">https://itcraftsman.hashnode.dev/airflow-3-cron-schedule-data-intervals</guid><category><![CDATA[Python]]></category><category><![CDATA[airflow]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[IT-Craftsman]]></dc:creator><pubDate>Mon, 12 Oct 2026 17:29:09 GMT</pubDate><content:encoded><![CDATA[<p>If you upgrade a DAG like this from Airflow 2 to Airflow 3 without changing a line, it can start processing a <strong>different day</strong> than before:</p>
<pre><code class="language-python">@dag(schedule="0 0 * * *", start_date=pendulum.datetime(2026, 10, 1, tz="UTC"))
def daily_orders():
    @task
    def extract(ds=None):
        load_orders_for(ds)   # which day is ds?
    extract()
</code></pre>
<p>Nothing is broken. The <em>default timetable</em> changed. Here is what is going on and how to make your DAGs independent of it.</p>
<h2>Airflow 2: a run covers an interval and starts after it ends</h2>
<p>In Airflow 2, <code>[scheduler] create_cron_data_intervals</code> defaults to <code>True</code>, so a bare cron string becomes a <code>CronDataIntervalTimetable</code>. Each run <strong>covers a data interval</strong> and is started <strong>after the interval ends</strong>:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>logical_date</th>
<th>data interval</th>
<th>actually starts</th>
</tr>
</thead>
<tbody><tr>
<td>"Oct 1 run"</td>
<td>2026-10-01 00:00</td>
<td>[Oct 1 00:00, Oct 2 00:00)</td>
<td>shortly after Oct 2 00:00</td>
</tr>
</tbody></table>
<p><code>logical_date</code> (and therefore <code>ds</code>) equals <code>data_interval_start</code>: 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.</p>
<h2>Airflow 3: a run is triggered at the cron time</h2>
<p>In Airflow 3 the default is <code>create_cron_data_intervals = False</code>, so a bare cron string becomes a <code>CronTriggerTimetable</code>. The run starts <em>at</em> the cron time, <code>logical_date</code> is the trigger time, and <code>data_interval_start</code> and <code>data_interval_end</code> are normally the same moment unless you configure an interval.</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>logical_date</th>
<th>data interval</th>
<th>actually starts</th>
</tr>
</thead>
<tbody><tr>
<td>run at Oct 2 00:00</td>
<td>2026-10-02 00:00</td>
<td>[Oct 2 00:00, Oct 2 00:00] (empty)</td>
<td>Oct 2 00:00</td>
</tr>
</tbody></table>
<p>So the run that starts at midnight on October 2nd has <code>ds = 2026-10-02</code> in Airflow 3, but processed <code>2026-10-01</code> in Airflow 2. If your task does <code>WHERE order_date = '{{ ds }}'</code>, it now loads the <em>current</em> (incomplete) day.</p>
<p>The official upgrade guide calls this out: if you rely on <code>data_interval_start</code>/<code>data_interval_end</code> or on <code>ds</code>/<code>ts</code>, either set <code>create_cron_data_intervals=True</code> before upgrading or pin an explicit timetable. (Another change while you are there: <code>catchup</code> now defaults to <code>False</code>.)</p>
<h2>The fix: stop depending on the default</h2>
<p>Pin the timetable in the DAG and give it an explicit interval. Then the DAG behaves the same regardless of the global config:</p>
<pre><code class="language-python">from datetime import timedelta
import pendulum
from airflow.sdk import dag, task
from airflow.timetables.trigger import CronTriggerTimetable

@dag(
    schedule=CronTriggerTimetable("0 0 * * *", timezone="UTC", interval=timedelta(days=1)),
    start_date=pendulum.datetime(2026, 10, 1, tz="UTC"),
    catchup=False,
)
def daily_orders():
    @task
    def extract(data_interval_start=None, data_interval_end=None):
        # run at 2026-10-02 00:00 gets [2026-10-01 00:00, 2026-10-02 00:00)
        load_orders(window_start=data_interval_start, window_end=data_interval_end)
    extract()

daily_orders()
</code></pre>
<p>Note: even with <code>interval=</code>, <code>logical_date</code> (and <code>ds</code>) is the trigger time (Oct 2), so template on <code>data_interval_start</code>/<code>data_interval_end</code>, not on <code>ds</code>.</p>
<p>And make the task read <strong>the window it is given</strong>, never "now":</p>
<pre><code class="language-python"># bad: depends on when the task happens to run (retries, backfills, catchup all break)
load_orders(window_start=date.today() - timedelta(days=1))

# good: depends only on the run
load_orders(window_start=data_interval_start, window_end=data_interval_end)
</code></pre>
<h2>A checklist before you upgrade</h2>
<ol>
<li><p><code>grep</code> your DAGs for <code>ds</code>, <code>ds_nodash</code>, <code>ts</code>, <code>logical_date</code>, <code>execution_date</code> (removed in Airflow 3), <code>data_interval_start</code>.</p>
</li>
<li><p>For each DAG with a bare cron string, decide: interval semantics or trigger semantics? Pin it explicitly.</p>
</li>
<li><p>Check manual triggers: Airflow 3's upgrade guide warns not to assume a manually triggered run's <code>data_interval</code> equals the <code>logical_date</code> you typed in.</p>
</li>
<li><p>Make tasks idempotent (delete-and-replace the run's window, or <code>MERGE</code> by key), so a run that processed the wrong day can simply be rerun.</p>
</li>
<li><p>Add a test that imports every DAG and asserts the timetable class you expect.</p>
</li>
</ol>
<hr />
<p>This is a condensed version of one question from the <em>Data Engineering Interview Kit 2026</em>. Interviewers love it because it tests whether you understand <em>why</em> idempotent, interval-based tasks matter. The free sample with 20 full questions (answers, trade-offs, common mistakes, follow-ups) is here: <strong><a href="https://payhip.com/b/18A5n">https://payhip.com/b/18A5n</a></strong></p>
<p><em>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.</em></p>
<p><em>Facts checked against the Airflow 3 documentation ("Upgrading to Airflow 3", "Timetables").</em></p>
]]></content:encoded></item></channel></rss>