Lesson 11 of 30
Changing Data
INSERT, UPDATE, and DELETE change rows. Every example here works on copies of the sample tables, so the original data stays intact for later lessons.
CREATE TABLE emp_copy AS SELECT * FROM employees;
ALTER TABLE emp_copy ADD PRIMARY KEY (id);
CREATE TABLE dept_copy AS SELECT * FROM departments;
ALTER TABLE dept_copy ADD PRIMARY KEY (id);
SELECT count(*) AS emp_rows FROM emp_copy; emp_rows
----------
12
(1 row)CREATE TABLE ... AS copies the rows and column types but not constraints, defaults, or indexes, which is why the primary keys are added afterward.
Example 1: INSERT one row, and DEFAULT
Name the columns. Columns left out get their default, or NULL when there is none. DEFAULT can also be written explicitly.
CREATE TABLE audit_log (
id serial PRIMARY KEY,
action text NOT NULL,
note text,
logged date DEFAULT DATE '2026-01-01'
);
INSERT INTO audit_log (action) VALUES ('start');
INSERT INTO audit_log (action, note, logged) VALUES ('load', DEFAULT, '2026-02-01');
SELECT * FROM audit_log ORDER BY id; id | action | note | logged
----+--------+------+------------
1 | start | | 2026-01-01
2 | load | | 2026-02-01
(2 rows)Always list the columns. INSERT INTO t VALUES (...) breaks when a column is added or reordered.
Example 2: multi-row INSERT and RETURNING
One statement with several rows is a single round trip and a single transaction. RETURNING sends back the inserted rows, including generated values such as serial ids.
INSERT INTO audit_log (action, note)
VALUES ('a', 'first'), ('b', 'second'), ('c', NULL)
RETURNING id, action; id | action
----+--------
3 | a
4 | b
5 | c
(3 rows)RETURNING also works with UPDATE and DELETE. Oracle uses RETURNING ... INTO, SQL Server uses OUTPUT, and MySQL has no equivalent.
Example 3: INSERT ... SELECT
Insert the rows produced by a query. Copy Sales employees earning at least 90000 into a new department:
INSERT INTO dept_copy (id, name) VALUES (6, 'Sales Leads');
INSERT INTO emp_copy (id, name, dept_id, manager_id, salary, hired_on)
SELECT id + 100, name || ' (lead)', 6, manager_id, salary, DATE '2026-01-01'
FROM employees
WHERE dept_id = 2 AND salary >= 90000
RETURNING id, name, dept_id; id | name | dept_id
-----+---------------+---------
106 | Divya (lead) | 6
107 | Sameer (lead) | 6
(2 rows)Example 4: UPDATE with WHERE and expressions
SET can use expressions that read the row's current values. The right side always sees the old values, so swaps work.
UPDATE emp_copy
SET salary = salary * 1.10,
name = upper(name)
WHERE dept_id = 3
RETURNING id, name, salary; id | name | salary
----+--------+--------
9 | VIKRAM | 132000
10 | ANITA | 88000
(2 rows)The salary * 1.10 result is stored as int, so the value is rounded to a whole number. RETURNING shows the new values.
Example 5: UPDATE ... FROM (update using a join)
Set each employee's salary from another table. FROM adds tables to the update; the WHERE clause links them. A target row that matches no joined row is left alone. If it matches several, one arbitrary match is used.
UPDATE emp_copy e
SET salary = s.new_salary
FROM (VALUES (2, 190000), (3, 155000)) AS s(id, new_salary)
WHERE e.id = s.id
RETURNING e.id, e.name, e.salary; id | name | salary
----+-------+--------
2 | Ravi | 190000
3 | Kiran | 155000
(2 rows)SQL Server and MySQL write this as UPDATE ... JOIN. Oracle uses MERGE or a correlated subquery.
Example 6: DELETE with a subquery and USING
Delete rows chosen by a subquery:
DELETE FROM emp_copy
WHERE dept_id IN (SELECT id FROM dept_copy WHERE name = 'Sales Leads')
RETURNING id, name; id | name
-----+---------------
106 | Divya (lead)
107 | Sameer (lead)
(2 rows)USING joins other tables into a delete, like FROM in an update:
DELETE FROM emp_copy e
USING dept_copy d
WHERE e.dept_id = d.id
AND d.name = 'HR'
RETURNING e.name AS deleted_employee, d.name AS department; deleted_employee | department
------------------+------------
Rohan | HR
(1 row)Example 7: the missing WHERE, and how to check first
Without WHERE, UPDATE and DELETE touch every row. Run the statement inside a transaction, check the row count, and ROLLBACK when it is wrong.
BEGIN;
UPDATE emp_copy SET salary = 0;
SELECT count(*) AS rows_with_zero_salary FROM emp_copy WHERE salary = 0;
ROLLBACK;
SELECT min(salary) AS min_salary_after_rollback FROM emp_copy; rows_with_zero_salary
-----------------------
11
(1 row)
min_salary_after_rollback
---------------------------
70000
(1 row)Other safeguards:
- Write the
WHEREfirst, run it as aSELECT, then change the verb. - Use
RETURNINGto see what changed. - In
psql,\set AUTOCOMMIT offmakes every statement wait for an explicitCOMMIT. - Test on a copy:
CREATE TEMP TABLE t AS SELECT * FROM real_table. A temp table disappears when the session ends.
Example 8: affected row counts
The server reports the number of rows affected as a command tag: INSERT 0 n, UPDATE n, DELETE n. Clients expose it (cmd.rowcount in psycopg, ExecuteNonQuery in ADO.NET). An UPDATE that matches no row reports UPDATE 0 and is not an error.
The same count is available in SQL with a CTE and RETURNING:
WITH changed AS (
UPDATE emp_copy SET salary = salary + 1000 WHERE dept_id = 1 RETURNING id
)
SELECT count(*) AS rows_updated FROM changed; rows_updated
--------------
5
(1 row)WITH changed AS (
UPDATE emp_copy SET salary = salary + 1000 WHERE dept_id = 99 RETURNING id
)
SELECT count(*) AS rows_updated FROM changed; rows_updated
--------------
0
(1 row)Example 9: DELETE vs TRUNCATE vs DROP
DELETE |
TRUNCATE |
DROP TABLE |
|
|---|---|---|---|
| Removes | rows matching WHERE |
all rows | rows and the table definition |
WHERE allowed |
yes | no | no |
| Speed on large tables | slow, row by row, dead rows left for vacuum | fast, discards the table's files | fast |
| Fires row triggers | yes | no (statement triggers only) | no |
| Rollback in PostgreSQL | yes | yes | yes |
| Resets identity/serial | no | only with RESTART IDENTITY |
table is gone |
| Foreign keys | checked per row | refused if referenced, unless CASCADE |
refused if referenced, unless CASCADE |
| Type | DML | DDL | DDL |
Rollback of TRUNCATE and DROP works in PostgreSQL and SQL Server. In MySQL and Oracle, DDL commits implicitly.
BEGIN;
DELETE FROM audit_log WHERE action IN ('a', 'b');
SELECT count(*) AS after_delete FROM audit_log;
TRUNCATE audit_log RESTART IDENTITY;
SELECT count(*) AS after_truncate FROM audit_log;
INSERT INTO audit_log (action) VALUES ('fresh') RETURNING id;
ROLLBACK;
SELECT count(*) AS after_rollback FROM audit_log; after_delete
--------------
3
(1 row)
after_truncate
----------------
0
(1 row)
id
----
1
(1 row)
after_rollback
----------------
5
(1 row)TRUNCATE is refused when another table has a foreign key to it:
TRUNCATE departments;ERROR: cannot truncate a table referenced in a foreign key constraint
DETAIL: Table "employees" references "departments".
HINT: Truncate table "employees" at the same time, or use TRUNCATE ... CASCADE.Example 10: upsert with ON CONFLICT DO NOTHING
INSERT ... ON CONFLICT handles a violation of a unique or primary key without raising an error. DO NOTHING skips the conflicting row.
INSERT INTO dept_copy (id, name) VALUES (1, 'Duplicate'), (7, 'Support')
ON CONFLICT (id) DO NOTHING
RETURNING id, name; id | name
----+---------
7 | Support
(1 row)Only the new row (7) is returned. Running the statement again inserts nothing, so it is safe to repeat.
Example 11: ON CONFLICT DO UPDATE
DO UPDATE changes the existing row instead. EXCLUDED is the row that was proposed for insertion.
INSERT INTO dept_copy (id, name) VALUES (1, 'Platform'), (8, 'Finance')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, (xmax = 0) AS inserted; id | name | inserted
----+----------+----------
1 | Platform | f
8 | Finance | t
(2 rows)xmax = 0 is true for a row that was just inserted and false for one that was updated. It is a PostgreSQL implementation detail, useful for demos, not for production logic.
The conflict target must match a unique index or constraint. The statement is idempotent: running it twice leaves the same table.
MySQL writes this as INSERT ... ON DUPLICATE KEY UPDATE. SQLite also supports ON CONFLICT. Oracle and SQL Server use MERGE.
Example 12: MERGE (PostgreSQL 15 and later)
MERGE compares a source to a target and applies INSERT, UPDATE, or DELETE per row. It is standard SQL. PostgreSQL 17 adds RETURNING to MERGE; version 16 does not.
MERGE INTO dept_copy t
USING (VALUES (2, 'Sales & Growth'), (9, 'Research')) AS s(id, name)
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET name = s.name
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name);
SELECT * FROM dept_copy ORDER BY id; id | name
----+----------------
1 | Platform
2 | Sales & Growth
3 | Marketing
4 | HR
5 | Legal
6 | Sales Leads
7 | Support
8 | Finance
9 | Research
(9 rows)ON CONFLICT relies on a unique index and is safe under concurrent inserts. MERGE does not need a unique index, but under concurrency two sessions can both take the NOT MATCHED branch; with a unique constraint one of them then fails with a unique violation. Use ON CONFLICT for a plain upsert.
Example 13: clean up
Drop the copies. DROP TABLE IF EXISTS name avoids an error when the table is absent.
DROP TABLE emp_copy, dept_copy, audit_log;
SELECT count(*) AS original_employees FROM employees; original_employees
--------------------
12
(1 row)Interview points
| Question | Answer |
|---|---|
DELETE vs TRUNCATE vs DROP |
DELETE removes chosen rows and can use WHERE and fire row triggers. TRUNCATE empties the table quickly and takes an exclusive lock. DROP removes the table itself. |
Can TRUNCATE be rolled back? |
In PostgreSQL and SQL Server yes. In MySQL and Oracle no, DDL commits. |
Why is DELETE of all rows slower than TRUNCATE? |
It processes each row, writes WAL per row, and leaves dead rows for VACUUM. |
| What makes an upsert idempotent? | A unique key plus ON CONFLICT: repeating the statement produces the same final table. DO NOTHING keeps the old row, DO UPDATE overwrites it. |
ON CONFLICT vs MERGE |
ON CONFLICT needs a unique index and is concurrency-safe. MERGE is standard, more general (can DELETE), and needs care under concurrency. |
| How do you avoid an accidental unbounded update? | Transaction plus row-count check, RETURNING, run the WHERE as a SELECT first. |
Summary
- List columns in
INSERT; useRETURNINGto get generated values and changed rows back. UPDATE ... FROMandDELETE ... USINGchange rows using a join.- Test destructive statements in a transaction and check the row count before
COMMIT. DELETEremoves rows,TRUNCATEempties a table fast,DROPremoves the table.ON CONFLICTgives an idempotent upsert;MERGE(PG 15+) covers multi-branch cases.
