Back to Practical SQL Queries
Q18Question 18 of 34

Deterministic Tie-Breakers in Latest-Row-Per-Group Queries

Key Concepts
  • Timestamps are frequently not unique due to batch imports, high throughput, or coarse precision columns.
  • Without a tie-breaker, ROW_NUMBER() picks one of the tied rows arbitrarily and can flip between runs.
  • Add a unique, stable tie-breaker to the ORDER BY, almost always the primary key: ORDER BY created_at DESC, id DESC.
  • The tie-breaker must be unique within the partition and immutable so results never change unexpectedly.
  • LIMIT 1 with ORDER BY timestamp has the same flapping problem and needs the same tie-breaker fix.
  • Use RANK() instead of ROW_NUMBER() when you genuinely want all rows tied for the latest value.
  • A composite index on (customer_id, created_at DESC, id DESC) supports this pattern efficiently.

Definition

Order only by timestamp and ties are broken arbitrarily and can flip between runs: add a unique tie-breaker like `id DESC` so the latest-row-per-group result is deterministic.
  • Tied timestamps – two rows in the same group can share the exact same created_at, making both equally eligible for row number one.
  • Non-deterministic output – without a secondary column, the engine picks one arbitrarily based on physical order, parallelism, or plan changes.
  • Flapping results – the same query can return a different "latest" row on different runs, after a vacuum, or after a plan change.
  • Fix – add a unique, stable tie-breaker to the ORDER BY, almost always the primary key: ORDER BY created_at DESC, id DESC.
  • RANK alternative – if you genuinely want all rows tied for latest, use RANK() instead of ROW_NUMBER() and accept multiple rows.

Imagine National Bank of India wants each customer's most recent transaction. If Rajesh made two transactions at exactly the same recorded second, the database has two equally "latest" candidates. Without a rule to break the tie, it might pick one today and the other tomorrow. Adding "if the timestamps tie, prefer the higher transaction id" gives a firm, repeatable rule.

Understanding the Concepts

What is the latest-row-per-group pattern?

The standard approach uses ROW_NUMBER() partitioned by group and ordered by the timestamp, keeping only the rows where the number equals one.

SQL
-- Latest transaction per customer at National Bank of India
SELECT customer_id, txn_id, amount, created_at
FROM (
    SELECT customer_id, txn_id, amount, created_at,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY created_at DESC
           ) AS rn
    FROM transactions
) ranked
WHERE rn = 1;

This works perfectly when created_at is unique per customer. But timestamps are frequently not unique.

When are timestamps not unique?

Timestamps can tie under several common scenarios.

SQL
-- Batch import: all rows get the same timestamp
INSERT INTO transactions (customer_id, amount, created_at)
VALUES
    (101, 5000.00, '2026-06-15 10:00:00'),
    (101, 3000.00, '2026-06-15 10:00:00');  -- same timestamp!

-- High-throughput: sub-millisecond precision may not differentiate
-- Coarse column: TIMESTAMP(0) truncates to seconds

What goes wrong without a tie-breaker?

When two rows tie on created_at, ROW_NUMBER() must pick one, but the SQL standard does not define which. The engine may choose based on physical row order, index scan direction, or parallelism.

SQL
-- Run 1: returns txn_id = 501 (amount 5000)
-- Run 2: returns txn_id = 502 (amount 3000)
-- Run 3: returns txn_id = 501 again
-- This is non-deterministic / flapping output

A report that should be stable silently changes. Reconciliation breaks. Downstream systems disagree.

How do you fix it with a tie-breaker?

Add a secondary ordering column that is unique and stable: almost always the primary key.

SQL
-- Deterministic: tie-breaker on primary key
SELECT customer_id, txn_id, amount, created_at
FROM (
    SELECT customer_id, txn_id, amount, created_at,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY created_at DESC, txn_id DESC  -- tie-breaker!
           ) AS rn
    FROM transactions
) ranked
WHERE rn = 1;

Now when timestamps tie, the higher txn_id wins. The result is the same every time.

What properties must the tie-breaker have?

The tie-breaker must be unique within the partition and stable over time.

| Property | Why it matters |
|---|---|
| Unique within partition | No residual ambiguity after the tie-break |
| Stable (immutable) | Results do not change if the tie-breaker value is updated |
| Monotonic | Consistent "latest" semantics (higher id = later insertion) |

A monotonic surrogate key like a SERIAL or BIGSERIAL primary key is ideal.

Does LIMIT 1 have the same flapping problem?

Yes. Even a simple LIMIT 1 over an ORDER BY timestamp without a tie-breaker has the same non-deterministic behavior.

SQL
-- Also flaps without a tie-breaker
SELECT * FROM transactions
WHERE customer_id = 101
ORDER BY created_at DESC
LIMIT 1;

-- Fix: add tie-breaker
SELECT * FROM transactions
WHERE customer_id = 101
ORDER BY created_at DESC, txn_id DESC
LIMIT 1;

When should you use RANK instead of ROW_NUMBER?

If you genuinely want all rows tied for latest, use RANK() and accept multiple rows per group.

SQL
-- Returns ALL transactions tied for the latest timestamp per customer
SELECT customer_id, txn_id, amount, created_at
FROM (
    SELECT customer_id, txn_id, amount, created_at,
           RANK() OVER (
               PARTITION BY customer_id
               ORDER BY created_at DESC
           ) AS rnk
    FROM transactions
) ranked
WHERE rnk = 1;

Use RANK when multiple results are acceptable ("all latest"). Use ROW_NUMBER with a tie-breaker when you need exactly one.

How do you implement this in Java?

Rajesh writes the deterministic latest-transaction query in the repository layer.

JAVA
package com.bank.repository;

import java.sql.*;
import java.math.BigDecimal;

public class TransactionRepository {

    public record LatestTransaction(
        long txnId, long customerId, BigDecimal amount, Timestamp createdAt
    ) {}

    public LatestTransaction findLatestByCustomer(Connection conn, long customerId)
            throws SQLException {
        String sql = "SELECT txn_id, customer_id, amount, created_at "
                   + "FROM transactions "
                   + "WHERE customer_id = ? "
                   + "ORDER BY created_at DESC, txn_id DESC "
                   + "LIMIT 1";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, customerId);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new LatestTransaction(
                        rs.getLong("txn_id"),
                        rs.getLong("customer_id"),
                        rs.getBigDecimal("amount"),
                        rs.getTimestamp("created_at")
                    );
                }
                return null;
            }
        }
    }
}

What index supports this query efficiently?

A composite index matching the partition and order columns avoids a sort.

SQL
-- Optimal index for latest-per-customer with tie-breaker
CREATE INDEX idx_txn_customer_latest
    ON transactions (customer_id, created_at DESC, txn_id DESC);

The engine seeks to customer_id = 101, walks backward through the descending index, and returns the first row without scanning the whole table.

What are common interview follow-up questions?

  • What if the tie-breaker itself is not unique? (Then the result is still non-deterministic; you need a column that is guaranteed unique within the partition.)
  • Can updated_at be a reliable tie-breaker? (No, because it changes over time and is not stable.)
  • How do you handle this in a CTE? (Same pattern: add the tie-breaker to the ORDER BY inside the window function.)

Visual Overview

DIAGRAM
  ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC)

  Customer 101 partition:
  ┌──────────┬────────────────────┬──────────┐
  │ txn_id   │ created_at         │ rn       │
  ├──────────┼────────────────────┼──────────┤
  │ 501      │ 2026-06-15 10:00   │ 1 or 2?  │ ◄── TIE!
  │ 502      │ 2026-06-15 10:00   │ 2 or 1?  │ ◄── TIE!
  │ 499      │ 2026-06-14 09:30   │ 3        │
  └──────────┴────────────────────┴──────────┘

  With tie-breaker: ORDER BY created_at DESC, txn_id DESC
  ┌──────────┬────────────────────┬──────────┐
  │ txn_id   │ created_at         │ rn       │
  ├──────────┼────────────────────┼──────────┤
  │ 502      │ 2026-06-15 10:00   │ 1        │ ◄── deterministic
  │ 501      │ 2026-06-15 10:00   │ 2        │
  │ 499      │ 2026-06-14 09:30   │ 3        │
  └──────────┴────────────────────┴──────────┘

Key Takeaway

When selecting the latest row per group, the ORDER BY inside the window function decides which row becomes number one. The result is only well-defined if the ordering columns uniquely identify one row per partition. The problem is that timestamps are very often not unique: under batch imports, high throughput, or coarse-precision columns, two rows can share the exact same created_at.

When two rows tie, ROW_NUMBER() must pick one, but the SQL standard does not define which. The engine may decide based on physical row order, parallelism, or a plan change, so the same query can return a different row on different runs. At the National Bank of India this caused a daily last-transaction snapshot to flap between two transactions Rajesh made at the same recorded second, which made downstream reconciliation match on some days and mismatch on others.

The fix is to add a secondary ordering column that is unique and stable, almost always the primary key: ORDER BY created_at DESC, txn_id DESC. Now when timestamps tie, the higher id wins, and the result is deterministic across every execution. The same applies to a LIMIT 1 query. If you genuinely want all rows tied for latest, switch to RANK() and accept multiple rows.

Remember one sentence: ordering only by timestamp breaks ties arbitrarily, so add a unique tie-breaker like id DESC to make the latest-row-per-group result deterministic.

---

Share
Use ← → arrow keys to navigate