UPSERT Fails on Composite UNIQUE Constraint Unless WITHOUT ROWID
Understanding the UPSERT Parse Error with Composite UNIQUE Constraints
Issue Overview
The core problem arises when attempting an INSERT ... ON CONFLICT
(UPSERT) operation targeting a composite UNIQUE
constraint in SQLite. The operation fails with a parse error:
Parse error: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint
This error occurs despite the table explicitly defining a composite UNIQUE
constraint that matches the columns specified in the ON CONFLICT
clause. The anomaly disappears when the table is declared as WITHOUT ROWID
, even though the schema remains otherwise identical.
Example Schema and Query
The following table definitions and UPSERT query demonstrate the issue:
-- Table definitions
CREATE TABLE a(id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL);
CREATE TABLE b(
id INTEGER PRIMARY KEY REFERENCES a ON DELETE CASCADE,
hash TEXT UNIQUE,
timeout INTEGER,
UNIQUE (id, hash) -- Composite UNIQUE constraint
);
CREATE TABLE c(hash TEXT, timeout INTEGER);
-- Problematic UPSERT
INSERT INTO b (id, hash, timeout)
SELECT $id, hash, timeout FROM c WHERE true
ON CONFLICT (id, hash) DO NOTHING; -- Fails with parse error
When b
is redefined as WITHOUT ROWID
, the same UPSERT succeeds:
CREATE TABLE b(...) WITHOUT ROWID; -- No other changes
-- UPSERT now works without error
Key Observations
- Schema Validity: The composite
UNIQUE (id, hash)
constraint is valid and enforced in bothWITH ROWID
andWITHOUT ROWID
tables. - Error Context: The parse error occurs during query compilation, indicating that SQLite’s parser does not recognize the composite constraint as a valid conflict target in
WITH ROWID
tables. - ROWID Impact: The presence or absence of the implicit
ROWID
column alters how SQLite internally manages constraints, affecting UPSERT behavior.
Root Causes of the UPSERT Parse Error
1. Implicit ROWID and Constraint Indexing
In WITH ROWID
tables, the ROWID
(or its alias, id
in this case) introduces hidden behavior:
- The
PRIMARY KEY
is implemented as aUNIQUE
index over theROWID
. - Composite
UNIQUE
constraints generate separate indexes, but their visibility to the UPSERT parser depends on internal SQLite logic.
In WITHOUT ROWID
tables, the PRIMARY KEY
is stored as the table’s clustered index, and composite UNIQUE
constraints are treated as first-class indexes. This structural difference resolves the parser’s ambiguity.
2. Parser Limitations in Constraint Matching
SQLite’s UPSERT implementation requires the ON CONFLICT
clause to reference exact column lists of a PRIMARY KEY
or UNIQUE
constraint. However, the parser may fail to associate the conflict target with a composite constraint in WITH ROWID
tables due to:
- Overlap with the
ROWID
alias (id
in this case), causing misidentification of the conflict target. - Internal prioritization of single-column constraints (e.g.,
hash TEXT UNIQUE
) over composite constraints during parsing.
3. SQLite Version-Specific Behavior
The error was observed in SQLite 3.41.1. Subsequent releases may address this inconsistency, but version-specific parser logic or indexing optimizations could contribute to the issue.
Resolving the UPSERT Composite Constraint Error
Step 1: Verify Constraint and Index Availability
Confirm that the composite UNIQUE
constraint is properly indexed using:
PRAGMA index_list(b); -- Check for an index on (id, hash)
If the index is missing, recreate the table or explicitly create the index:
CREATE INDEX idx_b_id_hash ON b(id, hash);
Step 2: Simplify the Schema
Isolate the issue by removing unrelated constraints and dependencies:
- Drop the
hash TEXT UNIQUE
constraint. - Temporarily disable foreign keys (
PRAGMA foreign_keys=OFF
). - Retest the UPSERT to determine if secondary constraints interfere.
Step 3: Use Explicit Index Names (Workaround)
While SQLite does not support referencing indexes by name in ON CONFLICT
, explicitly naming the composite constraint can improve clarity:
CREATE TABLE b(
...
CONSTRAINT u_id_hash UNIQUE (id, hash)
);
Step 4: Adopt WITHOUT ROWID Where Appropriate
If the table does not require ROWID
functionality, redefine it as WITHOUT ROWID
:
CREATE TABLE b(...) WITHOUT ROWID; -- Resolves parser ambiguity
Trade-offs:
WITHOUT ROWID
tables lackAUTOINCREMENT
support and have stricterPRIMARY KEY
requirements.- Storage efficiency improves for tables with large or frequent composite key lookups.
Step 5: Upgrade SQLite
Test the UPSERT in SQLite 3.41.2 or newer. Check the changelog for fixes related to:
- Constraint parsing in
INSERT ... ON CONFLICT
. - Index visibility in
WITH ROWID
tables.
Step 6: Report the Issue
If the problem persists across versions, submit a detailed report to SQLite’s issue tracker, including:
- Minimal reproducible schema.
- Observed vs. expected behavior.
- SQLite version and platform details.
Permanent Fix
For immediate resolution, use WITHOUT ROWID
or redefine constraints to avoid composite targets in WITH ROWID
tables. Monitor SQLite updates for parser improvements.
Final Notes
The interaction between ROWID
, constraint indexing, and UPSERT parsing reveals subtle complexities in SQLite’s architecture. By aligning table design with SQLite’s internal mechanics—such as preferring WITHOUT ROWID
for composite key-centric schemas—developers can avoid parser ambiguities and ensure reliable UPSERT operations.