Writing Data
So far every query has read the database β now we change it. INSERT adds rows, UPDATE modifies existing ones, DELETE and TRUNCATE remove them. Transactions wrap multiple writes into an all-or-nothing unit so the database never ends up half-updated. This chapter covers the syntax, the safety rails, and the ACID guarantees that make writes trustworthy.
INSERT
Single-row insert
The most basic write. List the target columns (optional but recommended) and the values in matching order.
-- Explicit column list β safe against future schema changes
INSERT INTO users (id, email, full_name, created_at)
VALUES (4, 'margaret@example.com', 'Margaret Hamilton', '2025-03-01 08:00:00');
-- Without column list β works, but brittle if someone adds a column later
INSERT INTO users
VALUES (5, 'alan@example.com', 'Alan Turing', '2025-03-02 09:00:00'); ALTER TABLE β¦ ADD COLUMN. Always name your columns.
Multi-row insert
Multiple VALUES tuples in a single statement β one round trip to the database, one parse, one WAL flush. Dramatically faster than issuing 1,000 individual inserts from application code.
INSERT INTO products (id, sku, name, price, category)
VALUES
(104, 'BK-003', 'Clean Code', 32.00, 'books'),
(105, 'MG-002', 'SQL Sticker', 2.50, 'merch'),
(106, 'BK-004', 'The Art of SQL', 29.99, 'books'); INSERT β¦ SELECT β copy from another query
Instead of literal values, feed the INSERT from any SELECT. Powerful for ETL steps, archiving, or populating summary tables. The column count and types must match.
-- Archive cancelled orders into a separate table (hypothetical)
INSERT INTO archived_orders (id, user_id, status, placed_at)
SELECT id, user_id, status, placed_at
FROM orders
WHERE status = 'cancelled'; Handling conflicts β upsert preview
What if the row already exists? By default a duplicate primary key (or unique constraint) causes an error. ON CONFLICT (PostgreSQL / SQLite) or ON DUPLICATE KEY UPDATE (MySQL) lets you decide: skip it, or update the existing row. The full pattern is covered in Chapter 11 β here's just enough to know it exists.
-- PostgreSQL / SQLite: insert or skip if email already exists
INSERT INTO users (id, email, full_name)
VALUES (4, 'margaret@example.com', 'Margaret Hamilton')
ON CONFLICT (email) DO NOTHING;
-- PostgreSQL: insert or update on conflict
INSERT INTO users (id, email, full_name)
VALUES (4, 'margaret@example.com', 'Margaret H.')
ON CONFLICT (email) DO UPDATE
SET full_name = EXCLUDED.full_name;
-- MySQL equivalent
INSERT INTO users (id, email, full_name)
VALUES (4, 'margaret@example.com', 'Margaret H.')
ON DUPLICATE KEY UPDATE full_name = VALUES(full_name); EXCLUDED (PostgreSQL/SQLite) refers to the row that failed to insert. In MySQL it's VALUES(col). SQLite requires the conflict target in parentheses: ON CONFLICT (col).
UPDATE
UPDATE changes values in existing rows. The WHERE clause decides which rows are affected. Forgetting it affects every row in the table β the single most destructive mistake in all of SQL.
Basic UPDATE with WHERE
-- Set one order's status to 'shipped'
UPDATE orders
SET status = 'shipped'
WHERE id = 1001;
-- Update multiple columns at once
UPDATE users
SET email = 'ada.lovelace@example.com',
full_name = 'Augusta Ada Lovelace'
WHERE id = 1; The accidental full-table update
Run this without the WHERE and every order becomes 'shipped':
-- β DANGER: missing WHERE β ALL rows affected
UPDATE orders SET status = 'shipped';
-- 4 rows updated (every single order, including cancelled ones) Safe practice β preview before writing
Before running any UPDATE, run the same WHERE as a SELECT and verify the row set. Then wrap the UPDATE in a transaction (Β§4) so you can ROLLBACK if the results aren't what you expect.
-- Step 1: preview
SELECT id, status FROM orders WHERE id = 1001;
-- Step 2: update inside a transaction
BEGIN;
UPDATE orders SET status = 'shipped' WHERE id = 1001;
-- Inspect the resultβ¦
SELECT id, status FROM orders WHERE id = 1001;
-- Happy? Commit. Not happy? ROLLBACK;
COMMIT; UPDATE β¦ FROM β updating from another table
Sometimes the new value comes from a related table. PostgreSQL and SQL Server support UPDATE β¦ FROM; MySQL uses UPDATE β¦ JOIN; standard SQL uses a correlated subquery. All three produce the same result.
-- PostgreSQL: set each order's status based on a lookup table
UPDATE orders o
SET status = s.new_status
FROM status_transitions s
WHERE s.order_id = o.id
AND s.approved = true;
-- MySQL equivalent
UPDATE orders o
JOIN status_transitions s ON s.order_id = o.id AND s.approved = true
SET o.status = s.new_status;
-- Standard SQL (correlated subquery)
UPDATE orders
SET status = (
SELECT s.new_status
FROM status_transitions s
WHERE s.order_id = orders.id AND s.approved = true
)
WHERE id IN (SELECT order_id FROM status_transitions WHERE approved = true); UPDATE β¦ FROM that matches multiple rows in the FROM table for a single target row. PostgreSQL will update the target once per match iteration β the final value is undefined (last-writer-wins). Always ensure the FROM clause produces at most one row per target row, or add a DISTINCT ON / ROW_NUMBER() subquery.
DELETE & TRUNCATE
DELETE removes rows that match a WHERE. TRUNCATE removes all rows, fast. They differ in speed, logging, and how they interact with foreign keys and transactions.
DELETE with a WHERE clause
Just like UPDATE, a missing WHERE deletes every row. The same "preview with SELECT, wrap in a transaction" discipline applies.
-- Delete a specific cancelled order
DELETE FROM orders WHERE id = 1004;
-- Delete all cancelled orders
DELETE FROM orders WHERE status = 'cancelled';
-- β Delete EVERYTHING β rarely what you want
DELETE FROM orders; -- 0 rows remain Cascade deletes and referential integrity
If order_items has a foreign key pointing at orders.id, deleting an order that still has items will fail with a constraint violation β unless the FK was defined with ON DELETE CASCADE (Chapter 07). Three possible outcomes:
-- If FK is ON DELETE RESTRICT (the default):
DELETE FROM orders WHERE id = 1001;
-- ERROR: update or delete on table "orders" violates foreign key constraint on "order_items"
-- If FK is ON DELETE CASCADE:
DELETE FROM orders WHERE id = 1001;
-- order 1001 deleted, AND all its order_items automatically deleted
-- If FK is ON DELETE SET NULL:
DELETE FROM orders WHERE id = 1001;
-- order_items.order_id set to NULL for those rows (usually undesirable) Safest
The parent row cannot be deleted while children exist. Forces the application to delete children first.
Convenient but dangerous
Deleting one parent row can silently wipe thousands of child rows. Use only when the children are truly meaningless without the parent.
Keeps children, orphans them
Sets the FK column to NULL. The child row survives but loses its relationship. Rarely the right default.
Delete bottom-up
Delete children (order_items) first, then the parent (orders). Works regardless of FK action and makes intent explicit.
TRUNCATE vs DELETE
Both empty a table, but the mechanism is fundamentally different.
TRUNCATE vs DELETE FROM table β same end state, different costs-- DELETE (slow, safe, triggers fire, rollback works everywhere)
DELETE FROM order_items;
DELETE FROM orders;
-- TRUNCATE (fast, resets sequences, no triggers)
TRUNCATE order_items, orders;
-- PostgreSQL: cascade to children + reset IDs
TRUNCATE orders CASCADE;
-- Also truncates order_items (FK child) and resets sequences. DELETE for anything row-specific or when triggers/FKs/rollback matter. Use TRUNCATE only to wipe a table completely in dev/test or during known-safe ETL bulk-reload steps.
Transactions & ACID
A transaction wraps one or more statements into a unit that either succeeds completely (COMMIT) or fails completely (ROLLBACK). No in-between. If the server crashes mid-transaction, the database acts as though nothing happened.
BEGIN, COMMIT, ROLLBACK
-- Transfer $50 from user 1 to user 2's account balance (hypothetical)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 50 WHERE user_id = 2;
COMMIT;
-- Both succeed, or neither does. No partial state is ever visible to other sessions.
-- If something goes wrong mid-way:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE user_id = 1;
-- Oops, user 2's account is locked / doesn't exist / whatever
ROLLBACK;
-- The first UPDATE is undone β user 1's balance is unchanged. ACID β the four guarantees
Every serious database promises these four properties. They're what separates a database from a folder full of CSV files.
When and why to wrap statements in a transaction
Three common patterns where an explicit transaction is essential:
-- β Multi-statement write that must be atomic
BEGIN;
INSERT INTO orders (id, user_id, status, placed_at)
VALUES (1005, 1, 'pending', NOW());
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (1005, 101, 2, 34.99);
COMMIT;
-- If the second INSERT fails (e.g. product 101 doesn't exist), both are rolled back.
-- β‘ Read-then-write where the read must not go stale
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE;
-- "FOR UPDATE" locks the row so no other transaction can change it mid-flight.
UPDATE accounts SET balance = balance - 50 WHERE user_id = 1;
COMMIT;
-- β’ Exploratory writes in production (the "safety net" pattern)
BEGIN;
DELETE FROM order_items WHERE order_id = 1001;
DELETE FROM orders WHERE id = 1001;
-- Check: did we delete exactly 1 order and 2 items?
-- SELECT COUNT(*) FROM orders WHERE id = 1001; β should be 0
-- If wrong, type: ROLLBACK;
COMMIT; - PostgreSQL: every statement outside a
BEGINblock auto-commits. - MySQL (InnoDB): same β
autocommit = 1by default.START TRANSACTIONorBEGINdisables it for that session untilCOMMIT/ROLLBACK. - SQLite: auto-commits each statement. Use
BEGINexplicitly for multi-statement atomicity.
INSERT: always name your columns. Multi-rowVALUESis one round trip.INSERT β¦ SELECTcopies from any query.UPDATE: a missingWHEREis silent data corruption. Preview with SELECT first. Wrap in a transaction.DELETE: same risk as UPDATE β preview and transact.ON DELETE CASCADEcan silently wipe child rows.TRUNCATE: fast bulk-delete, no triggers, resets sequences. PostgreSQL supports ROLLBACK; MySQL does not.- Transactions:
BEGIN β¦ COMMIT= all-or-nothing. ACID is always on β explicit transactions just group multiple statements into a single atomic unit.
β Chapter 05 β Joining Tables Β· π Continue to Chapter 07 β Schema Design β