SQL INSERT, UPDATE and DELETE
INSERT, UPDATE and DELETE are the SQL statements that change data; many teams call them DML, data manipulation language. INSERT adds rows, UPDATE changes columns of existing rows and DELETE removes rows. UPDATE orders SET status = 'CANCELLED' WHERE order_id = 9101 changes one order; without the WHERE line it would change every order in the table.
Testers use them on test databases to seed the data a test needs, to put records into states that are slow to reach through the product, and to check that the database rejects data that breaks its rules. Every change starts with the SELECT and WHERE you already know.
Why it matters
A wrong SELECT shows a wrong answer; a wrong UPDATE or DELETE destroys data. One missing condition can cancel every order on a shared test environment, and most clients commit each statement as soon as it succeeds, so there is no undo.
Used with discipline, the same statements make testing faster and deeper: a seed script gives every run the same starting data, an UPDATE creates an expired subscription in seconds, and a statement that breaks a rule on purpose shows whether the database protects the data on its own.
INSERT: one row or many
INSERT adds new rows: the column list, then the values in the same order. A column you leave out gets its DEFAULT, or NULL if it has none; a NOT NULL column without a default makes the statement fail. When the database generates the key, leave that column out and find the new row by a value you chose, such as the email.
A multi-row INSERT puts several lists of values after one VALUES keyword; PostgreSQL, MySQL, SQLite and SQL Server all accept it. The statement is atomic: if one row breaks a rule, none is inserted (in MySQL, with the default InnoDB engine).
- Name the columns: a statement that relies on the table's column order breaks, or fills the wrong columns, when the table changes.
- Text and dates go in single quotes, numbers and NULL do not; a quote inside text is doubled:
'O''Brien'. - Insert parents before children, because each foreign key checks that the parent exists (SQLite enforces foreign keys only after
PRAGMA foreign_keys = ON).
-- Single-row INSERT: the column list, then the values in the same order
INSERT INTO customers (customer_id, email, full_name, created_at)
VALUES (9001, 'qa.sara@example.com', 'QA Sara Ahmed', '2026-09-01 09:00:00');
-- 1 row inserted
-- Multi-row INSERT: one statement, one list of values per row
INSERT INTO orders (order_id, customer_id, status, total_amount, created_at)
VALUES (9101, 9001, 'PENDING', 45.00, '2026-09-01 10:00:00'),
(9102, 9001, 'PENDING', 120.00, '2026-09-01 10:05:00'),
(9103, 9001, 'PAID', 80.00, '2026-09-02 09:30:00');
-- 3 rows inserted, or none: one bad row rejects the whole statement
-- A child row: order 9102 must already exist
INSERT INTO order_items (order_item_id, order_id, product_sku, quantity, unit_price)
VALUES (9201, 9102, 'SKU-100', 2, 60.00);UPDATE and DELETE: count the rows first
UPDATE changes columns of existing rows and DELETE removes whole rows. The WHERE clause decides which rows are hit; without it, every row is. A WHERE that is slightly wrong is worse, because it looks right: a LIKE pattern that matches more than you meant, = NULL that matches nothing, an OR that escapes its AND.
So count first. Run the exact WHERE in a SELECT that lists the rows and in SELECT COUNT(*), and compare the count with what the task leads you to expect. Then replace only the lines above WHERE to make the UPDATE or DELETE, and compare the rows-affected number the client reports with your count. If they differ, stop.
- psql prints
UPDATE 2, SQL Server(2 rows affected). For an UPDATE, MySQL reports rows matched and rows changed, and by default counts as affected only the rows whose value really changed, so compare rows matched with your count. - Before deleting a parent row, count its children: a foreign key rejects the DELETE or, with
ON DELETE CASCADE, deletes them too, and the reported count covers only the table you named.
-- 1. The WHERE you intend, as a SELECT: look at the rows, then count them
SELECT order_id, status
FROM orders
WHERE customer_id = 9001 AND status = 'PENDING';
-- 9101 PENDING
-- 9102 PENDING
SELECT COUNT(*)
FROM orders
WHERE customer_id = 9001 AND status = 'PENDING';
-- 2, as expected: the test customer has two pending orders
-- 2. The same WHERE, only the lines above it changed
UPDATE orders
SET status = 'CANCELLED'
WHERE customer_id = 9001 AND status = 'PENDING';
-- rows affected: 2, equal to the count
-- 3. DELETE: count the children before removing the parent
SELECT COUNT(*) FROM order_items WHERE order_id = 9102; -- 1
DELETE FROM orders WHERE order_id = 9102;
-- rejected by the foreign key from order_items; with ON DELETE CASCADE
-- it would succeed, report 1 row and remove the item line as wellRehearse inside a transaction: BEGIN … ROLLBACK
Most clients start in autocommit mode (check the setting in yours): each statement is committed as soon as it succeeds. BEGIN opens a transaction instead: a group of statements that the database keeps or undoes as one unit. Your session sees the changes that follow, but other sessions, including the application, do not, until COMMIT makes them permanent; ROLLBACK discards them all. This holds under the default isolation level, the setting that decides what other sessions can see of a transaction that is not yet committed; the guide on testing transactions and database migrations explains the levels. SQL Server writes BEGIN TRANSACTION, and MySQL also accepts START TRANSACTION.
The routine: count with SELECT, BEGIN, run the change, compare the rows affected with the count, check the result with a SELECT inside the transaction, then ROLLBACK if anything differs or you were only rehearsing. COMMIT only when every number matches and the change is meant to stay.
Do this only on a test database: your own disposable one, or a shared environment whose owner, usually the QA lead or the team that runs it, has agreed. Changes to production data go through the team's change process, not a tester's SQL client.
- The application cannot see uncommitted rows: to test a screen with the new data you must COMMIT, and plan how to restore it.
- An open transaction locks the rows it changed and makes other writers wait: finish promptly.
- In PostgreSQL an error aborts the transaction; every later statement fails until ROLLBACK.
- ROLLBACK does not undo everything: PostgreSQL, MySQL and SQL Server do not hand back generated key values already used, and in MySQL
TRUNCATEorALTER TABLEcommits the open transaction implicitly.
Seeding test data and provoking constraint violations
Seeding loads known data before a test so that it starts from a state you chose. Keep the INSERT statements in a seed script next to the tests, and mark every row with a QA- prefix, an example.com email or an agreed key range, so that the script can first delete its own rows, children before parents.
SQL goes around the application: a row written by hand skips its validation, audit log and caches. That reaches states quickly, such as an order created 31 days ago, but can also produce data the product could never create, so note in the test case which data came from SQL.
DML also tests the database's own rules, its constraints: NOT NULL, the primary key, UNIQUE, foreign keys and CHECK, a condition every row must meet, such as CHECK (quantity > 0). Write a statement that breaks exactly one rule and predict the result: rejected with a constraint error, and no data changed. Constraints guard UPDATE and DELETE as well as INSERT. End each attempt with ROLLBACK, so that if the rule is missing the bad data does not stay: a statement that succeeds is the finding.
-- One broken rule per transaction, on your own test database.
-- If a rule is missing, ROLLBACK still removes the bad data.
BEGIN;
UPDATE order_items SET quantity = 0 WHERE order_item_id = 9201;
-- expected: rejected by CHECK (quantity > 0); quantity stays 2
ROLLBACK;
BEGIN;
DELETE FROM customers WHERE customer_id = 9001;
-- expected: rejected by the foreign key from orders (9101 to 9103)
ROLLBACK;
BEGIN;
INSERT INTO customers (customer_id, email, full_name, created_at)
VALUES (9002, 'qa.sara@example.com', 'QA Copy', '2026-09-01 09:00:00');
-- expected: rejected by UNIQUE (email); still one row with this email
ROLLBACK;Worked example: rehearsing a data-fix script
A ticket contains a data-fix script that a developer will run in production: cancel abandoned orders, meaning orders still PENDING from before 1 August with no captured payment. The ticket says it cancels three: 1201, 1202 and 1205. Staging holds a recent masked copy of production, and its owner agrees that you rehearse the script there, provided staging looks the same afterwards.
Counting first with the script's own WHERE returns 4. Listing those rows with their payments shows why: order 1188 is PENDING but has a CAPTURED payment, and the script has no condition on payments. Nothing has changed yet, so you stop and report.
- Finding 1: the script would cancel a paid order. The developer adds
NOT EXISTS (SELECT 1 FROM payments …), a subquery that leaves out orders with a captured payment, and the count becomes 3. NOT EXISTS, not NOT IN, because NOT IN matches nothing once its subquery returns a NULL. - Finding 2: order 1188 is paid but still PENDING. Report it separately: it points to a likely defect in the payment flow.
- Rehearsal: 3 rows affected, equal to the count; inside the transaction 1201, 1202 and 1205 are CANCELLED and the control row 1188 is still PENDING; after ROLLBACK all four are PENDING again.
- The queries, counts and final SELECT go into the ticket: the production run repeats the count first and goes ahead only if it still matches.
| Order | Script WHEREfirst count: 4 | Fixed WHEREcount: 3 | After UPDATEinside the transaction · 3 rows affected | After ROLLBACKstaging as before |
|---|---|---|---|---|
1201PENDING · no payment | matched | matched | CANCELLED | PENDING |
1202PENDING · no payment | matched | matched | CANCELLED | PENDING |
1205PENDING · no payment | matched | matched | CANCELLED | PENDING |
1188PENDING · payment CAPTURED | matched: a paid orderFail | not matchedPass | PENDINGcontrol row, unchangedPass | PENDING |
- Changed by the UPDATE, visible only inside the transaction
- Outlined: the row the script must not change
-- The developer's data-fix script, to run in production later:
-- UPDATE orders SET status = 'CANCELLED'
-- WHERE status = 'PENDING' AND created_at < '2026-08-01';
-- Ticket: "cancels 3 abandoned orders: 1201, 1202, 1205"
-- 1. Count first with the script's own WHERE
SELECT COUNT(*) FROM orders
WHERE status = 'PENDING' AND created_at < '2026-08-01';
-- 4, the ticket says 3: stop and list the rows with their payments
SELECT o.order_id, p.status AS payment
FROM orders o
LEFT JOIN payments p ON p.order_id = o.order_id
WHERE o.status = 'PENDING' AND o.created_at < '2026-08-01'
ORDER BY o.order_id;
-- 1188 CAPTURED <- paid, yet still PENDING
-- 1201 NULL
-- 1202 NULL
-- 1205 NULL
-- 2. The developer's fixed WHERE: count again
SELECT COUNT(*) FROM orders
WHERE status = 'PENDING' AND created_at < '2026-08-01'
AND NOT EXISTS (SELECT 1 FROM payments p
WHERE p.order_id = orders.order_id AND p.status = 'CAPTURED');
-- 3
-- 3. Rehearse on staging with the owner's agreement, then undo
BEGIN;
UPDATE orders SET status = 'CANCELLED'
WHERE status = 'PENDING' AND created_at < '2026-08-01'
AND NOT EXISTS (SELECT 1 FROM payments p
WHERE p.order_id = orders.order_id AND p.status = 'CAPTURED');
-- rows affected: 3, equal to the count
SELECT order_id, status FROM orders
WHERE order_id IN (1188, 1201, 1202, 1205);
-- 1188 PENDING, 1201 CANCELLED, 1202 CANCELLED, 1205 CANCELLED
ROLLBACK;
-- the same SELECT now shows all four orders PENDING againCommon mistakes
- Writing the UPDATE or DELETE before its WHERE has been run as a SELECT and counted.
- Carrying on when the rows-affected number differs from the count.
- Forgetting autocommit, so an experiment is already permanent.
- Leaving a transaction open and blocking everyone who writes the locked rows.
- Changing a shared test database without its owner's agreement, or touching rows you did not create.
- Expecting the application to see uncommitted changes, or reporting a defect caused by data that only SQL could create.
The QA perspective
On a team, write access to a database comes with an agreement: who owns each environment, which rows you may change (usually only your own) and how it is reset. Create data through the product or its API when the test is about that path; use SQL where it is faster, more precise or the only way.
Treat a data-changing statement like a small release: the statement, the expected count, the rows affected, the check and the commit-or-rollback decision go into the ticket or the test case. Rehearse a developer's data fix or migration the same way; your numbers become the acceptance check for the real run.
Practice exercise
Fix and rehearse a test-data reset
Before a points-expiry test, the test plan says: "Set points to 0 for the QA test customers in the GOLD or SILVER tier." A colleague drafted the UPDATE below. The QA lead allows changes only to rows with an @qa.example.com email.
Find out which rows the draft would change, fix it, write the rehearsal and test the CHECK constraint.
Test plan: "Set points to 0 for the QA test customers
in the GOLD or SILVER tier."
Allowed: only rows whose email ends in @qa.example.com
customers
customer_id email tier points
301 sara@qa.example.com GOLD 900
302 omar@qa.example.com SILVER 450
303 lina@qa.example.com BRONZE 120
304 fahad@partner.example.net SILVER 700
305 noura@qa.example.com SILVER 0
Constraint: CHECK (points >= 0)
Draft:
UPDATE customers
SET points = 0
WHERE email LIKE '%@qa.example.com' AND tier = 'GOLD' OR tier = 'SILVER';- Evaluate the draft's WHERE row by row and count the matches.
- Fix the WHERE, write the count-first SELECT and state the expected count.
- Write the BEGIN block with the expected rows affected, a check, and when to COMMIT or ROLLBACK.
- Write a statement that breaks
CHECK (points >= 0)and state its expected result.
Show the model answer
AND binds more tightly than OR, so the draft means (email LIKE '%@qa.example.com' AND tier = 'GOLD') OR tier = 'SILVER'. It matches 4 rows, 301, 302, 304 and 305, including 304, a partner's customer you may not change.
Fixed: WHERE email LIKE '%@qa.example.com' AND tier IN ('GOLD', 'SILVER'). SELECT COUNT(*) FROM customers with this WHERE returns 3 (301, 302 and 305).
BEGIN;, then the fixed UPDATE: expect 3 rows affected (MySQL by default reports 2 rows affected, with 3 matched and 2 changed, because 305 already has 0 points: its matched number equals the count). A SELECT inside the transaction must show 301, 302 and 305 at 0, 303 at 120 and 304 at 700. COMMIT only if every number matches, since the application sees only committed data; otherwise ROLLBACK.
BEGIN; UPDATE customers SET points = -1 WHERE customer_id = 303; ROLLBACK; Expected: rejected with an error that names the CHECK constraint, and 303 keeps its 120 points. If the statement succeeds, the constraint is missing: report it; the ROLLBACK keeps the -1 out of the data.
Frequently asked questions
Should I create test data with SQL or through the application?
Prefer the application or its API when the test is about how data is created, because that path runs validation, auditing and events. Use SQL to seed large or repeatable baselines, to reach states that take too long through the product and to test database rules directly, and record which data came from SQL.
Is it safe to run an UPDATE in production if I wrap it in BEGIN … ROLLBACK?
No. Even an uncommitted UPDATE locks rows that real users are trying to change, a COMMIT typed by mistake instead of ROLLBACK is permanent, and some statements commit implicitly. Production data is changed through your team's approved change process; testers rehearse such changes on a test database, as in the worked example.
What is the difference between DELETE and TRUNCATE?
DELETE removes the rows that match its WHERE; foreign keys and DELETE triggers (code the database runs by itself when rows are deleted) apply, and a transaction can roll it back. TRUNCATE empties the whole table and takes no WHERE. It does not fire DELETE triggers, it is refused or restricted when other tables reference the table, and in MySQL it commits implicitly. To remove test rows on a shared database, use DELETE with a WHERE.
References
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- Transactions — PostgreSQL DocumentationWhat a transaction is: several steps applied all-or-nothing with BEGIN, COMMIT and ROLLBACK, invisible to others until committed.
- SQL As Understood By SQLiteThe SQL grammar accepted by SQLite, statement by statement, with railroad diagrams.
- Constraints — PostgreSQL DocumentationCheck, not-null, unique, primary-key, foreign-key and exclusion constraints — the rules a database enforces and a tester verifies.
Practise with Mutqan tools
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Generate INSERT statements from a table definition, sample rows, CSV or JSON.
Convert tabular data into a SQL VALUES list or a VALUES-based derived table.
Turn a list of values (one per line or comma separated) into a quoted SQL IN (...) clause.
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Convert CSV data into CREATE TABLE and INSERT statements with inferred column types.
Go deeper
- SQL for TestersBefore you run anything: safe practice
- Testing Transactions and DB MigrationsTesting atomicity: commit, rollback and failure injection
- Database Testing for QA EngineersTest data: synthetic, seeded and resettable
- Database Testing for QA EngineersConstraints: the rules the database enforces
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.