SQL DML · Guide + 22 Interview Questions

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
22 Interview Questions Beginner to Advanced Real Examples Updated Oct 7, 2026
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
📤 Share:

On This Page

What DML Really Does

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.

-- customers table
-- customer_id | customer_name    | region | credit_limit
-- C001        | Alice Corp       | North  | 50000
-- C002        | Bob Traders      | South  | 25000
-- C003        | Charlie Ltd      | North  | 75000
-- C004        | Diana Retail     | West   | 40000
-- C005        | Ethan Supplies   | South  | 55000
-- C006        | Fiona Mart       | West   | NULL
-- C007        | George Wholesale | North  | 80000

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

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.

The explicit form (recommended)

INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000);

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.

The portable multi-row form

INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES
    ('C008', 'Henry Co',      'East', 45000),
    ('C009', 'Ivan Trading',  'East', 32000),
    ('C010', 'Julia Wholesale', 'West', 60000);

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.

-- Explicit columns (recommended)
INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C008', 'Henry Co', 'East', 45000);

-- Positional (avoid in production)
INSERT INTO customers
VALUES ('C009', 'Ivan Trading', 'East', 32000);

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.

-- Portable multi-row insert
INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES
    ('C008', 'Henry Co',       'East', 45000),
    ('C009', 'Ivan Trading',   'East', 32000),
    ('C010', 'Julia Wholesale','West', 60000);

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:

INSERT INTO customers (customer_id, customer_name, region, credit_limit)
VALUES ('C011', 'Kelly Foods', DEFAULT, DEFAULT);

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?
3. What does MERGE do?