SQL: Changing Data, Constraints and Transactions
INSERT, UPDATE and DELETE change rows. Constraints make the database reject changes that would break a rule, such as a salary below zero or an employee in a department that does not exist. A transaction groups several changes so that either all of them happen or none do, and its isolation level decides what it sees of other transactions running at the same time.
The examples use the schema from basics, and every result shown is PostgreSQL 17's output. The concurrency examples were run with two real connections to the same database, labelled A and B.
Inserting, updating and deleting
INSERT INTO employees (id, name, dept_id, salary, hired)
VALUES (8, 'Hal', 2, 4500, '2024-05-01')
RETURNING id, name;
id | name
----+------
8 | Hal
Listing the columns after the table name makes the insert independent of the table's column order. A column left out gets its default, or NULL: Hal's manager_id is NULL.
RETURNING makes INSERT, UPDATE and DELETE return the rows they changed. It saves a second query to read back generated ids or new values. It is a PostgreSQL feature, also in SQLite. MySQL does not have it.
UPDATE employees SET salary = salary * 1.10
WHERE dept_id = 2
RETURNING name, salary;
name | salary
------+--------
Dan | 6600
Eve | 5500
Hal | 4950
An UPDATE or DELETE without WHERE changes every row of the table. salary is an integer column, so the result of * 1.10 is rounded back to an integer when it is stored.
Updating and deleting with a join
PostgreSQL uses FROM in UPDATE, and USING in DELETE, to bring in other tables:
UPDATE employees e SET salary = salary + 100
FROM departments d
WHERE d.id = e.dept_id AND d.name = 'HR'
RETURNING e.name, e.salary; -- Fay | 5600
DELETE FROM assignments a
USING projects p
WHERE p.id = a.project_id AND p.name = 'Billing'
RETURNING a.emp_id, a.project_id; -- (2, 2) and (3, 2)
MySQL writes the same with UPDATE employees e JOIN departments d ON …. A subquery in WHERE, such as WHERE dept_id IN (SELECT id FROM departments WHERE name = 'HR'), works in every database.
Upsert: INSERT … ON CONFLICT
A plain insert of a key that already exists fails:
INSERT INTO departments VALUES (1, 'Platform');
ERROR: duplicate key value violates unique constraint "departments_pkey"
DETAIL: Key (id)=(1) already exists.
ON CONFLICT says what to do instead:
INSERT INTO departments VALUES (1, 'Platform')
ON CONFLICT (id) DO NOTHING; -- skips the row, no error
INSERT INTO departments VALUES (1, 'Platform'), (5, 'Support')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
RETURNING *;
id | name
----+----------
1 | Platform
5 | Support
Department 1 existed, so it was updated. Department 5 did not, so it was inserted. EXCLUDED is the row that the insert tried to add.
The conflict target, (id) here, must match a unique constraint or unique index. An upsert written as "check whether the row exists, then insert or update" in two statements can fail when two sessions run it at the same time: both find no row, and both insert. ON CONFLICT checks and writes in one step. MySQL's equivalent is INSERT … ON DUPLICATE KEY UPDATE, and SQLite accepts the PostgreSQL form.
Constraints
| Constraint | Rule |
|---|---|
NOT NULL |
the column must have a value |
UNIQUE |
no two rows have the same value (several NULLs are allowed) |
PRIMARY KEY |
NOT NULL and UNIQUE; the row's identity |
REFERENCES (foreign key) |
the value must exist in the other table, or be NULL |
CHECK (condition) |
the condition must not be false |
ALTER TABLE employees ADD CONSTRAINT salary_positive CHECK (salary > 0);
UPDATE employees SET salary = -1 WHERE id = 1;
ERROR: new row for relation "employees" violates check constraint "salary_positive"
A CHECK passes when its condition is true or NULL. CHECK (salary > 0) on a nullable column would accept NULL, because NULL > 0 is unknown, not false.
Foreign keys and deleting the referenced row
Deleting a department that still has employees fails:
DELETE FROM departments WHERE id = 3;
ERROR: update or delete on table "departments" violates foreign key constraint "employees_dept_id_fkey" on table "employees"
DETAIL: Key (id)=(3) is still referenced from table "employees".
That is the default, ON DELETE NO ACTION. The foreign key can declare a different behaviour:
| Option | When the referenced row is deleted |
|---|---|
NO ACTION / RESTRICT |
the delete fails (the default) |
CASCADE |
the referencing rows are deleted too |
SET NULL |
the referencing column is set to NULL |
SET DEFAULT |
the referencing column is set to its default |
CASCADE fits rows that cannot exist without their parent, such as an order's line items. It also means one DELETE can remove rows from many tables, through chains of cascades, without listing them.
PostgreSQL does not create an index on the referencing column automatically. Without an index on employees(dept_id), every delete from departments scans employees to check for references.
Transactions
A transaction starts with BEGIN and ends with COMMIT, which makes its changes permanent and visible to others, or ROLLBACK, which undoes them. Without BEGIN, each statement is its own transaction and commits as soon as it finishes.
BEGIN;
UPDATE employees SET salary = 1 WHERE id = 7;
SELECT salary FROM employees WHERE id = 7; -- 1, inside the transaction
ROLLBACK;
SELECT salary FROM employees WHERE id = 7; -- 4000, unchanged
Transactions guarantee four properties, called ACID:
- Atomicity: all of the transaction's changes happen, or none do.
- Consistency: constraints hold before and after.
- Isolation: concurrent transactions do not see each other's unfinished work, to a degree set by the isolation level.
- Durability: once
COMMITreturns, the changes survive a crash.
An error aborts the transaction
In PostgreSQL, after any error inside a transaction, every later statement is refused until the transaction ends:
BEGIN;
UPDATE employees SET salary = salary + 1 WHERE id = 5;
SELECT 1/0;
ERROR: division by zero
SELECT salary FROM employees WHERE id = 5;
ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK is the only way out, and it undoes the update too.
Savepoints
A savepoint marks a point inside a transaction that can be rolled back to without undoing everything:
BEGIN;
UPDATE employees SET salary = salary + 1 WHERE id = 6; -- 5501
SAVEPOINT s1;
UPDATE employees SET salary = -5 WHERE id = 6; -- ERROR: violates salary_positive
ROLLBACK TO SAVEPOINT s1; -- the error is cleared
COMMIT;
SELECT salary FROM employees WHERE id = 6; -- 5501
The first update survives. Rolling back to the savepoint undoes only what came after it, and also clears the aborted state.
Isolation levels
Isolation decides what one transaction sees of another transaction's committed changes while it is still running. PostgreSQL offers three levels in practice. Its default is Read Committed.
Read Committed: each statement sees the latest committed data
A: BEGIN ISOLATION LEVEL READ COMMITTED
A: SELECT salary FROM employees WHERE id = 1 → 9000
B: UPDATE employees SET salary = 9500 WHERE id = 1 (commits at once)
A: SELECT salary FROM employees WHERE id = 1 → 9500
A: COMMIT
The same query, twice in one transaction, returned two different values. This is a non-repeatable read. Each statement in Read Committed sees the data committed before that statement started.
Repeatable Read: one snapshot for the whole transaction
A: BEGIN ISOLATION LEVEL REPEATABLE READ
A: SELECT salary FROM employees WHERE id = 1 → 9000
B: UPDATE employees SET salary = 9500 WHERE id = 1 (commits)
A: SELECT salary FROM employees WHERE id = 1 → 9000
A: COMMIT
A Repeatable Read transaction sees a snapshot taken at its first query. B's committed change is invisible to A until A ends.
If A then tries to change a row that B changed after A's snapshot, PostgreSQL refuses:
A: BEGIN ISOLATION LEVEL REPEATABLE READ
B: BEGIN ISOLATION LEVEL REPEATABLE READ
A: SELECT salary … → 9000 B: SELECT salary … → 9000
A: UPDATE employees SET salary = salary + 100 WHERE id = 1
A: COMMIT
B: UPDATE employees SET salary = salary + 100 WHERE id = 1
→ ERROR: could not serialize access due to concurrent update
B: ROLLBACK
B's snapshot says the salary is 9000, but the row now holds 9100. Applying B's update would silently overwrite A's change, so PostgreSQL makes B fail instead. The application is expected to retry B's whole transaction.
Serializable, the strictest level, also detects conflicts between transactions that read and write different rows, and guarantees that the result is the same as running the transactions one after another. It reports the same kind of error, which again is handled by retrying.
Lost updates
A lost update happens when two sessions each read a value, compute a new one in the application, and write it back. Both started from the same old value, so one change disappears:
A: BEGIN B: BEGIN
A: SELECT salary … → 9000 B: SELECT salary … → 9000
A: UPDATE … SET salary = 9100 (9000 + 100)
A: COMMIT
B: UPDATE … SET salary = 9100 (9000 + 100)
B: COMMIT
after: 9100 expected 9200
Read Committed does not prevent this, because each statement is valid on its own. Three fixes:
- Compute in the database.
UPDATE … SET salary = salary + 100reads and writes the row in one statement. If two sessions run it at the same time, the second waits for the first to commit, then applies+ 100to the new value. Run that way, the result was 9200. - Lock the row when reading it.
SELECT … FOR UPDATElocks the selected rows until the transaction ends. In the test, B'sSELECT … FOR UPDATEwaited 1.5 seconds for A to commit, then read 9100, and B's update gave 9200. - Use Repeatable Read and retry on the serialization error shown above.
Common mistakes
UPDATEorDELETEwith a missing or wrongWHERE. Running it insideBEGIN, withRETURNINGto see what changed, leaves the chance toROLLBACK.- Check-then-insert in two statements under concurrency. Use
ON CONFLICTor a unique constraint. - No index on a foreign key column. Deletes and updates of the referenced table become slow.
- Read-modify-write in application code without a lock or a stronger isolation level.
- Not retrying Repeatable Read or Serializable transactions after a serialization error.