Skip to main content

✍️ CRUD Operations in SQL

Every application that stores data does four things with it: Create, Read, Update, and Delete. In SQL these map to INSERT, SELECT, UPDATE, and DELETE. This lesson turns those four verbs into muscle memory — and drills the one safety habit that separates careful engineers from the ones who accidentally erase production.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Map the CRUD acronym to its SQL statements and explain what each one changes
  • Write INSERT statements for single rows, multiple rows, and rows copied from a query
  • Modify data safely with UPDATE, including calculated values and updates driven by other tables
  • Remove data with DELETE and distinguish it from TRUNCATE
  • Apply the WHERE-first safety habit and wrap risky changes in a transaction

Estimated Time: 35–45 minutes  •  Difficulty: Beginner

Hands-on: Run a full create → read → update → delete cycle against a products table inside a transaction you can roll back.

In This Lesson

What CRUD Means

CRUD is the four basic operations any persistent store supports. It's such a universal shape that REST APIs, admin panels, and ORMs are all organized around it. In SQL the mapping is direct:

graph LR CRUD[CRUD] --> C["Create → INSERT"] CRUD --> R["Read → SELECT"] CRUD --> U["Update → UPDATE"] CRUD --> D["Delete → DELETE"] C --> Cn[Add new rows] R --> Rn[Retrieve rows] U --> Un[Modify existing rows] D --> Dn[Remove rows]
💡 Analogy — the librarian's day: A table is a shelf; CRUD is the librarian's routine. Create shelves a new book, Read looks one up, Update corrects its catalog entry, and Delete removes a damaged copy. Each has a fixed, careful procedure — and the destructive ones (update, delete) are done deliberately, never in bulk by accident.

You already learned Read in the previous lesson. Here we focus on the three that change data, because writes carry risk that reads never do.

Create: INSERT

INSERT adds new rows. Always list the columns explicitly — it documents intent and survives future schema changes.

-- Named columns (recommended)
INSERT INTO customers (customer_name, contact_name, country)
VALUES ('Acme Inc.', 'John Smith', 'USA');

You can omit the column list and supply a value for every column in table order, but that breaks silently the moment someone adds or reorders a column, so avoid it in real code.

INSERT adding a new highlighted row to a table A customers table with two existing rows gains a third, highlighted row for Acme Inc via an INSERT statement. INSERT INTO customers … customer_id customer_name country 1 ABC Company Germany 2 XYZ Ltd. France 3 Acme Inc. USA ↓ new row appended
Figure 1 — INSERT appends the highlighted row; existing rows are untouched.

Insert many rows at once

INSERT INTO products (product_name, unit_price, category_id) VALUES
    ('Organic Green Tea',   18.50, 1),
    ('Dark Chocolate Bar',   3.95, 2),
    ('Himalayan Pink Salt',  6.75, 2);

A single multi-row insert is far faster than many separate statements, because the database commits them together.

Insert from a query

-- Copy every US customer into an archive table
INSERT INTO customers_usa (customer_id, customer_name, contact_name)
SELECT customer_id, customer_name, contact_name
FROM customers
WHERE country = 'USA';

💡 Auto-generated keys and RETURNING

Primary keys are usually generated for you (SERIAL/IDENTITY in PostgreSQL, AUTO_INCREMENT in MySQL), so you omit that column. To get the new id back in one round trip, PostgreSQL and SQLite support RETURNING:

INSERT INTO users (username, email)
VALUES ('johndoe', 'john@example.com')
RETURNING user_id;

On MySQL you'd instead read LAST_INSERT_ID() right after the insert.

Upsert: insert or update

A very common need is "insert this row, but if it already exists, update it instead." Modern SQL handles this in one statement:

-- PostgreSQL / SQLite
INSERT INTO settings (user_id, theme)
VALUES (42, 'dark')
ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme;

-- MySQL
INSERT INTO settings (user_id, theme)
VALUES (42, 'dark')
ON DUPLICATE KEY UPDATE theme = VALUES(theme);

Read: SELECT (recap)

The previous lesson covered SELECT in depth. As a quick reminder, reading is the safe operation — it never changes data — so it's your best friend for previewing what a risky write will touch:

SELECT product_id, product_name, unit_price, units_in_stock
FROM products
WHERE category_id = 1
ORDER BY unit_price DESC;

Keep this pattern in mind: before every UPDATE or DELETE, run the same WHERE as a SELECT first.

Update: UPDATE

UPDATE changes values in existing rows. The SET clause says what to change; the WHERE clause says which rows.

-- One column, one row
UPDATE products
SET unit_price = 24.99
WHERE product_id = 15;

-- Several columns at once
UPDATE customers
SET contact_name = 'Maria Anders',
    phone        = '030-0074321',
    updated_at   = CURRENT_TIMESTAMP
WHERE customer_id = 4;

Update using the current value

The right-hand side can reference the existing column, which is how you increment, discount, or restock:

-- Give every Beverages product a 10% price rise
UPDATE products
SET unit_price = unit_price * 1.10
WHERE category_id = 1;

-- Deduct sold units and stamp the change
UPDATE products
SET units_in_stock = units_in_stock - 5,
    updated_at     = CURRENT_DATE
WHERE product_id = 10;

Update driven by another table

To set a value based on aggregated data from elsewhere, use a subquery. PostgreSQL and MySQL differ in syntax:

-- PostgreSQL: UPDATE ... FROM
UPDATE customers c
SET discount_rate = CASE
        WHEN o.order_count > 10 THEN 0.15
        WHEN o.order_count >  5 THEN 0.10
        ELSE 0.05
    END
FROM (
    SELECT customer_id, COUNT(*) AS order_count
    FROM orders GROUP BY customer_id
) o
WHERE c.customer_id = o.customer_id;

-- MySQL: UPDATE ... JOIN
UPDATE customers c
JOIN (
    SELECT customer_id, COUNT(*) AS order_count
    FROM orders GROUP BY customer_id
) o ON c.customer_id = o.customer_id
SET c.discount_rate = CASE
        WHEN o.order_count > 10 THEN 0.15
        WHEN o.order_count >  5 THEN 0.10
        ELSE 0.05
    END;

⚠️ An UPDATE with no WHERE changes every row

This one line silently rewrites the entire table:

-- DANGER: sets EVERY product to 9.99
UPDATE products SET unit_price = 9.99;

There is no "are you sure?" prompt. The WHERE clause is the only thing standing between you and a full-table overwrite.

Delete: DELETE & TRUNCATE

DELETE removes rows that match a condition.

-- Remove one specific customer
DELETE FROM customers WHERE customer_id = 45;

-- Remove all discontinued, out-of-stock products
DELETE FROM products
WHERE discontinued = TRUE AND units_in_stock = 0;

-- Delete driven by a subquery
DELETE FROM orders
WHERE customer_id IN (
    SELECT customer_id FROM customers
    WHERE last_order_date < CURRENT_DATE - INTERVAL '2 years'
);

⚠️ A DELETE with no WHERE empties the table

-- DANGER: deletes EVERY customer
DELETE FROM customers;

Same rule as UPDATE: no WHERE means "all rows."

TRUNCATE — empty a table fast

When you truly want to remove all rows, TRUNCATE is far faster than a bare DELETE because it deallocates the data instead of logging each row.

TRUNCATE TABLE temp_logs;
AspectTRUNCATEDELETE (no WHERE)
SpeedVery fast (bulk deallocation)Slower (row by row)
WHERE supportNo — all rows onlyYes — can target rows
TriggersUsually don't fireFire per-row triggers
Auto-incrementTypically reset to 1Not reset
RollbackTransactional in PostgreSQL; not in MySQL (implicit commit)Rollback-able inside a transaction

💡 Rule of thumb: use DELETE for targeted removals, TRUNCATE only to blank a whole table you're sure about (like a staging or cache table).

The Safety Habit

Destructive statements have no undo button once committed. Build these three reflexes now and they'll save you for the rest of your career.

  1. SELECT before you strike. Run your exact WHERE as a SELECT to see which rows will be affected.
  2. Wrap it in a transaction. Then you can inspect the row count and roll back if it looks wrong.
  3. Check the affected-row count. If you expected to change 1 row and it says 4,000, stop.
BEGIN;                                  -- start a transaction

-- 1. Preview
SELECT * FROM products WHERE product_id = 15;

-- 2. Make the change
UPDATE products SET unit_price = 24.99 WHERE product_id = 15;

-- 3. Verify, then decide
--   Looks right?  COMMIT;
--   Looks wrong?  ROLLBACK;
COMMIT;

✅ Why transactions matter

A transaction groups statements so they all succeed or all fail together (atomicity). Until you COMMIT, nobody else sees your changes and ROLLBACK restores the previous state perfectly. You'll study transactions in depth later — for now, use them as a seatbelt.

Worked Example: Product Lifecycle

Here is one product moving through all four CRUD operations, exactly as an e-commerce backend would drive it.

-- CREATE: a new product arrives
INSERT INTO products (product_name, supplier_id, category_id, unit_price, units_in_stock)
VALUES ('Organic Quinoa', 5, 7, 12.99, 25)
RETURNING product_id;                    -- say it returns 78

-- READ: confirm it landed
SELECT product_id, product_name, unit_price, units_in_stock
FROM products
WHERE product_id = 78;

-- UPDATE: price change + restock after a shipment
UPDATE products
SET unit_price     = 14.99,
    units_in_stock = units_in_stock + 50
WHERE product_id = 78;

-- DELETE: the product is discontinued
DELETE FROM products
WHERE product_id = 78;

The same round trip expressed as the conversation between an app and its database:

sequenceDiagram participant App as Web App participant DB as SQL Database App->>DB: INSERT INTO products (...) RETURNING product_id DB-->>App: product_id = 78 App->>DB: SELECT * FROM products WHERE product_id = 78 DB-->>App: row data App->>DB: UPDATE products SET unit_price = 14.99 WHERE product_id = 78 DB-->>App: 1 row updated App->>DB: DELETE FROM products WHERE product_id = 78 DB-->>App: 1 row deleted

Hands-on Exercise

🏋️ Run a full CRUD cycle inside a transaction

Objective: Create a small inventory table, then create, read, update, and delete rows — all inside a transaction you can safely roll back.

Step 1 — Set up

CREATE TABLE inventory (
    item_id    INTEGER PRIMARY KEY,
    name       VARCHAR(80) NOT NULL,
    price      DECIMAL(6,2) NOT NULL,
    qty        INTEGER NOT NULL,
    status     VARCHAR(20) DEFAULT 'active'
);

Step 2 — Your tasks

  1. Create: insert three items of your choice in a single statement.
  2. Read: list all items priced above $10, cheapest first.
  3. Update: raise the price of one specific item by 15% using its current price.
  4. Update: mark every item with qty = 0 as 'sold_out'.
  5. Delete: remove one item by its item_id — but preview it with a SELECT first.
💡 Hint

For task 3, the SET right-hand side can read the column: SET price = price * 1.15. For task 4, filter on WHERE qty = 0. Wrap everything between BEGIN; and ROLLBACK; so your table returns to its starting state when you're done experimenting.

✅ Solution
BEGIN;

-- 1. Create
INSERT INTO inventory (item_id, name, price, qty) VALUES
    (1, 'USB-C Cable',   8.50, 40),
    (2, 'Mechanical Keyboard', 79.00, 0),
    (3, 'Laptop Stand', 34.99, 12);

-- 2. Read
SELECT name, price FROM inventory
WHERE price > 10 ORDER BY price ASC;

-- 3. Update using current value
UPDATE inventory SET price = price * 1.15 WHERE item_id = 3;

-- 4. Bulk status update
UPDATE inventory SET status = 'sold_out' WHERE qty = 0;

-- 5. Preview, then delete
SELECT * FROM inventory WHERE item_id = 1;   -- confirm the target
DELETE FROM inventory WHERE item_id = 1;

ROLLBACK;   -- undo everything (use COMMIT to keep it)

Best Practices

✅ Do

  • List columns explicitly in every INSERT.
  • Preview with SELECT before any UPDATE or DELETE.
  • Wrap risky writes in a transaction so you can roll back.
  • Batch multi-row inserts into a single statement for speed.
  • Prefer soft deletes (a deleted_at flag) when data might need recovery.

❌ Don't

  • Never run UPDATE or DELETE without a WHERE unless you truly mean "all rows."
  • Don't use TRUNCATE on a table with foreign-key children expecting a cascade — it may be blocked.
  • Don't rely on TRUNCATE being reversible on MySQL — it implicitly commits.

Summary & Quiz

🎉 Key Takeaways

  • CRUD = INSERT (create), SELECT (read), UPDATE (modify), DELETE (remove).
  • INSERT handles one row, many rows, query results, upserts, and can return generated keys.
  • UPDATE's SET can reference existing column values and be driven by other tables.
  • DELETE targets rows; TRUNCATE blanks a whole table fast but with caveats.
  • The WHERE clause is the safety line — preview with SELECT and wrap writes in a transaction.

🎯 Quick Quiz

Question 1: What happens when you run UPDATE products SET unit_price = 9.99; with no WHERE?

Question 2: Which statement is best for permanently emptying a large staging table as fast as possible?

Question 3: You want the price rise to build on the existing price. Which SET is correct?

📚 Further Reading

🚀 What's Next?

You can now change data in a single table. Real applications spread data across many tables — so next you'll learn Joins and Relationships, the SQL feature that stitches those tables back together.