SQL Β· Intermediate

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.

01
Section One Β· Adding rows

INSERT

new data ('ada@…', 'Ada') ('linus@…', 'Linus') INSERT users table existing rows… + new rows appended

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');
Common mistake: omitting the column list then wondering why "the values shifted one column to the right" after an 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);
Dialect note: 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).
02
Section Two Β· Changing existing rows

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)
The full-table-update disaster β€” and how to prevent it
before UPDATE (no WHERE) id status 1001 paid 1002 paid 1003 shipped 1004 cancelled SET status='shipped' (no WHERE!) after β€” every row damaged id status 1001 shipped 1002 shipped 1003 shipped 1004 shipped a cancelled order is now "shipped" β€” silent data corruption Fix: wrap in a transaction (section 4) and always test with SELECT first. Many teams enable sql_safe_updates (MySQL) or require WHERE in production update scripts.

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);
Common mistake: an 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.
03
Section Three Β· Removing rows

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)
RESTRICT (default)

Safest

The parent row cannot be deleted while children exist. Forces the application to delete children first.

CASCADE

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.

SET NULL

Keeps children, orphans them

Sets the FK column to NULL. The child row survives but loses its relationship. Rarely the right default.

Best practice

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 FROM table (no WHERE) TRUNCATE table Speed Slow β€” deletes row by row, writes to WAL Fast β€” deallocates pages in bulk Logging Every row logged (full undo available) Minimal logging (page-level) ROLLBACK βœ“ fully rollback-able PostgreSQL: βœ“ (transactional) MySQL: βœ— (auto-commits) Triggers Row-level triggers fire for each row Triggers do NOT fire FK checks βœ“ enforced (CASCADE fires) Fails if child rows exist β€” use TRUNCATE … CASCADE Auto-ID Next ID continues from last value Resets to 1 (unless RESTART IDENTITY omitted)
-- 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.
When to use which: use 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.
04
Section Four Β· All or nothing

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.

ACID β€” every transaction gets all four, every time
A tomicity All statements in the transaction succeed, or none of them do. No partial writes are ever visible. A crash mid-transaction is the same as an explicit ROLLBACK. analogy: an ATM debit + credit β€” both or neither C onsistency A transaction moves the database from one valid state to another. Constraints (PK, FK, CHECK, NOT NULL) are enforced at commit β€” any violation rolls the whole thing back. analogy: the total money in the system never changes I solation Concurrent transactions don't interfere. Each sees a consistent snapshot β€” as if it were the only thing running. The isolation level (e.g. READ COMMITTED) controls how strict this really is in practice. D urability Once COMMIT returns, the data is on disk (or in the write-ahead log). A power failure one millisecond later won't lose it. The WAL is replayed on crash recovery to restore committed state. Every INSERT, UPDATE, DELETE you run outside an explicit BEGIN/COMMIT is implicitly wrapped in its own one-statement transaction β€” so ACID always applies, even without BEGIN.

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;
Dialect note β€” auto-commit:
  • PostgreSQL: every statement outside a BEGIN block auto-commits.
  • MySQL (InnoDB): same β€” autocommit = 1 by default. START TRANSACTION or BEGIN disables it for that session until COMMIT/ROLLBACK.
  • SQLite: auto-commits each statement. Use BEGIN explicitly for multi-statement atomicity.
Mental shortcut for the whole chapter:
  • INSERT: always name your columns. Multi-row VALUES is one round trip. INSERT … SELECT copies from any query.
  • UPDATE: a missing WHERE is silent data corruption. Preview with SELECT first. Wrap in a transaction.
  • DELETE: same risk as UPDATE β€” preview and transact. ON DELETE CASCADE can 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 β†’