MySQL Indexes and OFFSET Pagination
Published: 09 September 2026
Recently, I came across an unusual way to optimize a MySQL query that uses OFFSET. I then decided to investigate the structure of MySQL InnoDB indexes, as I had previously done for SQLite.
This short article describes that investigation.
First, I created a test table with 100,000 rows, a Primary Index, and one Secondary Index.
CREATE TABLE test_table
(
id INT AUTO_INCREMENT,
field1 INT,
field2 INT,
PRIMARY KEY (id),
INDEX (field2)
) ENGINE=InnoDB;
| id | field1 | field2 |
|---|---|---|
| 1 | 2 | 4 |
| 2 | 4 | 8 |
| 3 | 6 | 12 |
| 4 | 8 | 16 |
| 5 | 10 | 20 |
| ... | ... | ... |
| 100000 | 200000 | 400000 |
Then, I found a MySQL system table that contains information about the indexes in my test table. It shows index IDs, B-tree pages count, and tablespaces.
SELECT i.NAME,
i.INDEX_ID,
i.PAGE_NO,
i.SPACE,
t.NAME
FROM INFORMATION_SCHEMA.INNODB_INDEXES i
JOIN INFORMATION_SCHEMA.INNODB_TABLES t ON t.TABLE_ID = i.TABLE_ID
WHERE t.NAME = 'main/test_table';
There are exactly the two indexes I created for the table.
| NAME | INDEX_ID | PAGE_NO | SPACE | NAME |
|---|---|---|---|---|
| PRIMARY | 2305 | 4 | 455 | main/test_table |
| field2 | 2306 | 5 | 455 | main/test_table |
I found two tools (innodb_space and innodb-java-reader) to parse idb data. Unfortunately, they don't work with my MySQL version 8.0.
So I wrote Python scripts to parse the specific indexes I needed.
The following information about the Secondary Index INDEX (field2).
python bin/scan.py test_table.ibd 2306
{'page': 5, 'level': 1, 'records': 90, 'prev': '-', 'next': '-'}
{'page': 9, 'level': 0, 'records': 560, 'prev': '-', 'next': 10}
...
{'page': 248, 'level': 0, 'records': 1120, 'prev': 247, 'next': 249}
{'page': 249, 'level': 0, 'records': 880, 'prev': 248, 'next': '-'}
python bin/parse.py test_table.ibd 9 2305 2306
{'offset': 126, 'field2': 4, 'id': 1}
{'offset': 140, 'field2': 8, 'id': 2}
..
{'offset': 7938, 'field2': 2236, 'id': 559}
{'offset': 7952, 'field2': 2240, 'id': 560}
There is a graphical version of this index. It has 2 levels, 209 pages, 100,000 data records, and 90 records for searching only. Only leaf pages contain data.
Secondary indexes contain Primary ID. This means that a Secondary Index can serve as a covering index when selecting the id column.
SELECT field2, id
FROM test_table
WHERE field2 = 100;
EXPLAIN
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | test_table | null | ref | field2 | field2 | 5 | const | 1 | 100 | Using index |
Using index in Extra means MySQL uses only this index to retrieve the requested data.
This brings us to the problem with OFFSET.
SELECT field2
FROM test_table
WHERE field2 > 10
ORDER BY field2 ASC LIMIT 10
OFFSET 1000;
EXPLAIN
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | test_table | null | range | field2 | field2 | 5 | null | 50128 | 100 | Using where; Using index |
EXPLAIN ANALYZE
-> Limit/Offset: 10/1000 row(s) (cost=10039.62 rows=10) (actual time=0.286..0.288 rows=10 loops=1)
-> Filter: (test_table.field2 > 10) (cost=10039.62 rows=50128) (actual time=0.026..0.257 rows=1010 loops=1)
-> Covering index range scan on test_table using field2 over (10 < field2) (cost=10039.62 rows=50128) (actual time=0.021..0.195 rows=1010 loops=1)
The query uses Secondary Index only, but because of OFFSET it works very slowly.
First, MySQL finds the record with id: 12 because it's the first record that meets WHERE field2 > 10 condition. It must then read and count all records
until it reaches OFFSET 1000.
| id | field2 | |
|---|---|---|
| 1 | 4 | |
| 2 | 8 | |
| 3 | 12 | found |
| 4 | 16 | skipped |
| 5 | 18 | skipped |
| ... | ... | skipped |
| 1003 | 4012 | returned |
To avoid this unnecessary reading, we can use cursor and point MySQL directly to the target record.
SELECT field2
FROM test_table
WHERE field2 >= 4012
ORDER BY field2 ASC LIMIT 10;
EXPLAIN
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | test_table | null | range | field2 | field2 | 5 | null | 50128 | 100 | Using where; Using index |
EXPLAIN ANALYZE
-> Limit: 10 row(s) (cost=10039.62 rows=10) (actual time=0.027..0.032 rows=10 loops=1)
-> Filter: (test_table.field2 >= 4012) (cost=10039.62 rows=50128) (actual time=0.025..0.030 rows=10 loops=1)
-> Covering index range scan on test_table using field2 over (4012 <= field2) (cost=10039.62 rows=50128) (actual time=0.019..0.023 rows=10 loops=1)
This cursor-based query is much faster than the OFFSET query, even on this small table.
| TYPE | TIME |
|---|---|
| OFFSET | actual time=0.286..0.288 |
| CURSOR | actual time=0.027..0.032 |
Now let's see what the Primary Index contains.
There is information about the Primary Index.
python bin/scan.py test_table.ibd 2305
{'page': 4, 'level': 1, 'records': 208, 'prev': '-', 'next': '-'}
{'page': 6, 'level': 0, 'records': 241, 'prev': '-', 'next': '7'}
...
{'page': 366, 'level': 0, 'records': 483, 'prev': 365, 'next': 367}
{'page': 367, 'level': 0, 'records': 261, 'prev': 366, 'next': '-'}
python bin/parse.py test_table.ibd 6 2305 2306
{'offset': 126, 'id': 1, 'field1': 2, 'field2': 4}
{'offset': 157, 'id': 2, 'field1': 4, 'field2': 8}
...
{'offset': 7535, 'id': 240, 'field1': 480, 'field2': 960}
{'offset': 7566, 'id': 241, 'field1': 482, 'field2': 964}
There is a graphical version of the Primary Index. It has 2 levels, 267 pages, 100,000 data records, and 208 records for searching only. Only leaf pages contain data.
The number of pages much higher because the page size is the same, but each record contains more data.
MySQL calls this a Clustered Index. It contains all the table's data but is ordered by the id column only.
Let's select a column from Cluster Index while using Secondary Index for the search and OFFSET processing.
SELECT field1
FROM test_table
WHERE field2 > 10
ORDER BY field2 ASC LIMIT 10
OFFSET 1000;
EXPLAIN
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | test_table | null | range | field2 | field2 | 5 | null | 50128 | 100 | Using index condition |
EXPLAIN ANALYZE
-> Limit/Offset: 10/1000 row(s) (cost=10081.85 rows=10) (actual time=1.524..1.528 rows=10 loops=1)
-> Index range scan on test_table using field2 over (10 < field2), with index condition: (test_table.field2 > 10) (cost=10081.85 rows=50128) (actual time=0.214..1.487 rows=1010 loops=1)
First, when MySQL parses the query, it marks query as need data from Cluster Index because of field1. Event while skipping MySQL fetches data from
Cluster Index, which make this query much slower and more expensive.
We can prevent MySQL from fetching this unnecessary data during OFFSET processing by creating a subquery that uses only columns available in the Secondary Index and then
joining its result back to the same table.
SELECT field1
FROM test_table
JOIN (SELECT id FROM test_table WHERE field2 > 10 ORDER BY field2 ASC LIMIT 10 OFFSET 1000) AS test_table_join
ON test_table_join.id = test_table.id
ORDER BY field2 ASC;
EXPLAIN
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | PRIMARY | <derived2> | null | ALL | null | null | null | null | 1010 | 100 | Using temporary; Using filesort |
| 1 | PRIMARY | test_table | null | eq_ref | PRIMARY | PRIMARY | 4 | test_table_join.id | 1 | 100 | null |
| 2 | DERIVED | test_table | null | range | field2 | field2 | 5 | null | 50128 | 100 | Using where; Using index |
EXPLAIN ANALYZE
-> Sort: test_table.field2 (actual time=0.307..0.307 rows=10 loops=1)
-> Stream results (cost=10296.74 rows=10) (actual time=0.282..0.296 rows=10 loops=1)
-> Nested loop inner join (cost=10296.74 rows=10) (actual time=0.281..0.294 rows=10 loops=1)
-> Table scan on test_table_join (cost=10040.88..10043.24 rows=10) (actual time=0.272..0.273 rows=10 loops=1)
-> Materialize (cost=10040.62..10040.62 rows=10) (actual time=0.271..0.271 rows=10 loops=1)
-> Limit/Offset: 10/1000 row(s) (cost=10039.62 rows=10) (actual time=0.260..0.262 rows=10 loops=1)
-> Filter: (test_table.field2 > 10) (cost=10039.62 rows=50128) (actual time=0.022..0.230 rows=1010 loops=1)
-> Covering index range scan on test_table using field2 over (10 < field2) (cost=10039.62 rows=50128) (actual time=0.017..0.157 rows=1010 loops=1)
-> Single-row index lookup on test_table using PRIMARY (id=test_table_join.id) (cost=0.25 rows=1) (actual time=0.001..0.001 rows=1 loops=10)
With this approach, MySQL does not fetch data from the clustered index while skipping rows for OFFSET.
| TYPE | TIME |
|---|---|
| OFFSET | actual time=1.524..1.528 |
| OFFSET | actual time=0.307..0.307 |
It works much faster than previous OFFSET query. However, when possible, a cursor-based query is preferable.

