{"record":{"id":"0245e285ef3f5ec8","repo":"clockworklabs/SpacetimeDB","slug":"unique-constraint-violation-during-merge-violati","errorCode":null,"errorMessage":"Unique constraint violation during merge: {violation:?}","messagePattern":"Unique constraint violation during merge: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/datastore/src/locking_tx_datastore/mut_tx.rs","lineNumber":2463,"sourceCode":"        }\n\n        // Check that each pair of unique indices can be merged.\n        for &index_id in &index_ids {\n            let can_merge_result = {\n                let commit_idx = &commit_table.indexes[&index_id];\n                let tx_idx = &tx_table.indexes[&index_id];\n                let is_deleted = |ptr: &RowPointer| tx_delete_table.contains(*ptr);\n                commit_idx.can_merge(tx_idx, is_deleted)\n            };\n            if let Err(violation) = can_merge_result {\n                let cols = commit_table.indexes[&index_id].indexed_columns().clone();\n                let violation = commit_table\n                    .get_row_ref(commit_blob_store, violation)\n                    .expect(\"row came from scanning the table\")\n                    .project(&cols)\n                    .expect(\"cols should be valid for this table\");\n                revert(commit_table, tx_table, index_ids.len());\n                return Err(anyhow::anyhow!(\"Unique constraint violation during merge: {violation:?}\").into());\n            }\n        }\n\n        // Take the pointer map if this is the first unique index.\n        let pointer_map = if !had_unique {\n            tx_table.take_pointer_map();\n            commit_table.take_pointer_map()\n        } else {\n            None\n        };\n\n        // Update the pending schema change with index info.\n        // The last pushed change is our ConstraintAdded from create_st_constraint.\n        // Replace it with the enriched version.\n        if let Some(last) = self.tx_state.pending_schema_changes.last_mut() {\n            *last = PendingSchemaChange::ConstraintAdded(table_id, constraint_id, index_ids, pointer_map);\n        }\n","sourceCodeStart":2445,"sourceCodeEnd":2481,"githubUrl":"https://github.com/clockworklabs/SpacetimeDB/blob/9e0d92412ff2248f401a8ad12d535f2b5ac30912/crates/datastore/src/locking_tx_datastore/mut_tx.rs#L2445-L2481","documentation":"Thrown at commit time by the locking datastore when a mutable transaction's unique-index changes cannot be merged into committed state. Before folding the transaction in, each unique index in the tx table is checked against the committed index via can_merge, ignoring only rows this transaction deletes; a surviving key collision aborts the merge, the tables are reverted, and the error carries the offending row projected onto the indexed columns.","triggerScenarios":"A reducer inserts or updates a row whose unique-indexed columns match a row already committed by a concurrent transaction (write-write conflict caught at merge, not at insert). Also fires when the transaction deletes a different row than the one it collides with, or when a unique index was created over pre-existing duplicate data.","commonSituations":"Concurrent reducers racing to create the same entity (sessions, usernames, leaderboard entries); client retries re-running a create reducer; adding a #[unique] column or unique index to a table that already contains duplicates; randomized/fuzz workloads (dst) inserting colliding rows.","solutions":["In the reducer, probe the unique index before writing (e.g. ctx.db.table.col().find(&value)) and update or delete the existing row instead of inserting","Design upsert reducers to delete-then-insert the colliding row inside the same transaction so can_merge ignores the deleted row","When adding a unique index to existing data, deduplicate colliding rows before applying the schema change","Retry the reducer with a different key when the conflict comes from a concurrent writer"],"exampleFix":"// before: insert blindly; concurrent tx commits the same key first, merge fails\nfn create_user(ctx: &ReducerContext, email: String, name: String) {\n    ctx.db.user().insert(User { email, name });\n}\n\n// after: probe the unique index and update the colliding committed row\nfn create_user(ctx: &ReducerContext, email: String, name: String) {\n    match ctx.db.user().email().find(&email) {\n        Some(mut row) => {\n            row.name = name;\n            ctx.db.user().email().update(row);\n        }\n        None => {\n            ctx.db.user().insert(User { email, name });\n        }\n    }\n}","handlingStrategy":"validation","validationCode":"// In a reducer, probe the unique index before writing\nfn insert_if_free(ctx: &ReducerContext, email: &str, row: User) -> Result<(), String> {\n    if ctx.db.user().email().find(&email).is_some() {\n        return Err(format!(\"duplicate key on user.email: {email}\"));\n    }\n    ctx.db.user().insert(row);\n    Ok(())\n}","typeGuard":null,"tryCatchPattern":"Match the commit error, extract the projected row from the message to identify the colliding index and columns, and surface it to the caller as a duplicate-key error; do not blindly retry — the transaction was already reverted and the committed row is still there.","preventionTips":["Model every unique index with a typed lookup accessor and use it for existence checks before insert","Prefer update-or-insert (upsert) reducers over raw inserts for keyed entities","Before adding a unique index, run a deduplication pass over existing table data","In concurrent-write designs, assume any insert can hit a committed key and handle the rejection path"],"tags":["rust","spacetimedb","datastore","unique-index","commit","transaction"],"backgroundTag":"unique-constraint-violation","analyzedSha":"9e0d92412ff2248f401a8ad12d535f2b5ac30912","analyzedAt":"2026-08-20T06:08:37.179Z","contentChangedAt":"2026-08-20T06:08:37.179Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}