Window functions let a report retain individual readings while adding information from related rows. A sensor reading can show its predecessor, its change, and its share of the sensor’s total without reducing the sensor to one output row. Correctness depends on defining the population, the partition, and the ordering before interpreting those extra columns.

Define a neighbourhood with meaning

PARTITION BY identifies independent sequences. A lag over readings partitioned by sensor compares values from the same sensor; omitting that partition can compare the first reading of one sensor with the last reading of another. The ORDER BY inside OVER defines which row is previous. Use a stable sequence key, or a timestamp plus a unique tie-breaker when timestamps alone can repeat.

Lag refers to a preceding row in this ordered population, not necessarily to the preceding minute, day, or scheduled observation. Gaps in collection do not create missing rows automatically. If a calculation requires the previous calendar day, define that calendar relationship explicitly or construct the required time series before applying the calculation. A neighbour in a table is not proof of a regular sampling interval.

At the beginning of a partition, lag returns its default, normally SQL NULL. That accurately represents the absence of a predecessor. Replacing it with zero changes the meaning of the first delta and should follow a business rule. PostgreSQL also respects null values in lag and lead; a null-valued preceding reading is not silently skipped in search of an earlier nonnull value.

Totals and shares retain detail

An aggregate used with OVER preserves the input rows while adding a result. sum(value) OVER (PARTITION BY sensor) has no window ordering, so it repeats the whole sensor total. Adding an order without considering the default frame can change that total into a cumulative amount and therefore change every calculated share.

Cast to numeric before division if fractional shares are required. Dividing integers can truncate the result, making small contributions appear as zero. The example uses NULLIF to turn a zero denominator into SQL NULL: an undefined share remains undefined. That policy is explicit; substituting zero, rejecting the dataset, or presenting a separate status would be different business choices.

Not every window calculation uses a moving frame. Aggregates and functions such as last_value depend on the frame, while lag and lead navigate partition rows. A narrow ROWS frame does not, by itself, redefine which preceding partition row lag returns. Choose each function and its window according to the question rather than applying one frame formula to all of them.

When it breaks

WHERE filters the window’s input before the window values are calculated. Selecting only the newest reading in the same query leaves lag with no older row to inspect. To display one reading with its existing history, compute the window in a subquery or view, then filter the result outside. Conversely, filtering first is correct when excluded readings should not participate in the comparison.

Joins can also multiply rows before the window runs. A reading joined to several labels can appear several times, inflating totals and changing neighbouring positions. Verify the input’s intended grain before adding window calculations. The syntax does not repair duplicated events or guarantee a single cheap scan; different orders, large partitions, and sorting can dominate execution cost.

Check it quickly

Save this as row-context.sql and run psql -X -v ON_ERROR_STOP=1 -f row-context.sql in a disposable PostgreSQL 18 database. The script creates temporary readings and a view inside a transaction, compares both filtering orders, and rolls back the fixture.

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE readings (id integer, sensor text, value integer);
INSERT INTO readings VALUES (1,'A',10),(2,'A',15),(3,'A',12),(4,'B',0);
CREATE TEMP VIEW contextual AS
SELECT *, lag(value) OVER (PARTITION BY sensor ORDER BY id) AS previous_value,
       sum(value) OVER (PARTITION BY sensor) AS total,
       value::numeric / NULLIF(sum(value) OVER (PARTITION BY sensor),0) AS share
FROM readings;
SELECT id,sensor,value,previous_value,value-previous_value AS delta,total,round(share,3) AS share
FROM contextual ORDER BY id;
SELECT id,previous_value FROM contextual WHERE id=3;
SELECT id,lag(value) OVER (PARTITION BY sensor ORDER BY id) AS previous_value
FROM readings WHERE id=3;
DO $$ BEGIN
  IF (SELECT previous_value FROM contextual WHERE id=3) IS DISTINCT FROM 15 THEN
    RAISE EXCEPTION 'previous row was lost';
  END IF;
  IF (SELECT share FROM contextual WHERE sensor='B') IS NOT NULL THEN
    RAISE EXCEPTION 'zero denominator policy not preserved';
  END IF;
  IF (SELECT count(*) FROM contextual) <> 4 THEN RAISE EXCEPTION 'rows collapsed'; END IF;
  IF (SELECT array_agg(previous_value ORDER BY id) FROM contextual WHERE sensor='A')
     IS DISTINCT FROM ARRAY[NULL,10,15]::integer[] THEN RAISE EXCEPTION 'wrong lag sequence'; END IF;
  IF EXISTS (SELECT FROM contextual WHERE sensor='A' AND total IS DISTINCT FROM 37) THEN
    RAISE EXCEPTION 'wrong partition total';
  END IF;
  IF (SELECT previous_value FROM (
        SELECT lag(value) OVER (PARTITION BY sensor ORDER BY id) AS previous_value
        FROM readings WHERE id=3
      ) AS filtered) IS NOT NULL THEN RAISE EXCEPTION 'WHERE did not narrow the window input'; END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 retained all four readings. Sensor A’s previous values were NULL, 10, 15, its deltas were NULL, 5, minus 3, and its total was 37 on every row. Numeric shares rounded to three decimals were 0.270, 0.405, and 0.324; rounding individual rows need not produce an exactly one-valued displayed sum.

Filtering the completed view to id 3 preserved its predecessor of 15. Filtering the source to id 3 before lag produced NULL instead. Sensor B’s first predecessor and zero-total share were both NULL for different reasons. Assertions check row preservation, the predecessor sequence, totals, and filtering semantics, so a refactor that changes the window population cannot pass merely because it still returns a row.

Sources