Back to Spark Streaming & Structured Streaming
Q16Question 16 of 22

SQL window functions: month-over-month balance growth and duplicate detection

Key Concepts
  • Window functions compute across related rows without collapsing them, unlike GROUP BY which squashes rows into one group.
  • LAG reads a value from a previous row in partition order, enabling month-over-month comparisons by looking back one or two periods.
  • ROW_NUMBER numbers rows within a partition so the first occurrence is row 1 and any row above 1 is a suspected duplicate.
  • The OVER clause defines PARTITION BY (grouping), ORDER BY (row ordering), and an optional frame (surrounding rows).
  • Handle the first month's null from LAG with COALESCE, and choose the ORDER BY inside ROW_NUMBER deliberately to control which copy is the original.
  • Both patterns have real financial impact in banking: duplicate detection prevents double-charges, and growth analytics drive relationship management.

Definition

Window functions compute across related rows without collapsing them, so `AVG ... OVER` with `LAG` finds month-over-month growth and `ROW_NUMBER() OVER (PARTITION BY ...)` flags duplicates.
  • Window function – Performs calculations across a set of rows related to the current row using an OVER clause, without collapsing them into a single group like GROUP BY does.
  • OVER clause – Defines PARTITION BY (independent groups), ORDER BY (row ordering within each group), and an optional frame (how many surrounding rows the function sees).
  • LAG – Reads a value from a previous row in the partition order, letting you compare the current month to the prior month.
  • ROW_NUMBER – Numbers rows sequentially within each partition, so the first occurrence is row 1 and any row above 1 is a suspected duplicate.
  • Banking use cases – Month-over-month balance growth powers relationship-management dashboards, and duplicate detection catches double-posted transactions during reconciliation.

Imagine the National Bank of India wants to know which customers are growing their savings three months running, and to catch accidental double-charges. A plain GROUP BY would squash each customer into one number and lose the per-month detail. Window functions are like giving each monthly summary row a sticky note that can peek at its neighbours: "what was my average balance?" and "what was last month's average?" That peek is LAG. For duplicates, you line up identical-looking transactions and number them one, two, three; row one is genuine and any number above one is a suspected duplicate, which is ROW_NUMBER.

Understanding the Concepts

What is a window function?

A window function is written as a function followed by an OVER clause. Unlike GROUP BY, a window function keeps every input row and adds the computed value as a new column. The OVER clause has three optional parts: PARTITION BY, ORDER BY, and an optional frame.

SQL
-- Basic window function syntax
SELECT
    account_id,
    txn_month,
    avg_daily_balance,
    LAG(avg_daily_balance) OVER (
        PARTITION BY account_id
        ORDER BY txn_month
    ) AS prev_month_balance
FROM monthly_balances;
TEXT
+------------+-----------+-------------------+--------------------+
| account_id | txn_month | avg_daily_balance | prev_month_balance |
+------------+-----------+-------------------+--------------------+
| ACC-001    | 2024-01   |         150000.00 |               NULL |
| ACC-001    | 2024-02   |         175000.00 |          150000.00 |
| ACC-001    | 2024-03   |         200000.00 |          175000.00 |
+------------+-----------+-------------------+--------------------+

How do you find month-over-month balance growth?

The recipe has two layers. First, bucket transactions into months and compute each account's average daily balance per month. Second, compare each month to the one before using LAG.

SQL
-- Step 1: Compute monthly average daily balance
WITH monthly_avg AS (
    SELECT
        account_id,
        DATE_TRUNC('month', txn_date) AS txn_month,
        AVG(daily_balance) AS avg_balance
    FROM nbi_daily_balances
    WHERE txn_date >= CURRENT_DATE - INTERVAL '4 months'
    GROUP BY account_id, DATE_TRUNC('month', txn_date)
),
-- Step 2: Compare to previous month using LAG
with_prev AS (
    SELECT
        account_id,
        txn_month,
        avg_balance,
        LAG(avg_balance, 1) OVER (
            PARTITION BY account_id ORDER BY txn_month
        ) AS prev_1,
        LAG(avg_balance, 2) OVER (
            PARTITION BY account_id ORDER BY txn_month
        ) AS prev_2
    FROM monthly_avg
)
-- Step 3: Filter for 3 consecutive increases
SELECT account_id, txn_month, avg_balance
FROM with_prev
WHERE avg_balance > prev_1
  AND prev_1 > prev_2
ORDER BY account_id;
TEXT
Result: Accounts with 3 months of continuous growth
+------------+-----------+-------------+
| account_id | txn_month | avg_balance |
+------------+-----------+-------------+
| ACC-10045  | 2024-03   |   250000.00 |
| ACC-10098  | 2024-03   |   180000.00 |
+------------+-----------+-------------+

How do you detect duplicate transactions?

The key idea is defining what "duplicate" means, usually the same account, amount, and timestamp. Use ROW_NUMBER() to number identical rows; row 1 is the original, anything above 1 is a suspected duplicate.

SQL
-- Detect duplicate transactions
WITH numbered AS (
    SELECT
        txn_id,
        account_id,
        amount,
        txn_time,
        ROW_NUMBER() OVER (
            PARTITION BY account_id, amount, txn_time
            ORDER BY txn_id  -- earliest ID = original
        ) AS rn
    FROM nbi_transactions
)
-- List duplicates (rn > 1)
SELECT txn_id, account_id, amount, txn_time
FROM numbered
WHERE rn > 1
ORDER BY account_id, txn_time;
TEXT
Duplicate transactions found:
+--------+------------+-----------+---------------------+
| txn_id | account_id |    amount |            txn_time |
+--------+------------+-----------+---------------------+
| TXN-55 | ACC-10045  | 25000.00  | 2024-03-15 14:30:00 |
| TXN-89 | ACC-10098  |  5000.00  | 2024-03-20 09:15:00 |
+--------+------------+-----------+---------------------+

How do you deduplicate using ROW_NUMBER?

To keep only the original and remove duplicates, filter where rn = 1.

SQL
-- Keep only the original transaction (deduplicate)
WITH numbered AS (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY account_id, amount, txn_time
            ORDER BY txn_id
        ) AS rn
    FROM nbi_transactions
)
SELECT txn_id, account_id, amount, txn_time
FROM numbered
WHERE rn = 1;  -- only the first occurrence

How do you use this in Spark SQL?

The same SQL works in Spark SQL, either through the SQL API or the DataFrame API with the Window specification.

JAVA
import org.apache.spark.sql.expressions.Window;
import org.apache.spark.sql.expressions.WindowSpec;
import static org.apache.spark.sql.functions.*;

// DataFrame API equivalent of LAG for month-over-month
WindowSpec monthWindow = Window
    .partitionBy("accountId")
    .orderBy("txnMonth");

Dataset<Row> withPrev = monthlyAvg
    .withColumn("prevBalance", lag("avgBalance", 1).over(monthWindow))
    .withColumn("prevBalance2", lag("avgBalance", 2).over(monthWindow));

Dataset<Row> growing = withPrev
    .filter(col("avgBalance").gt(col("prevBalance")))
    .filter(col("prevBalance").gt(col("prevBalance2")));

// DataFrame API equivalent of ROW_NUMBER for dedup
WindowSpec dedupWindow = Window
    .partitionBy("accountId", "amount", "txnTime")
    .orderBy("txnId");

Dataset<Row> deduped = transactions
    .withColumn("rn", row_number().over(dedupWindow))
    .filter(col("rn").equalTo(1))
    .drop("rn");

What are edge cases?

The first month has no previous value, where LAG returns null. Handle it by treating null as no-growth. For duplicates, distinguish true business duplicates from legitimately repeated transactions by including a reliable unique dimension.

SQL
-- Handle null from LAG (first month has no previous)
SELECT
    account_id,
    txn_month,
    avg_balance,
    COALESCE(
        LAG(avg_balance) OVER (PARTITION BY account_id ORDER BY txn_month),
        avg_balance  -- default: no growth if no previous month
    ) AS prev_balance
FROM monthly_avg;

-- Distinguish true dupes from legitimate repeats
-- Include transaction_ref to avoid false positives
ROW_NUMBER() OVER (
    PARTITION BY account_id, amount, txn_time, merchant_id
    ORDER BY txn_id
)
-- Two separate coffees at the same merchant at the same time
-- are still different if they have different transaction_refs

What about performance?

Window functions run after WHERE and GROUP BY but can be expensive on huge tables. Partition data by date and index on the partition and order keys to help the engine.

SQL
-- Performance: partition table by month, index on account_id
CREATE INDEX idx_balance_acct_month
    ON nbi_daily_balances (account_id, txn_date);

-- The optimizer can scan only the relevant months
SELECT *
FROM nbi_daily_balances
WHERE txn_date >= CURRENT_DATE - INTERVAL '4 months';

What are common mistakes?

A common mistake is using the wrong ORDER BY inside ROW_NUMBER, which determines which copy is considered the "original." Another mistake is forgetting that LAG returns null for the first row in the partition.

SQL
-- WRONG: ORDER BY random column, "original" is unpredictable
ROW_NUMBER() OVER (
    PARTITION BY account_id, amount, txn_time
    ORDER BY RANDOM()  -- which row is "first" is random!
)

-- CORRECT: ORDER BY a stable, meaningful column
ROW_NUMBER() OVER (
    PARTITION BY account_id, amount, txn_time
    ORDER BY txn_id  -- earliest ID is the original
)

What should a senior engineer know?

In a bank, duplicate detection and month-over-month analytics have real financial consequences. Double-counting a transaction affects reported numbers, and missing a duplicate charge hurts customers. The ORDER BY inside ROW_NUMBER must deliberately choose which copy is the original. The LAG comparison must handle nulls for the first period. Both patterns should be tested with edge cases before deployment.

Visual Overview

DIAGRAM
  Month-over-Month Growth with LAG

  Account ACC-001 monthly balances:
  ┌──────────┬───────────┬──────────────┬──────────────┐
  │  Month   │ AvgBal    │ LAG(1)       │ LAG(2)       │
  ├──────────┼───────────┼──────────────┼──────────────┤
  │ 2024-01  │ 150,000   │ NULL         │ NULL         │
  │ 2024-02  │ 175,000   │ 150,000      │ NULL         │
  │ 2024-03  │ 200,000   │ 175,000      │ 150,000      │
  └──────────┴───────────┴──────────────┴──────────────┘
                   ▲            ▲              ▲
                   │            │              │
              200K > 175K  AND 175K > 150K  → 3-month growth!

  Duplicate Detection with ROW_NUMBER

  PARTITION BY (account_id, amount, txn_time):
  ┌────────┬───────────┬──────────┬────────────────┬────┐
  │ txn_id │ account   │  amount  │ txn_time       │ rn │
  ├────────┼───────────┼──────────┼────────────────┼────┤
  │ TXN-42 │ ACC-10045 │ 25000.00 │ 2024-03-15 ... │  1 │ ◀── original
  │ TXN-55 │ ACC-10045 │ 25000.00 │ 2024-03-15 ... │  2 │ ◀── DUPLICATE
  └────────┴───────────┴──────────┴────────────────┴────┘
  Keep rn=1, flag rn>1 as duplicates

Key Takeaway

Window functions compute across a set of rows related to the current row without collapsing them into one group, which is the key difference from GROUP BY. They are written as a function plus an OVER clause with PARTITION BY to split rows into independent groups, ORDER BY to order rows within each partition, and an optional frame to bound the surrounding rows.

For accounts whose average daily balance rose month over month for three months, the approach is two layers. First bucket transactions into months and compute each account's average daily balance per month. Then compare each month to the one before using LAG, where LAG with offsets one and two returns the previous months' values, so you can test that the latest month is greater than the prior and the prior is greater than the one before.

For duplicate detection, define what "duplicate" means (same account, amount, and timestamp), then use ROW_NUMBER over that partition ordered by transaction id. Row one is the legitimate original and anything above one is a suspected duplicate. Filter where row number is greater than one to list duplicates, or keep only row number one to deduplicate.

At the National Bank of India these two patterns powered real features. The growth query found customers like Rajesh whose savings rose three months straight, feeding a relationship-management dashboard. The duplicate query caught double-posted transactions from Kafka producer retries during reconciliation. In a bank both matter financially, because missing a duplicate charge or miscounting growth directly affects customers and reported numbers.

Remember one sentence: LAG across month buckets finds consecutive growth, ROW_NUMBER within look-alike partitions finds duplicates, and both keep every row visible unlike GROUP BY.

---

Share
Use ← → arrow keys to navigate