How to check transaction isolation level

Open two processes against the same database, hold an uncommitted UPDATE in one and read the row from the other. On SQLite there is no level to set: it is serializable, one writer at a time. The reader saw 500 throughout the writer's transaction and 900 only after its own COMMIT.

Why check this

Isolation decides what a concurrent request sees, and the answer changes with the engine and with the journal mode. Check it when a service moves to a new database, when a report and a writer start running at the same hour, and when a bug report says two users saw different totals a second apart.

The failure this catches is the stale read that nobody notices. A reporting query opens a long transaction, a writer commits during it, and the report keeps serving the snapshot it opened with. The numbers are internally consistent and minutes old, and no error is logged anywhere.

Prerequisites

CREATE TABLE orders (id INTEGER PRIMARY KEY, ref TEXT NOT NULL UNIQUE, total INTEGER NOT NULL);
INSERT INTO orders (ref, total) VALUES ('A-1000', 500);
#!/bin/sh
# Hold a write lock on shop.db from the sqlite3 shell for $1 seconds, then commit.
{ echo ".bail on"
  echo "BEGIN IMMEDIATE;"
  echo "UPDATE orders SET total = 900 WHERE ref = 'A-1000';"
  echo "SELECT 'shell: uncommitted UPDATE in place';"
  sleep "$1"
  echo "COMMIT;"
  echo "SELECT 'shell: COMMIT returned without error';"
} | sqlite3 shop.db
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('shop.db');
const t0 = Date.now();
const at = () => String(Date.now() - t0).padStart(4) + ' ms';
const total = () => db.prepare("SELECT total FROM orders WHERE ref='A-1000'").get().total;
db.exec('BEGIN');                       // deferred: the snapshot opens at the first read
console.log('%s reader  read 1 inside transaction: %d', at(), total());
setTimeout(() => {
  console.log('%s reader  read 2 inside the same transaction: %d', at(), total());
  db.exec('COMMIT');
  console.log('%s reader  read 3 after COMMIT: %d', at(), total());
  db.close();
}, 4000);

Steps

  1. Step 1.

    Ask the engine for its isolation level the way the other engines are asked.

    sqlite3 shop.db <<'EOF'
    .bail off
    SELECT 'journal_mode        = ' || (SELECT * FROM pragma_journal_mode);
    SELECT 'read_uncommitted    = ' || (SELECT * FROM pragma_read_uncommitted);
    SHOW TRANSACTION ISOLATION LEVEL;
    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    EOF
    
    journal_mode        = delete
    read_uncommitted    = 0
    Parse error near line 4: near "SHOW": syntax error
    SHOW TRANSACTION ISOLATION LEVEL;
    ^--- error here
    Parse error near line 5: near "SET": syntax error
    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    ^--- error here

    There is no level to report and none to set. What SQLite has instead is a journal mode, and delete is the default. Record that value before you interpret anything below it.

  2. Step 2.

    Run the writer and the reader together in the default journal mode.

    ( ./hold.sh 3 & sleep 0.7; node --disable-warning=ExperimentalWarning reader.mjs; wait )
    
    shell: uncommitted UPDATE in place
     0 ms reader  read 1 inside transaction: 500
    Runtime error near line 5: database is locked (5)
    4005 ms reader  read 2 inside the same transaction: 500
    4006 ms reader  read 3 after COMMIT: 500

    Read line 3. The writer's COMMIT failed because the reader was holding a read transaction. In this mode a long report can stop a write from landing, and the value stays 500 everywhere.

  3. Step 3.

    Switch the database to write-ahead logging and run the identical test.

    rm -f shop.db* && sqlite3 shop.db < schema.sql && sqlite3 shop.db "PRAGMA journal_mode=WAL;" && ( ./hold.sh 3 & sleep 0.7; node --disable-warning=ExperimentalWarning reader.mjs; wait )
    
    wal
    shell: uncommitted UPDATE in place
     0 ms reader  read 1 inside transaction: 500
    shell: COMMIT returned without error
    4009 ms reader  read 2 inside the same transaction: 500
    4010 ms reader  read 3 after COMMIT: 900

    Four facts in five lines. The commit now succeeds while the reader is open. Read 1 is 500, so there is no dirty read. Read 2 is still 500 after the commit landed, so the transaction is repeatable. Read 3, on a new transaction, is 900.

  4. Step 4.

    Try to get a dirty read on purpose. Save as dirty-reader.mjs, then run it against the same writer.

    import { DatabaseSync } from 'node:sqlite';
    const db = new DatabaseSync('shop.db');
    db.exec('PRAGMA read_uncommitted = 1');
    console.log('reader  read_uncommitted reports %d',
      db.prepare('PRAGMA read_uncommitted').get().read_uncommitted);
    console.log('reader  total while the writer holds an uncommitted UPDATE: %d',
      db.prepare("SELECT total FROM orders WHERE ref='A-1000'").get().total);
    db.close();
    

    Recreate the database in WAL mode and run the pair.

    rm -f shop.db* && sqlite3 shop.db < schema.sql && sqlite3 shop.db "PRAGMA journal_mode=WAL;" > /dev/null && ( ./hold.sh 3 & sleep 0.7; node --disable-warning=ExperimentalWarning dirty-reader.mjs; wait )
    
    shell: uncommitted UPDATE in place
    reader  read_uncommitted reports 1
    reader  total while the writer holds an uncommitted UPDATE: 500
    shell: COMMIT returned without error

    The pragma accepted the value and reads it back as 1. The read is still clean. read_uncommitted only lowers isolation between connections that share a cache inside one process, and two processes never do.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The reader sees a value the writer has not committed | A dirty read, and this engine does not do it | Recheck that both sides are separate processes on the same file. | | Two reads in one transaction differ | The transaction is not repeatable on that engine | Name the engine and its level in the defect, not the application code. | | The writer's COMMIT fails while a reader is open | Rollback journal mode, where a reader blocks the final write lock | Set PRAGMA journal_mode=WAL and rerun step 2 before filing anything. | | A value keeps its old total long after the writer committed | A read transaction left open across requests | Find the missing COMMIT. The snapshot lives as long as the transaction. |

Thresholds

SQLite serves one writer at a time and gives every read transaction a serializable snapshot. There are no selectable levels. Source: https://www.sqlite.org/isolation.html

Common mistakes

Sign: The isolation test is written with two connections opened inside one program.Cause: Connections in one process can share a page cache, which is the one situation where read_uncommitted changes what a query returns. Two operating system processes, as in step 2, is the arrangement a service and a report actually have.
Sign: PRAGMA read_uncommitted reports 1 and the isolation report says dirty reads are possible.Cause: Setting it succeeds and changes nothing across processes. In step 4 the pragma read back as 1 and the query still returned the committed value. A pragma that accepts a value is not a pragma that applied it.
Sign: A concurrency result is reported without the journal mode.Cause: The same two processes gave opposite answers in steps 2 and 3. In `delete` mode the writer could not commit at all; in `wal` mode it committed while the reader carried on. Read `PRAGMA journal_mode` first and put the value in the report.
Sign: A four level menu is assumed because every database has one.Cause: SQLite answers both `SHOW TRANSACTION ISOLATION LEVEL` and `SET TRANSACTION ISOLATION LEVEL` with a parse error. Read committed, repeatable read and their siblings come from other engines, and none of them was exercised on this machine, so treat those names as unverified until you run this procedure against the engine in question.

What to check next

FAQ

How do I show the transaction isolation level?

It depends on the engine, and on SQLite there is nothing to show. SHOW TRANSACTION ISOLATION LEVEL returns a parse error, as step 1 records. Report the journal mode and the result of the two process test instead, since that is what the behaviour actually follows.

Does WAL mode lower the isolation level?

No. It changes who blocks whom. In step 3 the reader kept a serializable snapshot while the writer committed, which rollback journal mode did not allow at all. The reader never saw an uncommitted value in either mode.

Why does my second read return the same stale row?

The transaction is still open, so it is still serving the snapshot taken at its first read. That is the guarantee working. Commit or roll back between reads when you want current data, as read 3 in step 3 does.

Can I test read committed or repeatable read here?

Not on SQLite. Those names belong to engines this procedure was not run against. Run the same two process script against that engine, with its own client, and record what its reads return before quoting a level.

Which process should hold the write?

The one that behaves like your service. This page holds the write in the shell and reads from Node, so the slow reader is the application. Swap them and rerun if your real writer is the application.

Verified

Verified by Maks Vernysqlite3 shell (Android platform-tools) 3.50.6node 22.23.2node:sqlite SQLite 3.51.3

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

advanced12 minpublished updated Maks Verny