Appendix B: SQL for Data Engineering

Not a SQL tutorial. This is the subset a data engineer uses constantly, with the traps that produce wrong numbers rather than errors.

Every example runs against the Kestrel schema (Appendix A). Dialect differences are noted where they change behaviour rather than syntax.


B.1 The Ones That Produce Wrong Numbers Silently

These are first because they are the ones that ship.

-- 1. NOT IN with a NULL returns NOTHING. Silently.
SELECT * FROM products
 WHERE sku NOT IN (SELECT sku FROM discontinued);   -- 0 rows if any sku is NULL

SELECT * FROM products p                            -- correct
 WHERE NOT EXISTS (SELECT 1 FROM discontinued d WHERE d.sku = p.sku);

-- 2. COUNT(col) skips NULLs; COUNT(*) does not.
SELECT count(*), count(shipped_at) FROM orders;     -- different numbers

-- 3. An INNER JOIN silently drops the rows you were counting.
SELECT c.customer_id, count(o.order_id)             -- customers with 0 orders vanish
  FROM customers c JOIN orders o USING (customer_id) GROUP BY 1;

SELECT c.customer_id, count(o.order_id)             -- correct
  FROM customers c LEFT JOIN orders o USING (customer_id) GROUP BY 1;

-- 4. A WHERE clause on the right side of a LEFT JOIN makes it an INNER JOIN.
SELECT ... FROM customers c
  LEFT JOIN orders o USING (customer_id)
 WHERE o.status = 'shipped';                        -- inner join now

SELECT ... FROM customers c                          -- correct: move it to ON
  LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'shipped';

-- 5. Integer division truncates before you multiply.
SELECT 5500 / 21900 * 10000 AS bps;                 -- 0
SELECT 10000 * 5500 / 21900 AS bps;                 -- 2511

-- 6. Aggregating a joined fact multiplies it.
SELECT o.order_id, sum(ol.cents), sum(s.shipping_cents)   -- both inflated
  FROM orders o JOIN order_lines ol USING (order_id)
                JOIN shipments s USING (order_id);

Number 6 is the fan trap (ch6), and it is the single most expensive item on this list because the result looks plausible. Aggregate each fact separately and join the aggregates.


B.2 Window Functions

The most valuable feature in this list.

SELECT
  order_id, customer_id, placed_at, net_cents,

  row_number() OVER (PARTITION BY customer_id ORDER BY placed_at, order_id)  AS seq,
  rank()       OVER (ORDER BY net_cents DESC)                                AS rnk,
  dense_rank() OVER (ORDER BY net_cents DESC)                                AS drnk,

  lag(placed_at)  OVER (PARTITION BY customer_id ORDER BY placed_at)         AS prev_order,
  lead(placed_at) OVER (PARTITION BY customer_id ORDER BY placed_at)         AS next_order,

  first_value(net_cents) OVER w                                              AS first_order_value,
  sum(net_cents)         OVER (PARTITION BY customer_id ORDER BY placed_at
                               ROWS BETWEEN UNBOUNDED PRECEDING
                                        AND CURRENT ROW)                     AS running_total
FROM gold.fct_order
WINDOW w AS (PARTITION BY customer_id ORDER BY placed_at
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING);

Three things that catch people:

ROW_NUMBER needs a total order. ORDER BY placed_at alone, with ties, returns a different row on every run — and a uniqueness test still passes. Always add a tie-breaker (ch20, ch38 CS2).

RANK leaves gaps and DENSE_RANK does not. 1, 1, 3 versus 1, 1, 2.

LAST_VALUE does not do what you expect. With the default frame it returns the last row of the current peer group, which is usually the current row. Use an explicit frame:

last_value(x) OVER (PARTITION BY k ORDER BY t
                    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)

B.3 The Frame Clause

The default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and it is not what most people mean.

ROWS   counts physical rows
RANGE  includes every PEER -- every row with the same ORDER BY value
GROUPS counts distinct peer groups

When the ordering column has duplicates they differ:

-- two orders on 2026-03-05
sum(x) OVER (ORDER BY order_date ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- includes rows up to this one

sum(x) OVER (ORDER BY order_date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- includes BOTH rows of 2026-03-05, for both of them

A running total over a date column with multiple rows per date is the standard place this bites.


B.4 Deduplication

-- keep the latest version of each order. Note the TIE-BREAK.
WITH ranked AS (
  SELECT *, row_number() OVER (PARTITION BY order_id
                               ORDER BY updated_at DESC, cdc_lsn DESC) AS rn
    FROM bronze.orders_raw
)
SELECT * FROM ranked WHERE rn = 1;

QUALIFY removes the subquery, in Snowflake, BigQuery, DuckDB, and Databricks:

SELECT * FROM bronze.orders_raw
QUALIFY row_number() OVER (PARTITION BY order_id
                           ORDER BY updated_at DESC, cdc_lsn DESC) = 1;

Postgres has no QUALIFY. Use DISTINCT ON, which is a Postgres extension and is often faster:

SELECT DISTINCT ON (order_id) *
  FROM bronze.orders_raw
 ORDER BY order_id, updated_at DESC, cdc_lsn DESC;

B.5 As-Of Joins

Point-in-time correctness (ch32). Portable form:

SELECT l.customer_id, l.event_ts, f.orders_to_date
  FROM labels l
  LEFT JOIN LATERAL (
      SELECT orders_to_date
        FROM features
       WHERE customer_id = l.customer_id
         AND valid_from <= l.event_ts        -- <= not <
       ORDER BY valid_from DESC
       LIMIT 1
  ) f ON true;

Native support exists in DuckDB, Snowflake, ClickHouse, and Databricks:

SELECT * FROM labels ASOF LEFT JOIN features
  ON labels.customer_id = features.customer_id
 AND labels.event_ts >= features.valid_from;

Read your engine's boundary semantics. >= versus > is a row of data per entity, and the defaults differ.


B.6 Gaps and Islands, and Sessionization

Finding runs of consecutive values, and the standard sessionization pattern:

WITH marked AS (
  SELECT customer_id, event_ts,
         CASE WHEN event_ts - lag(event_ts) OVER (PARTITION BY customer_id
                                                  ORDER BY event_ts)
                   > INTERVAL '30 minutes'
              OR lag(event_ts) OVER (PARTITION BY customer_id ORDER BY event_ts) IS NULL
              THEN 1 ELSE 0 END AS is_new_session
    FROM silver.stg_events
),
sessioned AS (
  SELECT *, sum(is_new_session) OVER (PARTITION BY customer_id
                                      ORDER BY event_ts
                                      ROWS UNBOUNDED PRECEDING) AS session_seq
    FROM marked
)
SELECT customer_id, session_seq,
       min(event_ts) AS started_at, max(event_ts) AS ended_at,
       count(*) AS events
  FROM sessioned GROUP BY 1, 2;

The ROWS UNBOUNDED PRECEDING is load-bearing — with RANGE, simultaneous events land in the same peer group and the running sum jumps.


B.7 Set Operations and Anti-Joins

-- rows in A not in B, on multiple columns
SELECT a.* FROM a
 WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.k1 = a.k1 AND b.k2 = a.k2);

-- symmetric difference: what a reconciliation needs
SELECT COALESCE(o.k, n.k) AS k, o.amount AS old_amount, n.amount AS new_amount
  FROM old_output o
  FULL OUTER JOIN new_output n USING (k)
 WHERE o.k IS NULL OR n.k IS NULL OR o.amount <> n.amount;

-- EXCEPT / INTERSECT: whole-row, and they deduplicate
SELECT k FROM a EXCEPT SELECT k FROM b;          -- EXCEPT ALL keeps duplicates

The FULL OUTER JOIN is the reconciliation form (ch37 §37.6). An inner join cannot see a row that one side produced and the other did not, which is the most common difference.


B.8 Pivot and Unpivot

-- pivot, portable
SELECT customer_id,
       sum(CASE WHEN status = 'shipped'   THEN 1 ELSE 0 END) AS shipped,
       sum(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
  FROM orders GROUP BY 1;

-- unpivot, portable
SELECT customer_id, 'shipped' AS status, shipped AS n FROM t
UNION ALL
SELECT customer_id, 'cancelled', cancelled FROM t;

DuckDB, Snowflake, and Spark have PIVOT/UNPIVOT. The dynamic case — pivoting on values you do not know in advance — requires generated SQL in every engine, and that is the honest answer to the interview question.


B.9 Dialect Differences That Matter

Postgres DuckDB Snowflake BigQuery
QUALIFY
DISTINCT ON
ASOF JOIN
String concat \|\| \|\| \|\| CONCAT
Date truncate date_trunc date_trunc date_trunc DATE_TRUNC
Current date current_date current_date current_date() CURRENT_DATE()
Cast x::type x::type x::type CAST(x AS type)
Arrays x[1] 1-based 1-based 1-based x[OFFSET(0)] 0-based
Identifier case folds lower insensitive folds upper sensitive
Money numeric DECIMAL NUMBER NUMERIC

Two that cause real bugs:

Snowflake folds unquoted identifiers to upper case and Postgres folds to lower. A quoted identifier in one is not the same object in the other.

BigQuery arrays are zero-based and everything else is one-based.


B.10 Reading a Plan

EXPLAIN (ANALYZE, BUFFERS)  SELECT ...;   -- Postgres: actual rows + IO
EXPLAIN ANALYZE             SELECT ...;   -- DuckDB
EXPLAIN                     SELECT ...;   -- Spark; add FORMATTED for detail

Four things to look for, in order:

Which relation is scanned, and how much of it. Seq Scan on order_lines (rows=6,480,000) when you expected a day's worth means a predicate did not prune.

Whether the estimate matches reality. Postgres prints rows=1000 and actual rows=2,400,000 when statistics are stale; the plan was chosen on the estimate.

Where the joins are and in what order. A nested loop over a large outer relation is usually the problem.

Whether a sort or a hash spills. Sort Method: external merge Disk: 412000kB means memory was insufficient.

In Spark, read the scan node:

FileScan parquet [order_id,net_cents]
  PartitionFilters: [isnotnull(event_date), (event_date = 2026-03-14)]   <- pruning worked
  PushedFilters: [IsNotNull(customer_id)]
  ReadSchema: struct<order_id:bigint,net_cents:bigint>                    <- projection worked

An empty PartitionFilters on a partitioned table is Chapter 1's $3,840 bug.


B.11 Money, Time, and Types

-- money: integer cents, always
net_revenue_cents  BIGINT NOT NULL     -- never FLOAT, never DOUBLE
-- and cast explicitly when summing in DuckDB, or SUM returns HUGEINT
SELECT sum(net_revenue_cents)::BIGINT FROM ...;

-- time: store UTC, be explicit at boundaries
placed_at   TIMESTAMPTZ NOT NULL       -- an instant
event_date  DATE NOT NULL              -- a partition key, derived explicitly

The timezone trap (ch38 CS1): WHERE placed_at >= '2026-11-01' against a timestamptz is evaluated in the session timezone. Two sides of a reconciliation with different session settings compare different months. State the timezone in the query:

WHERE placed_at >= TIMESTAMPTZ '2026-11-01 00:00:00+00'
  AND placed_at <  TIMESTAMPTZ '2026-12-01 00:00:00+00'

And never use BETWEEN on timestamps — it is inclusive at both ends, so a row at exactly midnight lands in two months.


B.12 Performance Habits

Filter before you join, not after. The optimizer usually does this and cannot when the predicate involves a function of a joined column.

Do not apply functions to a column you are filtering on. WHERE CAST(event_ts AS DATE) = '...' defeats partition pruning; WHERE event_ts >= ... AND event_ts < ... does not.

SELECT * on a columnar table reads every column. Name them.

Aggregate before joining when the join fans out.

EXISTS beats IN on a large subquery, and beats COUNT(*) > 0 always, because it can stop at the first match.

Check statistics before optimizing. ANALYZE in Postgres; a stale plan is a much more common cause of a slow query than a missing index.