Two engineers comparing Python JSON changes with database query results and reviewing a SQLAlchemy test checklist.

SQLAlchemy Committed. Why Is the Nested JSON Still Unchanged?

Fri, Oct 23, 2026

A transaction can commit cleanly while the JSON change you intended never reaches the database. That sounds contradictory only if “Python object changed,” “ORM noticed,” “UPDATE ran,” and “a later reader sees the new document” are treated as one event. They are not. For SQLAlchemy JSON work, those are separate persistence boundaries, and an operator should demand evidence at each one.

The narrow case here uses CPython 3.13.5, SQLAlchemy 2.0.50, SQLite 3.46.1 on Linux, one disposable local database file per scenario, one writer at a time, DB-API autocommit=False, and Session(..., expire_on_commit=False) for the writer. The JSON document is fixed: {"status":"draft","settings":{"retries":1},"tags":["A"]}. The only intended edits are either top-level status = "ready" or nested settings.retries = 2.

The operational question is not whether commit() returned. It is whether the intended full document survives a new Session after the writer has closed. The preparation run supplied for this article, dated October 23, 2026, shows both kinds of failure that matter: ordinary JSON in-place mutation can be invisible to the unit of work, and MutableDict can still miss a mutation inside a nested plain dictionary. SQLAlchemy’s JSON documentation explicitly says in-place mutation is not detected by the base JSON type, while the mutable extension explicitly says MutableDict does not recursively track values inside itself.

That makes the decision rule strict: ACCEPT_PERSISTED only after an independent session reads the complete intended JSON document. A successful commit, a changed writer-side object, or membership in Session.dirty is supporting evidence at most. None of those signals alone closes the persistence question.

Define the JSON document that the database must retain

Start with the document contract, not with the ORM API. The baseline is deliberately small enough to inspect as a whole:

{
  "status": "draft",
  "settings": {"retries": 1},
  "tags": ["A"]
}

For the top-level case, the only accepted persisted result is:

{
  "status": "ready",
  "settings": {"retries": 1},
  "tags": ["A"]
}

For the nested case, the only accepted persisted result is:

{
  "status": "draft",
  "settings": {"retries": 2},
  "tags": ["A"]
}

The untouched fields matter. Checking only settings.retries == 2 can miss accidental loss or type drift elsewhere in the JSON value. The observer should compare the entire decoded mapping, including status, the nested settings mapping, the tags list, and their value types.

This article starts after parsing. The input is already valid JSON represented as ordinary Python containers. That is a different boundary from rejecting duplicate JSON names during decoding, where information can be lost before an ORM ever sees a mapping. Here the representation is already unambiguous; the risk is that a valid in-memory edit never becomes an ORM-managed database write.

The writer-side illusion is easy to produce. Execute:

obj.payload["settings"]["retries"] = 2

and then print obj.payload. Python correctly shows 2. With expire_on_commit=False, a successful commit() does not automatically expire that object, so reading obj.payload again can still show the edited value. SQLAlchemy documents the normal post-commit expiration behavior and the expire_on_commit switch that disables it; disabling expiration is therefore a deliberate laboratory choice, not a repair for missing writes and not the Session default.

That retained Python value answers only one question: what does this object currently contain? It does not answer whether the unit of work detected a change, whether an UPDATE executed, whether that UPDATE committed, or whether another session can read it.

The mapping boundary is equally explicit. The plain model uses mapped_column(sa.JSON). The tracked model uses mapped_column(MutableDict.as_mutable(sa.JSON)). SQLAlchemy’s JSON type documentation states that base JSON does not detect in-place structure mutation and that assigning a replacement JSON structure triggers a change event. The mutable extension documentation says MutableDict emits change events for changes to the dictionary itself but does not recursively make contained values mutable.

There is one versioning detail worth keeping visible. The preparation evidence is specifically SQLAlchemy 2.0.50, released May 24, 2026. The SQLAlchemy 2.0 documentation consulted on October 23, 2026 currently renders as 2.0.55, released October 16, 2026. The 2.0.55 changelog includes a mutable-extension fix for the MutableDict |= operator, not a change to the documented nonrecursive behavior at issue here. Treat the supplied Q1–Q9 results as patch-level 2.0.50 evidence, and treat the newer 2.0 docs as the current contract reference rather than silently relabeling the run.

The connection side is frozen too. The fixture passes connect_args={"autocommit": False}. Python 3.13 documents the sqlite3.connect(..., autocommit=...) parameter and distinguishes it from legacy transaction control. For this lab, however, no conclusion depends on changing connection admission, isolation strategy, concurrent writers, or vendor-specific JSON operators. One writer owns one disposable file, and the question is whether SQLAlchemy turns a Python JSON mutation into a durable-enough committed row that a new local session can read back.

Instrument memory, dirty state, UPDATE and fresh readback

A reliable diagnosis needs four signals kept separate.

Python mutation asks whether the in-memory mapping contains the intended edit immediately after application code runs. ORM change detection asks whether the instance appears in Session.dirty before flush. SQL execution asks whether an UPDATE reached the engine’s before_cursor_execute event. Fresh-session persistence asks what a new session reads after the writer closes.

Each signal has a limit. Session.dirty is intentionally optimistic. SQLAlchemy’s Session API explains that an instance can be considered dirty even when flush later finds no net attribute change and emits no SQL. Conversely, an event listener attached to before_cursor_execute records an attempted statement before the DB-API call completes. Seeing an UPDATE there is stronger than merely seeing a dirty object, but it is still not a commit receipt. A later rollback can erase its effect, and an execution failure can prevent persistence.

The strongest boundary in this fixture is therefore the independent readback. SQLAlchemy’s Session Basics describes flush as the unit-of-work step that sends pending changes to the database and states that Session.commit() unconditionally flushes before COMMIT; if no pending changes are detected, no SQL is emitted. That means “commit succeeded” is perfectly compatible with “there were no ORM changes to write.”

The complete fixture below includes its negative controls, cleanup, assertions, and source digest logic. The preparation record reports execution on October 23, 2026 with CPython 3.13.5, SQLAlchemy 2.0.50, and SQLite 3.46.1.

python -m pip install "SQLAlchemy==2.0.50"
python json_tracking_lab.py > json-tracking-evidence.json
import copy
import hashlib
import json
import platform
import sqlite3
import tempfile
from pathlib import Path

import sqlalchemy as sa
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import DeclarativeBase, Session, mapped_column
from sqlalchemy.orm.attributes import flag_modified

if not debug:
    raise RuntimeError('Run without -O; diagnostic assertions are required.')

BASE = {'status': 'draft', 'settings': {'retries': 1}, 'tags': ['A']}


class Base(DeclarativeBase):
    pass


class Plain(Base):
    tablename = 'plain_documents'
    id = mapped_column(sa.Integer, primary_key=True)
    payload = mapped_column(sa.JSON)


class Tracked(Base):
    tablename = 'tracked_documents'
    id = mapped_column(sa.Integer, primary_key=True)
    payload = mapped_column(MutableDict.as_mutable(sa.JSON))


def run(case, model, operation, persisted, update_count, rollback=False):
    with tempfile.TemporaryDirectory(prefix='json-tracking-lab-') as tmp:
        engine = sa.create_engine('sqlite:///' + str(Path(tmp) / 'owned.db'),
                                  connect_args={'autocommit': False})
        try:
            Base.metadata.create_all(engine)
            with Session(engine) as seed, seed.begin():
                seed.add(model(id=1, payload=copy.deepcopy(BASE)))
            updates = []

            @sa.event.listens_for(engine, 'before_cursor_execute')
            def record(conn, cursor, statement, parameters, context, executemany):
                if statement.lstrip().upper().startswith('UPDATE'):
                    updates.append({'sql': statement, 'parameters': repr(parameters)})

            with Session(engine, expire_on_commit=False) as writer:
                obj = writer.get(model, 1)
                if operation == 'top':
                    obj.payload['status'] = 'ready'
                elif operation == 'nested':
                    obj.payload['settings']['retries'] = 2
                elif operation == 'deepcopy':
                    candidate = copy.deepcopy(obj.payload)
                    candidate['settings']['retries'] = 2
                    obj.payload = candidate
                elif operation == 'flag':
                    obj.payload['settings']['retries'] = 2
                    flag_modified(obj, 'payload')
                elif operation == 'late_shallow':
                    obj.payload['settings']['retries'] = 2
                    obj.payload = dict(obj.payload)
                else:
                    raise ValueError(operation)
                dirty_before = obj in writer.dirty
                memory_before = copy.deepcopy(obj.payload)
                writer.flush()
                if rollback:
                    writer.rollback()
                else:
                    writer.commit()
                memory_after = copy.deepcopy(obj.payload)
            saved_updates = list(updates)
            with Session(engine) as observer:
                actual = copy.deepcopy(observer.get(model, 1).payload)
            target = copy.deepcopy(BASE)
            if persisted == 'top':
                target['status'] = 'ready'
            elif persisted == 'nested':
                target['settings']['retries'] = 2
            assert actual == target, (case, actual, target)
            assert len(saved_updates) == update_count, (case, saved_updates)
            return {'case': case, 'mapping': model.__name__, 'operation': operation,
                    'dirty_before_flush': dirty_before, 'memory_before': memory_before,
                    'memory_after': memory_after, 'observer': actual,
                    'finish': 'ROLLBACK' if rollback else 'COMMIT',
                    'updates': saved_updates}
        finally:
            engine.dispose()


if name == '__main__':
    cases = [run('Q1', Plain, 'top', 'baseline', 0),
             run('Q2', Plain, 'nested', 'baseline', 0),
             run('Q3', Plain, 'deepcopy', 'nested', 1),
             run('Q4', Tracked, 'top', 'top', 1),
             run('Q5', Tracked, 'nested', 'baseline', 0),
             run('Q6', Tracked, 'flag', 'nested', 1),
             run('Q7', Tracked, 'deepcopy', 'nested', 1),
             run('Q8', Tracked, 'late_shallow', 'baseline', 0),
             run('Q9', Tracked, 'deepcopy', 'baseline', 1, rollback=True)]
    print(json.dumps({'python': platform.python_version(), 'sqlalchemy': sa.__version__,
                      'sqlite': sqlite3.sqlite_version, 'expire_on_commit': False,
                      'driver_autocommit': False,
                      'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                      'cases': cases, 'status': 'PASS_9_CASES'}, indent=2))

The fixture’s stated source SHA-256 is cae59d76e6ec04a1c25e12d5ae498c4945407d9d4ea9f3ee4a3077adea310ecf. That digest identifies the source artifact; it is not an authenticity proof and does not prove that the logic is correct. The useful evidence is the combination of source identity, runtime labels, per-case results, completion status, and the independent observer result.

The event hook intentionally records only UPDATE statements. For successful whole-document writes, the SQLite statement shape is expected to be equivalent to UPDATE <table> SET payload=? WHERE <table>.id = ?; the fixture itself records the actual statement text and bound-parameter representation for the run. The preparation summary provides per-case UPDATE counts; it does not include the exact captured SQL statements or parameter strings.

The observer is opened only after the writer context exits. That matters because Session.get() first consults a session’s identity map. A “verification” query through the same writer can return or preserve the same Python object and therefore is not an independent persistence oracle. SQLAlchemy documents the identity-map behavior of Session and Session.get(). A separate session after writer close removes that particular ambiguity.

Reproduce plain JSON and test MutableDict boundaries

Q1 and Q2 establish the failure mode without the mutable extension. Q1 changes only the top-level status key on a plain JSON mapping. Q2 changes only settings.retries. The preparation evidence says both cases emitted zero UPDATEs and the observer read the baseline document.

That result follows the documented detection boundary: SQLAlchemy’s base JSON type does not detect an in-place mutation to the Python structure. Python is doing exactly what it should. obj.payload['status'] = 'ready' changes the dictionary object, and obj.payload['settings']['retries'] = 2 changes the nested dictionary. The serializer is not the problem because the serializer is never asked to persist a changed attribute. There is no detected attribute change to serialize.

Operationally, Q1 and Q2 should both be REPAIR. A clean return from commit() does not upgrade them to success. SQLAlchemy documents that commit first flushes, but when no pending changes are detected, no SQL is emitted. Therefore “commit succeeded, JSON unchanged” is not evidence that SQLite rejected an update. In these cases, the ORM never produced one.

The writer memory makes the failure more deceptive. With expire_on_commit=False, the edited Python object can remain edited after commit. Q1 can still show status: ready in the writer while the fresh observer reads status: draft. Q2 can still show retries: 2 while the observer reads 1. This is exactly why writer memory is a diagnostic signal, not an acceptance oracle.

Q4 then changes the mapping to MutableDict.as_mutable(JSON) and repeats only the top-level edit:

obj.payload['status'] = 'ready'

The preparation evidence says Q4 emitted one UPDATE and the independent observer read the top-level change. This is the use case MutableDict is designed to cover: it emits change events when its own dictionary contents change. For a code path whose contract truly permits only top-level dictionary edits, this is a legitimate and low-friction repair. It is not evidence that arbitrary nested JSON edits are now tracked.

Q5 makes that distinction concrete. It uses the same MutableDict mapping but mutates the nested plain dictionary:

obj.payload['settings']['retries'] = 2

The supplied run says Q5 emitted zero UPDATEs and the observer retained the baseline. MutableDict owns the outer mapping, but obj.payload['settings'] is still a regular nested dictionary in this fixture. SQLAlchemy states directly that MutableDict does not apply mutable tracking to values inside itself and is not sufficient for recursive JSON changes without an additional implementation. Custom recursive mutable classes are outside this article’s scope. The nested call path must use a different explicit strategy.

This evidence also separates two kinds of database review. Reconciling SQLite batch identities asks whether a database write can be mapped back to the correct inserted rows. Here the earlier question is whether an ORM-managed UPDATE existed at all. Do not jump ahead to rowcount, connection state, or database-level debugging when the unit of work never marked the JSON attribute as changed.

A useful triage sequence is therefore: inspect the Python object immediately after mutation; inspect ORM change detection; record whether flush attempts an UPDATE; then close the writer and read the full document in a new session. If the Python value changed but no UPDATE appeared, stay at the ORM boundary. If an UPDATE appeared but the observer disagrees, move down to flush, commit, rollback, mapping, and database evidence. The sequence avoids blaming the wrong layer.

The first explicit alternative can now be qualified precisely: top-level MutableDict edits are acceptable only when the mutation itself occurs on the outer mutable dictionary. They solve Q4. They do not solve Q5. Any review standard that says “the column uses MutableDict, therefore nested edits are safe” is too broad for SQLAlchemy’s documented contract.

Build safe replacements and explicit marking without trusting dirty state

The most mechanically robust repair in this bounded lab is to construct a new value before touching the ORM-owned object, edit the new value, and then assign it back to the mapped attribute. Q3 does this on plain JSON; Q7 does it on MutableDict:

candidate = copy.deepcopy(obj.payload)
candidate['settings']['retries'] = 2
obj.payload = candidate

The order is the point. copy.deepcopy() first establishes a separate object graph whose nested settings dictionary is not shared with the currently loaded ORM value. The code then mutates only that candidate. Finally, assignment replaces the mapped attribute with a structurally different document. SQLAlchemy’s JSON documentation says replacement assignment triggers a change event, which is the contract this pattern uses.

The supplied preparation evidence says Q3 and Q7 each emitted one UPDATE and the fresh observer read settings.retries == 2 while preserving status: draft and tags: ['A']. That makes both cases ACCEPT_PERSISTED under this article’s decision rule. The acceptance is not because deep copy is inherently magical; it is because the sequence creates a replacement value, the ORM emits the update, commit completes, and a new session confirms the complete intended document.

This second alternative has clear ownership costs. A full deepcopy creates a second Python object graph. Whole-document assignment normally means the ORM persists the JSON column as a whole rather than expressing a database-specific subdocument operator. For large documents, that may have CPU, memory, serialization, logging, and write-amplification consequences that this tiny fixture does not measure. Those are qualification questions for the real workload, not reasons to weaken the correctness boundary. This article deliberately makes no PostgreSQL, MySQL, JSONB-operator, concurrent-update, or lost-update claims.

The rule “copy before edit” also prevents a subtle aliasing mistake. If application code first mutates a nested dictionary that still belongs to the loaded ORM value, then constructs a shallow outer copy, the supposed replacement may already be equal to the current attribute value. That is what Q8 is designed to expose:

obj.payload['settings']['retries'] = 2
obj.payload = dict(obj.payload)

The nested mutation happens first. dict(obj.payload) then creates a new outer dictionary, but the nested settings dictionary has already been changed through the original object. In the supplied SQLAlchemy 2.0.50 preparation run, Q8 had dirty=True, emitted zero UPDATEs, and the observer retained the baseline. This is a measured patch-level counterexample, not a universal statement that every shallow assignment on every release will behave identically.

The documented reason not to trust dirty membership is broader and stable: SQLAlchemy describes Session.dirty as optimistic and says flush can determine that there is no net attribute change, in which case no SQL operation occurs. Q8 is a particularly useful operational demonstration because it defeats the comforting check engineers often add after discovering a missed update: “the object is dirty now, so we fixed it.” In this case, dirty membership overstates pending SQL.

That is also why preserving child rows during conflict handling is only an adjacent lesson, not the same problem. A database statement can execute and still have unintended relational effects; Q8 stops earlier. Its danger is a Python/ORM state that looks changed while no net UPDATE is attempted.

The third explicit alternative is deliberate flag_modified() after the nested mutation. Q6 does exactly this:

obj.payload['settings']['retries'] = 2
flag_modified(obj, 'payload')

SQLAlchemy’s attribute utilities define flag_modified() as marking a specific attribute modified and establishing an unconditional change event for that attribute; the attribute must have a value present. The supplied preparation evidence says Q6 emitted one UPDATE and persisted the intended nested document, so Q6 is ACCEPT_PERSISTED.

flag_modified() is intentionally an owned alternative, not a recursive mutation framework. Its correctness depends on the caller doing three things together: mutating the intended loaded JSON value, calling flag_modified() on the correct mapped instance and attribute, and doing so before the flush that must carry the change. If one call path forgets the flag, the nested mutation remains vulnerable. If code flags the wrong attribute or object, the notification does not repair the intended write. The API also does not infer what nested path changed or validate business intent; it tells the ORM to treat the loaded attribute as modified.

That leads to a practical selection rule among the three supported approaches. Use top-level MutableDict mutation only for APIs whose edits are genuinely on the outer dictionary. Prefer deep-copy-before-edit replacement when the code can afford explicit whole-document replacement and you want the mutation to be self-contained in one ownership pattern. Use flag_modified() when an in-place nested edit is deliberate, localized, reviewed, and the team can enforce the notification as part of the mutation contract. None of the three alternatives removes the need for readback tests.

Run rollback as a control and reconcile the nine-case persistence ledger

A persistence test without a negative transaction control can accidentally confuse “an UPDATE was emitted” with “the new value is committed.” Q9 exists to keep those statements separate. It uses the tracked mapping, constructs the nested replacement correctly, calls flush(), and then deliberately calls rollback() instead of commit().

The supplied preparation evidence says Q9 emitted one UPDATE and the observer still read the baseline. That is the expected oracle. SQLAlchemy documents that Session.rollback() rolls back the current transaction and that objects remaining in the session are fully expired after rollback regardless of the expire_on_commit setting. In this fixture, memory_after = copy.deepcopy(obj.payload) occurs after rollback, so accessing the expired attribute can reload the baseline rather than preserve the pre-rollback candidate. That behavior is importantly different from the commit cases, where expire_on_commit=False intentionally leaves writer memory unexpired.

Therefore Q9 is ACCEPT_ROLLBACK, not ACCEPT_PERSISTED and not REPAIR. The control passed because the write was attempted and then correctly did not survive the rollback boundary. A diagnostic suite that expected the observer to see retries: 2 after Q9 would be testing the opposite of transactional rollback.

The nine cases can now be reconciled as one persistence ledger. The dirty-state entries below reflect the mutation semantics of the fixture; Q8’s dirty=True is explicitly part of the supplied preparation observation, while the decisive supplied outcomes are the update counts and observer documents. The “attempted SQL” column gives the statement class because the preparation summary does not include exact captured SQL strings.

Case

Mapping and operation

Dirty before flush

Attempted UPDATE

Writer view around commit/rollback

Fresh observer

Decision

Q1

Plain JSON, top-level in-place status edit

No expected ORM notification

None

Shows status: ready with commit retention

Baseline

REPAIR

Q2

Plain JSON, nested in-place retries edit

No expected ORM notification

None

Shows retries: 2 with commit retention

Baseline

REPAIR

Q3

Plain JSON, deep-copy then nested edit and replace

Yes

One whole-column UPDATE

Intended nested document

retries: 2

ACCEPT_PERSISTED

Q4

MutableDict, top-level status edit

Yes

One whole-column UPDATE

Intended top-level document

status: ready

ACCEPT_PERSISTED

Q5

MutableDict, nested in-place retries edit

No outer mutable event expected

None

Shows retries: 2 with commit retention

Baseline

REPAIR

Q6

MutableDict, nested edit plus flag_modified

Yes

One whole-column UPDATE

Intended nested document

retries: 2

ACCEPT_PERSISTED

Q7

MutableDict, deep-copy then nested edit and replace

Yes

One whole-column UPDATE

Intended nested document

retries: 2

ACCEPT_PERSISTED

Q8

MutableDict, nested edit then too-late shallow outer copy

Yes, observed

None

Shows retries: 2 before/after commit

Baseline

REPAIR

Q9

MutableDict, deep-copy replacement, flush, then rollback

Yes before rollback

One UPDATE, then rollback

Post-rollback access resolves to baseline

Baseline

ACCEPT_ROLLBACK

The table exposes why no single intermediate signal is sufficient. Q1, Q2, and Q5 prove that changed Python memory does not imply dirty ORM state or SQL. Q8 proves that even dirty ORM state does not imply SQL. Q9 proves that attempted SQL does not imply committed persistence. Q3, Q4, Q6, and Q7 reach acceptance only because all required stages line up through a fresh-session full-document read.

The “full document” requirement deserves emphasis. For a nested edit, an observer assertion should be equivalent to:

assert actual == {
    'status': 'draft',
    'settings': {'retries': 2},
    'tags': ['A'],
}

Do not weaken that to actual['settings']['retries'] == 2 unless the surrounding fields are separately governed. JSON persistence bugs can involve replacement, omission, coercion, or unintended edits outside the target path. This fixture happens to use a tiny document specifically so a whole-document oracle is cheap and obvious.

Likewise, do not promote the engine event hook into an audit guarantee. before_cursor_execute is intentionally before execution. It proves that SQLAlchemy was about to call the cursor with an UPDATE, which is excellent evidence for locating the mutation boundary. It does not prove that the cursor succeeded, that commit succeeded, that the file survived a crash, or that another concurrent writer did not later replace the value. The fixture combines the attempted statement with successful flush/commit and post-close readback because each closes a different gap. Process-crash durability and concurrency are outside this lab.

Choose ACCEPT_PERSISTED, REPAIR, RERUN, HOLD, or ACCEPT_ROLLBACK

An operational playbook becomes useful when it turns observations into deterministic actions. Use the following decision matrix for this bounded workflow.

Evidence state

Decision

Required action

Independent session reads the exact intended full JSON document after writer close

ACCEPT_PERSISTED

Record mapping, mutation path, update evidence, runtime, and full readback

Python memory changed but ORM notification or UPDATE is absent, or observer remains old

REPAIR

Fix the mutation strategy; do not treat successful commit as success

Deliberate rollback case emits the expected write attempt and independent observer retains the old full document

ACCEPT_ROLLBACK

Keep as a negative persistence oracle

ORM version, mapping, mutation wrapper, driver mode, or database/runtime changes materially

RERUN

Execute the complete controlled ledger under the new qualification target

Intended value, authoritative old state, or writer ownership cannot be established

HOLD

Stop automatic repair; obtain authoritative intent or owner decision

This policy is intentionally stronger than connection-level success. Qualifying SQLite connection enforcement is an example of validating a database connection before trusting business writes. The JSON problem here sits at another boundary: even a correctly admitted connection cannot persist a mutation the ORM never recognized. Connection qualification and mutation qualification solve different failure classes.

ACCEPT_PERSISTED requires exact observer equality, not merely one changed leaf, a true dirty flag, one attempted statement, or a successful return from commit(). This decision should be used for Q3, Q4, Q6, and Q7 in the supplied preparation record.

REPAIR applies to Q1, Q2, Q5, and Q8 because the intended document did not appear in the independent observer. The specific repair should match the call path. A top-level edit can move to MutableDict. A nested edit can use deep-copy-before-edit replacement or a deliberate flag_modified() contract. A Q8-style path must be rewritten so the replacement is built before the original ORM-owned graph is touched.

RERUN is not a euphemism for “try again until green.” It means the qualification target changed. A new SQLAlchemy patch, a mapping change from plain JSON to MutableDict, a refactor from replacement to in-place mutation, a driver transaction-mode change, or a database version change invalidates the old evidence as proof of the new stack. Re-run all cases that establish the relevant boundary, retaining runtime labels and source identity with the results.

The SQLAlchemy version distinction is a good example. The supplied run is 2.0.50, while current 2.0 docs are 2.0.55. The later changelog contains a MutableDict operator fix, which is enough reason not to pretend a 2.0.50 execution is a 2.0.55 execution even though the nonrecursive contract remains documented. Qualification evidence belongs to a concrete runtime and source revision.

HOLD is the most important recovery decision when history is ambiguous. If an application says a request “succeeded” but the database still has the old JSON, the success flag does not reconstruct the user’s intended nested value. Nor does the retained state of some long-lived Python object necessarily provide authoritative intent after the fact. Without the intended value, authoritative previous document, and ownership of the writer action, automated repair can invent data.

ACCEPT_ROLLBACK is reserved for the explicit negative control. It should never be applied to an accidental rollback or a production request whose desired outcome was persistence. The label says the control behaved correctly, not that the business write succeeded.

Recover missed edits and release a mutation policy with regression ownership

Once a missed-write pattern is discovered, the immediate technical fix is only half the job. The larger task is to identify which historical and current code paths could have performed the unsafe mutation and to separate recoverable intent from missing intent.

Search application code for mapped JSON attributes that are mutated in place. The highest-risk forms in this scope are direct outer-dictionary edits on plain JSON, nested dictionary or list mutations under either plain JSON or nonrecursive MutableDict, and “repair” code that mutates first and reassigns a shallow copy later. Also search for helper functions that receive obj.payload, mutate a nested child, and return None; that pattern can hide the mutation boundary from the caller who owns the session.

For each path, classify the intended mutation strategy in code review rather than allowing callers to improvise. A policy can be simple:

  1. Plain JSON values are treated as replace-only application objects. Build a candidate separately, mutate it, then assign it.

  2. MutableDict permits top-level dictionary mutation, but nested mutation still requires replacement or explicit marking.

  3. Any path using flag_modified() owns a tested wrapper that performs the nested edit and the flag in the same operation before flush.

  4. Persistence tests close the writer and assert the full document from a new session.

Lists are included in the policy even though this fixture does not mutate tags. SQLAlchemy’s mutable documentation makes the same nonrecursive point for MutableList: nested values are not automatically tracked merely because the outer list is mutable. The safest policy language therefore describes ownership boundaries, not one special key named settings.

For already-committed missed writes, recovery begins from authoritative intent. Sources might include an idempotent request record, an append-only command log, a validated upstream system of record, or another retained business artifact. This article does not assume any such source exists. When it does exist, reconstruct the complete target document from authoritative old state plus authoritative intended change, apply one of the qualified mutation strategies, and verify through a fresh session.

When intent is absent, HOLD. A 200 response, completed job marker, or “commit succeeded” log line proves at most that the application reached some control-flow boundary. Q1, Q2, Q5, and Q8 demonstrate why that signal cannot tell an operator what nested value should have been stored. Missing intent is not recoverable from transaction success alone.

Release engineering should preserve the evidence that qualifies the repair. Keep the fixture source revision or digest, interpreter version, SQLAlchemy version, SQLite runtime version, mapping under test, driver transaction mode, expire_on_commit choice, per-case update count, finish action, and full observer result. The supplied fixture already emits most of those fields. A digest is useful for identifying exactly which source was tested, but it should not be described as proof that the run was authentic or the code correct.

The regression suite should retain unsafe cases, not only green repairs. Q1 and Q2 prove what plain in-place mutation fails to do. Q5 proves MutableDict is nonrecursive in the tested configuration. Q8 protects against the tempting late-shallow-copy patch. Q9 proves the observer does not merely echo a flushed but rolled-back write. Removing those controls after adopting Q3/Q6/Q7 would make it easier for a later refactor to reintroduce the original error invisibly.

Application rollback is separate from database rollback. If a new wrapper causes unexpected behavior in deployment, revert the application mutation policy to the previously qualified implementation, not to the known-unsafe in-place path. Then RERUN the controlled fixture against the exact code, mapping, ORM, and database combination being considered. The goal is a reversible software change with reproducible evidence, not a runtime flag that silently changes persistence semantics.

Operational observability should also record the distinction between “request completed” and “persistence verified.” Broader surrounding API observability is useful for request tracing and error context, but the database-facing signal here is narrower: whether the intended JSON document actually crossed the ORM and transaction boundaries. Avoid logs that print the writer object after commit and label that output “database state.” With expire_on_commit=False, that is precisely the misleading evidence this lab is designed to expose.

A good production wrapper does not need to run a second read for every ordinary write if the system’s performance model cannot support that. But the mutation strategy itself must be covered by integration tests that do use independent readback, and higher-risk workflows can choose stronger runtime verification based on business requirements. The key engineering lesson is to know which boundary each signal validates instead of treating all green signals as interchangeable.

Strengthen database judgment beyond a successful commit

SQLAlchemy’s unit of work is reliable only when the ORM is given a change it can see. The base JSON type does not detect arbitrary in-place structure mutation; MutableDict adds change events for the outer dictionary but does not recursively instrument nested values; Session.dirty can be optimistic; commit can succeed while emitting no DML; and a writer configured with expire_on_commit=False can retain a value that never reached storage. Those are separate documented behaviors, not one mysterious JSON failure.

The repair playbook is correspondingly concrete. Use top-level MutableDict edits only at the level it actually tracks. For nested edits, either build a deep replacement before touching the original or deliberately call flag_modified() as part of an owned mutation operation. Reject the Q8 pattern in which the original is mutated before a shallow reassignment. Keep rollback as a negative oracle. Above all, accept persistence only when an independent session reads the complete intended document.

The supplied October 23, 2026 preparation evidence supports that boundary for CPython 3.13.5, SQLAlchemy 2.0.50, and SQLite 3.46.1 under the stated single-writer fixture. It does not establish concurrent lost-update protection, vendor-specific JSON operator behavior, crash durability, or behavior of an untested ORM release.

For engineers who want broader grounding in database operations beyond this specific SQLAlchemy lab, Refonte Learning’s Database Administrator Essentials program page lists a three-month, 12–14-hours-per-week curriculum covering database design, SQL optimization, backup and recovery, performance, security, migration, and monitoring, with practical projects, educational mentorship, and a potential internship; the page also states its admission requirements and completion certificates. The page does not list SQLAlchemy, SQLite, or this nested-JSON fixture as named curriculum content.

The principal operational habit is simpler than any particular API: separate mutation, detection, execution, transaction outcome, and readback. When those boundaries are visible, “commit succeeded but JSON stayed unchanged” stops being a paradox. It becomes a diagnosable persistence failure with a defined repair and an evidence-based acceptance gate.