#
#
:keyset
:keyset
is the fastest paginator for SQL collections.
:keyset
- It works with:
ActiveRecord::Relation
orSequel::Dataset
sets- Single or multiple ordered columns
- Any combination of order directions
- Unlike the classic OFFSET pagination:
- Its performance is reliably fast from start to end, no matter how big your table is.
- It's completely accurate. Even with insertions or deletions during browsing, it will never repeat or miss records.
- Does not suffer from
RangeError
s
It has limited UI support
It's mostly used for API or infinite scrolling.
For KEYSET pagination with UI support...
Use the :keynav_js paginator.
@pagy, @records = pagy(:keyset, set, **options)
<!-- The only supported helper -->
<%== @pagy.next_tag(text: 'Next page >') %>
There are a few peculiar aspects of the keyset pagination technique that you might not be familiar with. Here is a concise list:
IMPORTANT!
Almost all the constraints below can be avoided by using the :keynav_js paginator when you need a proper UI.
With the standard keyset pagination technique...
- You can only paginate from one page to the next: no jumping to arbitrary pages.
- The
set
must beuniquely ordered
. Add the primary key (usually:id
) as the last order column to be sure. - You should add the most suitable DB index for your ordering strategy, or it will be slow.
With Pagy :keyset
...
You don't know the previous
and the last
page; you only know the first
and next
pages for performance and simplicity.
If you want to paginate backward, like: last
... prev
... prev
, just call reverse_order
on your set, and proceed forward
like:
first
... next
... next
... It does exactly the same: just faster and simpler.
Ensure that your set is uniquely ordered
, and that your tables have the appropriate indexes.
Depending on your order requirements, here is how you set it up:
If you don't need any specific ordering...
order(:id)
is the simplest choice because the id
column is unique and already indexed.
It is fast out of the box without any setup.
If you need a specific ordering...
- In order to make it work...
- Ensure the uniquenes of the last ordered columns OR append your primary keys to your order.
- In order to make it fast...
- Ensure to have an index including the exact same columns and ordering direction of your set.
keyset: {...}
- Set it only to force the
keyset
hash of column/order pairs. (It is set automatically from the set order)
- Set it only to force the
tuple_comparison: true
- Enable the tuple comparison e.g.
(brand, id) > (:brand, :id)
. It works only with the same direction order, hence, it's ignored for mixed order. Check how your DB supports it (yourkeyset
should include onlyNOT NULL
columns).
- Enable the tuple comparison e.g.
pre_serialize: serialize
- Set it to a
lambda
that receives thekeyset_attributes
hash. Modify this hash directly to customize the serialization of specific values (e.g., to preserveTime
object precision). The lambda's return value is ignored.serialize = lambda do |attributes| # Convert it to a string matching the stored value/format in SQLite DB attributes[:created_at] = attributes[:created_at].strftime('%F %T.%6N') end
- Set it to a
See also Common Options
records
- The
Array
of fetched records for the current page.
- The
See also Common Readers
- You pass an
uniquely ordered
set
and pagy pulls the:limit
of records of the first page. - You request the
next
URL, which has thepage
query string param set to thecutoff
of the current page. - At each request, the new
page
is decoded into arguments that are coupled with awhere
filter query, and a:limit
of new records is retrieved. - The collection ends when
pagy.next.nil?
.
The cutoff
of a page
is the value that identifies where the page
has ended, and the next
one begins.
Let's consider an example of a simple set
of 29 records, with an id
column populated by character keys, and its order is:
order(:id)
.
Assuming a LIMIT of 10, the "first page" will just include the first 10 records in the set
: no cutoff
value is known so
far...
│ first page (10) >│ rest (19) >│
beginning of set >[· · · · · · · · · ·]· · · · · · · · · · · · · · · · · · ·]< end of set
At this point, it's the exact same first page pulled with OFFSET pagination, however, we don't want to use OFFSET to get the records after the first 10: that would be slow in a big table, so we need a way to identify the beginning of the next page without COUNTing the records (i.e., the whole point we want to avoid for performance).
So we read the id
of the last one, which is the value X
in our example... and that is the cutoff
value of the first page. It
can be described like:
"the point up to the value X
in the id
column".
Notice that this is not like saying "up to the record X
". It's important to understand that a cutoff
refers just to a cutoff
value of a column (or the values of multiple column, in case of multi-columns keysets).
Indeed, that very record could be deleted right after we read it, and our cutoff-X
will still be the valid truth that we
paginated the set
up to the "X" value, cutting any further record off the page
...
│ first page (10) >│ second page (10) >│ rest (9) >│
beginning of set >[· · · · · · · · · X]· · · · · · · · · ·]· · · · · · · · ·]< end of set
â–²
cutoff-X
For getting the next
page of records (i.e. the "second page") we pull the next
10 records AFTER the cutoff-X
. Again, we
read the id
of the last one, which is Y
: so we have our new cutoff-Y
, which is the end of the current page
, and the next
will go AFTER it...
│ first page (10) >│ second page (10) >│ last page (9) >│
beginning of set >[· · · · · · · · · X]· · · · · · · · · Y]· · · · · · · · ·]< end of set
â–² â–²
cutoff-X cutoff-Y
When we pull the next
page from the cutoff-Y
, we find only the remaining 9 records, which means that it's the "last page",
which naturally ends with the end of the set
, so it doesn't have any cutoff
value to separate it from further records.
Keynotes
- A
cutoff
identifies a "cutoff value", for apage
in theset
. It is not a record, nor a reference to it. - Its value is extracted from the
keyset attributes values
array of the last record of thepage
, converted to JSON, and encoded as a Base64 URL-safe string, for easy use in URLs.- The
:keyset
paginator embeds it in the request URL; the:keynav_js
paginator caches it on the clientsessionStorage
.
- The
- All the
page
s but the last, end with thecutoff
. - All the
page
s but the first, begin AFTER thecutoff
of the previouspage
.
#
1. Records may repeat or be missing from successive pages
The set may not be uniquely ordered
# Neither column is unique
Product.order(:name, :production_date)
Append the primary key to the order
# Add the :id as the last column
Product.order(:name, :production_date, :id)
You may have an encoding problem
The generic to_json
method used to encode the page
may lose some information when decoded
- Check the actual executed DB query and the actual stored value
- Identify the column that has a format that doesn't match with the keyset
- Override the encoding with the
:pre_serialize option
#
2. The order is OK, but the DB is still slow
Most likely the index is not right, or your case needs a custom query
- Ensure that the composite index reflects exactly the columns sequence and order of your keyset
- Research your specific DB features, type of index, and performance for different ordering. Use SQL
EXPLAIN ANALYZE
or similar tool to confirm. - Consider using the same direction order, enabling the
:tuple_comparison
, and changing type of index (different DBs may behave differently). - Consider overriding the
Keyset#compose_predicate
method.