Model.save() returns normally and writes nothing after the instance's INSERT was rolled back

Open
#2,383 2 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 2 days

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
40/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
postgresql, python
Domain
databases

Research direction

The bug is that Model.save() leaves pk and _saved_in_db = True set even when the surrounding transaction (or savepoint) rolls back, so a retry issues a silent UPDATE. Start by reading the save path in tortoise/models.py and how in_transaction() in tortoise/transactions.py propagates rollback to instances saved inside it, then check whether the transaction context can notify or reset the saved state. The reporter's tortoise_save_after_rollback.py script is a ready-made reproduction; run its retry and savepoint arms before and after a fix, and add regression tests covering both, plus that force_update already raises. Done means the second save() either inserts or raises instead of committing zero rows.

Written by the indexing model from the issue text.

Description

Describe the bug

When save() inserts a new instance inside in_transaction() and the block then raises, the transaction rolls back, but the instance keeps its pk and _saved_in_db = True. If the application saves the same instance again, for example on a retry, Tortoise sends an UPDATE for the old pk (UPDATE "item" SET "arm"=$1,"value"=$2 WHERE "id"=$3 in the script below). That matches no row, save() returns normally, the transaction commits, and nothing is written. Changing a field before the retry makes no difference.

The same happens with a savepoint: a nested in_transaction() saves the instance and raises, and the outer block catches the error, saves the instance again and commits without the row.

To Reproduce

The script below runs these arms, each with a new instance, and reads the outcome from a separate connection:

Arm Second call Transaction Rows
retry (the first case above) returned normally committed 0
retry-changed (a field changed before the retry) returned normally committed 0
savepoint (the second case above) returned normally committed 0
savepoint-changed (a field changed before the retry) returned normally committed 0
control (a new instance, saved once) returned normally committed 1
force_update (as retry, with save(force_update=True)) raised IntegrityError rolled back 0

Run it against a disposable server. DATABASE_URL must be able to create databases: the program first drops any database named tortoise_save_after_rollback, creates it, and drops it at the end. Install tortoise-orm[asyncpg,psycopg]==1.1.9: both commands also use asyncpg, for the setup and witness connections.

DATABASE_URL=postgresql://[email protected]:5432/postgres python tortoise_save_after_rollback.py asyncpg
DATABASE_URL=postgresql://[email protected]:5432/postgres python tortoise_save_after_rollback.py psycopg
tortoise_save_after_rollback.py
"""Tortoise ORM: save() after a rolled-back transaction returns normally and writes nothing.

    DATABASE_URL=postgresql://[email protected]:5432/postgres \
      python tortoise_save_after_rollback.py [asyncpg|psycopg]

DATABASE_URL must be able to create a database. The program first drops any database
named tortoise_save_after_rollback, creates it, and drops it at the end. Tortoise uses
the backend named on the command line. A separate asyncpg connection, in autocommit,
reads what PostgreSQL did.

In each arm, save() runs inside in_transaction(), or inside a nested in_transaction()
(a savepoint), and the block then raises, so the INSERT is rolled back. The same
instance is then saved again. The witness reads pg_xact_status() of the transaction IDs
captured inside the transactions, and counts the arm's rows.
"""

import asyncio
import os
import platform
import sys
from urllib.parse import urlsplit, urlunsplit

import asyncpg
import tortoise
from tortoise import Tortoise, fields
from tortoise.exceptions import IntegrityError
from tortoise.models import Model
from tortoise.transactions import in_transaction

URL = os.environ["DATABASE_URL"]
NAME = "tortoise_save_after_rollback"


class Item(Model):
    id = fields.IntField(primary_key=True)
    arm = fields.TextField()
    value = fields.IntField()

    class Meta:
        table = "item"


class AppError(Exception):
    pass


async def current_xid(conn):
    rows = await conn.execute_query_dict("select pg_current_xact_id()::text as xid")
    return rows[0]["xid"]


async def fate(witness, xid):
    status = await witness.fetchval("select pg_xact_status($1::text::xid8)", xid)
    return f"pg_xact_status({xid}) {status}"


async def rows(witness, arm):
    count = await witness.fetchval("select count(*) from item where arm = $1", arm)
    return f"rows with arm={arm!r}: {count}"


async def failed_attempt(witness, obj):
    try:
        async with in_transaction() as conn:
            await obj.save()
            xid = await current_xid(conn)
            raise AppError("the application fails after save()")
    except AppError:
        pass
    print(f"  first save() returned, pk={obj.pk}; the block raised AppError")
    print(f"  first transaction: {await fate(witness, xid)}")
    print(f"  after the rollback: pk={obj.pk}, _saved_in_db={obj._saved_in_db}, {await rows(witness, obj.arm)}")


async def retry(witness, change=False):
    arm = "retry-changed" if change else "retry"
    print(f"{arm}: save() in in_transaction(), the block raises; then"
          + (" value = 2 and" if change else "") + " save() again in a new in_transaction()")
    obj = Item(arm=arm, value=1)
    await failed_attempt(witness, obj)
    if change:
        obj.value = 2
    async with in_transaction() as conn:
        await obj.save()
        xid = await current_xid(conn)
    print("  second save() returned normally")
    print(f"  second transaction: {await fate(witness, xid)}")
    print(f"  {await rows(witness, arm)}")


async def savepoint(witness, change=False):
    arm = "savepoint-changed" if change else "savepoint"
    print(f"{arm}: in an outer in_transaction(), save() in a nested in_transaction() that raises;"
          " the error is caught; then" + (" value = 2 and" if change else "") + " save() again in the outer block")
    obj = Item(arm=arm, value=1)
    async with in_transaction() as conn:
        try:
            async with in_transaction():
                await obj.save()
                raise AppError("the application fails after save()")
        except AppError:
            pass
        print(f"  first save() returned, pk={obj.pk}; the nested block raised AppError")
        print(f"  after the savepoint rollback: pk={obj.pk}, _saved_in_db={obj._saved_in_db}")
        if change:
            obj.value = 2
        await obj.save()
        xid = await current_xid(conn)
        print("  second save() returned normally")
    print("  the outer block exited normally")
    print(f"  outer transaction: {await fate(witness, xid)}")
    print(f"  {await rows(witness, arm)}")


async def control(witness):
    print("control: save() of a new instance in in_transaction()")
    obj = Item(arm="control", value=1)
    async with in_transaction() as conn:
        await obj.save()
        xid = await current_xid(conn)
    print(f"  save() returned, pk={obj.pk}")
    print(f"  transaction: {await fate(witness, xid)}")
    print(f"  {await rows(witness, 'control')}")


async def force_update(witness):
    print("force_update: as retry, but the second call is save(force_update=True)")
    obj = Item(arm="force_update", value=1)
    await failed_attempt(witness, obj)
    try:
        async with in_transaction() as conn:
            xid = await current_xid(conn)
            await obj.save(force_update=True)
        print("  second save(force_update=True) returned normally")
    except IntegrityError as exc:
        print(f"  second save(force_update=True) raised IntegrityError: {exc}")
    print(f"  second transaction: {await fate(witness, xid)}")
    print(f"  {await rows(witness, 'force_update')}")


async def run(backend):
    if backend == "psycopg":
        import psycopg
        import psycopg_pool

        driver = f"psycopg {psycopg.__version__}, psycopg_pool {psycopg_pool.__version__}"
    else:
        driver = f"asyncpg {asyncpg.__version__}"
    print(
        f"python {platform.python_version()}, tortoise-orm {tortoise.__version__},"
        f" backend {backend} ({driver}), {platform.system()} {platform.machine()}"
    )
    admin = await asyncpg.connect(URL, server_settings={"application_name": "repro-admin"})
    print(f"server_version {await admin.fetchval('show server_version')}")
    await admin.execute(f"drop database if exists {NAME} with (force)")
    await admin.execute(f"create database {NAME}")

    url = urlsplit(URL)
    scheme = "postgres" if backend == "asyncpg" else "psycopg"
    db_url = urlunsplit((scheme, url.netloc, f"/{NAME}", "application_name=repro-tortoise", ""))
    await Tortoise.init(db_url=db_url, modules={"models": ["__main__"]})
    await Tortoise.generate_schemas()

    witness = await asyncpg.connect(
        URL, database=NAME, server_settings={"application_name": "repro-witness"}
    )
    await retry(witness)
    await retry(witness, change=True)
    await savepoint(witness)
    await savepoint(witness, change=True)
    await control(witness)
    await force_update(witness)
    await witness.close()

    await Tortoise.close_connections()
    await admin.execute(f"drop database {NAME} with (force)")
    await admin.close()


def main():
    backend = sys.argv[1] if len(sys.argv) > 1 else "asyncpg"
    if backend not in ("asyncpg", "psycopg"):
        sys.exit("usage: tortoise_save_after_rollback.py [asyncpg|psycopg]")
    asyncio.run(run(backend))


if __name__ == "__main__":
    main()
Output with the asyncpg backend (the psycopg runs differ only in the version line and the transaction IDs)
python 3.13.11, tortoise-orm 1.1.9, backend asyncpg (asyncpg 0.32.0), Darwin arm64
server_version 18.6 (Debian 18.6-1.pgdg13+2)
retry: save() in in_transaction(), the block raises; then save() again in a new in_transaction()
  first save() returned, pk=1; the block raised AppError
  first transaction: pg_xact_status(755) aborted
  after the rollback: pk=1, _saved_in_db=True, rows with arm='retry': 0
  second save() returned normally
  second transaction: pg_xact_status(756) committed
  rows with arm='retry': 0
retry-changed: save() in in_transaction(), the block raises; then value = 2 and save() again in a new in_transaction()
  first save() returned, pk=2; the block raised AppError
  first transaction: pg_xact_status(757) aborted
  after the rollback: pk=2, _saved_in_db=True, rows with arm='retry-changed': 0
  second save() returned normally
  second transaction: pg_xact_status(758) committed
  rows with arm='retry-changed': 0
savepoint: in an outer in_transaction(), save() in a nested in_transaction() that raises; the error is caught; then save() again in the outer block
  first save() returned, pk=3; the nested block raised AppError
  after the savepoint rollback: pk=3, _saved_in_db=True
  second save() returned normally
  the outer block exited normally
  outer transaction: pg_xact_status(759) committed
  rows with arm='savepoint': 0
savepoint-changed: in an outer in_transaction(), save() in a nested in_transaction() that raises; the error is caught; then value = 2 and save() again in the outer block
  first save() returned, pk=4; the nested block raised AppError
  after the savepoint rollback: pk=4, _saved_in_db=True
  second save() returned normally
  the outer block exited normally
  outer transaction: pg_xact_status(761) committed
  rows with arm='savepoint-changed': 0
control: save() of a new instance in in_transaction()
  save() returned, pk=5
  transaction: pg_xact_status(763) committed
  rows with arm='control': 1
force_update: as retry, but the second call is save(force_update=True)
  first save() returned, pk=6; the block raised AppError
  first transaction: pg_xact_status(764) aborted
  after the rollback: pk=6, _saved_in_db=True, rows with arm='force_update': 0
  second save(force_update=True) raised IntegrityError: Can't update object that doesn't exist. PK: 6
  second transaction: pg_xact_status(765) aborted
  rows with arm='force_update': 0
Server log for the retry arm (log_statement=all, and auto_explain with log_analyze)
2026-10-25 01:20:48.245 UTC [107] app=repro-tortoise xid=0 LOG:  statement: BEGIN;
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=0 LOG:  execute __asyncpg_stmt_2__: INSERT INTO "item" ("arm","value") VALUES ($1,$2) RETURNING "id"
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=0 DETAIL:  Parameters: $1 = 'retry', $2 = '1'
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=755 LOG:  duration: 0.100 ms  plan:
	Query Text: INSERT INTO "item" ("arm","value") VALUES ($1,$2) RETURNING "id"
	Query Parameters: $1 = 'retry', $2 = '1'
	Insert on item  (cost=0.00..0.01 rows=1 width=40) (actual rows=1.00 loops=1)
	  ->  Result  (cost=0.00..0.01 rows=1 width=40) (actual rows=1.00 loops=1)
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=755 LOG:  execute __asyncpg_stmt_3__: select pg_current_xact_id()::text as xid
2026-10-25 01:20:48.248 UTC [107] app=repro-tortoise xid=755 LOG:  statement: ROLLBACK;
…
2026-10-25 01:20:48.250 UTC [107] app=repro-tortoise xid=0 LOG:  statement: BEGIN;
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 LOG:  execute __asyncpg_stmt_6__: UPDATE "item" SET "arm"=$1,"value"=$2 WHERE "id"=$3
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 DETAIL:  Parameters: $1 = 'retry', $2 = '1', $3 = '1'
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 LOG:  duration: 0.003 ms  plan:
	Query Text: UPDATE "item" SET "arm"=$1,"value"=$2 WHERE "id"=$3
	Query Parameters: $1 = 'retry', $2 = '1', $3 = '1'
	Update on item  (cost=0.15..8.17 rows=0 width=0) (actual rows=0.00 loops=1)
	  ->  Index Scan using item_pkey on item  (cost=0.15..8.17 rows=1 width=42) (actual rows=0.00 loops=1)
	        Index Cond: (id = 1)
	        Index Searches: 1
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 LOG:  execute __asyncpg_stmt_3__: select pg_current_xact_id()::text as xid
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=756 LOG:  statement: COMMIT;

Expected behavior

The second save() should either write the row or raise. It should not return normally after writing nothing. save(force_update=True) already raises in this situation (the force_update arm).

Additional context

  • Tortoise ORM 1.1.9 with asyncpg 0.32.0, and with psycopg 3.3.6 (psycopg_pool 3.3.3), on PostgreSQL 18.6; Python 3.13.11 on macOS arm64. Three runs per backend gave the same result. I ran only the two PostgreSQL backends. I didn't run develop, but Model.save() is unchanged there.
  • From the source: save() takes the update path because _saved_in_db is set, calls execute_update(), and ignores the count it returns (models.py#L1186-L1192). It sets _saved_in_db = True after the statement (models.py#L1198), and I found nothing in Tortoise that resets it when the transaction or savepoint rolls back. With force_update=True the same count raises (models.py#L1180-L1183). In the runs, _saved_in_db was still True after every rollback; with execute_update() wrapped, it returned 0 for every retry, and auto_explain showed each retry's UPDATE matching 0 rows.
  • Django 6.1.2 persisted all four retries on the same server. So did SQLAlchemy 2.1.5, where the rollback makes the object transient and the retry adds it to a session again.

Which direction would you prefer? Or is saving an instance again after a rollback not meant to be supported?

  1. Reset _saved_in_db for the instances inserted in a transaction or savepoint when that scope rolls back. The savepoint arms show that this has to work per savepoint level, since the outer transaction still commits. Open PR #2293 proposes the same kind of per-scope tracking for on_commit() callbacks.
  2. When the UPDATE in the default path matches no row, raise as force_update=True does, or fall back to an INSERT as Django does. Two cautions: execute_update() also returns 0 without sending anything when no field needs updating (executor.py#L489-L490), and #1212 (2022, on 0.18.1 and 0.19.2) reported a count of 0 on MySQL for a row that exists, so the count may not be reliable on every backend.

If one of these works for you, I'm happy to prepare a PR with regression tests for the plain and savepoint cases.

Dominant language
Python
Stars
5.6k
Forks
540
Avg merge
4d 10h
Merged PRs (30d)
31

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from tortoise/tortoise-orm

All issues in tortoise/tortoise-orm

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.