]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
rewrite the docs on SQLite transaction handling
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 12 May 2025 19:25:07 +0000 (15:25 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 12 May 2025 19:25:07 +0000 (15:25 -0400)
SQLite has added the new "connection.autocommit" mode and
associated fixes for pep-249 as of python 3.12.   they plan to
default to using this attribute as of python 3.16.   Get
on top of things by rewriting the whole doc section here, removing
old cruft about sqlalchemy isolation levels that was not correct
in any case, update recipes in a more succinct and unified way.

References: #12585
Change-Id: I9d1de8dcc27f1731ecd3c723718942148dcd0a1a

lib/sqlalchemy/dialects/sqlite/aiosqlite.py
lib/sqlalchemy/dialects/sqlite/base.py
lib/sqlalchemy/dialects/sqlite/pysqlite.py

index ab27e834620edcacb9961537bc023aa1a02cb3bf..ad718a4ae8b8424c3d159e19911e5167b67a24a1 100644 (file)
@@ -50,33 +50,10 @@ in Python and use them directly in SQLite queries as described here: :ref:`pysql
 Serializable isolation / Savepoints / Transactional DDL (asyncio version)
 -------------------------------------------------------------------------
 
-Similarly to pysqlite, aiosqlite does not support SAVEPOINT feature.
+A newly revised version of this important section is now available
+at the top level of the SQLAlchemy SQLite documentation, in the section
+:ref:`sqlite_transactions`.
 
-The solution is similar to :ref:`pysqlite_serializable`. This is achieved by the event listeners in async::
-
-    from sqlalchemy import create_engine, event
-    from sqlalchemy.ext.asyncio import create_async_engine
-
-    engine = create_async_engine("sqlite+aiosqlite:///myfile.db")
-
-
-    @event.listens_for(engine.sync_engine, "connect")
-    def do_connect(dbapi_connection, connection_record):
-        # disable aiosqlite's emitting of the BEGIN statement entirely.
-        # also stops it from emitting COMMIT before any DDL.
-        dbapi_connection.isolation_level = None
-
-
-    @event.listens_for(engine.sync_engine, "begin")
-    def do_begin(conn):
-        # emit our own BEGIN
-        conn.exec_driver_sql("BEGIN")
-
-.. warning:: When using the above recipe, it is advised to not use the
-   :paramref:`.Connection.execution_options.isolation_level` setting on
-   :class:`_engine.Connection` and :func:`_sa.create_engine`
-   with the SQLite driver,
-   as this function necessarily will also alter the ".isolation_level" setting.
 
 .. _aiosqlite_pooling:
 
index 1501e594f354878453a37be6337de303010f9d29..b78423d3297f299607c871b0adbcaf004ccbbd01 100644 (file)
@@ -136,95 +136,199 @@ name to be ``INTEGER`` when compiled against SQLite::
 
     `Datatypes In SQLite Version 3 <https://sqlite.org/datatype3.html>`_
 
-.. _sqlite_concurrency:
-
-Database Locking Behavior / Concurrency
----------------------------------------
-
-SQLite is not designed for a high level of write concurrency. The database
-itself, being a file, is locked completely during write operations within
-transactions, meaning exactly one "connection" (in reality a file handle)
-has exclusive access to the database during this period - all other
-"connections" will be blocked during this time.
-
-The Python DBAPI specification also calls for a connection model that is
-always in a transaction; there is no ``connection.begin()`` method,
-only ``connection.commit()`` and ``connection.rollback()``, upon which a
-new transaction is to be begun immediately.  This may seem to imply
-that the SQLite driver would in theory allow only a single filehandle on a
-particular database file at any time; however, there are several
-factors both within SQLite itself as well as within the pysqlite driver
-which loosen this restriction significantly.
-
-However, no matter what locking modes are used, SQLite will still always
-lock the database file once a transaction is started and DML (e.g. INSERT,
-UPDATE, DELETE) has at least been emitted, and this will block
-other transactions at least at the point that they also attempt to emit DML.
-By default, the length of time on this block is very short before it times out
-with an error.
-
-This behavior becomes more critical when used in conjunction with the
-SQLAlchemy ORM.  SQLAlchemy's :class:`.Session` object by default runs
-within a transaction, and with its autoflush model, may emit DML preceding
-any SELECT statement.   This may lead to a SQLite database that locks
-more quickly than is expected.   The locking mode of SQLite and the pysqlite
-driver can be manipulated to some degree, however it should be noted that
-achieving a high degree of write-concurrency with SQLite is a losing battle.
-
-For more information on SQLite's lack of write concurrency by design, please
-see
-`Situations Where Another RDBMS May Work Better - High Concurrency
-<https://www.sqlite.org/whentouse.html>`_ near the bottom of the page.
-
-The following subsections introduce areas that are impacted by SQLite's
-file-based architecture and additionally will usually require workarounds to
-work when using the pysqlite driver.
+.. _sqlite_transactions:
+
+Transactions with SQLite and the sqlite3 driver
+-----------------------------------------------
+
+As a file-based database, SQLite's approach to transactions differs from
+traditional databases in many ways.  Additionally, the ``sqlite3`` driver
+standard with Python (as well as the async version ``aiosqlite`` which builds
+on top of it) has several quirks, workarounds, and API features in the
+area of transaction control, all of which generally need to be addressed when
+constructing a SQLAlchemy application that uses SQLite.
+
+Legacy Transaction Mode with the sqlite3 driver
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The most important aspect of transaction handling with the sqlite3 driver is
+that it defaults (which will continue through Python 3.15 before being
+removed in Python 3.16) to legacy transactional behavior which does
+not strictly follow :pep:`249`.  The way in which the driver diverges from the
+PEP is that it does not "begin" a transaction automatically as dictated by
+:pep:`249` except in the case of DML statements, e.g. INSERT, UPDATE, and
+DELETE.   Normally, :pep:`249` dictates that a BEGIN must be emitted upon
+the first SQL statement of any kind, so that all subsequent operations will
+be established within a transaction until ``connection.commit()`` has been
+called.   The ``sqlite3`` driver, in an effort to be easier to use in
+highly concurrent environments, skips this step for DQL (e.g. SELECT) statements,
+and also skips it for DDL (e.g. CREATE TABLE etc.) statements for more legacy
+reasons.  Statements such as SAVEPOINT are also skipped.
+
+In modern versions of the ``sqlite3`` driver as of Python 3.12, this legacy
+mode of operation is referred to as
+`"legacy transaction control" <https://docs.python.org/3/library/sqlite3.html#sqlite3-transaction-control-isolation-level>`_, and is in
+effect by default due to the ``Connection.autocommit`` parameter being set to
+the constant ``sqlite3.LEGACY_TRANSACTION_CONTROL``.  Prior to Python 3.12,
+the ``Connection.autocommit`` attribute did not exist.
+
+The implications of legacy transaction mode include:
+
+* **Incorrect support for transactional DDL** - statements like CREATE TABLE, ALTER TABLE,
+  CREATE INDEX etc. will not automatically BEGIN a transaction if one were not
+  started already, leading to the changes by each statement being
+  "autocommitted" immediately unless BEGIN were otherwise emitted first.   Very
+  old (pre Python 3.6) versions of SQLite would also force a COMMIT for these
+  operations even if a transaction were present, however this is no longer the
+  case.
+* **SERIALIZABLE behavior not fully functional** - SQLite's transaction isolation
+  behavior is normally consistent with SERIALIZABLE isolation, as it is a file-
+  based system that locks the database file entirely for write operations,
+  preventing COMMIT until all reader transactions (and associated file locks)
+  have completed.  However, sqlite3's legacy transaction mode fails to emit BEGIN for SELECT
+  statements, which causes these SELECT statements to no longer be "repeatable",
+  failing one of the consistency guarantees of SERIALIZABLE.
+* **Incorrect behavior for SAVEPOINT** - as the SAVEPOINT statement does not
+  imply a BEGIN, a new SAVEPOINT emitted before a BEGIN will function on its
+  own but fails to participate in the enclosing transaction, meaning a ROLLBACK
+  of the transaction will not rollback elements that were part of a released
+  savepoint.
+
+Legacy transaction mode first existed in order to faciliate working around
+SQLite's file locks.  Because SQLite relies upon whole-file locks, it is easy to
+get "database is locked" errors, particularly when newer features like "write
+ahead logging" are disabled.   This is a key reason why ``sqlite3``'s legacy
+transaction mode is still the default mode of operation; disabling it will
+produce behavior that is more susceptible to locked database errors.  However
+note that **legacy transaction mode will no longer be the default** in a future
+Python version (3.16 as of this writing).
+
+.. _sqlite_enabling_transactions:
+
+Enabling Non-Legacy SQLite Transactional Modes with the sqlite3 or aiosqlite driver
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Current SQLAlchemy support allows either for setting the
+``.Connection.autocommit`` attribute, most directly by using a
+:func:`._sa.create_engine` parameter, or if on an older version of Python where
+the attribute is not available, using event hooks to control the behavior of
+BEGIN.
+
+* **Enabling modern sqlite3 transaction control via the autocommit connect parameter** (Python 3.12 and above)
+
+  To use SQLite in the mode described at `Transaction control via the autocommit attribute <https://docs.python.org/3/library/sqlite3.html#transaction-control-via-the-autocommit-attribute>`_,
+  the most straightforward approach is to set the attribute to its recommended value
+  of ``False`` at the connect level using :paramref:`_sa.create_engine.connect_args``::
+
+    from sqlalchemy import create_engine
+
+    engine = create_engine(
+        "sqlite:///myfile.db", connect_args={"autocommit": False}
+    )
+
+  This parameter is also passed through when using the aiosqlite driver::
+
+    from sqlalchemy.ext.asyncio import create_async_engine
+
+    engine = create_async_engine(
+        "sqlite+aiosqlite:///myfile.db", connect_args={"autocommit": False}
+    )
+
+  The parameter can also be set at the attribute level using the :meth:`.PoolEvents.connect`
+  event hook, however this will only work for sqlite3, as aiosqlite does not yet expose this
+  attribute on its ``Connection`` object::
+
+    from sqlalchemy import create_engine, event
+
+    engine = create_engine("sqlite:///myfile.db")
+
+
+    @event.listens_for(engine, "connect")
+    def do_connect(dbapi_connection, connection_record):
+        # enable autocommit=False mode
+        dbapi_connection.autocommit = False
+
+* **Using SQLAlchemy to emit BEGIN in lieu of SQLite's transaction control** (all Python versions, sqlite3 and aiosqlite)
+
+  For older versions of ``sqlite3`` or for cross-compatiblity with older and
+  newer versions, SQLAlchemy can also take over the job of transaction control.
+  This is achieved by using the :meth:`.ConnectionEvents.begin` hook
+  to emit the "BEGIN" command directly, while also disabling SQLite's control
+  of this command using the :meth:`.PoolEvents.connect` event hook to set the
+  ``Connection.isolation_level`` attribute to ``None``::
+
+
+    from sqlalchemy import create_engine, event
+
+    engine = create_engine("sqlite:///myfile.db")
+
+
+    @event.listens_for(engine, "connect")
+    def do_connect(dbapi_connection, connection_record):
+        # disable sqlite3's emitting of the BEGIN statement entirely.
+        dbapi_connection.isolation_level = None
+
+
+    @event.listens_for(engine, "begin")
+    def do_begin(conn):
+        # emit our own BEGIN.   sqlite3 still emits COMMIT/ROLLBACK correctly
+        conn.exec_driver_sql("BEGIN")
+
+  When using the asyncio variant ``aiosqlite``, refer to ``engine.sync_engine``
+  as in the example below::
+
+    from sqlalchemy import create_engine, event
+    from sqlalchemy.ext.asyncio import create_async_engine
+
+    engine = create_async_engine("sqlite+aiosqlite:///myfile.db")
+
+
+    @event.listens_for(engine.sync_engine, "connect")
+    def do_connect(dbapi_connection, connection_record):
+        # disable aiosqlite's emitting of the BEGIN statement entirely.
+        dbapi_connection.isolation_level = None
+
+
+    @event.listens_for(engine.sync_engine, "begin")
+    def do_begin(conn):
+        # emit our own BEGIN.  aiosqlite still emits COMMIT/ROLLBACK correctly
+        conn.exec_driver_sql("BEGIN")
 
 .. _sqlite_isolation_level:
 
-Transaction Isolation Level / Autocommit
-----------------------------------------
-
-SQLite supports "transaction isolation" in a non-standard way, along two
-axes.  One is that of the
-`PRAGMA read_uncommitted <https://www.sqlite.org/pragma.html#pragma_read_uncommitted>`_
-instruction.   This setting can essentially switch SQLite between its
-default mode of ``SERIALIZABLE`` isolation, and a "dirty read" isolation
-mode normally referred to as ``READ UNCOMMITTED``.
-
-SQLAlchemy ties into this PRAGMA statement using the
-:paramref:`_sa.create_engine.isolation_level` parameter of
-:func:`_sa.create_engine`.
-Valid values for this parameter when used with SQLite are ``"SERIALIZABLE"``
-and ``"READ UNCOMMITTED"`` corresponding to a value of 0 and 1, respectively.
-SQLite defaults to ``SERIALIZABLE``, however its behavior is impacted by
-the pysqlite driver's default behavior.
-
-When using the pysqlite driver, the ``"AUTOCOMMIT"`` isolation level is also
-available, which will alter the pysqlite connection using the ``.isolation_level``
-attribute on the DBAPI connection and set it to None for the duration
-of the setting.
-
-The other axis along which SQLite's transactional locking is impacted is
-via the nature of the ``BEGIN`` statement used.   The three varieties
-are "deferred", "immediate", and "exclusive", as described at
-`BEGIN TRANSACTION <https://sqlite.org/lang_transaction.html>`_.   A straight
-``BEGIN`` statement uses the "deferred" mode, where the database file is
-not locked until the first read or write operation, and read access remains
-open to other transactions until the first write operation.  But again,
-it is critical to note that the pysqlite driver interferes with this behavior
-by *not even emitting BEGIN* until the first write operation.
+Using SQLAlchemy's Driver Level AUTOCOMMIT Feature with SQLite
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-.. warning::
+SQLAlchemy has a comprehensive database isolation feature with optional
+autocommit support that is introduced in the section :ref:`dbapi_autocommit`.
 
-    SQLite's transactional scope is impacted by unresolved
-    issues in the pysqlite driver, which defers BEGIN statements to a greater
-    degree than is often feasible. See the section :ref:`pysqlite_serializable`
-    or :ref:`aiosqlite_serializable` for techniques to work around this behavior.
+For the ``sqlite3`` and ``aiosqlite`` drivers, SQLAlchemy only includes
+built-in support for "AUTOCOMMIT".    Note that this mode is currently incompatible
+with the non-legacy isolation mode hooks documented in the previous
+section at :ref:`sqlite_enabling_transactions`.
 
-.. seealso::
+To use the ``sqlite3`` driver with SQLAlchemy driver-level autocommit,
+create an engine setting the :paramref:`_sa.create_engine.isolation_level`
+parameter to "AUTOCOMMIT"::
+
+    eng = create_engine("sqlite:///myfile.db", isolation_level="AUTOCOMMIT")
+
+When using the above mode, any event hooks that set the sqlite3 ``Connection.autocommit``
+parameter away from its default of ``sqlite3.LEGACY_TRANSACTION_CONTROL``
+as well as hooks that emit ``BEGIN`` should be disabled.
+
+Additional Reading for SQLite / sqlite3 transaction control
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Links with important information on SQLite, the sqlite3 driver,
+as well as long historical conversations on how things got to their current state:
+
+* `Isolation in SQLite <https://www.sqlite.org/isolation.html>`_ - on the SQLite website
+* `Transaction control <https://docs.python.org/3/library/sqlite3.html#transaction-control>`_ - describes the sqlite3 autocommit attribute as well
+  as the legacy isolation_level attribute.
+* `sqlite3 SELECT does not BEGIN a transaction, but should according to spec <https://github.com/python/cpython/issues/54133>`_ - imported Python standard library issue on github
+* `sqlite3 module breaks transactions and potentially corrupts data <https://github.com/python/cpython/issues/54949>`_ - imported Python standard library issue on github
 
-    :ref:`dbapi_autocommit`
 
 INSERT/UPDATE/DELETE...RETURNING
 ---------------------------------
@@ -264,38 +368,6 @@ To specify an explicit ``RETURNING`` clause, use the
 
 .. versionadded:: 2.0  Added support for SQLite RETURNING
 
-SAVEPOINT Support
-----------------------------
-
-SQLite supports SAVEPOINTs, which only function once a transaction is
-begun.   SQLAlchemy's SAVEPOINT support is available using the
-:meth:`_engine.Connection.begin_nested` method at the Core level, and
-:meth:`.Session.begin_nested` at the ORM level.   However, SAVEPOINTs
-won't work at all with pysqlite unless workarounds are taken.
-
-.. warning::
-
-    SQLite's SAVEPOINT feature is impacted by unresolved
-    issues in the pysqlite and aiosqlite drivers, which defer BEGIN statements
-    to a greater degree than is often feasible. See the sections
-    :ref:`pysqlite_serializable` and :ref:`aiosqlite_serializable`
-    for techniques to work around this behavior.
-
-Transactional DDL
-----------------------------
-
-The SQLite database supports transactional :term:`DDL` as well.
-In this case, the pysqlite driver is not only failing to start transactions,
-it also is ending any existing transaction when DDL is detected, so again,
-workarounds are required.
-
-.. warning::
-
-    SQLite's transactional DDL is impacted by unresolved issues
-    in the pysqlite driver, which fails to emit BEGIN and additionally
-    forces a COMMIT to cancel any transaction when DDL is encountered.
-    See the section :ref:`pysqlite_serializable`
-    for techniques to work around this behavior.
 
 .. _sqlite_foreign_keys:
 
index a2f8ce0ac2f397a58a7da114166263bf7b65f99d..d4b1518a3efc87c1d3a4465bf9459a58b079e29e 100644 (file)
@@ -352,76 +352,10 @@ Then use the above ``MixedBinary`` datatype in the place where
 Serializable isolation / Savepoints / Transactional DDL
 -------------------------------------------------------
 
-In the section :ref:`sqlite_concurrency`, we refer to the pysqlite
-driver's assortment of issues that prevent several features of SQLite
-from working correctly.  The pysqlite DBAPI driver has several
-long-standing bugs which impact the correctness of its transactional
-behavior.   In its default mode of operation, SQLite features such as
-SERIALIZABLE isolation, transactional DDL, and SAVEPOINT support are
-non-functional, and in order to use these features, workarounds must
-be taken.
+A newly revised version of this important section is now available
+at the top level of the SQLAlchemy SQLite documentation, in the section
+:ref:`sqlite_transactions`.
 
-The issue is essentially that the driver attempts to second-guess the user's
-intent, failing to start transactions and sometimes ending them prematurely, in
-an effort to minimize the SQLite databases's file locking behavior, even
-though SQLite itself uses "shared" locks for read-only activities.
-
-SQLAlchemy chooses to not alter this behavior by default, as it is the
-long-expected behavior of the pysqlite driver; if and when the pysqlite
-driver attempts to repair these issues, that will be more of a driver towards
-defaults for SQLAlchemy.
-
-The good news is that with a few events, we can implement transactional
-support fully, by disabling pysqlite's feature entirely and emitting BEGIN
-ourselves. This is achieved using two event listeners::
-
-    from sqlalchemy import create_engine, event
-
-    engine = create_engine("sqlite:///myfile.db")
-
-
-    @event.listens_for(engine, "connect")
-    def do_connect(dbapi_connection, connection_record):
-        # disable pysqlite's emitting of the BEGIN statement entirely.
-        # also stops it from emitting COMMIT before any DDL.
-        dbapi_connection.isolation_level = None
-
-
-    @event.listens_for(engine, "begin")
-    def do_begin(conn):
-        # emit our own BEGIN
-        conn.exec_driver_sql("BEGIN")
-
-.. warning:: When using the above recipe, it is advised to not use the
-   :paramref:`.Connection.execution_options.isolation_level` setting on
-   :class:`_engine.Connection` and :func:`_sa.create_engine`
-   with the SQLite driver,
-   as this function necessarily will also alter the ".isolation_level" setting.
-
-
-Above, we intercept a new pysqlite connection and disable any transactional
-integration.   Then, at the point at which SQLAlchemy knows that transaction
-scope is to begin, we emit ``"BEGIN"`` ourselves.
-
-When we take control of ``"BEGIN"``, we can also control directly SQLite's
-locking modes, introduced at
-`BEGIN TRANSACTION <https://sqlite.org/lang_transaction.html>`_,
-by adding the desired locking mode to our ``"BEGIN"``::
-
-    @event.listens_for(engine, "begin")
-    def do_begin(conn):
-        conn.exec_driver_sql("BEGIN EXCLUSIVE")
-
-.. seealso::
-
-    `BEGIN TRANSACTION <https://sqlite.org/lang_transaction.html>`_ -
-    on the SQLite site
-
-    `sqlite3 SELECT does not BEGIN a transaction <https://bugs.python.org/issue9924>`_ -
-    on the Python bug tracker
-
-    `sqlite3 module breaks transactions and potentially corrupts data <https://bugs.python.org/issue10740>`_ -
-    on the Python bug tracker
 
 .. _pysqlite_udfs: