Summary
The SQLAlchemy extension has no channel for execution-time bind parameters. paginate / apaginate always call conn.execute(query) and conn.scalar(count_query) without a parameters argument, so the only way to get a value into a query is to bake it into the statement beforehand.
For a plain select(), Select.params(...) bakes the value into the clause tree and it survives pagination, so this usually goes unnoticed. But statement-baking is not a general substitute for execution-time parameters, and there are common cases where no value can be supplied at all:
- Raw SQL (
text()) - even when values are baked with TextClause.bindparams(...), they are dropped in the count query, because create_count_query rebuilds the statement from the raw SQL string (text(...query.text...)) and does not carry the bound values across.
- ORM-compiled parameters (
column_property, deferred, with_loader_criteria) - Select.params(...) cannot reach these, because their SQL is generated during ORM compilation, after .params() values were captured.
In both cases the query cannot be paginated at all today.
What works (no change needed)
A bindparam in the statement body of a select(), bound via .params(), survives pagination - item query and count query both receive the value:
stmt = (
select(Post)
.where(Post.author == bindparam("who"))
.order_by(Post.id)
.params(who="john")
)
page = await apaginate(session, stmt, params) # OK: items + total correct
What does not work
1. Raw SQL - baked bindparams lost in the count query
stmt = text("SELECT id, author FROM post WHERE author = :who ORDER BY id").bindparams(who="john")
await apaginate(session, stmt, params)
# -> InvalidRequestError: A value is required for bind parameter 'who'
# (raised by the count query: SELECT count(*) FROM (SELECT id, ...))
The item query would work; the count query fails because create_count_query reconstructs a fresh text() from the raw SQL string and drops the bound values.
2. column_property / deferred - .params() cannot reach the parameter
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
is_saved: Mapped[bool] = column_property(
select(func.count(Saved.id) > 0)
.where(
Saved.post_id == literal_column("post.id"),
Saved.user_username == bindparam("current_username"),
)
.correlate_except(Saved)
.scalar_subquery(),
deferred=True,
)
stmt = select(Post).options(undefer(Post.is_saved)).order_by(Post.id)
# .params() does NOT reach the column_property bindparam (fails even outside pagination):
await session.execute(stmt.params(current_username="john"))
# -> InvalidRequestError: A value is required for bind parameter 'current_username'
# The only thing that binds it is an execution-time params dict:
await session.execute(stmt, {"current_username": "john"}) # works
# ...but paginate never forwards a params dict, so this is unreachable through the public API.
Root cause
In fastapi_pagination/ext/sqlalchemy.py, all three execution sites call execute / scalar with no parameters argument:
_limit_offset_flow β conn.execute(query)
_sqlalchemy_inline_count_flow β conn.execute(paginated_query)
_total_flow β conn.scalar(count_query)
Proposed solution
Add an optional bind_params: Mapping[str, Any] | None = None argument to paginate / apaginate, threaded through _sqlalchemy_flow down to the three execution sites, and forwarded as the parameters argument to conn.execute(query, bind_params) / conn.scalar(count_query, bind_params).
# raw SQL
await apaginate(session, text("... WHERE author = :who ..."), params, bind_params={"who": "john"})
# column_property / deferred
await apaginate(session, select(Post).options(undefer(Post.is_saved)), params,
bind_params={"current_username": "john"})
Notes:
- Fully backward compatible: default
None reproduces the current behavior (conn.execute(query, None) == conn.execute(query)).
- Applies to both the item query and the count query (needed for the
text() case, where the count query is where the value is currently lost).
- Verified with a prototype against
fastapi-pagination==0.15.15 / SQLAlchemy==2.0.51. With bind_params forwarded, all three cases pass:
text() - items and total both correct.
column_property / deferred - correct value per row, correct total.
- queries without
bind_params - behavior unchanged (no regression).
Alternatives considered
Select.params(...) - works only for select() statement-body parameters; does not cover text() count queries or ORM-compiled parameters.
with_expression() / query_expression() - a good workaround for the column_property case specifically, but does not address raw SQL or the general absence of an execution-time parameter channel.
Implementation
I would like to implement this myself. If the direction and the bind_params argument name are acceptable, I'll open a PR threading the argument through the sync and async entrypoints (including the @overload signatures), with tests for the text() count-query case and the column_property / deferred case, plus a short docs note.
Summary
The SQLAlchemy extension has no channel for execution-time bind parameters.
paginate/apaginatealways callconn.execute(query)andconn.scalar(count_query)without aparametersargument, so the only way to get a value into a query is to bake it into the statement beforehand.For a plain
select(),Select.params(...)bakes the value into the clause tree and it survives pagination, so this usually goes unnoticed. But statement-baking is not a general substitute for execution-time parameters, and there are common cases where no value can be supplied at all:text()) - even when values are baked withTextClause.bindparams(...), they are dropped in the count query, becausecreate_count_queryrebuilds the statement from the raw SQL string (text(...query.text...)) and does not carry the bound values across.column_property,deferred,with_loader_criteria) -Select.params(...)cannot reach these, because their SQL is generated during ORM compilation, after.params()values were captured.In both cases the query cannot be paginated at all today.
What works (no change needed)
A
bindparamin the statement body of aselect(), bound via.params(), survives pagination - item query and count query both receive the value:What does not work
1. Raw SQL - baked
bindparamslost in the count queryThe item query would work; the count query fails because
create_count_queryreconstructs a freshtext()from the raw SQL string and drops the bound values.2.
column_property/deferred-.params()cannot reach the parameterRoot cause
In
fastapi_pagination/ext/sqlalchemy.py, all three execution sites callexecute/scalarwith noparametersargument:_limit_offset_flowβconn.execute(query)_sqlalchemy_inline_count_flowβconn.execute(paginated_query)_total_flowβconn.scalar(count_query)Proposed solution
Add an optional
bind_params: Mapping[str, Any] | None = Noneargument topaginate/apaginate, threaded through_sqlalchemy_flowdown to the three execution sites, and forwarded as theparametersargument toconn.execute(query, bind_params)/conn.scalar(count_query, bind_params).Notes:
Nonereproduces the current behavior (conn.execute(query, None)==conn.execute(query)).text()case, where the count query is where the value is currently lost).fastapi-pagination==0.15.15/SQLAlchemy==2.0.51. Withbind_paramsforwarded, all three cases pass:text()- items and total both correct.column_property/deferred- correct value per row, correct total.bind_params- behavior unchanged (no regression).Alternatives considered
Select.params(...)- works only forselect()statement-body parameters; does not covertext()count queries or ORM-compiled parameters.with_expression()/query_expression()- a good workaround for thecolumn_propertycase specifically, but does not address raw SQL or the general absence of an execution-time parameter channel.Implementation
I would like to implement this myself. If the direction and the
bind_paramsargument name are acceptable, I'll open a PR threading the argument through the sync and async entrypoints (including the@overloadsignatures), with tests for thetext()count-query case and thecolumn_property/deferredcase, plus a short docs note.