Managing Tables with DML Statements: Insert, Update, Delete, Merge
Data Manipulation Language is used when you need to change data in a table. INSERT
adds new rows, UPDATE changes existing data, and DELETE removes rows
that are no longer needed. MERGE is useful when you need to insert new rows and
update existing ones based on a matching condition. These changes are usually handled inside a
transaction, where COMMIT makes the changes permanent and ROLLBACK
undoes changes that have not yet been committed. This guide covers the main DML statements with
practical examples using the same customer and order tables, followed by twenty-two questions
commonly asked in SQL interviews.
🎯 Key Takeaways
INSERT adds rows; INSERT ALL adds many in one go
UPDATE changes rows; always pair with WHERE
DELETE removes rows; TRUNCATE removes all faster
MERGE does insert-or-update in one statement
COMMIT / ROLLBACK end the transaction
22 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
Kailas Warade
✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines.
He writes the SQL interview guides at Sankalan Data Tech, focused on
practical, real-world answers.
🏆 Oracle Certified💼 Data Engineer📝 SQL & PL/SQL Guides📍 Pune, India
DML stands for Data Manipulation Language. It is the part of SQL you use when you need
to change the data stored in a table. INSERT adds rows, UPDATE
changes existing rows, DELETE removes rows, and MERGE can
handle inserts and updates based on a matching condition.
The main difference between DML and queries is simple: SELECT reads data,
while DML changes it. That difference becomes important when working with real data. A
missing WHERE condition in an UPDATE or DELETE
can affect far more rows than intended, so understanding how these statements work is
essential.
DML also goes hand in hand with transactions. COMMIT makes the changes
permanent, while ROLLBACK undoes changes that have not been committed.
SAVEPOINT lets you mark a point within a transaction and roll back to that
point without undoing everything. Understanding these commands helps you make changes
safely and recover when something goes wrong.
The Sample Data We Use in Every Example
Same customers and orders tables as the rest of the series. Every DML statement below
modifies these two tables, and you can run them side by side with the rest of the tutorial
set.
Three things in this dataset make the DML examples interesting:
Multiple customers and orders, so batch updates and deletes have something real to affect.
Fiona Mart has a NULL credit limit, which is useful when demonstrating the danger of running UPDATE without a WHERE clause.
Only four of the seven customers have orders, which is why the "delete customers with no orders" examples have a meaningful target.
DML vs DDL — The Difference That Matters
DML and DDL look similar at a distance — both involve SQL statements that change something
in the database. But they operate on entirely different things, and the consequences of a
mistake are very different.
Aspect
DML
DDL
What it operates on
The rows inside a table
The structure of a table or other database object
Common statements
INSERT, UPDATE, DELETE, MERGE
CREATE, ALTER, DROP, TRUNCATE
Transaction control
Can be committed or rolled back
Auto-committed by default; not reversible
Effect of a mistake
Worst case, you roll back
Worst case, you lose a table or a column permanently
Typical frequency
Millions of times a day
A handful of times per release cycle
The practical difference is reversal. A DML statement inside a transaction can be undone
with ROLLBACK. A DDL statement cannot — it takes effect immediately, and there
is no undo button.
-- DML: reversible until you commit
DELETE FROM orders WHERE order_status = 'Pending';
ROLLBACK; -- the pending orders come back
-- DDL: not reversible
DROP TABLE orders; -- the table is gone
That is why production DML runs inside explicit transactions and DDL changes run during
scheduled maintenance windows with database backups as the safety net. The mental model is
different, and the discipline has to be too.
INSERT — Adding Rows
INSERT brings new rows into a table. Two forms — with column names and without
— and one of them is dramatically safer than the other.
Column names are listed explicitly, and values are matched position by position. The
database takes each value and puts it in the named column. Columns not listed get their
default values, or NULL if no default is defined.
This is the form to use in production code. It survives schema changes. If someone later
adds a column to the customers table — say a contact_email column between
region and credit_limit — this statement keeps working because
it names the columns it cares about.
The positional form (avoid in production)
INSERT INTO customers
VALUES ('C009', 'Ivan Trading', 'East', 32000);
Column names are omitted. Values are matched to columns by their physical order in the
table — first value to first column, second to second, and so on. It works. It is shorter.
It is also fragile.
The same schema change that broke the previous form does not break this one — it breaks
it silently. The new contact_email column falls between two values, the
values shift by one position, and the row that gets inserted has Ivan Trading's region
stored in the email column and a phone number where the credit limit should be. The insert
succeeds. The data is wrong.
The rule I follow: list the columns every time, even on small tables. It costs a few
seconds to type and saves hours of debugging when the schema evolves.
Inserting Multiple Rows at Once
Inserting one row per statement is slow when you have hundreds or thousands of rows to
add. Batch inserts do the same work in a single round trip.
Three rows in one statement. Every major database supports this form. The database
processes the whole statement as a single unit — either all three rows are inserted, or
none of them are if something fails.
Oracle's INSERT ALL
INSERT ALL
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000)
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C009', 'Ivan Trading', 'East', 32000)
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C010', 'Julia Wholesale', 'West', 60000)
SELECT * FROM dual;
Oracle uses INSERT ALL with multiple INTO clauses. The trailing
SELECT * FROM dual is Oracle's way of terminating the statement — dual is a
one-row dummy table that exists for exactly this purpose.
INSERT ALL can also insert into different tables in the same statement, and
it supports conditional clauses (WHEN). That makes it the tool of choice for
complex data loading where different rows belong in different places.
Why batching matters
Each statement sent to the database involves network round trips, parsing, planning, and
transaction overhead. Sending a thousand statements to insert a thousand rows means a
thousand of each. Sending one statement with a thousand value tuples means the overhead
is paid once.
The improvement is not subtle. On real systems, batching row inserts regularly cuts load
times by a factor of ten or more. Any time you find yourself looping through data in
application code and calling INSERT once per row, that is the moment to batch.
INSERT ... SELECT — Copying Data
When the rows you want to insert already exist somewhere in the database, you do not need
to spell them out. You use INSERT INTO ... SELECT ... and let the query fetch
them.
Copying filtered rows into another table
INSERT INTO high_value_customers (customer_id, customer_name, total_spend)
SELECT c.customer_id,
c.customer_name,
SUM(o.order_amount)
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
HAVING SUM(o.order_amount) > 15000;
Every customer whose total order value exceeds 15,000 gets copied into the summary table.
On our data, that is Alice Corp (28,500), Charlie Ltd (22,000), and Diana Retail (18,000).
Bob Traders, with a total of 7,700, does not qualify.
Archiving rows and removing them from the source
INSERT INTO orders_archive (order_id, customer_id, order_amount, order_status)
SELECT order_id, customer_id, order_amount, order_status
FROM orders
WHERE order_status = 'Delivered'
AND order_id < 1007;
DELETE FROM orders
WHERE order_status = 'Delivered'
AND order_id < 1007;
The insert copies the old delivered orders to an archive table. The delete removes them
from the live table. Together, they are the standard way to trim a production table
without losing the data forever.
Order matters: insert first, delete second. Doing them in the opposite order loses the
rows if the insert fails. And both statements should be in the same transaction so a
failure halfway through does not leave the two tables out of sync.
The column matching rule
The columns in the target and source must be compatible — same type or convertible, same
count. The names do not need to match. The following is valid:
INSERT INTO customer_backup (id, name, region_code, limit_amount)
SELECT customer_id, customer_name, region, credit_limit
FROM customers;
The target columns have different names, but the values line up positionally and the types
are compatible. This is why naming the columns explicitly on the target side is
important — it makes the mapping clear and prevents accidental mismatches.
UPDATE — Changing Rows
UPDATE modifies existing rows. The syntax is simple, and the danger is real:
forget the WHERE clause and every row in the table gets the same new value.
A targeted update
UPDATE customers
SET credit_limit = credit_limit * 1.1
WHERE region = 'North';
Every North-region customer gets a 10% bump to their credit limit. Alice Corp, Charlie
Ltd, and George Wholesale are affected. Everyone else is untouched. On our data, Alice
goes from 50,000 to 55,000, Charlie from 75,000 to 82,500, and George from 80,000 to
88,000.
What happens when you forget WHERE
UPDATE customers
SET credit_limit = 10000;
Every single row in the customers table gets a credit limit of 10,000. Seven rows. Fiona
Mart's NULL becomes 10,000. George Wholesale's 80,000 becomes 10,000. There is no undo
once the transaction commits.
The professional habit: run the equivalent SELECT first. Before you run any
UPDATE on production data, run:
SELECT COUNT(*) FROM customers WHERE region = 'North';
That tells you how many rows the UPDATE is about to affect. If the number is not what you
expected, stop and investigate before running the UPDATE. If it is, run the update with
confidence.
Updating multiple columns at once
UPDATE customers
SET region = 'Northeast',
credit_limit = 60000
WHERE customer_id = 'C001';
A single statement sets two columns on one row. The comma-separated list in the
SET clause accepts any number of assignments. All of them apply to the same
set of rows identified by the WHERE clause.
Conditional Updates with CASE
Sometimes different rows need different new values. Rather than running one update per
rule, you can apply them all in a single statement using a CASE expression
inside the SET clause.
Different increases per region
UPDATE customers
SET credit_limit = CASE
WHEN region = 'North' THEN credit_limit * 1.10
WHEN region = 'South' THEN credit_limit * 1.05
WHEN region = 'West' THEN credit_limit * 1.02
ELSE credit_limit
END;
One statement, three rules. North customers get 10%, South get 5%, West get 2%. Everyone
else keeps their current limit. On our data, Alice Corp (North, 50,000) becomes 55,000,
Bob Traders (South, 25,000) becomes 26,250, Diana Retail (West, 40,000) becomes 40,800.
Without the CASE expression, the same result would take three separate
UPDATE statements. The CASE version is not just shorter — it is atomic.
Either all three updates apply or none of them do. The three-statement version can fail
halfway, leaving the data in a partial state.
Handling NULL with CASE
UPDATE customers
SET credit_limit = CASE
WHEN credit_limit IS NULL THEN 10000
WHEN credit_limit < 30000 THEN credit_limit * 1.15
ELSE credit_limit
END;
Three outcomes. Customers with a NULL credit limit get a starting value of 10,000.
Customers below 30,000 get a 15% bump. Everyone else stays the same. Fiona Mart, with
her NULL, is handled by the first branch.
Note that the NULL check comes first. Once a matching branch is found, CASE
stops evaluating — so if the NULL case were last, the earlier credit_limit < 30000
condition would never match for Fiona (NULL is not less than 30,000), and her limit would
stay NULL. Order matters, and listing the most specific cases first is the discipline to
follow.
Updating with Joins
Sometimes the new value depends on data from another table. You can join the tables and
update in the same statement, though the syntax varies between databases.
MySQL — the simplest form
UPDATE customers c
JOIN orders o ON c.customer_id = o.customer_id
SET c.credit_limit = o.order_amount * 3
WHERE o.order_id = 1004;
Charlie Ltd's order 1004 is 22,000. His credit limit becomes 66,000. The join brings the
order amount into scope, and the SET clause uses it.
PostgreSQL — using FROM
UPDATE customers c
SET credit_limit = o.order_amount * 3
FROM orders o
WHERE c.customer_id = o.customer_id
AND o.order_id = 1004;
Same intent, different syntax. PostgreSQL puts the joined table in a FROM
clause and the join condition goes in the WHERE clause with everything else.
Oracle and SQL Server — MERGE or subquery
-- Via a correlated subquery
UPDATE customers c
SET credit_limit = (
SELECT o.order_amount * 3
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_id = 1004
)
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id AND o.order_id = 1004
);
The WHERE EXISTS clause is essential — without it, every customer's credit
limit would be set, and customers with no matching order would get NULL because the
correlated subquery returns NULL when it finds nothing.
The pattern that never fails: whenever you update with a subquery or a
join, always add a WHERE EXISTS clause that matches the same condition. It
prevents the "update every row" bug that costs production tables more data than almost
any other mistake.
DELETE — Removing Rows
DELETE removes rows. Like UPDATE, the danger lives in the
WHERE clause. Forget it and the table is empty.
A targeted delete
DELETE FROM orders
WHERE order_status = 'Pending';
Only pending orders are removed. On our data, order 1004 (Charlie Ltd's 22,000 pending
order) is the only one affected. The rest of the table is untouched.
Deleting based on a subquery
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_amount > 20000
);
Any customer with an order above 20,000 gets removed. On our data, that is Charlie Ltd —
his order 1004 is 22,000. Everyone else stays.
The subquery selects the customers to delete; the outer DELETE removes them. It is a
common pattern for removing rows based on conditions that can only be expressed by
looking at another table.
Deleting all rows but keeping the table
DELETE FROM orders;
No WHERE clause. Every row is removed. The table structure remains — columns,
indexes, constraints, triggers all still exist. The table is empty but ready to use.
This works, but for large tables TRUNCATE is far more efficient. The next
section explains why.
Deleting duplicates with a CTE
WITH numbered AS (
SELECT order_id,
ROW_NUMBER() OVER (PARTITION BY customer_id, order_amount ORDER BY order_id) AS rn
FROM orders
)
DELETE FROM orders
WHERE order_id IN (SELECT order_id FROM numbered WHERE rn > 1);
This removes duplicate orders — rows that share the same customer and amount, keeping only
the earliest one. The CTE assigns row numbers within each group of duplicates, and the
DELETE removes every row with a row number above 1.
This is the standard recipe for de-duplicating a table. It works on any table with a
unique column (order_id here) and any combination of columns that identify a duplicate.
DELETE vs TRUNCATE
Both remove rows. They differ in what they leave behind, what they log, and what they let
you do.
Aspect
DELETE
TRUNCATE
Filtering
Yes — WHERE clause
No — removes everything
Speed on large tables
Slow — logs every row
Very fast — removes in one operation
Rollback
Yes (before commit)
Varies — Oracle: yes; PostgreSQL/MySQL: yes with care; SQL Server: yes if inside transaction
Fires triggers
Yes
No
Resets auto-increment
No
Yes
Foreign key constraints
Respects them
Fails if referenced by another table
When to use DELETE
DELETE FROM orders WHERE order_status = 'Pending';
Filtered removal. Use when you want to remove a subset of rows, respect foreign keys, or
fire triggers (audit logs, cascade updates).
When to use TRUNCATE
TRUNCATE TABLE temp_orders;
Complete wipe of a table you own. Use for staging tables, temporary data, or any table you
want completely reset. The speed difference on a table with millions of rows is dramatic —
seconds instead of minutes.
One thing to know: TRUNCATE resets auto-increment counters. If your table had rows with ids
1 through 1000, TRUNCATE resets the counter, and the next row inserted gets id 1 again.
DELETE does not — the next id would be 1001. Which behaviour you want depends on whether
the id column is expected to be unique over time.
Removing Duplicate Rows
Duplicates are the tax you pay for not designing the schema with a unique constraint.
When they show up in production, the trick is to identify them without losing the
original.
Identifying duplicates first
SELECT customer_id, order_amount, COUNT(*) AS occurrences
FROM orders
GROUP BY customer_id, order_amount
HAVING COUNT(*) > 1;
That shows every combination of customer and amount that appears more than once. Before
removing any duplicates, run this query. It tells you how many there are and which rows
are involved. If the answer surprises you, investigate before proceeding.
Removing duplicates with a window function
WITH numbered AS (
SELECT order_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id, order_amount
ORDER BY order_id
) AS rn
FROM orders
)
DELETE FROM orders
WHERE order_id IN (SELECT order_id FROM numbered WHERE rn > 1);
The CTE assigns row numbers within each duplicate group. The DELETE removes every row with
a row number above 1, which keeps the earliest row (by order_id) and discards the rest.
Same recipe as the previous example, expanded.
Alternative — keep the row with the latest change
WITH numbered AS (
SELECT order_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id, order_amount
ORDER BY order_id DESC
) AS rn
FROM orders
)
DELETE FROM orders
WHERE order_id IN (SELECT order_id FROM numbered WHERE rn > 1);
Same shape, different ORDER BY. The DESC means the highest
order_id wins, so the most recently added duplicate survives. Change the ordering column
and direction to control which row survives.
The right choice depends on the business rule. Sometimes the earliest record is the
authoritative one. Sometimes the latest. Sometimes the one with the most complete data.
The window function approach lets you control that with one line.
MERGE — Insert-or-Update in One Statement
MERGE performs an upsert — if a row exists, update it; if not, insert it.
Before MERGE, that took two statements and a race condition. MERGE makes it atomic.
The basic pattern
MERGE INTO customers c
USING staging_customers s
ON (c.customer_id = s.customer_id)
WHEN MATCHED THEN
UPDATE SET c.customer_name = s.customer_name,
c.region = s.region,
c.credit_limit = s.credit_limit
WHEN NOT MATCHED THEN
INSERT (customer_id, customer_name, region, credit_limit)
VALUES (s.customer_id, s.customer_name, s.region, s.credit_limit);
The MERGE takes rows from the source (staging_customers) and compares them
against the target (customers) on customer_id. If a matching
target row exists, it is updated. If not, a new row is inserted.
On our data, if the staging table had C001 with a new credit limit, that row would be
updated. If it had C008 (a customer not yet in the target), that row would be inserted.
Everything happens in one statement.
Why MERGE matters for ETL
Data pipelines regularly load data from a source system into a warehouse. New rows need
to be inserted; existing rows need to be updated. Doing this without MERGE means writing
a check-then-act pattern in application code — which has a race condition and needs to be
wrapped in transactions manually.
MERGE does the whole job in one statement with the atomicity guarantees of the database.
When two processes try to load the same customer at the same time, MERGE serialises them
so one update wins cleanly, rather than both trying to insert and one failing with a
unique constraint violation.
DELETE inside MERGE
MERGE INTO customers c
USING staging_customers s
ON (c.customer_id = s.customer_id)
WHEN MATCHED AND s.is_deleted = 'Y' THEN
DELETE
WHEN MATCHED THEN
UPDATE SET c.credit_limit = s.credit_limit
WHEN NOT MATCHED THEN
INSERT (customer_id, customer_name, region, credit_limit)
VALUES (s.customer_id, s.customer_name, s.region, s.credit_limit);
MERGE can also delete. The WHEN MATCHED AND ... form lets you specify a
condition — if the source row is marked as deleted, remove the target row instead of
updating it. That makes MERGE a complete synchronisation tool: insert new, update changed,
delete removed.
Not every database supports MERGE natively. Oracle, SQL Server, DB2, and PostgreSQL 15+
do. MySQL uses INSERT ... ON DUPLICATE KEY UPDATE for the same purpose, with
slightly different syntax and behaviour.
COMMIT, ROLLBACK, SAVEPOINT
DML changes are not immediately permanent. They live inside a transaction until you decide
what to do with them. That is the safety net.
COMMIT — make changes permanent
UPDATE customers SET credit_limit = credit_limit * 1.1 WHERE region = 'North';
COMMIT;
The update is now permanent and visible to other sessions. It cannot be rolled back. The
transaction ends, and the next statement starts a new one.
ROLLBACK — discard uncommitted changes
DELETE FROM orders WHERE order_status = 'Pending';
-- Wait, that was a mistake
ROLLBACK;
The pending orders come back. Everything since the last COMMIT is undone. This is why
running DML inside transactions is the standard practice — you have an undo button for
the duration of the transaction.
The window closes the moment you COMMIT. Any DML after the COMMIT is a new transaction,
and rolling back only undoes the new work.
SAVEPOINT — partial rollback
UPDATE customers SET credit_limit = 55000 WHERE customer_id = 'C001';
SAVEPOINT after_alice;
UPDATE customers SET credit_limit = 30000 WHERE customer_id = 'C002';
SAVEPOINT after_bob;
UPDATE customers SET credit_limit = 90000 WHERE customer_id = 'C003';
-- Realise the third update was wrong, but the first two were fine
ROLLBACK TO SAVEPOINT after_bob;
COMMIT;
The rollback undoes everything after the savepoint — Charlie Ltd's update is discarded —
but keeps the two updates before it. Alice Corp's and Bob Traders's new limits are
preserved. The COMMIT then makes those two permanent.
SAVEPOINTs are how you handle complex transactions where an early step succeeded but a
later step failed. Without them, the choice would be all-or-nothing: discard everything
or keep the mistake.
The transaction lifecycle
1 BEGIN (implicit)→2 DML statements→3 SAVEPOINT (optional)→4 COMMIT or ROLLBACK
The discipline that saves production data: always run DML inside an
explicit transaction, run the equivalent SELECT first to confirm which rows
are affected, and do not commit until you have reviewed the row counts. Three habits that
together prevent almost every DML accident.
22 Interview Questions and Answers
These are the DML questions that come up most often. Every answer stands on its own — read
them in any order, or work through them in sequence to build up the full picture.
/
🔍
No matching questions found
Try a keyword like MERGE, UPDATE, or TRUNCATE.
DML is Data Manipulation Language. It changes the data inside tables —
INSERT, UPDATE, DELETE, and
MERGE. DDL is Data Definition Language. It changes the structure of
tables and other database objects — CREATE, ALTER,
DROP, TRUNCATE.
-- DML: changes the rows
INSERT INTO customers (customer_id, customer_name) VALUES ('C008', 'Henry Co');
UPDATE customers SET credit_limit = 60000 WHERE customer_id = 'C001';
DELETE FROM orders WHERE order_status = 'Pending';
-- DDL: changes the structure
CREATE TABLE archived_orders (order_id NUMBER, customer_id VARCHAR2(10));
ALTER TABLE customers ADD (contact_email VARCHAR2(100));
DROP TABLE archived_orders;
The practical difference is reversal. DML changes live inside a transaction — you
can ROLLBACK and undo them until you COMMIT. DDL changes
are auto-committed by default and cannot be undone. That is why production DML runs
in explicit transactions and DDL runs during maintenance windows with backups as
the safety net.
INSERT INTO adds new rows to a table. There are two ways to write it,
and one of them is dramatically safer than the other.
The first form lists the columns explicitly. The second relies on physical column
order in the table. Both work today. Only the first one keeps working tomorrow when
someone adds a new column in the middle of the schema.
The positional form fails silently in that scenario — the values shift by one
position, and Ivan Trading's region ends up stored in whatever column happens to
be where the email should be. The insert succeeds, the data is wrong, and nothing
tells you.
List the columns every time. It costs a few seconds and prevents an entire class
of bugs that only show up during schema migrations.
You can insert several rows in one statement by providing multiple value tuples.
Every major database supports this. Oracle also has a variant called
INSERT ALL.
One statement, three rows. The database treats the whole thing as a single
transaction — either all three rows are inserted or none of them are.
Oracle's alternative uses INSERT ALL with a series of
INTO clauses:
INSERT ALL
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000)
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C009', 'Ivan Trading', 'East', 32000)
SELECT * FROM dual;
Both forms are faster than three separate single-row INSERT statements because
they involve one round trip to the database instead of three. On real systems,
batching regularly cuts load times by ten times or more.
When the rows you want already exist somewhere in the database, you use
INSERT INTO ... SELECT ... to fetch and copy them in one statement.
INSERT INTO high_value_customers (customer_id, customer_name, total_spend)
SELECT c.customer_id,
c.customer_name,
SUM(o.order_amount)
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
HAVING SUM(o.order_amount) > 15000;
Every customer whose total order value exceeds 15,000 gets copied into the summary
table. On our data, that is Alice Corp (28,500), Charlie Ltd (22,000), and Diana
Retail (18,000). Bob Traders, with a total of 7,700, does not qualify.
The WHERE clause or HAVING clause inside the SELECT
controls which rows get copied. This is how you move filtered data between tables
without writing per-row logic in application code.
The target columns and source columns must be compatible — same count, compatible
types. The names do not need to match. If the target table has columns named
id, name, and amount, you can still copy
from a source with customer_id, customer_name, and
order_total — the values are matched by position, not by name.
UPDATE modifies existing rows. The syntax names the table, lists the
columns to change in the SET clause, and (usually) filters the rows
in a WHERE clause.
UPDATE customers
SET credit_limit = credit_limit * 1.1
WHERE region = 'North';
Every North-region customer gets a 10% bump. On our data, Alice Corp (50,000 →
55,000), Charlie Ltd (75,000 → 82,500), and George Wholesale (80,000 → 88,000) are
affected. Everyone else is untouched.
Forget the WHERE clause and every row gets the same new value:
UPDATE customers SET credit_limit = 10000;
All seven customers now have a 10,000 credit limit. Fiona Mart's NULL becomes
10,000. George Wholesale's 80,000 becomes 10,000. There is no undo once the
transaction commits.
The professional habit that prevents this: run the equivalent
SELECT first. SELECT COUNT(*) FROM customers WHERE region = 'North'
tells you how many rows the UPDATE will affect. If the number matches expectation,
proceed. If not, investigate.
Two patterns. The first applies the same change to every row that matches a
condition. The second applies different changes to different rows, using a CASE
expression.
-- Same change for a group
UPDATE customers
SET credit_limit = credit_limit + 5000
WHERE region = 'South';
Every South-region customer gets a 5,000 increase. On our data, Bob Traders (25,000
→ 30,000) and Ethan Supplies (55,000 → 60,000). Everyone else is untouched.
-- Different changes for different groups
UPDATE customers
SET credit_limit = CASE
WHEN region = 'North' THEN credit_limit * 1.10
WHEN region = 'South' THEN credit_limit * 1.05
WHEN region = 'West' THEN credit_limit * 1.02
ELSE credit_limit
END;
One statement, three rules. North customers get 10%, South get 5%, West get 2%. On
our data, Alice Corp (50,000 → 55,000), Bob Traders (25,000 → 26,250), Diana Retail
(40,000 → 40,800). Fiona Mart's NULL is not affected because NULL * 1.02 is NULL.
The CASE version has one big advantage over three separate UPDATE statements: it
runs as a single atomic operation. Either all three updates apply or none do. The
three-statement version can fail halfway, leaving the data in a partial state that
is hard to recover from.
The WHERE clause is what limits an UPDATE to specific rows. Without
it, every row in the table gets updated.
UPDATE customers
SET credit_limit = credit_limit + 5000
WHERE region = 'North'
AND credit_limit < 60000;
Two conditions combined with AND. Only North-region customers with a
current credit limit below 60,000 get the increase. On our data, that is just Alice
Corp (50,000 → 55,000). Charlie Ltd (75,000) and George Wholesale (80,000) are both
above the threshold and are left alone.
Any condition that works in a regular SELECT works in the WHERE clause
of an UPDATE. Comparisons, IN, BETWEEN,
LIKE, IS NULL, subqueries — all valid.
The habit worth building: whatever filter you write in the WHERE clause, run it as
a SELECT first. The count of rows the SELECT returns is the count of rows the
UPDATE will affect. Getting that number in front of you before the update runs is
the difference between confidence and a phone call at 3 AM.
DELETE FROM removes rows that match a condition. Without a condition,
every row is removed.
-- Filtered delete
DELETE FROM orders
WHERE order_status = 'Pending';
On our data, only order 1004 (Charlie Ltd's 22,000 pending order) is removed. The
rest of the table is untouched.
-- Unfiltered delete
DELETE FROM orders;
Every row is removed. The table structure remains — columns, indexes, constraints
all still exist. The table is empty but ready for new data.
The mistake to avoid: running DELETE FROM tablename when you meant
DELETE FROM tablename WHERE .... The first form removes everything.
If the transaction has not committed, ROLLBACK saves you. If it has,
the data is gone.
The habit worth building: before any DELETE, run the equivalent
SELECT COUNT(*) and confirm the count. Then run the DELETE and check
the rows-affected count the database reports. If it says zero when you expected
five, the WHERE clause matched nothing. If it says a million when you expected
five, you have just deleted your production data.
Two options. DELETE FROM tablename removes every row but keeps the
table, indexes, and constraints. TRUNCATE TABLE tablename does the
same thing much faster, with some caveats.
-- Option 1: DELETE (slow on large tables)
DELETE FROM orders;
-- Option 2: TRUNCATE (fast, all-or-nothing)
TRUNCATE TABLE orders;
DELETE logs each row it removes, so it can be rolled back before the
commit. On a table with millions of rows, that logging adds up to a lot of time.
TRUNCATE removes all rows in a single operation without logging them
individually. It is dramatically faster on large tables. It also resets any
auto-increment counter, so the next inserted row starts from the beginning of the
sequence.
The choice comes down to three questions. Do you need to filter? (Only DELETE can.)
Do you need to roll back? (DELETE, yes. TRUNCATE, usually yes inside a transaction
but varies by database.) Does the table have a foreign key pointing at it?
(TRUNCATE fails; DELETE respects the constraint.)
For clearing a staging table or wiping temporary data, TRUNCATE is almost always
the right choice. For anything else, DELETE gives you more control.
The WHERE clause in a DELETE works exactly like it does
in a SELECT — it filters which rows the statement acts on.
DELETE FROM customers
WHERE region = 'West'
AND credit_limit IS NULL;
That removes West-region customers whose credit limit is NULL. On our data, that is
only Fiona Mart. Diana Retail is also in the West but has a credit limit, so she
stays.
Multiple conditions combine with AND, OR, and
NOT. Subqueries work too:
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_amount > 20000
);
That removes customers who have placed a very large order. On our data, only
Charlie Ltd — his order 1004 is 22,000.
The pattern to remember: the WHERE clause in a DELETE is where all your targeting
lives. Every condition you would write in a SELECT's WHERE clause works here. The
only difference is that the matching rows get removed instead of returned.
INSERT ALL is Oracle's batch insert syntax. It lets you insert multiple
rows — into the same table or different tables — in one statement.
INSERT ALL
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000)
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C009', 'Ivan Trading', 'East', 32000)
INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C010', 'Julia Wholesale', 'West', 60000)
SELECT * FROM dual;
Three rows inserted in one statement. The trailing SELECT * FROM dual
is required — Oracle uses the one-row dual table to terminate the
statement.
The full syntax also supports conditional inserts with WHEN clauses.
That means the same INSERT ALL statement can route different rows to different
tables based on conditions:
INSERT ALL
WHEN credit_limit >= 50000 THEN
INTO premium_customers VALUES (customer_id, customer_name, credit_limit)
WHEN credit_limit < 50000 THEN
INTO standard_customers VALUES (customer_id, customer_name, credit_limit)
SELECT customer_id, customer_name, credit_limit FROM customers;
That reads every customer and inserts them into one of two tables based on their
credit limit. It is a complete routing operation in a single statement.
For non-Oracle databases, the multi-row VALUES form is the portable
equivalent. Same effect, slightly different syntax.
INSERT INTO ... SELECT ... copies rows from one table to another in a
single statement. It is the standard way to move data between tables without
writing per-row logic in application code.
INSERT INTO customers_backup (customer_id, customer_name, region, credit_limit)
SELECT customer_id, customer_name, region, credit_limit
FROM customers
WHERE region = 'North';
Every North-region customer gets copied into the backup table. On our data, that is
Alice Corp, Charlie Ltd, and George Wholesale. The other four customers stay in the
original table only.
The WHERE clause controls which rows get copied. Without it, every row copies over.
The column list on both sides must match in count and be type-compatible, though the
names do not need to match. That is what lets you map between tables with different
schemas:
INSERT INTO customer_summary (id, name, total_limit)
SELECT customer_id, customer_name, credit_limit
FROM customers;
The target columns are named differently but the values line up positionally. The
insert succeeds as long as the types are compatible.
This is the pattern for archiving, for building summary tables, and for moving data
between environments. Whenever you find yourself writing application code that loops
through rows and inserts them one by one, check whether an
INSERT INTO ... SELECT would do the same job in a fraction of the code.
ROLLBACK undoes every uncommitted change in the current transaction.
It is the undo button for DML, and it works until you COMMIT.
DELETE FROM orders WHERE order_status = 'Pending';
-- Realise this was a mistake
ROLLBACK;
The pending orders come back. Every statement since the last COMMIT — or since the
start of the session if there was none — is undone.
ROLLBACK is what makes DML safe. You can experiment. You can run a
DELETE that matches more rows than you expected. You can run an UPDATE with the
wrong WHERE clause. As long as you have not committed, you can undo everything.
The window closes the moment you COMMIT. Any DML after the COMMIT is a
new transaction, and rolling back only undoes the new work. The changes before the
COMMIT are permanent.
Some databases put the session into "autocommit" mode by default — every statement
commits itself immediately, and ROLLBACK has nothing to work with.
MySQL is set up this way out of the box. Oracle is not. SQL Server, PostgreSQL, and
MySQL are configurable. Know which mode your session is in before you rely on
ROLLBACK.
COMMIT makes every change in the current transaction permanent. It
ends the transaction, and the changes become visible to other sessions.
UPDATE customers SET credit_limit = credit_limit * 1.1 WHERE region = 'North';
COMMIT;
The three North-region customers have their new credit limits. The changes are now
permanent and cannot be rolled back.
Before COMMIT, the changes exist in your session but are invisible to other users.
They can be rolled back. After COMMIT, they are part of the database and available
to everyone.
When you should commit: at the end of a logical unit of work, when everything has
succeeded. When you should not: in the middle of a multi-step operation that could
still fail. If a downstream step fails, rolling back is only possible if you have
not already committed the earlier steps.
The anti-pattern to avoid: committing inside a loop. Every COMMIT has overhead —
flushing to disk, updating transaction logs. A loop that commits after every row
on a million-row operation runs a million COMMITs and takes hours. Batch the
changes and commit once at the end.
A SAVEPOINT marks a point inside a transaction you can return to.
Rolling back to a savepoint undoes everything after it without affecting anything
before it.
UPDATE customers SET credit_limit = 55000 WHERE customer_id = 'C001';
SAVEPOINT after_alice;
UPDATE customers SET credit_limit = 30000 WHERE customer_id = 'C002';
SAVEPOINT after_bob;
UPDATE customers SET credit_limit = 90000 WHERE customer_id = 'C003';
-- Realise the third update was wrong
ROLLBACK TO SAVEPOINT after_bob;
COMMIT;
The rollback undoes Charlie Ltd's update — everything after
after_bob. Alice Corp's and Bob Traders's updates are preserved because
they were before the savepoint. The COMMIT then makes those two permanent.
Savepoints are how you handle long, multi-step transactions where an early step
succeeded but a later step failed. Without them, the choice is all-or-nothing:
discard the whole transaction or live with the mistake.
The syntax is standard across most databases. Oracle and PostgreSQL use
SAVEPOINT name and ROLLBACK TO SAVEPOINT name. SQL Server
uses SAVE TRANSACTION name and ROLLBACK TRANSACTION name.
The behaviour is the same; the words differ slightly.
One thing to know: savepoints are cleaned up when the transaction ends. A COMMIT or
full ROLLBACK discards all savepoints in the session. They only exist within the
lifetime of the transaction that created them.
When the new value depends on data from another table, you join the tables and
update in a single statement. The syntax varies between databases.
-- MySQL
UPDATE customers c
JOIN orders o ON c.customer_id = o.customer_id
SET c.credit_limit = o.order_amount * 3
WHERE o.order_id = 1004;
Charlie Ltd's order 1004 is 22,000. His credit limit becomes 66,000.
-- PostgreSQL
UPDATE customers c
SET credit_limit = o.order_amount * 3
FROM orders o
WHERE c.customer_id = o.customer_id
AND o.order_id = 1004;
Same effect, different syntax. PostgreSQL puts the joined table in the
FROM clause.
-- Oracle and SQL Server (portable form)
UPDATE customers c
SET credit_limit = (
SELECT o.order_amount * 3
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_id = 1004
)
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id AND o.order_id = 1004
);
The WHERE EXISTS is essential. Without it, every customer row would
get updated. Customers with no matching order would have their credit limit set to
NULL, because the correlated subquery returns NULL when it finds nothing.
The rule to remember whenever you update with a join or subquery: always add a
WHERE EXISTS clause that matches the same filter. It prevents the
"update every row" bug that has probably destroyed more production data than any
other single mistake.
You use a CTE with a window function to number the rows within each duplicate
group, then delete every row except the first.
WITH numbered AS (
SELECT order_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id, order_amount
ORDER BY order_id
) AS rn
FROM orders
)
DELETE FROM orders
WHERE order_id IN (SELECT order_id FROM numbered WHERE rn > 1);
The CTE assigns a row number within each group of duplicates — rows that share the
same customer and amount. The DELETE removes every row with a row number above 1,
keeping the earliest one in each group.
To keep the latest instead of the earliest, change the ORDER BY inside
the window function to DESC:
ROW_NUMBER() OVER (
PARTITION BY customer_id, order_amount
ORDER BY order_id DESC
)
Now the highest order_id in each duplicate group survives.
Before running any deduplication, run the SELECT version first to see what the
duplicates look like:
SELECT customer_id, order_amount, COUNT(*) AS occurrences
FROM orders
GROUP BY customer_id, order_amount
HAVING COUNT(*) > 1;
That tells you how many duplicates exist and which combinations are involved. If
the answer surprises you, investigate before proceeding — sometimes what looks like
a duplicate is actually two legitimately separate rows.
MERGE does insert-or-update in a single statement. It is how you
synchronise a staging table with a production table without writing the check in
application code.
MERGE INTO customers c
USING staging_customers s
ON (c.customer_id = s.customer_id)
WHEN MATCHED THEN
UPDATE SET c.credit_limit = s.credit_limit
WHEN NOT MATCHED THEN
INSERT (customer_id, customer_name, region, credit_limit)
VALUES (s.customer_id, s.customer_name, s.region, s.credit_limit);
Every row in the staging table gets compared against the target table by
customer_id. If a matching row exists, the target's credit limit is
updated. If not, a new row is inserted.
The reason this matters for data engineering: ETL pipelines regularly load fresh
data into a warehouse. New rows need to be inserted; existing rows need updating.
Before MERGE, this took two statements and a check — which had a race condition
when two processes loaded the same row at the same time. MERGE does the whole job
atomically.
MERGE also supports DELETE when a matched row should be removed instead
of updated:
WHEN MATCHED AND s.is_deleted = 'Y' THEN
DELETE
That makes MERGE a complete synchronisation tool — insert new rows, update changed
rows, delete removed rows, all in one statement.
Support varies. Oracle, SQL Server, DB2, and PostgreSQL 15+ support MERGE natively.
MySQL uses INSERT ... ON DUPLICATE KEY UPDATE for the same purpose with
different syntax.
A subquery in a DELETE lets you remove rows based on a condition that
depends on another table.
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders WHERE order_amount > 20000
);
The subquery identifies customers with large orders. The DELETE removes them. On our
data, only Charlie Ltd — his order 1004 is 22,000.
The alternative uses EXISTS, which is often faster on large tables
because it can stop as soon as it finds a match:
DELETE FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_amount > 20000
);
Same result, potentially faster on big datasets.
One caveat worth knowing: some databases do not allow a DELETE with a subquery that
references the same table being deleted from. If you hit "ORA-00907" or similar,
that is usually why. The workaround is to use a CTE that materialises the list
first, then delete based on the CTE's result.
You simply omit the columns that have defaults from the INSERT statement. The
database fills them in.
INSERT INTO customers (customer_id, customer_name)
VALUES ('C011', 'Kelly Foods');
If the region column has a default of 'Unknown' and the
credit_limit column has a default of 0, those values are
applied automatically. Kelly Foods gets a region of Unknown and a credit limit of
zero.
You can also ask the database to use defaults explicitly with the
DEFAULT keyword:
That form is useful when you are building INSERT statements programmatically and
want to be explicit about which columns use defaults versus which ones have real
values.
Columns without a default and without a value in the INSERT get NULL. That is why
defining defaults on columns that should never be NULL is important — otherwise
every insert that omits them gets NULL.
A CASE expression inside the SET clause lets you apply
different new values to different rows based on conditions. One statement, many
outcomes.
UPDATE customers
SET credit_limit = CASE
WHEN region = 'North' THEN credit_limit * 1.10
WHEN region = 'South' THEN credit_limit * 1.05
WHEN region = 'West' THEN credit_limit * 1.02
ELSE credit_limit
END;
Each row gets a different increase depending on its region. North customers get
10%, South 5%, West 2%, and everyone else stays the same.
The CASE expression can also handle NULL explicitly:
UPDATE customers
SET credit_limit = CASE
WHEN credit_limit IS NULL THEN 10000
WHEN credit_limit < 30000 THEN credit_limit * 1.15
ELSE credit_limit
END;
Fiona Mart's NULL becomes 10,000. Bob Traders (25,000) gets a 15% bump to 28,750.
Everyone else is untouched.
The one thing to remember about CASE: it stops at the first matching branch. If
you put the NULL check last, earlier numeric comparisons would never match for
NULL rows — because NULL is not less than 30,000. The NULL check has to come
first.
CASE in an UPDATE runs as a single atomic operation. Either all the branches apply
or none do. Running three separate UPDATE statements for the same intent does not
have that guarantee.
Bulk delete is just a DELETE with a WHERE clause that
matches many rows. The database removes them all in one statement.
DELETE FROM orders
WHERE order_status = 'Delivered'
AND order_amount < 10000;
That removes every delivered order under 10,000. On our data, that is order 1003
(7,500) and order 1007 (9,000). The other delivered orders — 1001 (12,000) and 1005
(3,200, but the status is Delivered so it qualifies) — hmm, let me trace this
carefully. Delivered orders are 1001 (12,000), 1003 (7,500), 1005 (3,200), 1007
(9,000). The condition is delivered AND under 10,000, so 1003, 1005, and 1007 all
qualify. Three rows removed.
Multiple conditions combine with AND and OR to build
precise filters. Subqueries extend the same pattern:
DELETE FROM orders
WHERE customer_id IN (
SELECT customer_id FROM customers WHERE region = 'North'
);
That removes every order belonging to North-region customers. Alice Corp, Charlie
Ltd, and George Wholesale are in the North, so their orders — 1001, 1003, 1004,
1007 — all go.
The performance of a bulk delete on a large table depends on whether the WHERE
clause can use an index. A delete filtered by an indexed column is fast. A delete
filtered by an unindexed column has to scan every row to decide what to remove.
For very large deletes — tens of millions of rows — one statement can lock the
table for a long time and fill the transaction log. In those cases, deleting in
batches with a loop (say, ten thousand rows per statement, committed between
batches) is the safer approach. It takes longer overall but keeps the table
available and the transaction log manageable.
Mistakes I Keep Seeing in Interviews
These come up repeatedly. None of them are exotic, and every one of them is easy to avoid
once you know it is coming.
Mistake one: running UPDATE or DELETE without a
WHERE clause by accident. Every row gets changed or removed. There is no undo
once the transaction commits. Run the equivalent SELECT first to see what you are about to
affect.
Mistake two: inserting rows without listing the columns. The positional
form works today and breaks silently the moment someone adds a column to the table. List
the columns every time — the three seconds it costs saves hours of debugging.
Mistake three: assuming DELETE and TRUNCATE are
interchangeable. DELETE can be filtered and rolled back. TRUNCATE cannot be filtered, is
much faster, and usually resets auto-increment counters. Wrong choice, wrong result.
Mistake four: forgetting the WHERE EXISTS clause on an
UPDATE with a subquery. Every row gets updated, and customers who do not match get NULL
for the column you were updating. This one has destroyed more production data than any
other mistake.
Mistake five: committing inside a loop. Every COMMIT has real overhead.
A million-row operation with a COMMIT per row takes hours. Batch the changes and commit
once.
Mistake six: using MERGE where a simple INSERT would work. MERGE is for
upserts. If you know the row does not exist, a plain INSERT is clearer and often faster.
When you know the row does exist, an UPDATE is clearer. Save MERGE for when you genuinely
do not know.
Follow-Up Questions Interviewers Ask
Getting the first answer right is only half the interview. What usually decides the
outcome is what happens next. Here is what to prepare for on the DML topics.
After "What is the difference between DML and DDL?" — expect
"Which statements can be rolled back?"
"What happens if you mix DML and DDL in the same transaction?"
"Is SELECT a DML statement?"
"Which DDL statements are the most dangerous?"
After "How do you insert data?" — expect
"What happens when you insert without listing columns?"
"How do you insert a NULL value explicitly?"
"What are default values and how do they interact with INSERT?"
"How would you insert a row and get its new id back?"
After "How do you update rows?" — expect
"What happens if you forget the WHERE clause?"
"How would you update a column using a value from another table?"
"Can you update multiple columns at once?"
"How does CASE work inside an UPDATE?"
After "How do you delete rows?" — expect
"What is the difference between DELETE and TRUNCATE?"
"Can you roll back a TRUNCATE?"
"What happens to foreign key constraints on DELETE?"
"How would you delete duplicate rows?"
After "What is MERGE?" — expect
"How does it differ from INSERT ... ON DUPLICATE KEY UPDATE?"
"Can MERGE delete rows?"
"What happens when two sessions run MERGE on the same row?"
"Which databases support MERGE?"
For every question above, prepare a second-level answer. The candidates who pass are not
the ones who memorised the first answer — they are the ones who kept going for another
thirty seconds and said something useful about why it matters.
Quick Quiz
Three questions to check whether the topics above have landed.
1. What is the difference between DELETE and TRUNCATE?
2. What happens when you run UPDATE customers SET credit_limit = 10000; without a WHERE clause?