How do savepoints and constraint failures behave inside a transaction, and when do you use FTS5 instead of LIKE?
Predict first
What do savepoints, constraint failures and FTS5 do?
import Foundation
import SQLite3
final class DB {
var h: OpaquePointer?
init(_ path: String = ":memory:", busy: Int32 = 0) { sqlite3_open(path, &h); sqlite3_busy_timeout(h, busy) }
deinit { sqlite3_close(h) }
@discardableResult func exec(_ sql: String) -> String? {
var err: UnsafeMutablePointer<CChar>?
if sqlite3_exec(h, sql, nil, nil, &err) != SQLITE_OK { defer { sqlite3_free(err) }; return String(cString: err!) }
return nil
}
func rows(_ sql: String) -> [[String]] {
var st: OpaquePointer?; var out: [[String]] = []
guard sqlite3_prepare_v2(h, sql, -1, &st, nil) == SQLITE_OK else { return [["error: " + String(cString: sqlite3_errmsg(h))]] }
defer { sqlite3_finalize(st) }
while sqlite3_step(st) == SQLITE_ROW {
out.append((0..<sqlite3_column_count(st)).map { i in sqlite3_column_text(st, i).map { String(cString: $0) } ?? "NULL" })
}
return out
}
}
let db = DB()
db.exec("CREATE TABLE t(v INTEGER)")
db.exec("BEGIN"); db.exec("INSERT INTO t VALUES(1)")
db.exec("SAVEPOINT sp"); db.exec("INSERT INTO t VALUES(2)")
db.exec("ROLLBACK TO sp"); db.exec("RELEASE sp")
db.exec("COMMIT")
print("rows after savepoint rollback:", db.rows("SELECT v FROM t").map { $0[0] })
db.exec("CREATE TABLE u(id INTEGER PRIMARY KEY, name TEXT NOT NULL CHECK(length(name) > 1))")
print("not null:", db.exec("INSERT INTO u(name) VALUES(NULL)") ?? "ok")
print("check:", db.exec("INSERT INTO u(name) VALUES('x')") ?? "ok")
db.exec("BEGIN")
print("failing statement inside a transaction:", db.exec("INSERT INTO u(name) VALUES('x')") ?? "ok")
db.exec("INSERT INTO u(name) VALUES('ok')")
db.exec("COMMIT")
print("committed rows:", db.rows("SELECT name FROM u").map { $0[0] })
db.exec("CREATE VIRTUAL TABLE doc USING fts5(body)")
db.exec("INSERT INTO doc VALUES('swift concurrency actors'), ('combine publishers'), ('actors and isolation')")
print("fts match:", db.rows("SELECT body FROM doc WHERE doc MATCH 'actors' ORDER BY rowid").map { $0[0] })
print("fts prefix:", db.rows("SELECT body FROM doc WHERE doc MATCH 'pub*'").map { $0[0] })
- rows after savepoint rollback: ["1"] / constraint errors / committed rows: ["ok"] / fts match returns both "actors" rows / prefix matches "combine publishers"
- rows after savepoint rollback: ["1", "2"]
- committed rows: []
Mental model. A savepoint is a pencil mark you can erase back to without tearing up the whole page. An FTS index is a book’s keyword index; LIKE is reading every page for the word.
A transaction makes several statements all-or-nothing and much faster (one commit instead of one per statement, which in a loop of thousands of inserts is the difference between seconds and milliseconds). SAVEPOINT nests: in the check the second insert is undone with ROLLBACK TO sp while the outer transaction commits the first, leaving ["1"]. Constraint failures (NOT NULL, CHECK, UNIQUE, foreign keys) fail the statement, not the transaction: the failing insert inside BEGIN reports its error, the transaction stays open, the next valid insert succeeds, and COMMIT keeps the good row (["ok"]). So check every statement’s result and decide whether to roll back (some errors, such as SQLITE_FULL or I/O errors, can already have rolled the transaction back for you).
For text search, LIKE '%word%' scans every row and cannot use an index, ignores word boundaries and ranking. FTS5 builds an inverted index: MATCH 'actors' finds whole-word matches (both rows with “actors”), 'pub*' is a prefix query, and you also get bm25 ranking, phrase and boolean queries, snippets and highlights. Use an external-content FTS table to index a normal table without duplicating the text, keep it in sync with triggers, and run searches on a background connection. The trade-offs: extra disk space and write cost, a tokeniser that suits your language (unicode61 by default, porter for English stemming, a trigram tokenizer for substring search), and diacritics and CJK need care. Core Data and SwiftData do not expose FTS, so apps that need search often keep a separate SQLite FTS index.
Rollback to a savepoint undoes part of a transaction; a failed statement does not abort it; FTS5 matches whole words and prefixes.
Common traps
- Assuming a failed statement aborts the transaction. It does not; the earlier statements are still pending until you commit or roll back.
- Using
LIKE '%word%'for search on a large table. It scans every row each time.
They might follow up with
- How do I do many inserts quickly? One transaction, a prepared statement reused with bound parameters, and a journal mode suited to the workload (Q331).
- Can I search for substrings with FTS5? Use the trigram tokenizer (
tokenize = 'trigram'), at the cost of a bigger index.
Related questions
- 13.9.2 How does SQLite’s dynamic typing and three-valued NULL logic bite: affinity, STRICT tables, IN and NOT IN?
- 13.9.3 How does write-ahead logging change locking, what does SQLITE_BUSY mean, and how do you configure busy…
- 13.2.2 How do you use SQLite safely and quickly: bound parameters, indexes, and schema migrations?
- 13.2.1 How do SQLite transactions, journal modes (WAL) and constraints work, and which defaults surprise people?
← How does write-ahead logging change locking, what does SQLITE_BUSY…