In the last lesson, we explored offset-based pagination and uncovered its significant performance and consistency issues, especially when dealing with large, dynamic datasets. The problems of slow queries on deep pages and the "shifting data" phenomenon make it unsuitable for many modern applications. This sets the stage for a more robust solution.
Today, we'll focus on that solution. You will learn to implement cursor-based pagination and explain its advantages for large datasets. This technique is a favorite in backend engineering interviews because it demonstrates a deep understanding of database performance, scalability, and stateless API design. By mastering this, you'll be well-equipped to design APIs that can handle massive amounts of data efficiently.
From Pages to Pointers: The Core Idea
Cursor-based pagination (also known as keyset pagination) abandons the concept of "page numbers" entirely. Instead of telling the server "give me page 5," the client says, "give me the next 10 items after the last one I saw."
This "last one I saw" is represented by a cursor, which is an opaque, stable pointer to a specific item in the sorted dataset. The client receives a cursor with each page of data and sends it back to the server to request the next page.
The diagram below contrasts this with the offset-based approach we discussed previously.

The flow is simple and powerful:
- The client makes an initial request without a cursor.
- The server returns the first page of results and a
next_cursorpointing to the last item in that page. - For the next page, the client sends a request including that
next_cursor. - The server uses the cursor to find its place in the dataset and returns the subsequent items, along with a new
next_cursor.
This process is visualized clearly in the following illustration.

The Advantages: Performance and Consistency
So, why is this approach so much better? It directly solves the two main problems of offset pagination. The PlanetScale video below gives an excellent overview of the concepts and benefits.
Pagination in MySQL - offset vs. cursor
This video provides a clear, database-centric explanation of cursor-based pagination, contrasting it with the offset method and highlighting its key strengths.
Please watch the following segments: Start with the introduction to cursor-based pagination. Pay close attention to how the cursor is created from the last seen record and used in a WHERE clause. Watch the explanation of how cursor pagination is more resilient to shifting records. This directly addresses the data inconsistency problem we discussed in the last lesson.
Let's break down the advantages you just saw.
-
High Performance at Scale:
Instead of usingOFFSET, which forces the database to scan and discard millions of rows, cursor pagination uses aWHEREclause. A query looks like this:SELECT * FROM users WHERE created_at < 'cursor_value' ORDER BY created_at DESC LIMIT 10;
If thecreated_atcolumn is indexed, the database can use the index to instantly seek to the correct position and start reading the 10 rows it needs. This means fetching "page" 1,000,000 is just as fast as fetching page 1. There is no performance degradation as you go deeper into the results. -
Stable and Consistent Data:
The "shifting data" problem disappears. Because the query is anchored to a stable value from a specific record (the cursor), it doesn't matter if new records are added or deleted before that point. The starting point for the next page remains the same relative to the data the user has already seen, preventing missed or repeated items.
The main tradeoff is that you lose the ability to jump to an arbitrary page number (e.g., "go to page 50"). For use cases like infinite scrolling or "Load More" buttons, this is a perfectly acceptable limitation.
Implementing Cursor-Based Pagination in Go
Now for the practical part. Given your background in Go and SQL, we'll walk through a complete implementation. While the concept is simple, a robust implementation has several important details to get right.
The Prerequisite: A Deterministic Order
For pagination to be reliable, the order of results must be the same every time you run the query. As you know from your DBMS course, SQL tables have no inherent order. You must use an ORDER BY clause. For cursor pagination, the column(s) you order by must be unique and sequential.
- A
created_attimestamp is a common choice, but what if two records have the exact same timestamp? You could miss records. - The solution is to add a unique secondary sort column, like the primary key (
id). This is called keyset pagination. TheORDER BYclause becomesORDER BY created_at DESC, id DESC.
The article "Cursor-Based Pagination in Go" provides an excellent conceptual breakdown of the logic before diving into code.
This article explains the core logic behind building the SQL queries for both forward and backward pagination, which is a common interview question.
Read the sections The Need for a Unique Column and The Logic. Pay close attention to: Why a non-unique column can cause you to skip records. The logic of using < for "next page" and > for "previous page" (when sorting DESC). The trick of reversing the sort order (ASC) to fetch the previous page and then reversing the result set in your application code.
A Complete Go Implementation
The article "Implementing cursor pagination in Golang" provides a fantastic, production-ready example using Go Fiber and GORM. It correctly implements keyset pagination with a composite key (created_at, id) and handles both forward and backward navigation.
Implementing cursor pagination in Golang: Go Fiber ...
This article is a step-by-step guide to building a cursor-paginated API from scratch in Go. It's a perfect practical exercise that ties everything together.
We'll focus on the core logic. Please read the following parts carefully: In the bookscontroller.go section, study the GetPaginatedBooks function. Specifically, look at how the whereStr is constructed: <span data-type="resource_reading_textrange" data-resource-subitem-id="89ef49a4" data-range-start="whereStr := fmt.Sprintf" data-range-end="decodedCursor["id"])">the WHERE clause</span>. This is the SQL implementation of keyset pagination for a composite key. Also in GetPaginatedBooks, notice the line query.Order("created_at " + sortOrder).Limit(int(limit) + 1). This is the limit + 1 trick. By fetching one extra item, the code can easily check if another page exists. Next, examine the helper functions in the pagination.go section. Look at how <span data-type="resource_reading_textrange" data-resource-subitem-id="bf9ba028" data-range-start="func encodeCursor(cursor Cursor)" data-range-end="return encodedCursor">encodeCursor</span> and <span data-type="resource_reading_textrange" data-resource-subitem-id="8d0874b4" data-range-start="func DecodeCursor(cursor string)" data-range-end="return cur, nil">DecodeCursor</span> use JSON and Base64 to create an opaque token. This prevents the client from knowing or manipulating the cursor's internal structure. Finally, review the API responses in the final section, noting how the <span data-type="resource_reading_textrange" data-resource-subitem-id="b97364ec" data-range-start="http://localhost:3000/books?per_page=2&sort_order=asc" data-range-end="prev_cursor": """>first response</span> returns a next_cursor and how that cursor is used in the <span data-type="resource_reading_textrange" data-resource-subitem-id="9fb98213" data-range-start="http://localhost:3000/books?per_page=2&sort_order=asc&cursor=" data-range-end="eyJjcmVhdGVkX2F0IjoiMjAyMi0xMi0xN1QxODoxNDo0Ny45NTcrMDk6MDAiLCJpZCI6IjYyZDg0M2Q3LTg4OWMtNDZjOC05OTU2LTEyNTI3YjJiMWEwZCIsInBvaW50c19uZXh0IjpmYWxzZX0=">next request</span>.
The concepts from the Go article are universal. The video below, though using Python, provides an excellent visual walkthrough of the limit + 1 trick and the process of making the cursor opaque via encoding.
API Cursor Pagination (Infinite Scroll) - Backend Engineering
This video by Caleb Curry visually demonstrates two key implementation details: encoding the cursor and using the limit + 1 technique.
Watch these two segments to reinforce what you saw in the Go article: Making the cursor opaque: This covers the motivation and implementation of encoding the cursor into a Base64 string. The limit + 1 trick: This shows how fetching one extra item determines if a next_cursor should be generated, and how to trim the extra item from the final response.
By combining these techniques, you get a robust and scalable pagination system:
- API Request:
GET /books?limit=10&cursor=eyJjcm...ZXh0Ijp0cnVlfQ== - Backend Logic:
- Base64-decode and JSON-unmarshal the cursor string to get the
created_atandidof the last item. - Query the database:
SELECT * FROM books WHERE (created_at, id) < ('2022-12-17T17:14:47Z', '6e4d9295-6b26...') ORDER BY created_at DESC, id DESC LIMIT 11; -- (limit + 1) - Check if 11 records were returned. If so, a next page exists.
- The
next_cursoris generated from the 10th record. - Return only the first 10 records in the response.
- Base64-decode and JSON-unmarshal the cursor string to get the
- API Response:
{ "data": [ ... 10 book objects ... ], "pagination": { "next_cursor": "eyJjcmVhdGVkX2F0Ijo...etc..." } }
Conclusion
In this lesson, you've moved beyond basic pagination to a scalable, production-grade solution. You've seen how cursor-based pagination solves the critical performance and consistency problems inherent in the offset method, a key piece of knowledge for any backend developer.
Key Takeaways:
- Core Mechanism: Cursor-based pagination uses a
WHEREclause on an indexed, unique, and sequential key (often a composite key like(timestamp, id)) to find the starting point for the next page. - Advantages: It offers excellent performance on large datasets by allowing the database to seek directly to the data. It is also resilient to changes in the dataset, preventing missed or repeated items.
- The Cursor: A cursor is an opaque token that points to the last record a user has seen. It should be encoded (e.g., Base64) to be treated as a black box by the client.
- Implementation: A common pattern is to query for
limit + 1records to efficiently check if a next page exists without a separateCOUNTquery. - Tradeoff: The primary limitation is the inability to jump to arbitrary pages, making it ideal for infinite-scroll feeds but less so for interfaces requiring numbered page links.
Now that we've covered how to return large collections of data correctly, our next step is to consider what happens when things go wrong. In the next lesson, we will focus on designing a consistent API error response format, ensuring that clients can reliably handle issues like invalid input or server failures.