| Crates.io | sqlx-paginated |
| lib.rs | sqlx-paginated |
| version | 0.3.0 |
| created_at | 2024-11-11 17:22:11.00222+00 |
| updated_at | 2026-01-07 14:16:18.843458+00 |
| description | A flexible, type-safe SQLx query builder for dynamic web APIs, offering seamless pagination, searching, filtering, and sorting. |
| homepage | |
| repository | https://github.com/alexandrughinea/sqlx-paginated |
| max_upload_size | |
| id | 1443999 |
| size | 328,189 |
A blazingly fast, type-safe, fluid query builder for dynamic APIs, offering seamless pagination, sorting and dynamic filtering on top of SQLx.
| Database | Status | Version | Features | Notes |
|---|---|---|---|---|
| PostgreSQL | Supported | 12+ | All features supported | Stable |
| SQLite | Testing | 3.35+ | All features supported | Stable |
| MySQL | Planned | 8.0+ | Core features planned | On roadmap, development starting in Q2 2026 |
Query builders
Missing features in existing solutions
Actix-web handler example:
use sqlx::Postgres;
use sqlx_paginated::{paginated_query_as, FlatQueryParams};
use actix_web::{web, Responder, HttpResponse};
async fn list_users(web::Query(params): web::Query<FlatQueryParams>) -> impl Responder {
let paginated_users = paginated_query_as::<User, Postgres>("SELECT * FROM users")
.with_params(params)
.fetch_paginated(&pool)
.await
.unwrap();
HttpResponse::Ok().json(json!(paginated_users))
}
let params = QueryParamsBuilder::<User>::new()
.with_pagination(1, 10)
.with_sort("created_at", QuerySortDirection::Descending)
.with_search("john", vec!["name", "email"])
.build();
paginated_query_as::<UserExample, Postgres>("SELECT * FROM users")
.with_params(initial_params)
.with_query_builder(|params| {
// Can override the default query builder (build_query_with_safe_defaults) with a complete custom one:
QueryBuilder::<UserExample, Postgres>::new()
.with_search(params) // Add or remove search feature from the query;
.with_filters(params) // Add or remove custom filters from the query;
.with_date_range(params) // Add or remove data range;
.with_raw_condition("") // Add raw condition, no checks.
.disable_protection() // This removes all column safety checks.
.with_combined_conditions(|builder| {
// ...
.build()
})
.disable_totals_count() // Disables the calculation of total record count
.fetch_paginated(&pool)
.await
.unwrap()
Primary users
Use cases
Add to Cargo.toml:
For PostgreSQL:
[dependencies]
sqlx_paginated = { version = "0.2.33", features = ["postgres"] }
For SQLite:
[dependencies]
sqlx_paginated = { version = "0.2.33", features = ["sqlite"] }
For both:
[dependencies]
sqlx_paginated = { version = "0.2.33", features = ["postgres", "sqlite"] }
PostgreSQL:
use sqlx::{PgPool, Postgres};
use sqlx_paginated::{QueryParamsBuilder, QuerySortDirection, paginated_query_as};
#[derive(sqlx::FromRow, serde::Serialize, Default)]
struct User {
id: i64,
first_name: String,
last_name: String,
email: String,
confirmed: bool,
created_at: Option<DateTime<Utc>>,
}
async fn get_users(pool: &PgPool) -> Result<PaginatedResponse<User>, sqlx::Error> {
let params = QueryParamsBuilder::<User>::new()
.with_pagination(1, 10)
.with_sort("created_at", QuerySortDirection::Descending)
.with_search("john", vec!["first_name", "last_name", "email"])
.build();
// Function syntax (recommended)
paginated_query_as::<User, Postgres>("SELECT * FROM users")
.with_params(params)
.fetch_paginated(pool)
.await
// Or using macro syntax
// paginated_query_as!(User, Postgres, "SELECT * FROM users")
}
SQLite:
use sqlx::{SqlitePool, Sqlite};
use sqlx_paginated::{QueryParamsBuilder, QuerySortDirection, paginated_query_as};
async fn get_users(pool: &SqlitePool) -> Result<PaginatedResponse<User>, sqlx::Error> {
let params = QueryParamsBuilder::<User>::new()
.with_pagination(1, 10)
.with_sort("created_at", QuerySortDirection::Descending)
.with_search("john", vec!["first_name", "last_name", "email"])
.build();
// Function syntax (recommended)
paginated_query_as::<User, Sqlite>("SELECT * FROM users")
.with_params(params)
.fetch_paginated(pool)
.await
// Or using macro syntax
// paginated_query_as!(User, Sqlite, "SELECT * FROM users")
}
See examples/sqlx-paginated-sqlite-example for a complete working REST API demonstrating:
Run with: cd examples/sqlx-paginated-sqlite-example && cargo run
For detailed query building patterns, see src/paginated_query_as/examples:
{
"records": [
{
"id": "409e3900-c190-4dad-882d-ec2d40245329",
"first_name": "John",
"last_name": "Smith",
"email": "john@example.com",
"confirmed": true,
"created_at": "2024-01-01T00:00:00Z"
}
],
"page": 1,
"page_size": 10,
"total_pages": 1
}
| Feature | HTTP parameters | Builder method |
|---|---|---|
| Pagination | page, page_size |
.with_pagination(page, size) |
| Sorting | sort_column, sort_direction |
.with_sort(column, direction) |
| Search | search, search_columns |
.with_search(term, columns) |
| Date range | date_after, date_before, date_column |
.with_date_range(after, before, column) |
| Filters | field=value, field[op]=value |
.with_filter_operator(field, operator, value) |
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
page |
integer | 1 |
1+ | Page number (1-indexed) |
page_size |
integer | 10 |
10-50 | Records per page |
GET /users?page=2&page_size=20
.with_pagination(2, 20)
| Parameter | Type | Default | Values | Description |
|---|---|---|---|---|
sort_column |
string | created_at |
Any valid column | Column to sort by |
sort_direction |
string | descending |
ascending, descending |
Sort order |
GET /users?sort_column=last_name&sort_direction=ascending
use sqlx_paginated::QuerySortDirection;
.with_sort("last_name", QuerySortDirection::Ascending)
| Parameter | Type | Default | Constraint | Description |
|---|---|---|---|---|
search |
string | null | Max 100 chars | Search term (sanitized: alphanumeric, spaces, hyphens) |
search_columns |
string | name,description |
Comma-separated | Columns to search |
GET /users?search=john&search_columns=first_name,last_name,email
.with_search("john", vec!["first_name", "last_name", "email"])
Generated SQL:
WHERE (LOWER("first_name") LIKE LOWER('%john%')
OR LOWER("last_name") LIKE LOWER('%john%')
OR LOWER("email") LIKE LOWER('%john%'))
| Parameter | Type | Default | Format | Description |
|---|---|---|---|---|
date_after |
datetime | null | ISO 8601 | Range start (>=) |
date_before |
datetime | null | ISO 8601 | Range end (<=) |
date_column |
string | created_at |
Column name | Target column |
GET /users?date_after=2024-01-01T00:00:00Z&date_before=2024-12-31T23:59:59Z
use chrono::{DateTime, Utc};
let start = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z").unwrap().into();
let end = DateTime::parse_from_rfc3339("2024-12-31T23:59:59Z").unwrap().into();
.with_date_range(Some(start), Some(end), Some("created_at"))
| Operator | HTTP syntax | Rust method | SQL output |
|---|---|---|---|
| Equal | field=value |
.with_filter("field", Some("value")) |
field = $1 |
| Not equal | field[ne]=value |
.with_filter_operator("field", NotEqual, "value") |
field != $1 |
| Greater than | field[gt]=value |
.with_filter_operator("field", GreaterThan, "value") |
field > $1 |
| Greater or equal | field[gte]=value |
.with_filter_operator("field", GreaterOrEqual, "value") |
field >= $1 |
| Less than | field[lt]=value |
.with_filter_operator("field", LessThan, "value") |
field < $1 |
| Less or equal | field[lte]=value |
.with_filter_operator("field", LessOrEqual, "value") |
field <= $1 |
| IN | field[in]=a,b,c |
.with_filter_in("field", vec!["a","b","c"]) |
field IN ($1,$2,$3) |
| NOT IN | field[nin]=a,b |
.with_filter_not_in("field", vec!["a","b"]) |
field NOT IN ($1,$2) |
| Is null | field[is_null]= |
.with_filter_null("field", true) |
field IS NULL |
| Is not null | field[is_not_null]= |
.with_filter_null("field", false) |
field IS NOT NULL |
| LIKE | field[like]=%pattern% |
.with_filter_like("field", "%pattern%") |
field LIKE $1 |
| Not like | field[nlike]=%pattern% |
.with_filter_not_like("field", "%pattern%") |
field NOT LIKE $1 |
GET /products?price[gte]=10&price[lte]=100
GET /users?role[in]=admin,moderator&status[ne]=banned
GET /users?deleted_at[is_null]=&email[is_not_null]=
GET /users?email[like]=%@company.com
use sqlx_paginated::{QueryParamsBuilder, QueryFilterOperator};
// Basic operators
QueryParamsBuilder::<Product>::new()
.with_filter_operator("price", QueryFilterOperator::GreaterThan, "10.00")
.with_filter_operator("stock", QueryFilterOperator::LessOrEqual, "100")
.with_filter("status", Some("active"))
.build()
// Convenience methods
QueryParamsBuilder::<User>::new()
.with_filter_in("role", vec!["admin", "moderator"])
.with_filter_null("deleted_at", true)
.build()
// Using QueryFilterCondition
use sqlx_paginated::QueryFilterCondition;
use std::collections::HashMap;
let mut filters = HashMap::new();
filters.insert("price".to_string(), QueryFilterCondition::greater_than("50.00"));
filters.insert("status".to_string(), QueryFilterCondition::not_equal("deleted"));
QueryParamsBuilder::<Product>::new()
.with_filter_conditions(filters)
.build()
use actix_web::{web::Query, HttpResponse};
use sqlx_paginated::{FlatQueryParams, paginated_query_as};
use sqlx::Postgres;
async fn list_users(
Query(params): Query<FlatQueryParams>,
pool: web::Data<PgPool>
) -> HttpResponse {
let result = paginated_query_as::<User, Postgres>("SELECT * FROM users")
.with_params(params)
.fetch_paginated(pool.get_ref())
.await
.unwrap();
HttpResponse::Ok().json(result)
}
Axum:
use axum::{extract::Query, Json, Extension};
use sqlx_paginated::{FlatQueryParams, paginated_query_as, PaginatedResponse};
use sqlx::{PgPool, Postgres};
async fn list_users(
Query(params): Query<FlatQueryParams>,
Extension(pool): Extension<PgPool>
) -> Json<PaginatedResponse<User>> {
let result = paginated_query_as::<User, Postgres>("SELECT * FROM users")
.with_params(params)
.fetch_paginated(&pool)
.await
.unwrap();
Json(result)
}
HTTP:
GET /products?search=laptop&search_columns=name,description
&price[gte]=500&price[lte]=2000&stock[gt]=0
&category[in]=computers,electronics
&status=active&deleted_at[is_null]=
&sort_column=price&sort_direction=ascending
&page=1&page_size=24
Rust:
use sqlx_paginated::{QueryParamsBuilder, QuerySortDirection, QueryFilterOperator};
let params = QueryParamsBuilder::<Product>::new()
.with_search("laptop", vec!["name", "description"])
.with_filter_operator("price", QueryFilterOperator::GreaterOrEqual, "500")
.with_filter_operator("price", QueryFilterOperator::LessOrEqual, "2000")
.with_filter_operator("stock", QueryFilterOperator::GreaterThan, "0")
.with_filter_in("category", vec!["computers", "electronics"])
.with_filter("status", Some("active"))
.with_filter_null("deleted_at", true)
.with_sort("price", QuerySortDirection::Ascending)
.with_pagination(1, 24)
.build();
struct, we can then perform search and filtering
against its own fields.#[derive(Serialize, Deserialize, FromRow, Default)]
pub struct User {
pub id: Option<Uuid>,
pub first_name: String,
pub last_name: String,
pub confirmed: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
confirmed=true filter.GET /v1/internal/users
?search=john
&search_columns=first_name,last_name,email
&sort_column=created_at
&sort_direction=descending
&date_before=2024-11-03T12:30:12.081598Z
&date_after=2024-11-02T12:30:12.081598Z
&page=1
&page_size=20
&confirmed=true
{
"page": 1,
"page_size": 20,
"total": 2,
"total_pages": 1,
"records": [
{
"id": "409e3900-c190-4dad-882d-ec2d40245329",
"first_name": "John",
"last_name": "Smith",
"email": "john.smith@example.com",
"confirmed": true,
"created_at": "2024-11-03T12:30:12.081598Z",
"updated_at": "2024-11-03T12:30:12.081598Z"
},
{
"id": "9167d825-8944-4428-bf91-3c5531728b5e",
"first_name": "Johnny",
"last_name": "Doe",
"email": "johnny.doe@example.com",
"confirmed": true,
"created_at": "2024-10-28T19:14:49.064626Z",
"updated_at": "2024-10-28T19:14:49.064626Z"
}
]
}
confirmed=true and first_name=Alex filters.first_name filter the value will be an exact match (case-sensitive).GET /v1/internal/users
?date_before=2024-11-03T12:30:12.081598Z
&date_after=2024-11-02T12:30:12.081598Z
&confirmed=true
&first_name=Alex
{
"page": 1,
"page_size": 20,
"total": 1,
"total_pages": 1,
"records": [
{
"id": "509e3900-c190-4dad-882d-ec2d40245329",
"first_name": "Alex",
"last_name": "Johnson",
"email": "alex.johnson@example.com",
"confirmed": true,
"created_at": "2024-11-02T12:30:12.081598Z"
}
]
}
| Query pattern | Impact | Recommendation |
|---|---|---|
| SELECT * | High impact | Specify needed columns |
| Large text columns | High impact | Use separate detail endpoint |
| Computed columns | Medium impact | Cache if possible |
| JSON aggregation | Medium impact | Limit array size |
-- Text search
CREATE INDEX idx_users_name_gin ON users USING gin(to_tsvector('english', name));
-- Composite indexes for common queries
CREATE INDEX idx_users_confirmed_created ON users(confirmed, created_at);
-- JSON indexes
CREATE INDEX idx_users_metadata ON users USING gin(metadata);
| Page size | Records | Performance impact |
|---|---|---|
| 1-10 | Optimal | Best |
| 11-50 | Good | Good |
| 51-100 | Caution | Monitor |
| 100+ | Poor | Not recommended |
I warmly welcome contributions from the community! If you have ideas, improvements, or fixes, we encourage you to submit a Pull Request. Your input is highly valued, and I'm excited to collaborate with you to make this project even better.
This project is licensed under the MIT License - see the LICENSE file for details.