Sitelet https://github.com/sqlalchemy/sqlalchemy/issues/13643
Skip to content

MutableDict |= and MutableList *= do not emit change events; changes are not persisted #13643

Description

@ashm-dev

Describe the bug

When modifying a MutableDict using the in-place union operator (|=) or a MutableList using in-place multiplication (*=), change events are not emitted. The underlying instance is not marked as modified (session.is_modified(instance) == False), and subsequent session.commit() or session.flush() silently skips persisting the changes to the database.

MutableDict does not implement __ior__ (introduced for Python dictionaries in PEP 584 / Python 3.9), and MutableList does not implement __imul__. As a result, the built-in dict.__ior__ and list.__imul__ run without calling self.changed().

In ticket #3853, upstream added __ior__, __iand__, __ixor__, and __isub__ to MutableSet, and __iadd__ (+=) to MutableList for the exact same reason. dict |= and list *= appear to have been overlooked.

To Reproduce

from sqlalchemy import JSON, create_engine, select
from sqlalchemy.ext.mutable import MutableDict, MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column


class Base(DeclarativeBase):
    pass


class Document(Base):
    __tablename__ = "doc"
    id: Mapped[int] = mapped_column(primary_key=True)
    payload: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON))
    tags: Mapped[list] = mapped_column(MutableList.as_mutable(JSON))


engine = create_engine("sqlite://")
Base.metadata.create_all(engine)

with Session(engine) as session:
    session.add(Document(id=1, payload={"a": 1}, tags=[1, 2]))
    session.commit()

    doc = session.get(Document, 1)
    # In-place mutations
    doc.payload |= {"b": 2}
    doc.tags *= 2

    print("In memory:", doc.payload, doc.tags)
    print("session.is_modified(doc):", session.is_modified(doc))  # False!

    session.commit()

with Session(engine) as session:
    reloaded = session.execute(select(Document.payload, Document.tags)).one()
    print("In DB after commit:      ", reloaded)  # ({'a': 1}, [1, 2]) -> changes silently lost!

Error

No error is raised; changes made via |= and *= are silently ignored by the unit of work and discarded upon session close.

Output of the snippet above:

In memory: {'a': 1, 'b': 2} [1, 2, 1, 2]
session.is_modified(doc): False
In DB after commit:       ({'a': 1}, [1, 2])

Possible fix

Implement __ior__ in MutableDict and __imul__ in MutableList:

--- a/lib/sqlalchemy/ext/mutable.py
+++ b/lib/sqlalchemy/ext/mutable.py
@@ -840,6 +840,10 @@
         dict.update(self, *a, **kw)
         self.changed()
 
+    def __ior__(self, other: Any) -> MutableDict[_KT, _VT]:
+        self.update(other)
+        return self
+
     if TYPE_CHECKING:
@@ -949,6 +953,11 @@
         self.extend(x)
         return self
 
+    def __imul__(self, x: SupportsIndex) -> MutableList[_T]:
+        list.__imul__(self, x)
+        self.changed()
+        return self
+
     def insert(self, i: SupportsIndex, x: _T) -> None:

Environment

  • SQLAlchemy: 2.1.2 (main at 749086a) / 2.0.x
  • Python: CPython 3.14.7 / 3.13 / 3.12
  • OS: Linux x86_64

Activity

  1. added
    bugSomething isn't working
    robots involved 🤖issue/discussion/PR that is located using machine processes such as LLMs / fuzzers etc
    on Oct 4, 2026
  2. zzzeek commented on Oct 4, 2026

    @zzzeek
    Member

    claude on behalf of zzzeek

    Reproduced: yes. Using the ORM with sqlite in-memory, I tried each in-place operator on the built-in mutable collections:

    op is_modified persisted
    MutableDict |= False no
    MutableList *= False no
    MutableList += True yes
    MutableSet |=, &=, -=, ^= True yes

    The only gaps are the two the report names. MutableDict has no __ior__, so dict.__ior__ runs without calling changed(). MutableList has no __imul__, so list.__imul__ doesn't call it either. The obj.attr op= x form then assigns the same object back to the attribute, and that assignment isn't counted as a change.

    Versions: main (2.1.4.dev) and rel_2_0 (2.0.55) give identical results, so this is not a 2.1 regression. The operators were missed when #3853 added MutableSet __ior__/__iand__/__ixor__/__isub__ and MutableList.__iadd__. Milestone is set to 2.0.x.

    Severity: low-to-moderate. Changes are silently lost, but neither operator is used much on a mapped JSON/pickle attribute. dict |= (PEP 584, Python 3.9+) is the likelier one to show up in real code. list *= is rare. The workaround is .update() for dicts, or flag_modified().

    Tests: these go in test/ext/test_mutable.py:

    • _MutableDictTestBase: add a test_operator_update next to test_update. It runs through MutableWithScalarPickleTest, MutableWithScalarJSONTest, MutableColumnCopyJSONTest, MutableAssociationScalar*Test, etc.
    • _MutableListTestBase: add a test_operator_multiply next to test_operator_extend. It runs through MutableListWithScalarPickleTest and MutableColumnCopyArrayTest.

    The existing tests don't catch this because they only cover the explicit methods plus the operators added in #3853. Nothing calls |= on a dict or *= on a list.

    Fix: in lib/sqlalchemy/ext/mutable.py:

    • MutableDict.__ior__(self, other): call self.update(other) and return self, the same pattern as MutableSet.__ior__.
    • MutableList.__imul__(self, n): call list.__imul__(self, n), then self.changed(), and return self.

    The diff proposed in the report is essentially right. Typing will probably need the same # type: ignore[override,misc] comments the sibling methods use.

  3. added this to the 2.0.x milestone on Oct 4, 2026
  4. ashm-dev commented on Oct 4, 2026

    @ashm-dev
    ContributorAuthor

    Thanks for the quick triage!

    Just to clarify regarding the robots involved label: I investigated the issue and wrote the reproducer myself, but used an LLM to help translate and format the report since English is not my native language.

    Happy to submit a PR with the fix and tests if this is open for pull requests.

  5. sqla-tester commented on Oct 5, 2026

    @sqla-tester
    Collaborator

    Mike Bayer has proposed a fix for this issue in the main branch:

    *emit change events for MutableDict |= and MutableList = https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/7030

  6. sqla-tester commented on Oct 5, 2026

    @sqla-tester
    Collaborator

    Mike Bayer has proposed a fix for this issue in the rel_2_0 branch:

    *emit change events for MutableDict |= and MutableList = https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/7032

  7. added a commit that references this issue on Oct 6, 2026
    4212fbf
  8. added a commit that references this issue on Oct 6, 2026
    5479e0e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcode review in progresscode has been provided that's in review as PR and/or gerritrobots involved 🤖issue/discussion/PR that is located using machine processes such as LLMs / fuzzers etc

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions