DEV BOX

'26/11/04 UPDATE

SQLITE / FIELD NOTES

I declared a FOREIGN KEY in SQLite. Why can an order for a missing customer still get in?

SQLite's foreign key check is off by default and must be turned on for each connection. Real runs on sqlite3 3.54.0, Python 3.12.14 and Node.js v24.21.0 compare when an order for a missing customer goes in and when it is refused, then cover the transactions and typos that silently leave the check off, and PRAGMA foreign_key_check for finding rows already in the file.

You wrote REFERENCES on a SQLite table, and an order pointing at a customer who does not exist is still saved without an error. The constraint is not wrong. SQLite's foreign key check is off by default and has to be turned on for each connection. After reading this you can confirm whether the current connection enforces it, avoid the cases where turning it on silently does nothing, and find the bad rows already in the file.

The examples were run on 2026-11-01 on macOS 27.0.1. The sqlite3 shell is the 3.54.0 that ships with macOS, Python is the system 3.9.6 (SQLite 3.54.0) and a uv-installed 3.12.14 (SQLite 3.53.1), and Node.js is v24.21.0 (SQLite 3.53.4). The customers and orders are fictional. Other operating systems and ORMs were not run.

The constraint is stored, but the check is off

Two ordinary tables, where an order points at a customer.

CREATE TABLE customers (
  id   INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);
CREATE TABLE orders (
  id          INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL
              REFERENCES customers(id) ON DELETE CASCADE,
  item        TEXT NOT NULL
);
INSERT INTO customers VALUES (1, 'Mina');
INSERT INTO orders VALUES (100, 1, 'keyboard');

In the examples below, each sqlite3 shop.db "..." line is one new connection. On a new connection PRAGMA foreign_keys was 0, and an INSERT pointing at the missing customer 999 finished with no error and exit code 0. SELECT then showed 101|999|ghost.

The SQLite documentation says foreign key constraints are off by default for backwards compatibility and must be enabled for each connection. The default has been OFF since 3.6.19, and because that might change, the documentation tells applications to set it themselves and not rely on the default.

Even with the check off, the schema keeps the constraint. PRAGMA foreign_key_check lists the rows that violate it, and on a connection with the check off it still returned orders|101|customers|0. The four fields are the child table, that row's rowid, the parent table and the constraint number.

sqlite3 3.54.0 output. PRAGMA foreign_keys is 0. INSERT INTO orders VALUES (101, 999, 'ghost') exits 0. SELECT shows 100|1|keyboard and 101|999|ghost. PRAGMA foreign_key_check shows orders|101|customers|0. INSERT after PRAGMA foreign_keys = ON fails with Error in 2nd command line argument: FOREIGN KEY constraint failed, exit 1. On the next connection PRAGMA foreign_keys is 0 again. On a connection with ON, 101|999|ghost can still be read and orders|101|customers|0 is still listed.
Actual output: on a new connection the check is off and the bad order goes in; only a connection that turned it on refuses.

Turning it on covers one connection, and old rows stay

On a connection that ran PRAGMA foreign_keys = ON first, the same kind of INSERT was refused with FOREIGN KEY constraint failed and exit code 1. The next command is a new connection, though, and the value was back to 0. The setting belongs to the connection, not to the database file. An app with a connection pool has to run this statement at the point where each new connection is created.

On the connection with the check on, order 101 from earlier could still be read and still appeared in foreign_key_check. Turning the check on did not go back over existing rows. Before enabling it on a file already in use, find the violating rows with this command first. Whether to delete them or restore the parent depends on the data, so this note does not decide that.

Two silent failures, and a CASCADE that does not run

The first is inside a transaction. After BEGIN, running PRAGMA foreign_keys = ON and reading the value gave 0. The documentation also says that this PRAGMA does nothing inside a transaction and returns no error.

The second is a typo. PRAGMA foreign_key = ON, with the s missing, also finished without an error, and the value was 0. The documentation says SQLite simply ignores an unknown PRAGMA. In both cases the statement that turns the check on tells you nothing; only reading the value back does.

ON DELETE CASCADE did not run on a connection with the check off either. After customer 1 was deleted, order 100 remained and foreign_key_check grew to two lines. On a connection with the check on, deleting customer 2 also deleted order 103, so the count was 0.

sqlite3 3.54.0 output. BEGIN; PRAGMA foreign_keys = ON; PRAGMA foreign_keys; COMMIT; prints 0. PRAGMA foreign_key = ON; PRAGMA foreign_keys; also prints 0. After DELETE FROM customers WHERE id = 1, SELECT shows 100|1|keyboard and 101|999|ghost. PRAGMA foreign_key_check shows orders|100|customers|0 and orders|101|customers|0. On a connection with ON, after adding customer 2 and order 103 and deleting customer 2, the count of order 103 is 0.
Actual output of the two silent failures, and of ON DELETE CASCADE not running on a connection with the check off.

The same file behaves differently depending on who opens it

The default depended on what wraps SQLite. Python's sqlite3 gave 0 on both 3.9.6 and 3.12.14. Node.js's node:sqlite gave 1 and refused the same INSERT. The v24 documentation also lists true as the default of enableForeignKeyConstraints, and passing false gave 0 and let the INSERT through. The documentation marks node:sqlite as stability 1.2 (release candidate).

So with the same shop.db and the same kind of statement, Python's default settings accepted the row and Node.js's default settings refused it. An app using node:sqlite with maintenance scripts run through Python or the sqlite3 shell meets exactly this difference. SQLite also has a compile option that sets the default, SQLITE_DEFAULT_FOREIGN_KEYS, and the documentation of the npm package better-sqlite3 says it builds with this set to 1 (not run). A version number will not tell you, so reading the value is quicker.

Python 3.12.14 py_default.py prints default foreign_keys = 0, inserted order for customer 999, after PRAGMA ON = 1, IntegrityError: FOREIGN KEY constraint failed. Node.js v24.21.0 node_default.mjs prints foreign_keys = 1 and ERR_SQLITE_ERROR: FOREIGN KEY constraint failed with no options, and foreign_keys = 0 and inserted order for customer 999 with enableForeignKeyConstraints false. On the same shop.db, py_same_file.py prints foreign_keys = 0, inserted, and node_same_file.mjs prints foreign_keys = 1, FOREIGN KEY constraint failed.
Actual output: Python and Node.js have different defaults, and the same kind of statement on the same file gets different results.

In Python, the autocommit setting swallows the PRAGMA

Python needs one more check. Under the default (legacy) transaction control, an INSERT opens a transaction implicitly. So a PRAGMA run after an INSERT and before commit() stayed at 0, and after commit() it became 1.

autocommit=False, added in 3.12, makes this plainer. The Python documentation says this value keeps a transaction open at all times and marks it as the recommended value. So running the PRAGMA right after connecting did not help. commit() did not help either, because it opens the next transaction straight away.

conn = sqlite3.connect(":memory:", autocommit=False)
conn.execute("PRAGMA foreign_keys = ON")
print(conn.execute("PRAGMA foreign_keys").fetchone()[0])  # 0

The documentation also says the default of autocommit will change to False in a future release (in the 3.12.16 and 3.15.2 pages the legacy behaviour is still the default). Two approaches worked on 3.12.14. Both need Python 3.12 or later, the version that added autocommit and setconfig. Connect with autocommit=True, run the PRAGMA and then set conn.autocommit = False; or use conn.setconfig(sqlite3.SQLITE_DBCONFIG_ENABLE_FKEY, True). Both gave 1. The first, wrapped in one function, looks like this.

def connect(path):
    conn = sqlite3.connect(path, autocommit=True)
    conn.execute("PRAGMA foreign_keys = ON")
    if conn.execute("PRAGMA foreign_keys").fetchone()[0] != 1:
        raise RuntimeError("foreign keys are not enforced")
    conn.autocommit = False
    return conn

A connection opened by this function refused the order for the missing customer with IntegrityError.

Python 3.12.14 py_txn.py output. legacy, in_transaction = True. legacy, PRAGMA ON inside transaction -> 0. legacy, PRAGMA ON after commit -> 1. autocommit=False, in_transaction = True. autocommit=False, PRAGMA ON -> 0. autocommit=True, PRAGMA ON -> 1. PRAGMA first, then autocommit=False -> 1. autocommit=False, setconfig -> 1 in_transaction = True. py_connect.py prints foreign_keys = 1 and IntegrityError: FOREIGN KEY constraint failed.
Actual output of the cases where an open transaction stops the PRAGMA in Python, and of the ways that work.

DEV NOTES

Foreign keys: the default depends on who opens the file

Reading PRAGMA foreign_keys and seeing 1 is the reliable check

  • Default on a new connection (observed)

    • sqlite3 shell 3.54.0: 0 (off)
    • Python sqlite3 3.9.6, 3.12.14: 0 (off)
    • Node.js node:sqlite v24.21.0: 1 (on)
  • Silently not turned on (observed)

    • The PRAGMA after BEGIN
    • PRAGMA foreign_key, with the s missing
    • Python: after an INSERT, before commit()
    • Python: on an autocommit=False connection
  • Where the app opens a connection

    PRAGMA foreign_keys = ON;

    • Turn it on for each connection (it is not stored in the file)
    • Read it back and check for 1
    • On a file in use, find violating rows first with foreign_key_check (old rows are not re-checked)

Observed on macOS 27.0.1 on 2026-11-01. Other operating systems, ORMs and better-sqlite3 were not run.

The defaults on a new connection, the cases that silently fail to turn the check on, and what to do where the app opens a connection.

Open SQLite from one place in your app, turn on PRAGMA foreign_keys there, and check that it reads back as 1. If the file is already in use, run PRAGMA foreign_key_check once before that and see how many violating rows there are.

THE END
RSS feedBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…