Logo
Englika

Fast pagination in SQL

Fast pagination in SQL

In this article, we will find out why LIMIT and OFFSET cannot be used to implement pagination and how to replace them.

During writing this article, I used PostgreSQL, so some SQL queries will work only for it (for example, a query with generate_series to fill a test table with data). However, all the pagination methods described below should work in any database.

Let's assume, that we have a chat with 1 million messages:

CREATE TABLE messages (id serial primary key, body text);
INSERT INTO messages (body) SELECT md5(random()::text) FROM generate_series(1,1000000);

Now we need to implement pagination so that the user loads messages in batches (100 messages in a batch) as the chat scrolls. This can be implemented in several ways.

Using LIMIT and OFFSET

The most popular way to implement pagination is to use LIMIT and OFFSET.

Let's try to get first 100 messages:

SELECT * FROM messages ORDER BY id ASC LIMIT 100;
 Limit  (cost=0.42..3.86 rows=100 width=37) (actual time=0.059..2.336 rows=100 loops=1)
   ->  Index Scan using messages_pkey on messages  (cost=0.42..34317.43 rows=1000000 width=37) (actual time=0.046..0.794 rows=100 loops=1)
 Planning Time: 0.072 ms
 Execution Time: 3.107 ms

As you can see, the query was executed in 3 ms. In the query plan, you can see that only 100 rows were scanned (Index Scan -> actual time -> rows=100). To execute the query, the B-tree index was used, which PostgreSQL creates automatically for all primary key columns.

Let's get the next 100 messages:

SELECT * FROM messages ORDER BY id ASC LIMIT 100 OFFSET 100;
 Limit  (cost=3.86..7.29 rows=100 width=37) (actual time=1.578..4.078 rows=100 loops=1)
   ->  Index Scan using messages_pkey on messages  (cost=0.42..34317.43 rows=1000000 width=37) (actual time=0.043..1.677 rows=200 loops=1)
 Planning Time: 0.051 ms
 Execution Time: 5.032 ms

The query was executed longer (5 ms). If you look at the query plan, you will notice that 200 rows have already been scanned.

Let's assume that the user scrolleed through 100000 messages (or automatically scrolled to the 100000th message when clicking on the link, which is quite realisctic) and loads the next 100 messages:

SELECT * FROM messages ORDER BY id ASC LIMIT 100 OFFSET 100000;
 Limit  (cost=3432.12..3435.56 rows=100 width=37) (actual time=1377.502..1379.512 rows=100 loops=1)
   ->  Index Scan using messages_pkey on messages  (cost=0.42..34317.43 rows=1000000 width=37) (actual time=0.046..712.494 rows=100100 loops=1)
 Planning Time: 0.127 ms
 Execution Time: 1380.311 ms

The query was executed in 1.3 sec. The number of scanned rows is 100100.

The problem with this approach is that the rows are always scanned from the beginning. At the beginning, we find the first row, scan and skip the OFFSET rows, and then scan and return the LIMIT rows. The larger the OFFSET, the more rows we need to scan, the longer query takes. As a result, each subsequent page will take longer and longer to load, because OFFSET + LIMIT rows will always be scanned to execute the query.

In practice, more complex queries are used, for which even with a small OFFSET, the execution speed becomes unacceptable. As a solution, you can set a limit on the maximum OFFSET (for example, 1000), but in this case the user will not be able to view more than OFFSET + LIMIT records.

Let's look at another problem. Let's get the last 5 messages:

SELECT * FROM messages ORDER BY id DESC LIMIT 5;
   id    |               body               
---------+----------------------------------
 1000000 | b35ebb4db40731d03b8b025cfe7adc19
  999999 | acfd591d18f38692f884d3afc6d5c979
  999998 | b22069d7485b2b4f8bed51a724718fee
  999997 | 17d76b8d7030485413c7458d15d0339d
  999996 | 3ff735c087295f3dc9b4c7323cda9457

Let's assume that the user started scrolling the list, and at that moment someone wrote a new message:

INSERT INTO messages (body) VALUES (md5(random()::text));

and after that, the user loads the next 5 messages:

SELECT * FROM messages ORDER BY id DESC LIMIT 5 OFFSET 5;
   id   |               body               
--------+----------------------------------
 999996 | 3ff735c087295f3dc9b4c7323cda9457
 999995 | e41cac0085cacf36e3fa1a3ad845a37d
 999994 | 068cbaadc0ab60f99e2284739c1061c8
 999993 | c3c53e27b7765ab7aa97268b3ca419e5
 999992 | d4d361538e8d1fe89316209c1d5185ea

As a result, the user received the same message twice (id=999996).

Using LIMIT and WHERE

The problem with the previous approach is that scanning rows always starts from the beginning. To solve this issue, we need to start scanning immediately from the specific row.

Let's get the first 5 messages:

SELECT * FROM messages ORDER BY id ASC LIMIT 5;
 id |               body               
----+----------------------------------
  1 | 7ca7a3f16457f5ec49ebf0cb6fabb52f
  2 | 30d67067325190ee3b922793efd7641e
  3 | 3e7b041658a1e5e29021c23498e9c870
  4 | 985ab7304175eb25a8b577df96baaa1e
  5 | a86c097cf8f341e859077f37cc65583b

When the next page is received by the user, not the page number will be sent, but the ID of the last row. In our example, the last row has id=5.

Let's get the next 5 messages:

SELECT * FROM messages WHERE id > 5 ORDER BY id ASC LIMIT 5;
 id |               body               
----+----------------------------------
  6 | fba0989cb6b59f81f8b0fc9844710005
  7 | 8eacebda19093d998d4a386c0f5e37d7
  8 | 456c1d71717ec23e47cf56a490aa77a6
  9 | 5c521635cd0ac8610178e4c5626b49d9
 10 | d7c4f563b55444499a16c5e675e5af66

The pagination works. Let's get 100 messages that follow after the 100000th.

SELECT * FROM messages WHERE id > 100000 ORDER BY id ASC LIMIT 100;
 Limit  (cost=0.42..4.11 rows=100 width=37) (actual time=0.090..2.790 rows=100 loops=1)
   ->  Index Scan using messages_pkey on messages  (cost=0.42..33203.93 rows=901743 width=37) (actual time=0.076..1.085 rows=100 loops=1)
         Index Cond: (id > 100000)
 Planning Time: 0.076 ms
 Execution Time: 3.723 ms

The query was executed in 3 ms (it takes about the same time to get the first page). When using OFFSET, the same query was executed in 1.3 sec. In the query plan, you can see that only 100 rows were scanned (Index Scan -> actual time -> rows=100), instead of 100100 rows when using OFFSET.

Since the column with id uses b-tree index, the first step is a quick search for the 100000th row, and then 100 next rows are scanned (this is due to the fact that in the b-tree index all the leaves of the tree [they contain references to rows] are connected by a bidirectional list).

Let's look at the problem that we had when adding a new row. Let's get the last 5 messages:

SELECT * FROM messages ORDER BY id DESC LIMIT 5;
   id    |               body               
---------+----------------------------------
 1000001 | 1fcaa624f42bc1851516b6f8f4e790cd
 1000000 | b35ebb4db40731d03b8b025cfe7adc19
  999999 | acfd591d18f38692f884d3afc6d5c979
  999998 | b22069d7485b2b4f8bed51a724718fee
  999997 | 17d76b8d7030485413c7458d15d0339d

add a new message:

INSERT INTO messages (body) VALUES (md5(random()::text));

and load the next page:

SELECT * FROM messages WHERE id < 999997 ORDER BY id DESC LIMIT 5;
   id   |               body               
--------+----------------------------------
 999996 | 3ff735c087295f3dc9b4c7323cda9457
 999995 | e41cac0085cacf36e3fa1a3ad845a37d
 999994 | 068cbaadc0ab60f99e2284739c1061c8
 999993 | c3c53e27b7765ab7aa97268b3ca419e5
 999992 | d4d361538e8d1fe89316209c1d5185ea

This time we received the row with id=999997 only 1 time.

Sorting by non-unique column

Let's create a table with products and fill it with data:

CREATE TABLE products (id serial primary key, name text, price int);

INSERT INTO products (name, price)
SELECT md5(random()::text), floor(random() * 1000) + 1
FROM generate_series(1,1000000);

Let's assume that we show the user products in ascending order by price. The price of products ranges from 1 to 1000.

At the beginning, let's look at the first 5 products:

SELECT * FROM products ORDER BY price ASC LIMIT 5;
  id  |               name               | price 
------+----------------------------------+-------
 3950 | e2af7265e17668e3b325608f3e2a5577 |     1
   70 | 3e2cd54660711330325abba843964780 |     1
 1561 | cf7deecf27ccfa0bcfff11862f86ac0f |     1
 3049 | d3158b4405d05aa8df6d6982eb1ee58e |     1
 4046 | 4d0fa7bbfbba435be18922e872e811a4 |     1

But how to load the next 5 products? If you do as follow:

SELECT * FROM products WHERE price > 1 ORDER BY price ASC LIMIT 5;
  id  |               name               | price 
------+----------------------------------+-------
 2093 | c11ba2e7237336d3e27021ffc4afcbfd |     2
 3556 | 4ccd277e9d3fe1493dfa3d451cbe1791 |     2
  770 | f5f6661726ae76c377f818fc1a2ea643 |     2
  898 | e9b5f5e0eddb4e29c713b30ce8ca001c |     2
 4243 | 60fd9a3c1fed481fad896ee3e0858c53 |     2

then in this case we will skip 984 other rows, which has price = 1, because the total number of these rows is 989.

SELECT COUNT(*) FROM products WHERE price = 1; -- 989

To solve this problem, it is also necessary to sort by some column with unique values that are supported by a b-tree index (or another index that supports more/less operators). For example, by the id column.

Let's create a new index for 2 columns: price and id. This index is needed to quickly execute queries in which these 2 columns are used in where.

CREATE INDEX products_price_id_idx ON products (price, id);

Let's get the first page:

SELECT * FROM products ORDER BY price ASC, id ASC LIMIT 5;
  id  |               name               | price 
------+----------------------------------+-------
   70 | 3e2cd54660711330325abba843964780 |     1
 1561 | cf7deecf27ccfa0bcfff11862f86ac0f |     1
 3049 | d3158b4405d05aa8df6d6982eb1ee58e |     1
 3950 | e2af7265e17668e3b325608f3e2a5577 |     1
 4046 | 4d0fa7bbfbba435be18922e872e811a4 |     1

The last row has price = 1 and id = 4096.

Let's get the next page:

-- Query for PostgreSQL
SELECT * FROM products
WHERE (price, id) > (1, 4046)
ORDER BY price ASC, id ASC
LIMIT 5;

-- Query for any database
SELECT * FROM products
WHERE price > 1 OR (price = 1 AND id > 4046)
ORDER BY price ASC, id ASC
LIMIT 5;
  id   |               name               | price 
-------+----------------------------------+-------
  5820 | 967db515852d6463f845749194f1a6ae |     1
  8388 | 2b11088f71918aae6920b6665d3c3544 |     1
  8821 | 489e2bfd77ba1f0fd5c37a50dc848e72 |     1
  9679 | fbc4f4e0ab73ec8bb05418a44f2a5917 |     1
 12252 | 89ab1cf6496e37cb4efa575e5d52d6aa |     1

The first 10 rows look like this:

SELECT * FROM products ORDER BY price ASC, id ASC LIMIT 10;
  id   |               name               | price 
-------+----------------------------------+-------
    70 | 3e2cd54660711330325abba843964780 |     1
  1561 | cf7deecf27ccfa0bcfff11862f86ac0f |     1
  3049 | d3158b4405d05aa8df6d6982eb1ee58e |     1
  3950 | e2af7265e17668e3b325608f3e2a5577 |     1
  4046 | 4d0fa7bbfbba435be18922e872e811a4 |     1
  5820 | 967db515852d6463f845749194f1a6ae |     1
  8388 | 2b11088f71918aae6920b6665d3c3544 |     1
  8821 | 489e2bfd77ba1f0fd5c37a50dc848e72 |     1
  9679 | fbc4f4e0ab73ec8bb05418a44f2a5917 |     1
 12252 | 89ab1cf6496e37cb4efa575e5d52d6aa |     1

Pagination works correctly.

Let's look at the plan and speed of query execution to get 5 next rows after the 100000th. At the beginning we will find the 100000th row:

SELECT * FROM products ORDER BY price ASC, id ASC LIMIT 1 OFFSET 100000;
  id   |               name               | price 
-------+----------------------------------+-------
 69113 | 17f845ab71b7cccc46333c2a5c33122d |   101

Let's look at the plan of the next query:

SELECT * FROM products
WHERE (price, id) > (101, 69113)
ORDER BY price ASC, id ASC
LIMIT 5;
 Limit  (cost=0.42..0.78 rows=5 width=41) (actual time=0.060..0.163 rows=5 loops=1)
   ->  Index Scan using products_price_id_idx on products  (cost=0.42..62991.89 rows=899098 width=41) (actual time=0.046..0.083 rows=5 loops=1)
         Index Cond: (ROW(price, id) > ROW(101, 69113))
 Planning Time: 0.070 ms
 Execution Time: 0.249 ms

The query was executed in 0.2 ms. 5 rows were scanned.

Using LIMIT + WHERE for pagination allows you to quickly load any page with results in the same time, because only LIMIT rows are always scanned. On the contrary, the selection of LIMIT + OFFSET rows with each next page is slower, because the larger the OFFSET, the more rows need to be scanned.

Cheat sheet

/* Pagination with sorting by unique column */

CREATE TABLE messages (id serial primary key, body text);

-- First 5 messages
SELECT * FROM messages ORDER BY id ASC LIMIT 5;

-- Next 5 messages
-- (the last message received has id = 5)
SELECT * FROM messages WHERE id > 5 ORDER BY id ASC LIMIT 5;
/* Pagination with sorting by non-unique column */

CREATE TABLE products (id serial primary key, name text, price int);
CREATE INDEX products_price_id_idx ON products (price, id);

-- First 5 products
SELECT * FROM products ORDER BY price ASC, id ASC LIMIT 5;

-- Next 5 products (for PostgreSQL)
-- (the last message received has price = 1, id = 4046)
SELECT * FROM products
WHERE (price, id) > (1, 4046)
ORDER BY price ASC, id ASC
LIMIT 5;

-- Next 5 products (for any database)
-- (the last message received has price = 1, id = 4046)
SELECT * FROM products
WHERE price > 1 OR (price = 1 AND id > 4046)
ORDER BY price ASC, id ASC
LIMIT 5; Limit  (cost=0.42..4.11 rows=100 width=37) (actual time=0.090..2.790 rows=100 loops=1)
   ->  Index Scan using messages_pkey on messages  (cost=0.42..33203.93 rows=901743 width=37) (actual time=0.076..1.085 rows=100 loops=1)
         Index Cond: (id > 100000)
 Planning Time: 0.076 ms
 Execution Time: 3.723 msINSERT INTO messages (body) VALUES (md5(random()::text));SELECT * FROM messages WHERE id < 999997 ORDER BY id DESC LIMIT 5;SELECT * FROM products ORDER BY price ASC LIMIT 5;SELECT * FROM products WHERE price > 1 ORDER BY price ASC LIMIT 5;

Related posts

How to get N rows per group in SQL

Let's assume that you are developing the home page in an online store. This page should display product categories with 10 products in each. How do you make a query to the database? What indexes do you need to create to speed up query execution?

How to get N rows per group in SQL