August 30, 2021·4 min read·engineeringpythonsqlitetransactions

Python 3.9.7 rolls back when SQLite context commits fail

Python 3.9.7 repairs SQLite connection context-manager cleanup after a failed commit. The fix also preserves the original exception when rollback succeeds.

A transaction context manager has work left after the last statement in its body succeeds. Its attempt to commit can fail, and that failure needs its own cleanup path. Python 3.9.7, released on August 30, fixes that case in sqlite3. The connection context manager now attempts a rollback after a failed commit so that the transaction does not continue holding the database lock through this path.

The release changelog records the change under issue 27334 and credits Luca Citi and Erlend E. Aasland. The small description identifies an important distinction in failure handling. A successful body and a successfully completed transaction are separate conditions.

I like Python's context-manager protocol because it gives cleanup a defined place in ordinary control flow. That benefit depends on the cleanup implementation handling failures in its own work. A database context cannot treat the absence of an earlier exception as proof that its final operation will succeed.

The body decides which operation comes next

The SQLite documentation shipped with this release describes a connection context manager that commits when the body finishes normally and rolls back when the body raises an exception. That is the intended use visible in application code. It still requires an implementation for the case where the selected commit itself fails.

The connection implementation makes the distinction explicit. pysqlite_connection_exit receives the exception information associated with leaving the body. When those values indicate normal completion, it selects commit and records that choice. Otherwise, it selects rollback.

The new behavior checks the result of that selected operation. If commit failed, it attempts rollback before returning the error to Python. A rollback selected because the body already failed does not enter the same commit-failure branch. The flag records why the operation was attempted, so cleanup can respond to the particular transition that failed.

This matters because a connection may remain alive after the exception reaches application code. The caller can catch the error and keep using the process, but the database state associated with the connection still requires attention. Reporting an error without releasing the transaction's resources leaves that responsibility unfinished.

Cleanup must retain the cause

The correction also handles exception preservation. Before attempting the additional rollback, the implementation saves the commit exception. If rollback succeeds, it restores that original exception. The application therefore receives the failure that prevented the transaction from committing, even though the intervening cleanup completed normally.

If rollback also fails, the implementation chains the exceptions. The caller can see that cleanup encountered another failure while the original commit problem remains part of the exception history. This is more informative than overwriting the commit error with an unrelated-looking cleanup exception.

The implementation's behavior is necessarily an attempt at cleanup. It cannot promise that rollback will always succeed, which is why the second error path exists. The release improves the response to commit failure without turning every database failure into a recoverable transaction.

This is a useful property for diagnostics as well as correctness. The first exception explains why the operation did not complete. A second exception describes the state of the recovery attempt. Preserving both lets a caller distinguish failed work with successful cleanup from failed work whose cleanup also failed.

The context covers a transaction

The release's connection implementation keeps close, commit, and rollback as distinct methods. Its __exit__ calls transaction operations and does not call close. That distinction matters when reading a block such as this one.

import sqlite3

connection = sqlite3.connect(":memory:")
try:
    connection.execute("CREATE TABLE item (value INTEGER)")
    with connection:
        connection.execute("INSERT INTO item VALUES (?)", (7,))
finally:
    connection.close()

The transaction block and connection lifetime have separate boundaries in this example. Regardless of whether leaving the transaction block succeeds, finally closes the connection. This maintenance fix changes what transaction cleanup attempts after a failed commit, so it should not be interpreted as adding automatic connection closure.

The distinction also prevents a misleading explanation of the patch. Its purpose is not to suppress a commit error and allow the program to continue as though the write succeeded. The error still propagates. The change attempts to roll back before the caller handles that error.

I prefer this behavior because it keeps the transaction's failure report aligned with the work that failed. Successful cleanup should release resources without erasing the original cause. When cleanup fails too, the caller needs that additional information rather than an artificially simplified result.

For code using SQLite connections as context managers, Python 3.9.7 makes the exit path more complete. A normal block exit still selects commit, and an exception in the body still selects rollback. A commit failure now triggers a rollback attempt with deliberate exception preservation, which covers the failure that occurs after the body has already finished.