Cursor-based and offset-based pagination helpers for Ecto queries.
Paginate builds paginated Ecto queries and shapes the results into a
%{data: ..., meta: ...} map. It supports two pagination strategies:
:cursor- keyset pagination. Pages are fetched relative to a cursor value (typically the primary key or another sortable column). Efficient for large datasets since it avoidsOFFSETscans, but does not support random page access.:offset- classic page/offset pagination. Supports jumping to an arbitrary page, at the cost of increasingly expensive queries for deep pages.
Example
query = Paginate.query(Post, :cursor, page_size: 25, cursor: 100)
posts = Repo.all(query)
Paginate.build_page_result(posts, :cursor, %{
cursor_field: :id,
page_size: 25
})
#=> %{data: [...], meta: %{next_cursor: 125, page_size: 25}}Defaults
:order_by-[asc: :id]:page-1:page_size-500
Summary
Functions
Convenience wrapper for build_page_result(data, :cursor, page_opts).
Convenience wrapper for build_page_result(data, :offset, page_opts).
Shapes query results into a page result map with pagination metadata.
Builds a paginated Ecto.Query from queryable using the given
pagination strategy.
Functions
Convenience wrapper for build_page_result(data, :cursor, page_opts).
See build_page_result/3 for details.
Convenience wrapper for build_page_result(data, :offset, page_opts).
See build_page_result/3 for details.
Shapes query results into a page result map with pagination metadata.
Returns a map with:
:data- the records for the current page:meta- pagination metadata, depending on the pagination type
Cursor pagination
Expects data to be the result of running a query built with
query(queryable, :cursor, ...), which fetches one extra record to detect
whether a next page exists. page_opts must be a map with:
:cursor_field- the column used as the cursor (the leading:order_bycolumn given toquery/4):page_size- the page size given toquery/4
The extra record is dropped from :data. The meta contains:
:next_cursor- the cursor value to fetch the next page, ornilwhen there are no more pages:page_size- the page size
Offset pagination
page_opts is a keyword list accepting :page and :page_size (falling
back to the module defaults). The meta contains:
:page- the current page number:page_size- the page size
Total count
For either pagination type, a total count can be included in the meta by
passing these options in page_opts:
:get_total- when truthy, executes a count aggregate and adds:total(the total number of records matching:query) to the meta:repo- theEcto.Repoused to run the count (required with:get_total):query- the queryable to count (required with:get_total). Pass the base queryable without limit/offset, otherwise only the current page is counted.
Examples
query = Paginate.query(Post, :cursor, page_size: 25)
posts = Repo.all(query)
Paginate.build_page_result(posts, :cursor, %{
cursor_field: :id,
page_size: 25
})
#=> %{data: [...], meta: %{next_cursor: 125, page_size: 25}}
query = Paginate.query(Post, :offset, page: 2, page_size: 50)
posts = Repo.all(query)
Paginate.build_page_result(posts, :offset,
page: 2,
page_size: 50,
get_total: true,
repo: Repo,
query: Post
)
#=> %{data: [...], meta: %{page: 2, page_size: 50, total: 1234}}
Builds a paginated Ecto.Query from queryable using the given
pagination strategy.
Pagination types
:cursor- keyset pagination. Filters rows relative to the:cursoroption using the leading:order_bycolumn, and fetchespage_size + 1rows sobuild_page_result/3can detect whether a next page exists.:offset- offset pagination. AppliesOFFSET (page - 1) * page_sizeandLIMIT page_size.
Options
:order_by- theorder_byexpression for the query. For:cursorpagination the leading entry must be an atom column, optionally tagged with:asc/:desc(e.g.[desc: :inserted_at]), and determines the cursor comparison field and direction. Defaults to[asc: :id].:page_size- maximum number of records per page. Defaults to500.:cursor(:cursoronly) - the cursor value from the previous page (seenext_cursorin the result meta). Whennil, the first page is returned. Rows are filtered with>for ascending order and<for descending order.:page(:offsetonly) - the 1-based page number. Defaults to1.
defaults can be passed to override the built-in default option values.
Examples
# First page, cursor pagination
Paginate.query(Post, :cursor, page_size: 25)
# Next page, using the cursor from the previous result
Paginate.query(Post, :cursor, page_size: 25, cursor: 100)
# Descending cursor pagination on another column
Paginate.query(Post, :cursor, order_by: [desc: :inserted_at], cursor: ~U[2026-01-01 00:00:00Z])
# Offset pagination, page 3
Paginate.query(Post, :offset, page: 3, page_size: 50)