Time series data in PostgreSQL raises a common question: how do you compute the difference between the current row and the one before it? The queries below sketch out the general techniques — this is an introduction to what windowing and analytics functions make possible, not a full tutorial.

Getting data in place

The examples start with a small sample set:

1

2

3

4

5

6

7

8

9

10

11

12

cypex=# CREATE TABLE t_oil

        (

          region       text,

          country      text,

          year         int,

          production   int,

          consumption  int

        );

cypex=# COPY t_oil

        FROM PROGRAM 'curl /secret/oil_ext.txt';

COPY 644

A superuser can pull the data in with COPY ... FROM PROGRAM; everyone else needs another route for loading the text file.

The lag function and the ordering problem

Differences between rows go through lag, but using it forces a prior question: what counts as "the previous row"? That only means something once an order exists, which is what the OVER clause supplies.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

cypex=# SELECT country, year, production,

               lag(production, 1) OVER (ORDER BY year)

        FROM   t_oil

        WHERE country = 'USA'

        LIMIT 10;

country | year | production |  lag  

---------+------+------------+-------

USA     | 1965 |       9014 |      

USA     | 1966 |       9579 |  9014

USA     | 1967 |      10219 |  9579

USA     | 1968 |      10600 | 10219

USA     | 1969 |      10828 | 10600

USA     | 1970 |      11297 | 10828

USA     | 1971 |      11156 | 11297

USA     | 1972 |      11185 | 11156

USA     | 1973 |      10946 | 11185

USA     | 1974 |      10461 | 10946

(10 rows)

Ordering by year is what guarantees the preceding year actually sits in the preceding row.

Computing the delta

Once the right row is reachable, the calculation itself is straightforward:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

cypex=# SELECT country, year,

               production - lag(production, 1) OVER (ORDER BY year) AS diff

        FROM   t_oil

        WHERE  country = 'USA'

        LIMIT 10;

country | year | diff

---------+------+------

USA     | 1965 |    

USA     | 1966 |  565

USA     | 1967 |  640

USA     | 1968 |  381

USA     | 1969 |  228

USA     | 1970 |  469

USA     | 1971 | -141

USA     | 1972 |   29

USA     | 1973 | -239

USA     | 1974 | -485

(10 rows)

Note the NULL in the first row: no previous row exists, so no difference is known.

Measuring against the first row

A related need is the gap between the current row and the first row of the set. PostgreSQL and ANSI SQL provide first_value, which returns the first row according to the ordering you specify:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

cypex=# SELECT t_oil.country,

               t_oil.year,

               t_oil.production,

               t_oil.production - first_value(t_oil.production) OVER (ORDER BY t_oil.year) AS diff_first

        FROM   t_oil

        WHERE  t_oil.country = 'USA'::text

        LIMIT 10;

country | year | production | diff_first

---------+------+------------+------------

USA     | 1965 |       9014 |          0

USA     | 1966 |       9579 |        565

USA     | 1967 |      10219 |       1205

USA     | 1968 |      10600 |       1586

USA     | 1969 |      10828 |       1814

USA     | 1970 |      11297 |       2283

USA     | 1971 |      11156 |       2142

USA     | 1972 |      11185 |       2171

USA     | 1973 |      10946 |       1932

USA     | 1974 |      10461 |       1447

(10 rows)

Every value here is expressed relative to that first row.

cypex visualization

Splitting the data with PARTITION BY

Add a second country and the simple approach breaks down. Ordering by year alone can land on an unrelated row; ordering by both columns still doesn't stop a match against a row from a different country. The PARTITION BY clause is the fix — PostgreSQL divides the data into groups and computes the difference inside each group.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

cypex=# SELECT country, year, production,

               lag(production) OVER (PARTITION BY country ORDER BY year) AS diff

        FROM   t_oil

        WHERE country IN ('Canada', 'Mexico')

               AND year < 1970;

country | year | production | diff

---------+------+------------+------

Canada  | 1965 |        920 |    

Canada  | 1966 |       1012 |  920

Canada  | 1967 |       1106 | 1012

Canada  | 1968 |       1194 | 1106

Canada  | 1969 |       1306 | 1194

Mexico  | 1965 |        362 |    

Mexico  | 1966 |        370 |  362

Mexico  | 1967 |        411 |  370

Mexico  | 1968 |        439 |  411

Mexico  | 1969 |        461 |  439

(10 rows)

Because each partition has its own first row, each one produces a NULL — evidence that the groups are handled in isolation.

Moving averages via sliding windows

Moving averages come up often enough alongside differences to deserve their own treatment. Application-level implementations are the wrong place for this work on performance grounds, so it belongs in SQL:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

cypex=# SELECT country, year, production,

       avg(production) OVER (ORDER BY year

   ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS mov

        FROM t_oil

        WHERE country IN ('Saudi Arabien')

              AND year BETWEEN 1975 AND 1990;

    country    | year | production |         mov          

---------------+------+------------+-----------------------

Saudi Arabien | 1975 |       7216 | 8465.6666666666666667

Saudi Arabien | 1976 |       8762 | 8487.7500000000000000

Saudi Arabien | 1977 |       9419 | 8758.4000000000000000

Saudi Arabien | 1978 |       8554 | 9369.2000000000000000

Saudi Arabien | 1979 |       9841 | 9668.0000000000000000

Saudi Arabien | 1980 |      10270 | 9176.4000000000000000

Saudi Arabien | 1981 |      10256 | 8455.8000000000000000

Saudi Arabien | 1982 |       6961 | 7394.4000000000000000

Saudi Arabien | 1983 |       4951 | 6060.6000000000000000

Saudi Arabien | 1984 |       4534 | 5051.0000000000000000

Saudi Arabien | 1985 |       3601 | 4578.6000000000000000

Saudi Arabien | 1986 |       5208 | 4732.4000000000000000

Saudi Arabien | 1987 |       4599 | 4952.6000000000000000

Saudi Arabien | 1988 |       5720 | 5653.4000000000000000

Saudi Arabien | 1989 |       5635 | 5764.7500000000000000

Saudi Arabien | 1990 |       7105 | 6153.3333333333333333

(16 rows)

The construct here is ROWS BETWEEN ... PRECEDING AND ... FOLLOWING, which fixes how many rows feed the aggregate — avg in this case. A moving average flattens the curve, producing a smoother line:

Cypex dashboard

In the visualization the moving average is visibly smoother than the underlying rate. Saudi Arabia acts as a swing producer and its output can shift sharply with the political situation, so smoothing this particular series is a sensible use of the technique.

Row comparison and duplicate detection

PostgreSQL also supports composite data types, meaning a row can be treated as a single element made of several components. Instead of listing fields in the SELECT clause, you can treat the whole table as one field:

1

2

3

4

5

6

7

8

9

cypex=# SELECT t_oil, lag(t_oil) OVER (ORDER BY year)

        FROM   t_oil

        WHERE  country = 'Mexico'  

               AND year IN (1980, 1981);

                  t_oil                  |                   lag                  

-----------------------------------------+-----------------------------------------

('North America',Mexico,1980,2129,1048) |

('North America',Mexico,1981,2553,1172) | ('North America',Mexico,1980,2129,1048)

(2 rows)

All columns of a row are then packed into a single field, and lag still works on it as usual.

The useful part is that = compares two rows directly — handy for spotting a row that was imported twice, or simply confirming that two consecutive rows match:

1

2

3

4

5

6

7

8

9

cypex=# SELECT t_oil = lag(t_oil) OVER (ORDER BY year)

        FROM   t_oil

        WHERE  country = 'Mexico'

               AND year IN (1980, 1981);

?column?

----------

f

(2 rows)

PostgreSQL walks field by field and returns true only when all of them are equal. In effect, lag can be turned to the purpose of detecting duplicate rows.