A comprehensive, production-ready pagination library for Go supporting multiple pagination strategies.
✅ Multiple Pagination Strategies
- Offset-based pagination (traditional page numbers)
- Cursor-based pagination (efficient, consistent results)
- Range-based pagination (HTTP Range header style)
- GraphQL connections (Relay-style)
✅ Production Ready
- Thread-safe with immutable setters
- Overflow-safe calculations
- Comprehensive validation
- Zero external dependencies
- ~94% test coverage
✅ Easy Integration
- HTTP request parsing
- SQL query generation
- Response formatting
- RESTful Link headers
✅ Developer Friendly
- Clean, fluent API
- Extensive documentation
- Type-safe with generics
- Framework agnostic
go get github.com/KARTIKrocks/go-paginate/v2Requirements: Go 1.24+
import "github.com/KARTIKrocks/go-paginate/v2"
// Parse from HTTP request
func handleUsers(w http.ResponseWriter, r *http.Request) {
p := paginate.FromRequest(r)
// Validate
if err := p.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Use with SQL
users := []User{}
db.Limit(p.Limit()).Offset(p.Offset()).Find(&users)
// Get total count
var total int64
db.Model(&User{}).Count(&total)
// Create response
response := paginate.NewPage(users, total, p)
json.NewEncoder(w).Encode(response)
}// Parse cursor from request
c := paginate.CursorFromRequest(r)
// Create cursor from last item
var users []User
db.Where("id > ?", lastID).Limit(c.Limit).Find(&users)
var nextCursor string
if len(users) == c.Limit {
lastUser := users[len(users)-1]
nextCursor = paginate.NewCursorFromID(lastUser.ID)
}
// Create response
response := paginate.NewCursorPageSimple(users, c.Limit, nextCursor)
json.NewEncoder(w).Encode(response)// Create paginator
p := paginate.New().
WithPage(2).
WithPageSize(25)
// Get offset and limit for SQL
offset := p.Offset() // 25
limit := p.Limit() // 25
// Check navigation
if p.HasNext(totalCount) {
nextPage := p.NextPage() // 3
}
// Generate SQL (PostgreSQL)
query := fmt.Sprintf("SELECT * FROM users %s", p.SQLClause())
// SELECT * FROM users LIMIT 25 OFFSET 25
// Generate SQL (MySQL)
query := fmt.Sprintf("SELECT * FROM users %s", p.SQLClauseMySQL())
// SELECT * FROM users LIMIT 25, 25// URL: /users?page=2&page_size=50
p := paginate.FromRequest(r)
// Also supports common alternatives
// ?page=2&limit=50
// ?page=2&per_page=50// Offset pagination response
page := paginate.NewPage(items, totalCount, p)
// {
// "items": [...],
// "total": 100,
// "page": 2,
// "page_size": 25,
// "total_pages": 4,
// "has_prev": true,
// "has_next": true
// }
// Cursor pagination response
cursorPage := paginate.NewCursorPage(items, 20, nextCursor, prevCursor, hasMore)
// {
// "items": [...],
// "next_cursor": "eyJpZCI6IjEyMyJ9",
// "prev_cursor": "eyJpZCI6Ijk4In0",
// "has_more": true,
// "limit": 20
// }conn := paginate.NewConnection(
items,
func(item User) string {
return paginate.NewCursorFromID(item.ID)
},
hasPrev,
hasNext,
totalCount,
)
// {
// "edges": [
// {"node": {...}, "cursor": "..."},
// ...
// ],
// "page_info": {
// "has_previous_page": false,
// "has_next_page": true,
// "start_cursor": "...",
// "end_cursor": "..."
// },
// "total_count": 100
// }// RFC 5988 compliant Link headers
links := paginate.BuildLinkHeader("https://api.example.com/users", p, totalCount)
w.Header().Set("Link", links.String())
// Link: <https://api.example.com/users?page=1&page_size=20>; rel="first",
// <https://api.example.com/users?page=1&page_size=20>; rel="prev",
// <https://api.example.com/users?page=3&page_size=20>; rel="next",
// <https://api.example.com/users?page=5&page_size=20>; rel="last"// Parse Range header
rng, err := paginate.RangeFromRequest(r)
// Range: items=0-24
// Use with SQL
query := fmt.Sprintf("SELECT * FROM users %s", rng.SQLClause())
// Create response
response := paginate.NewRangeResponse(items, rng, totalCount)
w.Header().Set("Content-Range", response.ContentRange())
// Content-Range: items 0-24/100p := paginate.FromRequest(r)
// Custom max page size for specific endpoints
if p.PageSize > 100 {
p = p.WithPageSize(100)
}
// Ensure page is within bounds
p = p.Clamp(totalCount)// Base paginator
base := paginate.New()
// Safe to use concurrently
go func() {
p1 := base.WithPage(1) // New instance
// Use p1...
}()
go func() {
p2 := base.WithPage(2) // Different new instance
// Use p2...
}()// Simple ID cursor
cursor, err := paginate.NewCursorFromID("user_123")
// Timestamp-based cursor (for time-ordered data)
cursor, err = paginate.NewCursorFromTimestamp(time.Now(), "user_123")
// Offset-based cursor (cursor API with offset backend)
cursor, err = paginate.NewCursorFromOffset(100)
// Decode cursor
data, err := paginate.DecodeCursor[any](cursor)
if err != nil {
// Handle invalid cursor
}
fmt.Println(data.ID, data.Timestamp, data.Offset)func ListUsers(db *gorm.DB, p *paginate.Paginator) (*paginate.Page[User], error) {
var users []User
var total int64
// Get total count
if err := db.Model(&User{}).Count(&total).Error; err != nil {
return nil, err
}
// Get page of results
err := db.Offset(int(p.Offset())).
Limit(p.Limit()).
Find(&users).Error
if err != nil {
return nil, err
}
return paginate.NewPage(users, total, p), nil
}func ListUsers(db *sqlx.DB, p *paginate.Paginator) (*paginate.Page[User], error) {
var users []User
query := fmt.Sprintf(`
SELECT * FROM users
ORDER BY created_at DESC
%s
`, p.SQLClause())
err := db.Select(&users, query)
if err != nil {
return nil, err
}
var total int64
db.Get(&total, "SELECT COUNT(*) FROM users")
return paginate.NewPage(users, total, p), nil
}const (
DefaultPage = 1 // Default page number
DefaultPageSize = 20 // Default items per page
MaxPageSize = 1000 // Maximum allowed page size
MinPageSize = 1 // Minimum allowed page size
)These can be referenced but not modified. If you need different limits, validate and clamp manually:
p := paginate.FromRequest(r)
if p.PageSize > 100 {
p = p.WithPageSize(100)
}New() *Paginator- Create with defaultsNewWithSize(pageSize int) *Paginator- Create with custom page sizeNewFromValues(page, pageSize int) *Paginator- Create with both valuesFromRequest(r *http.Request) *Paginator- Parse from HTTP requestFromQuery(q url.Values) *Paginator- Parse from query valuesFromMap(m map[string]any) *Paginator- Parse from map (for JSON)
WithPage(page int) *Paginator- Return new instance with pageWithPageSize(size int) *Paginator- Return new instance with page sizeOffset() int64- Get SQL offsetLimit() int- Get SQL limitHasNext(total int64) bool- Check if next page existsHasPrevious() bool- Check if previous page existsTotalPages(total int64) int- Calculate total pagesValidate() error- Validate parametersClone() *Paginator- Create a copyClamp(total int64) *Paginator- Adjust page to valid range
NewCursor() *CursorPaginator- Create with defaultsNewCursorWithLimit(limit int) *CursorPaginator- Create with custom limitCursorFromRequest(r *http.Request) *CursorPaginator- Parse from requestCursorFromQuery(q url.Values) *CursorPaginator- Parse from query values
Encode(data CursorData[any]) (string, error)- Encode cursor dataDecode() (*CursorData[any], error)- Decode the paginator's cursor
EncodeCursor[T any](data *CursorData[T]) (string, error)- Encode cursor dataDecodeCursor[T any](cursor string) (*CursorData[T], error)- Decode cursorNewCursorFromID(id string) (string, error)- Create cursor from IDNewCursorFromValue[T any](value T) (string, error)- Create cursor from typed valueNewCursorFromTimestamp(ts time.Time, id string) (string, error)- Create from timestampNewCursorFromOffset(offset int) (string, error)- Create from offset
var (
ErrInvalidPage error // Page < 1
ErrInvalidPageSize error // Page size out of bounds
ErrInvalidCursor error // Malformed cursor
ErrInvalidOffset error // Offset < 0
ErrInvalidRange error // Invalid range parameters
)Use errors.Is() for checking:
if errors.Is(err, paginate.ErrInvalidCursor) {
// Handle invalid cursor
}p := paginate.FromRequest(r)
if err := p.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}- Offset: Simple, good for small datasets, supports jumping to pages
- Cursor: Large datasets, real-time data, consistent results
- Range: File-like access patterns, partial content requests
// Prevent abuse
if p.PageSize > 100 {
p = p.WithPageSize(100)
}Offset pagination benefits from total count for UI (page numbers), but it can be expensive for large tables. Consider omitting for very large datasets:
// With count (better UX, slower)
page := paginate.NewPage(items, totalCount, p)
// Without count (faster, less info)
// Just set total to -1 or 0-- For offset pagination
CREATE INDEX idx_users_created_at ON users(created_at DESC);
-- For cursor pagination (compound index for tie-breaking)
CREATE INDEX idx_users_created_id ON users(created_at DESC, id DESC);- Pros: Simple, supports random access, easy to implement
- Cons: Slower for large offsets, inconsistent with concurrent writes
- Best for: Small to medium datasets, infrequent access to deep pages
- Pros: Consistent results, efficient for large datasets, better for real-time data
- Cons: No random access, more complex implementation
- Best for: Large datasets, infinite scroll, real-time feeds
- Pros: Standard HTTP semantics, good for downloads/exports
- Cons: Similar issues to offset pagination
- Best for: File-like resources, bulk exports
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Please ensure:
- Tests pass (
go test ./...) - Code is formatted (
go fmt ./...) - Linting passes (
golangci-lint run)
MIT License - see LICENSE file for details.
Inspired by pagination patterns from:
- GraphQL Cursor Connections Specification
- RFC 5988 (Web Linking)
- Common REST API patterns
Made with ❤️ for the Go community