top of page

Welcome
to NumpyNinja Blogs

NumpyNinja: Blogs. Demystifying Tech,

One Blog at a Time.
Millions of views. 

SQL LAG() vs Joins & Subquery - with a Simple Real-World Example

Jan 14
4 min read


When working with time-series or event-based data, one of the most common questions we ask is:

“What changed compared to the previous record?”

Example use cases:

  • Time between patient readings

  • Gap between user logins

  • Delay between transactions

  • Sensor data intervals

  • Event latency analysis

  • Healthcare monitoring intervals


SQL window functions are designed precisely for this kind of problem. They allow you to perform calculations across a set of related rows without collapsing the result set, unlike GROUP BY.


In this blog, we’ll break down SQL window functions using a very simple, practical example.


Assume we have glucose readings from a medical device stored in a table called dexcom.

Table: dexcom

patientid

date_time

101

2024-01-01 08:00:00

101

2024-01-01 10:00:00

101

2024-01-01 13:30:00

102

2024-01-01 09:00:00

102

2024-01-01 11:00:00

Business Question

How much time has passed between consecutive readings for each patient?


The SQL Query using Window function.

select patientid,
       date_time,
       date_time - lag(date_time) 
           over (partition by patientid order by date_time) as diff_time
from dexcom;

Let’s break it down step by step.


What Is a Window Function?

A window function performs a calculation across a window of rows related to the current row.

Key characteristics:

  • Rows are not grouped or reduced

  • You still see every row

  • Calculations can reference previous or next rows.

Window functions always use the OVER() clause.


LAG():

The window function we used here is LAG().

The LAG() function allows you to access a value from a previous row.

lag(date_time)

This means:

  • “Give me the date_time from the previous row”

But previous relative to what?That’s where OVER() comes in.


OVER():

over (partition by patientid order by date_time)

1. PARTITION BY patientid

This tells SQL:

  • Treat each patient separately

  • Calculations reset when patientid changes

So patient 101 and patient 102 are handled independently.

2. ORDER BY date_time

This defines the sequence:

  • Rows are ordered chronologically

  • “Previous row” now has a clear meaning

Without ORDER BY, LAG() would be meaningless.


Calculating the Time Difference

date_time - lag(date_time) over (...)

Here’s what happens:

  • SQL fetches the previous date_time

  • Subtracts it from the current date_time

  • Produces a time interval (diff_time)


Sample Output

patientid

date_time

diff_time

101

2024-01-01 08:00:00

NULL

101

2024-01-01 10:00:00

02:00:00

101

2024-01-01 13:30:00

03:30:00

102

2024-01-01 09:00:00

NULL

102

2024-01-01 11:00:00

02:00:00

Why is the first diff_time NULL?

Because there is no previous record for the first row of each patient.


How the Database Thinks

At execution time, the engine typically:

  1. Sorts data by patientid, date_time

  2. Processes each partition once

  3. Maintains a small in-memory buffer for previous rows

  4. Computes LAG() in a single pass


Execution Characteristics

  • Single table scan

  • No row explosion

  • Deterministic ordering

  • Minimal memory overhead

  • Optimizer-friendly

This is a linear-time operation relative to the dataset size.


Alternative 1: Self-Join (Previous Row Logic)

select d1.patientid,
       d1.date_time,
       d1.date_time - max(d2.date_time) as diff_time
from dexcom d1
left join dexcom d2
  on d1.patientid = d2.patientid
 and d2.date_time < d1.date_time
group by d1.patientid, d1.date_time;

How it works

  • For each row (d1), find earlier timestamps (d2)

  • Take the maximum earlier timestamp

  • Subtract it from the current timestamp

Why this is logically correct

  • It mimics the “previous row” concept

  • Produces the same result set

Why it is not best

  • Harder to read and maintain

  • Requires a GROUP BY

  • Can be much slower on large datasets

  • Easy to introduce bugs (missing join conditions)


Alternative 2: Correlated Subquery

select d1.patientid,
       d1.date_time,
       d1.date_time - (
           select max(d2.date_time)
           from dexcom d2
           where d2.patientid = d1.patientid
             and d2.date_time < d1.date_time
       ) as diff_time
from dexcom d1;

How it works

  • For each row, find the most recent prior timestamp

  • Subtract it from the current row

Why this is correct

  • Accurately models the “previous event” logic

  • Works even without window function support

Why it is not best

  • Executes subquery per row (often expensive)

  • Less expressive than LAG()

  • Harder for query optimizers to optimize


Alternative 3: Using ROW_NUMBER() + Self-Join

with ordered_data as (
    select patientid,
           date_time,
           row_number() over (partition by patientid order by date_time) as rn
    from dexcom
)
select c.patientid,
       c.date_time,
       c.date_time - p.date_time as diff_time
from ordered_data c
left join ordered_data p
  on c.patientid = p.patientid
 and c.rn = p.rn + 1;

How it works

  • Assigns an explicit sequence number per patient

  • Joins each row to its previous row

Why it is correct

  • Deterministic ordering

  • Clear relationship between rows

Why it is not best

  • More verbose

  • Requires extra memory

  • Adds cognitive overhead

  • Still inferior to LAG() for clarity



Execution Plan Comparison

Window Function Plan

Seq Scan → Sort → Window Aggregate → Output

Self-Join Plan

Seq Scan
   ↘
    Hash/Loop Join → Aggregate → Output
   ↗
Seq Scan

More operators = more CPU, memory, and risk


Why Optimizers Prefer Window Functions

Modern SQL optimizers (Postgres, Snowflake, BigQuery, SQL Server, Oracle):

  • Recognize window functions as ordered analytics

  • Avoid unnecessary joins

  • Push down predicates

  • Minimize intermediate data

Joins + aggregates:

  • Are harder to reorder

  • Require larger temporary datasets

  • Increase spill-to-disk risk


Correctness: Semantic vs Accidental

Window Function

lag(date_time) over (partition by patientid order by date_time)

This explicitly means:

“Previous event for this patient in time order.”


Join-Based Logic

max(date_time) where date_time < current

The correctness of the output created by this depends on:

  • No duplicates

  • Correct join conditions

  • Proper grouping

Window functions encode intent, not just logic


Maintainability & Extensibility

Want to add:

  • Next event? → LEAD()

  • Two rows back? → LAG(col, 2)

  • Rolling averages? → AVG() OVER (...)

  • Gaps larger than X? → simple CASE

With joins, each enhancement:

  • Adds complexity

  • Adds new joins or subqueries

  • Increases bug risk


When Are Joins Acceptable?

Joins are still valid when:

  • Your SQL engine does not support window functions

  • You are working with very small datasets

  • You need to compare different tables

But for row-to-row analytics on the same dataset, joins are a workaround—not a best practice.


Conclusion :

If your problem involves comparing a row with previous or next rows, window functions are the correct abstraction.

They are:

  • Faster

  • Safer

  • More readable

  • Easier to extend

  • Optimizer-friendly



 
 

+1 (302) 200-8320

NumPy_Ninja_Logo (1).png

Numpy Ninja Inc. 8 The Grn Ste A Dover, DE 19901

© Copyright 2025 by Numpy Ninja Inc.

  • Twitter
  • LinkedIn
bottom of page