Skip to content

We've built 200+ tools — open your toolbox, there's something in here you probably need.

Module 10 of 1345 min readBeginner

Modifying data: INSERT, UPDATE, DELETE, and DDL

The write side of SQL: insert, update and delete rows, define tables with keys and constraints, and wrap changes in transactions.

Module 10 of 13

Listen along

Read “Modifying data: INSERT, UPDATE, DELETE, and DDL” aloud

Plays in your browser using on-device text-to-speech — nothing leaves the page.

Everything so far has been the read side of SQL: SELECT and its friends. But data has to get INTO the database, and it has to be corrected, updated, and sometimes removed. That is the write side — DML (Data Manipulation Language: INSERT, UPDATE, DELETE) and DDL (Data Definition Language: CREATE, ALTER, DROP).

The write side is where SQL gets serious. A wrong SELECT wastes a minute. A wrong UPDATE or DELETE changes real data — a SACCO member's loan balance, a payment record, a member's phone number. This module teaches the writes and, just as importantly, the discipline that keeps them from becoming disasters.

Read this before you run anything

Every UPDATE and DELETE in this module has a WHERE clause. The single most expensive mistake in SQL is running UPDATE or DELETE without one — it hits every row in the table. Before you change data: SELECT the exact rows first, wrap the change in a transaction, and keep your finger off the run button until the row count looks right.

INSERT: putting rows in

The basic form names the columns you are filling and gives one row of values.

sql
INSERT INTO members (national_id, name, phone, branch_id, joined_date)
VALUES ('12345678', 'Amina Otieno', '+254712345678', 3, '2026-07-29');

Always list the columns explicitly. INSERT INTO members VALUES (...) without a column list depends on the physical column order, which silently breaks the day someone adds a column. Naming the columns is the same discipline as naming columns in SELECT instead of SELECT *.

Multi-row INSERT

You can insert many rows in one statement — faster and atomic (all rows land, or none do).

sql
INSERT INTO loan_products (name, max_amount, max_tenor_months, interest_rate, requires_guarantor)
VALUES
('Salary Advance', 100000, 6, 0.05, false),
('School Fees Loan', 300000, 12, 0.09, true),
('Business Loan', 1000000, 36, 0.13, true);

INSERT ... SELECT

You can feed an INSERT from a query instead of literal VALUES. This is how you copy or archive rows. Here we snapshot every fully repaid loan into a history table:

sql
INSERT INTO loans_archive (loan_id, member_id, principal, disbursement_date, closed_date)
SELECT id, member_id, principal, disbursement_date, NOW()
FROM loans
WHERE status = 'completed';

RETURNING: get back what you just wrote

Postgres lets INSERT hand you columns from the rows it created — most usefully the auto-generated id, so you do not need a second query to find out what id the database assigned.

sql
INSERT INTO loan_applications (member_id, product_id, requested_amount, application_date, status)
VALUES (42, 2, 250000, NOW(), 'pending')
RETURNING id, application_date;

RETURNING works on UPDATE and DELETE too

UPDATE ... RETURNING * shows you exactly which rows changed and their new values; DELETE ... RETURNING * shows what you removed. It is both a convenience and a safety check — you see the damage before you trust it.

UPDATE: changing rows in place

UPDATE sets new column values on the rows that match the WHERE clause.

sql
UPDATE members
SET phone = '+254799999999'
WHERE id = 42;

You can set several columns at once, and the new value can be an expression referring to the current value. Here we apply a monthly interest charge to every active loan's outstanding balance:

sql
UPDATE loans
SET outstanding_balance = outstanding_balance * 1.013,
last_interest_applied = CURRENT_DATE
WHERE status = 'active';

UPDATE without WHERE hits every row

UPDATE members SET phone = '+254700000000'; — with no WHERE — rewrites the phone number of every member in the table. There is no undo outside a transaction. The database will not warn you; it does exactly what you asked. This is the classic 3am mistake.

UPDATE ... FROM: update using another table

Sometimes the new value comes from a related table. Postgres supports UPDATE ... FROM (a join in an update). Here we recompute each loan's outstanding balance from the sum of its repayments:

sql
UPDATE loans l
SET outstanding_balance = l.principal - paid.total_paid
FROM (
SELECT loan_id, SUM(principal_portion) AS total_paid
FROM repayments
GROUP BY loan_id
) paid
WHERE paid.loan_id = l.id;

The FROM clause names the source (here a subquery of repayment totals), and the WHERE clause joins it to the rows being updated. Only loans with matching repayment rows are touched.

DELETE vs TRUNCATE

DELETE removes the rows that match a WHERE clause. Like UPDATE, forgetting WHERE removes everything.

sql
DELETE FROM loan_applications
WHERE status = 'rejected'
AND decision_date < NOW() - INTERVAL '2 years';

TRUNCATE empties an entire table in one fast operation. It does not scan or log individual rows, cannot carry a WHERE clause, and is not easily rolled back on all systems. Use it only when you genuinely want to wipe a whole table (a staging table you reload nightly, say).

  • DELETE ... WHERE — removes selected rows; slower; fully transactional; fires row triggers; can be rolled back
  • DELETE with no WHERE — removes every row, one at a time
  • TRUNCATE table — removes every row in bulk; very fast; resets sequences with RESTART IDENTITY; skips per-row triggers; meant for whole-table wipes

Prefer DELETE for anything selective

Reach for TRUNCATE only when you mean 'empty this entire table'. For 'remove these specific rows' you need DELETE with a WHERE — TRUNCATE cannot filter, and its speed comes precisely from not caring which rows it destroys.

UPSERT: INSERT ... ON CONFLICT DO UPDATE

A common need: insert a row, but if one with the same key already exists, update it instead of erroring. This is an upsert (update-or-insert). Postgres spells it ON CONFLICT.

sql
INSERT INTO member_balances (member_id, savings_balance, updated_at)
VALUES (42, 15000, NOW())
ON CONFLICT (member_id)
DO UPDATE SET
savings_balance = EXCLUDED.savings_balance,
updated_at = EXCLUDED.updated_at;

If no row for member 42 exists, this inserts one. If one does exist (a conflict on the member_id unique key), it runs the DO UPDATE instead. EXCLUDED is a special alias for the row you tried to insert, so EXCLUDED.savings_balance is the 15000 you supplied. ON CONFLICT ... DO NOTHING is the other variant: insert if new, silently skip if it already exists.

ON CONFLICT needs a constraint to conflict on

The column(s) in ON CONFLICT (...) must have a unique constraint or be the primary key — that is what defines what 'already exists' means. Upsert on member_id only works because member_id is unique in member_balances.

DDL: defining the tables themselves

DDL creates and alters the structure that DML fills. You read schemas constantly; sometimes you also create them. Here is a CREATE TABLE that uses every constraint worth knowing:

sql
CREATE TABLE loans (
id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL REFERENCES members(id),
product_id BIGINT NOT NULL REFERENCES loan_products(id),
principal NUMERIC(14,2) NOT NULL CHECK (principal > 0),
interest_rate NUMERIC(5,4) NOT NULL,
tenor_months INTEGER NOT NULL CHECK (tenor_months BETWEEN 1 AND 60),
outstanding_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','active','completed','defaulted')),
disbursement_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  • BIGSERIAL PRIMARY KEY — an auto-incrementing integer id that uniquely identifies each row
  • NUMERIC(14,2) — exact decimal for money: 14 total digits, 2 after the point. Never store money in FLOAT — rounding errors are real cash
  • NOT NULL — the column must always have a value; the insert fails otherwise
  • REFERENCES members(id) — a FOREIGN KEY: every member_id must exist in members, so you cannot create a loan for a member who does not exist
  • DEFAULT 0 / DEFAULT NOW() — the value used when the INSERT does not supply one
  • CHECK (...) — a rule the row must satisfy: a positive principal, a sane tenor, a status from a known set

Constraints are the database defending itself

A CHECK, a NOT NULL, a FOREIGN KEY is the schema refusing to hold nonsense — a negative loan, a payment for a member who does not exist, a loan with no status. The database enforces these on every write, no matter which app or analyst is writing. That guarantee is worth more than any amount of careful application code.

ALTER TABLE

Schemas evolve. ALTER TABLE changes an existing table without recreating it — add a column, add a constraint, change a default.

sql
ALTER TABLE members ADD COLUMN kyc_verified BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE loans ADD CONSTRAINT chk_balance_nonneg CHECK (outstanding_balance >= 0);
ALTER TABLE members ALTER COLUMN phone DROP NOT NULL;

DROP

DROP removes an object entirely — a table, and all its data, gone. There is no WHERE and no partial version.

sql
DROP TABLE loans_staging;
-- IF EXISTS avoids an error when the table may already be gone:
DROP TABLE IF EXISTS loans_staging;

DROP TABLE is not DELETE

DELETE FROM loans empties the loans table but the table (and its structure) survives. DROP TABLE loans destroys the table itself. On a production database, DROP is almost never something you run casually — and never on a live table without a backup.

Transactions: why writes belong in BEGIN/COMMIT

A transaction groups several statements so they succeed or fail as one unit. Consider a loan repayment: you reduce the loan's outstanding balance AND insert a repayment record. If the first succeeds and the second fails, the books are now wrong — money vanished. A transaction makes both happen or neither.

sql
BEGIN;
UPDATE loans
SET outstanding_balance = outstanding_balance - 5000
WHERE id = 77;
INSERT INTO repayments (loan_id, paid_date, amount, principal_portion, interest_portion)
VALUES (77, CURRENT_DATE, 5000, 4500, 500);
COMMIT;

Nothing is permanent until COMMIT. If either statement errors, or you realise the loan id was wrong, ROLLBACK undoes everything back to the BEGIN as if it never happened:

sql
BEGIN;
UPDATE loans SET outstanding_balance = 0 WHERE status = 'active'; -- oops, no id!
SELECT id, outstanding_balance FROM loans WHERE status = 'active'; -- inspect the damage
ROLLBACK; -- undo it all, nothing was saved

The four letters worth memorising: ACID

Transactions give you Atomicity (all-or-nothing), Consistency (constraints hold at commit), Isolation (concurrent transactions do not see each other's half-done work), and Durability (once committed, it survives a crash). This is what separates a real database from a spreadsheet — and why financial systems run on relational databases.

The write-side safety drill

Adopt this as muscle memory for every UPDATE and DELETE against real data:

  1. Write the WHERE clause as a SELECT first: SELECT * FROM loans WHERE id = 77; — confirm it returns exactly the rows you intend, and count them
  2. Wrap the change in BEGIN; ... so nothing is committed yet
  3. Run the UPDATE / DELETE and check the reported row count against what your SELECT returned
  4. Optionally use RETURNING * to see the actual changed rows
  5. COMMIT only when it all looks right — otherwise ROLLBACK and start over

Check your understanding

You mean to correct one member's phone number and run: UPDATE members SET phone = '+254700111222'; with no WHERE clause, then COMMIT. What happens?

Check your understanding

A member_balances table has member_id as a unique key, and a row for member 42 already exists. You run: INSERT INTO member_balances (member_id, savings_balance) VALUES (42, 15000) ON CONFLICT (member_id) DO UPDATE SET savings_balance = EXCLUDED.savings_balance; What is the result?

Check your understanding

You need to remove only the loan applications that were rejected more than two years ago, keeping everything else. Which statement is correct?

Check your understanding

A CHECK constraint reads CHECK (tenor_months BETWEEN 1 AND 60). How many of these attempted tenor_months values would the constraint REJECT: 0, 1, 60, 61, 12?

values

Exercise · try it first

A SACCO member (id 77) makes a loan repayment of KES 5,000 against loan id 210, of which KES 4,200 is principal and KES 800 is interest. You must: (1) reduce the loan's outstanding_balance by the principal portion, (2) record the repayment in the repayments table, and (3) if the outstanding balance reaches zero, mark the loan 'completed'. Write this as a single safe transaction, and explain why it must be a transaction and not three loose statements.

Stuck? Ask Mwalimu (bottom-right) to check your reasoning.

Exercise · try it first

Design and create a repayments table for the SACCO loan system using CREATE TABLE. It must: link each repayment to a loan and to a member (foreign keys), never allow a repayment without a loan, store money exactly, split each payment into a principal portion and an interest portion whose sum cannot exceed the total amount, default the paid_date to today, timestamp when the row was created, and restrict paid_by_method to a known set of channels. Write the DDL and justify each constraint.

Stuck? Ask Mwalimu (bottom-right) to check your reasoning.

Further reading

  1. 01

    PostgreSQL Documentation — Data Manipulation and Data Definition

    PostgreSQL Global Development GroupThe authoritative reference for INSERT, UPDATE, DELETE, ON CONFLICT, CREATE TABLE, and transactions.

  2. 02

    SQL Cookbook

    Anthony Molinaro · O'Reilly · 2020Recipes for inserting, updating, and merging data safely.

  3. 03

    SQL Performance Explained

    Markus Winand · 2012Why constraints and keys matter, and how writes interact with indexes.

Loading progress…
LeadAfrikPublic Economics Hub