CREATE, INSERT, UPDATE, DELETE
Everything so far has been read-only. This chapter changes data — which means it is the chapter where a mistake actually costs something. Teach the safety habits alongside the syntax, not afterwards.
Analysts often think they will never need this. They do: building a temporary table for an analysis, loading a CSV, fixing a data-quality issue, creating a view for a dashboard.
CREATE TABLE
CREATE TABLE IF NOT EXISTS assignments ( assignment_id INTEGER PRIMARY KEY, course_id INTEGER NOT NULL, title TEXT NOT NULL, max_points INTEGER NOT NULL DEFAULT 100, due_date DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE );
No result table (1 statements executed, 0 ms)
Reading it line by line is the lesson: the type says what can be stored, and the constraints say what is allowed.
CREATE TABLE IF NOT EXISTS makes a setup script re-runnable without errors. DROP TABLE IF EXISTS does the same at the other end — which is why the Chapter 1 setup script starts with four of them.
You can also create a table directly from a query — extremely useful for snapshotting an analysis:
CREATE TABLE student_summary AS SELECT s.student_id, s.first_name || ' ' || s.last_name AS full_name, COUNT(e.enrollment_id) AS courses, ROUND(AVG(e.grade), 1) AS avg_grade FROM students s LEFT JOIN enrollments e ON s.student_id = e.student_id GROUP BY s.student_id;
No result table (1 statements executed, 1 ms)
The new table gets the rows and column types, but no primary key, no foreign keys, no constraints, no indexes. It is a snapshot, not a real table. Fine for analysis; never the basis of a production table.
Constraints
Constraints are rules the database enforces for you. Every rule you push into the schema is a rule that can never be broken by a buggy script at 3 a.m.
| Constraint | Guarantees | Example |
|---|---|---|
PRIMARY KEY |
Unique + not NULL, one per table | student_id INTEGER PRIMARY KEY |
NOT NULL |
A value must be supplied | first_name TEXT NOT NULL |
UNIQUE |
No two rows share this value | email TEXT UNIQUE |
CHECK |
A custom condition holds | CHECK (grade BETWEEN 0 AND 100) |
DEFAULT |
Value used when none is given | status TEXT DEFAULT 'active' |
FOREIGN KEY |
Must point at an existing parent row | REFERENCES courses(course_id) |
CREATE TABLE students_v2 ( student_id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, first_name TEXT NOT NULL, gender TEXT CHECK (gender IN ('M','F')), birth_date DATE CHECK (birth_date < DATE('now')), status TEXT NOT NULL DEFAULT 'active', CHECK (LENGTH(first_name) >= 2) -- table-level check );
No result table (1 statements executed, 0 ms)
Application code can be bypassed — by a second app, an admin script, a manual fix, an intern with a SQL client. The database is the last line of defence, and it never forgets. A CHECK (grade BETWEEN 0 AND 100) makes a grade of 5000 impossible, not merely unlikely.
ON DELETE — what happens to the children
FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE RESTRICT -- default: refuse FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE -- delete children too FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE SET NULL -- orphan them
DELETE FROM courses WHERE course_id = 2; with ON DELETE CASCADE silently deletes five enrollments as well — and if those had cascading children, those go too. One statement, an unknown blast radius. Prefer the default RESTRICT and delete deliberately, in order.
For backwards compatibility, SQLite does not enforce foreign keys by default. You must run this in every session:
PRAGMA foreign_keys = ON;
Without it, an insert referencing a non-existent student succeeds happily. This surprises everybody the first time.
INSERT (and upsert)
-- Named columns — the only form you should use in saved code INSERT INTO students (student_id, first_name, last_name, gender, city, birth_date, enrolled_date) VALUES (11, 'Nodira', 'Yusupova', 'F', 'Tashkent', '2005-04-01', '2026-09-01'); -- Positional — shorter, but breaks the day a column is added INSERT INTO students VALUES (11, 'Nodira', 'Yusupova', 'F', 'Tashkent', '2005-04-01', '2026-09-01'); -- Many rows in one statement — much faster than many statements INSERT INTO courses (course_id, title, category, credits, price) VALUES (7, 'Power BI', 'Data', 3, 1600000), (8, 'Excel for Analysts', 'Data', 2, 800000); -- Columns with a DEFAULT or allowing NULL may be omitted INSERT INTO courses (course_id, title) VALUES (9, 'Statistics II'); -- credits gets its DEFAULT of 3; category and price become NULL
INSERT ... SELECT
Insert the result of a query — how you copy, archive or transform data in bulk:
CREATE TABLE top_students (student_id INTEGER, full_name TEXT, avg_grade REAL); INSERT INTO top_students (student_id, full_name, avg_grade) SELECT s.student_id, s.first_name || ' ' || s.last_name, AVG(e.grade) FROM students s JOIN enrollments e USING (student_id) WHERE e.grade IS NOT NULL GROUP BY s.student_id HAVING AVG(e.grade) >= 85; -- inserts Aziza 90.0, Dilnoza 88.0, Malika 89.0, Otabek 95.5
No result table (2 statements executed, 1 ms)
Upsert — insert or update
-- SQLite / PostgreSQL INSERT INTO courses (course_id, title, price) VALUES (1, 'Python Basics', 1300000) ON CONFLICT (course_id) DO UPDATE SET price = excluded.price; -- Ignore the row instead of failing INSERT OR IGNORE INTO courses (course_id, title) VALUES (1, 'Python Basics'); -- MySQL INSERT INTO courses (course_id, title, price) VALUES (1, 'Python Basics', 1300000) ON DUPLICATE KEY UPDATE price = VALUES(price);
Upsert is what every data-loading script needs: run it twice and the result should be the same as running it once. That property is called idempotency, and it is what makes a pipeline safe to re-run after a failure.
UPDATE
-- Change one row UPDATE students SET city = 'Tashkent' WHERE student_id = 7; -- Several columns at once UPDATE enrollments SET grade = 85, status = 'completed' WHERE enrollment_id = 3; -- Compute the new value from the old one UPDATE courses SET price = price * 1.10 WHERE category = 'Data'; -- Conditional update with CASE UPDATE enrollments SET status = CASE WHEN grade >= 60 THEN 'completed' ELSE 'failed' END WHERE grade IS NOT NULL AND status = 'active';
No result table (4 statements executed, 0 ms)
Before every UPDATE or DELETE:
-- STEP 1: see exactly which rows you are about to change SELECT * FROM students WHERE student_id = 7; -- STEP 2: only now, swap SELECT * for the UPDATE, keeping the same WHERE UPDATE students SET city = 'Tashkent' WHERE student_id = 7;
| student_id | first_name | last_name | gender | city | birth_date | enrolled_date |
|---|---|---|---|---|---|---|
| 7 | Doston | Alimov | M | Tashkent | 2004-02-08 | 2025-09-10 |
1 row
1 ms
An UPDATE without a WHERE changes every row in the table, and there is no undo outside a transaction. This is the most common way people damage a production database.
Updating from another table (dialects differ noticeably here):
-- SQLite / PostgreSQL: correlated subquery UPDATE students SET city = 'Tashkent' WHERE student_id IN ( SELECT student_id FROM enrollments WHERE course_id = 5 ); -- PostgreSQL also has UPDATE ... FROM UPDATE students s SET city = t.city FROM temp_city_fixes t WHERE s.student_id = t.student_id;
DELETE, TRUNCATE, DROP
DELETE |
TRUNCATE |
DROP |
|
|---|---|---|---|
| Removes | Chosen rows | All rows | The whole table |
Has WHERE |
Yes | No | No |
| Table survives | Yes | Yes | No |
| Can roll back | Yes (in a transaction) | Usually no | Usually no |
| Speed on a big table | Slow (logs each row) | Very fast | Very fast |
| Fires triggers | Yes | No | — |
DELETE FROM enrollments WHERE status = 'dropped'; -- removes 1 row DELETE FROM enrollments; -- removes ALL 20 rows DROP TABLE IF EXISTS top_students; -- table gone entirely
No result table (3 statements executed, 0 ms)
Use DELETE FROM table; — SQLite optimises exactly this case internally. To also reset auto-increment counters, delete the table's row from sqlite_sequence.
ALTER TABLE
ALTER TABLE changes a table's structure after it already holds data.
-- add a column (existing rows get NULL, or the DEFAULT) ALTER TABLE students ADD COLUMN phone TEXT; ALTER TABLE students ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1; -- rename a column (SQLite 3.25+) ALTER TABLE students RENAME COLUMN phone TO phone_number; -- rename the table ALTER TABLE students RENAME TO learners; -- drop a column (SQLite 3.35+) ALTER TABLE students DROP COLUMN phone_number;
SQLite supports only add column, rename column, rename table and drop column. You cannot change a column's type, add a constraint, or add a foreign key to an existing table. PostgreSQL and MySQL support all of those.
The standard workaround is the four-step table rebuild — worth showing once, because students will meet it:
BEGIN TRANSACTION; CREATE TABLE students_new ( ... new definition ... ); INSERT INTO students_new SELECT ... FROM students; DROP TABLE students; ALTER TABLE students_new RENAME TO students; COMMIT;
Adding a NOT NULL column to a table that already has rows fails, because those rows would need a value and have none. Supply a DEFAULT, or add the column as nullable, UPDATE it, then tighten it (where the database allows tightening).
Transactions — COMMIT and ROLLBACK
A transaction groups several statements into one all-or-nothing unit. Either every statement takes effect, or none does.
BEGIN TRANSACTION; UPDATE students SET city = 'Samarkand' WHERE student_id = 1; DELETE FROM enrollments WHERE enrollment_id = 20; -- Check the result before making it permanent SELECT * FROM students WHERE student_id = 1; COMMIT; -- make it permanent -- ROLLBACK; — or undo everything since BEGIN
Before any risky UPDATE or DELETE: BEGIN TRANSACTION; → run it → SELECT to check → COMMIT if correct, ROLLBACK if not. This turns an irreversible mistake into a five-second recovery. It is the single most valuable habit in this chapter.
The classic reason transactions exist is a bank transfer:
BEGIN TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT;
If the database crashes between the two statements, the transaction guarantees that money is not simply destroyed. Without it, 100 leaves account 1 and never arrives anywhere.
Normalization (1NF, 2NF, 3NF)
Normalization is the process of organising tables so that each fact is stored exactly once. Start with a badly designed table and fix it step by step — that is the only way this topic ever makes sense.
-- The bad table: everything in one place enrollments_bad +----+-------------+---------------------+------------------+-------+---------------+ | id | student | courses | teacher | grade | teacher_phone | +----+-------------+---------------------+------------------+-------+---------------+ | 1 | Aziza K. | Python, SQL, ML | Sardor Yoqubov | 92 | +998901112233 | | 2 | Bekzod T. | Python, Statistics | Sardor Yoqubov | 75 | +998901112233 | +----+-------------+---------------------+------------------+-------+---------------+
Violation: courses holds a comma-separated list. You cannot filter, join or count it without string surgery, and WHERE courses = 'SQL' finds nothing.
Fix: one row per student-course pair.
| id | student | course | teacher | grade | teacher_phone | | 1 | Aziza K. | Python | Sardor Yoqubov | 92 | +998901112233 | | 2 | Aziza K. | SQL | Rustam Halilov | 88 | +998903334455 | | 3 | Aziza K. | ML | Nodira Alimova | NULL | +998905556677 |
2NF — no partial dependency on part of a composite key
Violation: with a composite key of (student, course), the student's own attributes — city, birth date — depend on the student alone, not on the pair. So they get repeated on every row that student appears in.
Fix: move student attributes into a students table and reference it by student_id.
3NF — no dependency on a non-key column
Violation: teacher_phone does not depend on the enrollment at all; it depends on teacher, which is not the key. Sardor's phone number is stored once per lesson he teaches — and when he changes it, every one of those rows must be updated, or the database contradicts itself.
Fix: a teachers table holding the phone once, referenced by teacher_id.
Apply all three and you get exactly the schema from Chapter 1. That is the point of the exercise — the four tables were not arbitrary.
| Form | Rule | Problem it prevents |
|---|---|---|
| 1NF | One value per cell; no repeating groups | Unsearchable lists inside cells |
| 2NF | 1NF + no partial dependency on part of the key | Repeated attributes of one entity |
| 3NF | 2NF + no dependency between non-key columns | Update anomalies (the phone number) |
Normalization is for systems that write data (OLTP) — it protects correctness. Analytics systems (OLAP, data warehouses) deliberately denormalize: they duplicate data into wide tables so reports need fewer joins and run faster. That is the star schema. Both are correct; they optimise for different things. Say this to students explicitly, or they will think every wide table they meet is a mistake.
Views
A view is a saved query that behaves like a table. It stores no data — it re-runs the query every time it is used.
CREATE VIEW enrollment_details AS SELECT e.enrollment_id, s.first_name || ' ' || s.last_name AS student, c.title AS course, COALESCE(t.full_name, '(none)') AS teacher, e.grade, e.status, e.enrolled_at FROM enrollments e JOIN students s ON e.student_id = s.student_id JOIN courses c ON e.course_id = c.course_id LEFT JOIN teachers t ON e.teacher_id = t.teacher_id; -- now query it like any table SELECT student, course, grade FROM enrollment_details WHERE grade >= 90; DROP VIEW IF EXISTS enrollment_details;
- Hide complexity. Students can answer real questions against
enrollment_detailsbefore they can write a four-table join themselves. - One definition, many users. "Active student" is defined once; every report agrees.
- Security. Grant access to a view that excludes salary or phone number instead of the underlying table.
- Stability. The view's shape can stay the same while the tables underneath are refactored.
Every query against a view re-executes the whole underlying query. A view over a slow six-table join is still slow — it just looks tidy. When you need the results stored, that is a materialized view (PostgreSQL, Oracle) or a plain table you refresh on a schedule. SQLite has neither; use CREATE TABLE AS.
Practice set
-- 1. Create an assignments table with sensible constraints CREATE TABLE IF NOT EXISTS assignments ( assignment_id INTEGER PRIMARY KEY, course_id INTEGER NOT NULL REFERENCES courses(course_id), title TEXT NOT NULL, max_points INTEGER NOT NULL DEFAULT 100 CHECK (max_points > 0), due_date DATE ); -- 2. Insert three assignments in one statement INSERT INTO assignments (assignment_id, course_id, title, due_date) VALUES (1, 2, 'JOIN exercises', '2026-03-01'), (2, 2, 'Window functions', '2026-03-15'), (3, 1, 'Python loops', '2026-03-10'); -- 3. Snapshot a summary table from a query CREATE TABLE course_stats AS SELECT c.course_id, c.title, COUNT(e.enrollment_id) AS enrollments, ROUND(AVG(e.grade),1) AS avg_grade FROM courses c LEFT JOIN enrollments e USING (course_id) GROUP BY c.course_id, c.title; -- 4. Safe update: look first, then change SELECT * FROM enrollments WHERE enrollment_id = 3; BEGIN TRANSACTION; UPDATE enrollments SET grade = 85, status = 'completed' WHERE enrollment_id = 3; SELECT * FROM enrollments WHERE enrollment_id = 3; ROLLBACK; -- or COMMIT if it looks right -- 5. Raise Data-category prices by 10% BEGIN TRANSACTION; UPDATE courses SET price = ROUND(price * 1.10) WHERE category = 'Data'; SELECT title, price FROM courses WHERE category = 'Data'; ROLLBACK; -- 6. Set status from grade using CASE UPDATE enrollments SET status = CASE WHEN grade >= 60 THEN 'completed' ELSE 'failed' END WHERE grade IS NOT NULL; -- 7. Create the reporting view CREATE VIEW IF NOT EXISTS enrollment_details AS SELECT e.enrollment_id, s.first_name || ' ' || s.last_name AS student, c.title AS course, e.grade, e.status FROM enrollments e JOIN students s USING (student_id) JOIN courses c USING (course_id); -- 8. Clean up everything this exercise created DROP VIEW IF EXISTS enrollment_details; DROP TABLE IF EXISTS course_stats; DROP TABLE IF EXISTS assignments;
Interview questions
Q: DELETE vs TRUNCATE vs DROP?
DELETE removes selected rows, can have a WHERE, is transactional and fires triggers. TRUNCATE empties the table quickly with no WHERE and usually cannot be rolled back. DROP removes the table itself.
Q: What is a transaction, and what does ACID stand for?
A group of statements treated as one all-or-nothing unit. Atomicity, Consistency, Isolation, Durability.
Q: Why put constraints in the database rather than the application?
Application code can be bypassed by other apps, scripts or manual access. The database enforces the rule for every client, permanently.
Q: What is the difference between PRIMARY KEY and UNIQUE?
A primary key is unique and not NULL, and there is exactly one per table. A unique constraint permits a NULL (usually one) and a table may have several.
Q: What does ON DELETE CASCADE do, and when is it dangerous?
Deleting a parent row automatically deletes its children. Dangerous because one small delete can cascade through several tables with no warning; RESTRICT is a safer default.
Q: Explain 1NF, 2NF and 3NF in one sentence each.
1NF: one value per cell. 2NF: 1NF plus no column depending on only part of a composite key. 3NF: 2NF plus no non-key column depending on another non-key column.
Q: When would you deliberately denormalize?
In analytics/OLAP systems, where read performance matters more than write anomalies — wide tables and star schemas mean fewer joins per report.
Q: What is a view, and does it store data?
A named saved query. It stores no data and re-executes each time. A materialized view does store results and must be refreshed.
Q: How do you write an insert that is safe to re-run?
Use an upsert — INSERT ... ON CONFLICT DO UPDATE (SQLite/PostgreSQL) or ON DUPLICATE KEY UPDATE (MySQL) — so the operation is idempotent.
Summary
CREATE TABLEdeclares types and rules.CREATE TABLE AS SELECTsnapshots a query but copies no constraints or indexes.- Constraints —
PRIMARY KEY,NOT NULL,UNIQUE,CHECK,DEFAULT,FOREIGN KEY— are the database's last line of defence and cannot be bypassed. - SQLite needs
PRAGMA foreign_keys = ON;every session or foreign keys are not enforced at all. INSERT: name your columns; insert many rows per statement; useINSERT ... SELECTfor bulk work and upsert for idempotent loads.- Always
SELECTfirst with the sameWHERE, then swap in theUPDATE/DELETE. MissingWHEREchanges the entire table. BEGIN TRANSACTION→ change → check →COMMITorROLLBACK. This is the habit that turns a disaster into a five-second fix. ACID = Atomicity, Consistency, Isolation, Durability.- Normalization: 1NF one value per cell · 2NF no partial dependency · 3NF no non-key dependency. Applying all three to a messy table reproduces the Chapter 1 schema exactly.
- Denormalization is correct for analytics — star schemas trade write-safety for read speed.
- Views are saved queries that store nothing: they hide complexity, centralise definitions and restrict access — but they do not make a slow query fast.
Next: Chapter 11 — indexes, EXPLAIN and why one query takes 3 milliseconds and another takes 3 minutes.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.