Skip to content

Modifying Data & Transactions

Reading data is forgiving; writing it is not. This page covers the three write statements, the upsert patterns both engines support, and the transaction semantics that decide what happens when things go wrong.

INSERT INTO authors (name, birth_year) VALUES ('N. K. Jemisin', 1972);

Always name the columns. INSERT INTO authors VALUES (...) depends on column order and breaks the moment someone runs ALTER TABLE ... ADD COLUMN.

Omitted columns take their DEFAULT, or NULL if there isn’t one. A NOT NULL column with no default and no supplied value is an error.

One statement, many rows — far faster than N statements because it is one parse, one plan, one round trip.

INSERT INTO authors (name, birth_year) VALUES
('N. K. Jemisin', 1972),
('Ann Leckie', 1966),
('Becky Chambers', 1985);

Both engines support this. For bulk loads of thousands of rows, use the engine’s loader instead:

Terminal window
# PostgreSQL: server-side COPY, or \copy from the client
psql -d bookstore -c "\copy books(title, year, price) FROM 'books.csv' CSV HEADER"
# SQLite
sqlite3 bookstore.db ".mode csv" ".import --skip 1 books.csv books"

Insert the result of a query. The select list must line up positionally with the column list.

INSERT INTO archived_books (id, title, year)
SELECT id, title, year FROM books WHERE year < 1970;

This is also how you copy between tables, deduplicate into a new table, or backfill a column.

INSERT INTO events DEFAULT VALUES; -- every column takes its default; both engines
UPDATE books SET price = price * 1.10 WHERE year < 1980;
UPDATE books SET price = 12.00, in_print = false WHERE id = 3;

The right-hand side sees the old values of the row, so SET a = b, b = a swaps them correctly in both engines.

To set values based on a join, both engines support UPDATE ... FROM — PostgreSQL always, SQLite since 3.33.0.

UPDATE books
SET price = p.new_price
FROM pending_prices AS p
WHERE p.book_id = books.id;

Note the shape: the target table is not repeated in FROM, and the join condition lives in WHERE.

The fully portable alternative uses a correlated subquery:

UPDATE books
SET price = (SELECT new_price FROM pending_prices p WHERE p.book_id = books.id)
WHERE EXISTS (SELECT 1 FROM pending_prices p WHERE p.book_id = books.id);

The WHERE EXISTS is essential. Without it, books with no pending price get their price set to NULL.

DELETE FROM books WHERE year < 1900;
DELETE FROM books; -- every row

Deleting via a related table:

-- Portable
DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE city IS NULL);
-- PostgreSQL's join form
DELETE FROM orders AS o USING customers AS c
WHERE c.id = o.customer_id AND c.city IS NULL;

PostgreSQL also has TRUNCATE, which empties a table far faster than DELETE and reclaims disk space immediately:

TRUNCATE TABLE orders;
TRUNCATE orders, order_items RESTART IDENTITY CASCADE;

SQLite has no TRUNCATE; DELETE FROM t with no WHERE triggers an internal truncate optimisation that does the same job.

“Insert, or update if it already exists.” Both engines implement the same ON CONFLICT syntax — PostgreSQL 9.5+ and SQLite 3.24+.

CREATE TABLE page_views (
path TEXT PRIMARY KEY,
views INTEGER NOT NULL DEFAULT 0,
last_seen TEXT
);

Skip the row silently if it conflicts:

INSERT INTO page_views (path, views) VALUES ('/about', 1)
ON CONFLICT DO NOTHING;
INSERT INTO page_views (path, views) VALUES ('/about', 1)
ON CONFLICT (path) DO NOTHING; -- only this constraint counts as a conflict

Without a conflict target, any unique-constraint violation is swallowed — including ones you didn’t anticipate. Naming the target is safer.

The real upsert. The pseudo-table excluded holds the row you tried to insert.

INSERT INTO page_views (path, views, last_seen)
VALUES ('/about', 1, '2026-08-09')
ON CONFLICT (path) DO UPDATE
SET views = page_views.views + excluded.views,
last_seen = excluded.last_seen;

Run it twice and views becomes 2. Rules that apply to both engines:

  • DO UPDATE requires a conflict target — a column list, or an expression matching a unique index.
  • The target must correspond to a real unique constraint or unique index. ON CONFLICT (title) on a non-unique column is an error.
  • Qualify ambiguous names: page_views.views is the existing row, excluded.views is the proposed row.
  • You can add a WHERE to the update to make it conditional:
ON CONFLICT (path) DO UPDATE SET views = excluded.views
WHERE page_views.views < excluded.views; -- only move the counter forward
  • A WHERE after the conflict target narrows which index entries arbitrate — used with partial indexes:
INSERT INTO page_views (path, views) VALUES ('/about', 1)
ON CONFLICT (path) WHERE views > 0 DO UPDATE SET views = excluded.views;

PostgreSQL additionally allows targeting a named constraint:

-- PostgreSQL only
ON CONFLICT ON CONSTRAINT page_views_pkey DO UPDATE SET views = excluded.views;

SQLite has an older, blunter mechanism on INSERT and UPDATE:

INSERT OR IGNORE INTO page_views (path, views) VALUES ('/about', 1);
INSERT OR REPLACE INTO page_views (path, views) VALUES ('/about', 1);
REPLACE INTO page_views (path, views) VALUES ('/about', 1); -- same thing

INSERT OR IGNORE is equivalent to ON CONFLICT DO NOTHING and is harmless, just non-portable.

PostgreSQL 15 added the SQL-standard MERGE, which can insert, update, and delete in one pass:

-- PostgreSQL 15+
MERGE INTO page_views AS t
USING incoming AS s ON t.path = s.path
WHEN MATCHED THEN UPDATE SET views = t.views + s.views
WHEN NOT MATCHED THEN INSERT (path, views) VALUES (s.path, s.views);

SQLite has no MERGE. For plain upserts, ON CONFLICT is simpler and works in both.

RETURNING makes a write statement return rows, so you get generated IDs, computed defaults, and old-vs-new values without a second query. PostgreSQL has always had it; SQLite added it in 3.35.0 (March 2021).

INSERT INTO authors (name, birth_year) VALUES ('Ann Leckie', 1966)
RETURNING id, name;
UPDATE books SET price = price * 1.1 WHERE year < 1980
RETURNING id, title, price;
DELETE FROM orders WHERE qty = 0
RETURNING *;
id | name
----+------------
4 | Ann Leckie

This is the correct way to get an auto-generated primary key — it is atomic and works with multi-row inserts, unlike last_insert_rowid() or currval().

A transaction groups statements so they succeed or fail as a unit. ACID names the four guarantees:

  • Atomicity — all statements commit, or none do. A crash mid-transaction leaves no partial work.
  • Consistency — constraints hold at commit; the database never moves to an invalid state.
  • Isolation — concurrent transactions don’t see each other’s uncommitted work.
  • Durability — once COMMIT returns, the data survives a power loss.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

If the second UPDATE fails, ROLLBACK undoes the first. Without a transaction, money vanishes.

ROLLBACK; -- discard everything since BEGIN

Both engines accept BEGIN, BEGIN TRANSACTION, COMMIT, END (as a synonym for COMMIT), and ROLLBACK.

Outside an explicit transaction, every statement is its own transaction in both engines. That is why a single UPDATE affecting a million rows is still all-or-nothing.

The practical consequence: inserting 10,000 rows one statement at a time means 10,000 commits, each with an fsync. Wrapping them in one transaction can be 100× faster.

BEGIN;
INSERT INTO books (...) VALUES (...); -- ×10000
COMMIT;

PostgreSQL uses MVCC: each transaction sees a snapshot of the database. Writers never block readers and readers never block writers, because an updated row is written as a new version while the old version stays visible to transactions that started earlier.

Set the isolation level per transaction:

BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;
-- or
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Level What you get
Read Committed (default) Each statement sees a fresh snapshot. Two identical SELECTs in one transaction can return different data.
Repeatable Read The whole transaction sees one snapshot taken at its first statement. No non-repeatable reads, no phantoms.
Serializable As if transactions ran one at a time. Detects write skew and other anomalies.

PostgreSQL accepts READ UNCOMMITTED for compatibility but treats it as READ COMMITTED — it never shows uncommitted data.

When you need to read a row and then update it based on what you read, take a lock:

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- blocks other writers
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

FOR UPDATE variants: FOR NO KEY UPDATE (weaker), FOR SHARE, FOR KEY SHARE. Adding SKIP LOCKED gives you a work-queue pattern:

SELECT id FROM jobs WHERE status = 'pending'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;

SQLite supports none of these — it doesn’t need row locks because it doesn’t have concurrent writers.

SQLite has no MVCC in the PostgreSQL sense and no row locks. Concurrency is managed at the database file level.

Three ways to start a transaction:

BEGIN; -- same as BEGIN DEFERRED
BEGIN DEFERRED; -- take no lock now; acquire on first read/write
BEGIN IMMEDIATE; -- take the write lock now
BEGIN EXCLUSIVE; -- take the write lock and block other readers (rollback-journal mode)

A deferred transaction that reads first and writes later must upgrade its lock. If another connection wrote in the meantime, the upgrade fails with SQLITE_BUSY — and the error cannot be resolved by waiting, because your read snapshot is already stale. You must roll back and retry.

The default rollback journal blocks readers during a write. Write-Ahead Logging removes that:

PRAGMA journal_mode = WAL;

In WAL mode, readers see a consistent snapshot and are never blocked by the writer; the writer is never blocked by readers. There is still exactly one writer at a time for the whole database — that limit is architectural.

WAL is persistent (set once per database file, not per connection) and creates two sidecar files, -wal and -shm, alongside your .db. Copying only the .db file while the WAL is non-empty gives you an incomplete backup — use .backup or VACUUM INTO instead.

Pair it with a busy timeout so contending connections wait instead of erroring instantly:

PRAGMA busy_timeout = 5000; -- milliseconds

Common production preamble for a SQLite connection:

PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL; -- safe with WAL; much faster than FULL

A savepoint is a named point inside a transaction you can roll back to without discarding everything. Both engines support them with identical syntax.

BEGIN;
INSERT INTO authors (name) VALUES ('Author A');
SAVEPOINT sp1;
INSERT INTO authors (name) VALUES ('Author B');
ROLLBACK TO SAVEPOINT sp1; -- Author B is gone, Author A survives, txn continues
RELEASE SAVEPOINT sp1; -- discard the savepoint, keep the work
COMMIT;

ROLLBACK TO does not end the transaction — it rewinds it. RELEASE forgets the savepoint and merges its work into the enclosing transaction.

Two practical uses:

Partial failure handling in PostgreSQL. Since an error aborts the transaction, wrap the risky statement:

BEGIN;
INSERT INTO log (msg) VALUES ('starting');
SAVEPOINT maybe;
INSERT INTO books (id, title) VALUES (1, 'duplicate'); -- might violate the PK
-- on error:
ROLLBACK TO maybe;
-- carry on
INSERT INTO log (msg) VALUES ('done');
COMMIT;

Nested logic. SQLite’s SAVEPOINT name outside any transaction starts one, so library code can use savepoints without knowing whether a transaction is already open.

  • Always list columns in INSERT; batch rows into one statement, or use COPY / .import for bulk loads.
  • UPDATE/DELETE without WHERE hit every row — check with a SELECT first, or work inside a transaction.
  • ON CONFLICT ... DO UPDATE with the excluded pseudo-table is the portable upsert (PostgreSQL 9.5+, SQLite 3.24+).
  • Avoid SQLite’s INSERT OR REPLACE — it deletes and reinserts, losing columns and firing cascades.
  • RETURNING gives you generated keys atomically; PostgreSQL always, SQLite 3.35+.
  • Every statement is its own transaction by default; batching writes into one transaction is a large speedup.
  • PostgreSQL: MVCC, three usable isolation levels, and any error aborts the transaction — retry serialization failures, use savepoints for partial failure.
  • SQLite: one writer at a time; use BEGIN IMMEDIATE for write transactions, and enable WAL plus a busy timeout.