SQL INSERT, UPDATE, DELETE — 14,000 Lost to Missing WHERE
An UPDATE missing WHERE clause wiped 14,000 salaries instantly.
20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- INSERT adds new rows — always name columns explicitly to survive schema changes
- UPDATE modifies existing rows — WHERE clause is mandatory in practice, not optional syntax
- DELETE removes entire rows — without WHERE it empties the whole table instantly with no warning
- Bulk INSERT with multi-row VALUES reduces network round-trips by 10-50x vs single-row inserts in a loop
- Production rule: run SELECT with your WHERE clause first, verify the row count, then convert to UPDATE or DELETE
- Biggest mistake: forgetting WHERE on UPDATE/DELETE — no undo without an open transaction
- Soft Delete (UPDATE SET is_deleted = true) is the default pattern in regulated systems — Hard Delete is a deliberate architectural choice, not a casual convenience
SQL INSERT, UPDATE, and DELETE are the three Data Manipulation Language (DML) commands that let you change the data inside your tables. INSERT adds new rows, UPDATE modifies existing values, and DELETE removes rows. They're the operational core of any database-backed application — without them, your database is a read-only archive.
The critical thing every developer learns the hard way: UPDATE and DELETE without a WHERE clause operate on every row in the table. That's how you lose 14,000 customer records in one keystroke. PostgreSQL, MySQL, SQL Server, and SQLite all behave identically here — no safety net unless you explicitly add one.
These commands sit alongside SELECT (which reads data) and DDL commands like CREATE TABLE (which defines structure). You'll use INSERT for logging events, populating seed data, or accepting user registrations. UPDATE is your go-to for changing order statuses, updating profile information, or incrementing counters.
DELETE handles cleanup — removing expired sessions, purging test data, or GDPR compliance deletions. The pattern is universal: specify the table, define the change, and always — always — constrain the operation with a WHERE clause unless you genuinely intend to affect every row.
Where this gets dangerous: in production, a missing WHERE on an UPDATE or DELETE is a career-limiting move. Unlike INSERT (which always adds new rows), UPDATE and DELETE are destructive by default. Best practices include wrapping mutations in transactions (BEGIN/COMMIT), running SELECT first to preview affected rows, and using database-level safeguards like row-level security or read-replica testing.
Tools like Flyway or Liquibase can enforce migration patterns, but they won't save you from ad-hoc queries. The rule: never run an UPDATE or DELETE in production without a WHERE clause you've verified against a SELECT with the same filter.
Think of a database table like a whiteboard in a classroom. INSERT is writing something new on the board. UPDATE is erasing one word and replacing it with a corrected version. DELETE is wiping an entire row clean with a damp cloth — gone, not just hidden.
That's literally the whole model: three actions, one purpose, keeping your data accurate.
The part that trips people up: the whiteboard has no undo button unless you took a photo first. The photo is a transaction — a checkpoint you set before making changes, so you can restore the board if something goes wrong. Senior engineers take the photo every time before touching real data. Juniors learn why after the first incident.
INSERT, UPDATE, and DELETE are the three DML commands that every live application depends on. Every user signup maps to an INSERT. Every profile edit maps to an UPDATE. Every account deletion maps to a DELETE. If you've ever used a web application, these three statements have been running on your behalf dozens of times a day.
The commands themselves are not complicated. A beginner can write a correct INSERT in five minutes. What separates junior from senior engineers is not knowledge of the syntax — it's the operational discipline around execution. A missing WHERE clause on an UPDATE or DELETE is not a recoverable learning exercise in production. It is a P1 incident. Entire salary columns get zeroed. Customer records vanish. Finance teams notice six hours later during batch processing, not immediately, which means the damage window is wide.
I've reviewed post-mortems on incidents exactly like this. The SQL was technically valid. The database executed it correctly. There was no bug. There was just a missing five-character clause, autocommit was on, and there was no pre-execution verification step in the team's process.
This guide covers syntax, execution patterns, and the operational habits that prevent catastrophic data loss — not because the material is exotic, but because the habits have to be built before you're running DML against a production table with ten million rows and no rollback window.
Why Your Data Disappears Without a WHERE Clause
SQL INSERT, UPDATE, and DELETE are the three Data Manipulation Language (DML) statements that change data in a table. INSERT adds new rows, UPDATE modifies existing rows, and DELETE removes rows. The core mechanic is that each operates on a set of rows defined by an optional WHERE clause — omit it, and the operation applies to every row in the table. This is not a safety feature; it's the logical consequence of set-based semantics. A single UPDATE without a WHERE can rewrite millions of rows in milliseconds. A DELETE without a WHERE empties the table instantly. There is no undo in standard SQL. In practice, these statements are the backbone of every transactional system. INSERT is used for logging, user registration, and batch loading. UPDATE handles state changes, inventory adjustments, and profile edits. DELETE cleans up stale records, enforces retention policies, or removes orphaned data. The critical property is that they are all-or-nothing within a transaction — you can wrap them in BEGIN/COMMIT/ROLLBACK to protect against partial failures. But the most common production incident? A missing WHERE clause on an UPDATE or DELETE that corrupts an entire table. The fix is not a tool — it's discipline: always write the SELECT version first, verify the row count, then convert to UPDATE or DELETE.
Setting the Scene — The Table We'll Work With Throughout This Guide
Before touching INSERT, UPDATE, or DELETE, we need a stable foundation. Think of a database table like a structured spreadsheet where the column names are fixed by the schema and every row represents one unique real-world entity. The column definitions — their data types, constraints, and nullability — are the contract that every DML statement must honor.
We're going to use an employees table for a fictional company throughout this guide. It's simple enough to understand at a glance but realistic enough to demonstrate the production patterns that actually matter: a primary key that prevents duplicate records, a NOT NULL constraint on the name that enforces data quality, and a DECIMAL type for salary that stores exact financial values without the floating-point rounding errors you'd get from FLOAT.
That last one is worth pausing on. The difference between DECIMAL(10,2) and FLOAT for a salary column is not a pedantic data-type argument. In a payroll system, FLOAT arithmetic introduces rounding errors at the cent level. Multiply a FLOAT salary by a tax rate across ten thousand employees and the accumulated error becomes meaningful. DECIMAL stores exact values. Always use DECIMAL or NUMERIC for financial columns.
Also notice that department is nullable — no NOT NULL constraint. This is intentional. In a real organization, an employee might exist in the system before being assigned to a department (newly onboarded, contractor pending assignment). Forcing NOT NULL on department would cause INSERTs to fail for those legitimate cases. Schema design decisions like this directly determine what DML is possible later.
-- io.thecodeforge: Production employees schema -- Design decisions documented inline — schema choices constrain DML forever. CREATE TABLE employees ( employee_id INT PRIMARY KEY, -- Unique identifier: rejects duplicates at the DB level full_name VARCHAR(100) NOT NULL, -- Mandatory: INSERT fails if omitted or NULL department VARCHAR(50), -- Nullable: employees can exist before assignment salary DECIMAL(10, 2) -- Exact precision: never use FLOAT for financial values ); -- Verify the schema before writing any DML. -- In PostgreSQL, \d employees shows columns, types, and constraints. -- In MySQL, DESCRIBE employees gives the same information. -- Run this in every target environment before deploying DML scripts. -- \d employees -- Confirm the table is empty before the guide begins. SELECT * FROM employees;
- PRIMARY KEY columns reject duplicate values at the storage engine level — INSERT fails immediately if the key already exists, no matter what the application expects
- NOT NULL columns require a value on every INSERT — omitting them or explicitly passing NULL raises an error before a single row is written
- DECIMAL(10,2) stores exact financial values — FLOAT introduces sub-cent rounding errors that compound across millions of calculations
- Nullable columns (no NOT NULL) accept NULL as a valid value — WHERE column = NULL never matches; you must use WHERE column IS NULL
- Schema changes between environments (staging vs production) cause DML that works in one environment to fail in another — always run \d or DESCRIBE in the target environment before deploying DML scripts
SQL INSERT — Writing New Rows Into Your Table
INSERT is how data enters your system. Every user account, every order, every log event starts life as an INSERT statement somewhere in the call stack. The syntax has one job: map values to columns and write a new row to the table.
There are two patterns you'll use in practice, and they serve different purposes. Single-row INSERT handles real-time events — one user completing a signup form, one sensor publishing a reading, one webhook arriving. Bulk (multi-row) INSERT handles batch operations — seeding a test environment, importing a dataset, processing a queue of queued events in a background job. The performance difference between them is not academic. Single-row INSERTs inside a loop make one network round-trip per row. Bulk INSERT with multi-row VALUES makes one round-trip for the entire batch. For a 10,000-row import, that's the difference between 10,000 individual network calls and one.
The non-negotiable rule: always name your columns explicitly. The position-based form — INSERT INTO employees VALUES (1, 'Sarah', 'Engineering', 72000.00) — works until the moment someone adds a middle_name column between full_name and department. After that schema change, every position-based INSERT either fails with a type mismatch or, worse, silently stores values in the wrong columns. Explicit column naming makes your INSERT statements resilient to schema evolution and self-documenting for anyone reading the code later.
One more pattern worth knowing for production: INSERT ... ON CONFLICT, which handles the case where a row might or might not already exist. Without it, a duplicate primary key causes the entire INSERT to fail. With ON CONFLICT, you can choose to skip the row, update specific columns, or raise a custom error. This is what's commonly called an 'upsert' — a single statement that handles both the insert case and the update case without requiring a SELECT first.
-- io.thecodeforge: Production INSERT patterns -- PATTERN 1: Single-row INSERT with explicit column names. -- This is the only safe INSERT form — position-based inserts break on schema changes. INSERT INTO employees (employee_id, full_name, department, salary) VALUES (1, 'Sarah Mitchell', 'Engineering', 72000.00); -- PATTERN 2: Bulk INSERT — one statement, one network round-trip, six rows. -- Preferred over looping single-row inserts for any batch > 10 rows. -- Target 500-1000 rows per statement for optimal throughput. INSERT INTO employees (employee_id, full_name, department, salary) VALUES (2, 'James Okafor', 'Marketing', 55000.00), (3, 'Priya Nair', 'Engineering', 68000.00), (4, 'Carlos Mendes', 'HR', 48000.00), (5, 'Lisa Thornton', 'Engineering', 76000.00), (6, 'Derek Huang', 'Marketing', 59000.00); -- PATTERN 3: Upsert — handles the case where the row may already exist. -- ON CONFLICT (employee_id): if a row with this primary key already exists... -- DO UPDATE SET ...: update only the specified columns (not the whole row). -- EXCLUDED. prefix refers to the VALUES that were attempted in the INSERT. -- Use this for idempotent operations — safe to run multiple times. INSERT INTO employees (employee_id, full_name, department, salary) VALUES (3, 'Priya Nair', 'Engineering', 72000.00) -- Priya already exists (id=3) ON CONFLICT (employee_id) DO UPDATE SET salary = EXCLUDED.salary, department = EXCLUDED.department; -- Result: Priya's salary is updated to 72000.00, no duplicate row created. -- MySQL equivalent: INSERT ... ON DUPLICATE KEY UPDATE salary = VALUES(salary) -- Verify all rows after insertion. SELECT * FROM employees ORDER BY employee_id ASC;
SQL UPDATE — Modifying Existing State Without Breaking It
UPDATE is the command that keeps data current. Password changes, price adjustments, address corrections, status transitions — all of these are UPDATEs under the hood. The mechanism is straightforward: identify which rows to change (WHERE), specify what to change them to (SET), and the database engine applies the modification atomically to every matched row.
The WHERE clause is where the danger lives. Without it, 'every matched row' means 'every row in the table.' The database engine doesn't distinguish between 'I want to update one row' and 'I want to update all rows' — it executes whatever you tell it to. A missing WHERE clause on a 10,000-row employees table is not caught by the SQL parser, not caught by the query planner, and not caught by the storage engine. It executes correctly and commits instantly if autocommit is on. The only thing that catches it is you, before you run it.
The SELECT-first workflow is the habit that prevents incidents. Before writing an UPDATE, write the SELECT equivalent first: SELECT FROM employees WHERE employee_id = 1. Verify that the result set contains exactly the rows you intend to modify. Count them. Then, and only then, convert the statement to an UPDATE by replacing SELECT with UPDATE employees SET salary = ... . This takes an extra 10 seconds. Over a career, it will save you hours.
For batch UPDATEs affecting large numbers of rows, the performance concern shifts from correctness to concurrency. An UPDATE that modifies 5 million rows in a single statement holds row-level locks on all affected rows for the duration of the operation — potentially minutes. Other transactions trying to write to any of those rows queue behind it. On a busy system, this causes cascading timeout failures across your API layer. The mitigation is batching: update 10,000 rows at a time with a COMMIT between batches, keeping the lock window short enough that concurrent writes are not blocked for an unacceptable duration.
-- io.thecodeforge: Production UPDATE patterns -- PATTERN 1: Single-row targeted update via Primary Key. -- Primary Key in WHERE ensures exactly one row is matched — the safest UPDATE pattern. UPDATE employees SET salary = 80000.00 WHERE employee_id = 1; -- WHERE clause written first — non-negotiable habit -- Verify: SELECT full_name, salary FROM employees WHERE employee_id = 1; -- PATTERN 2: Multi-column update in one statement. -- Multiple SET assignments separated by commas — one atomic operation. -- Carlos is moving from HR to Engineering with a corresponding salary adjustment. UPDATE employees SET department = 'Engineering', salary = 62000.00 WHERE employee_id = 4; -- PATTERN 3: Conditional batch update — applying logic to a segment. -- Increase Marketing salaries by 10%. salary * 1.10 uses the current value. -- SELECT-first verification step (run this, confirm row count, then UPDATE): -- SELECT full_name, salary, salary * 1.10 AS new_salary -- FROM employees WHERE department = 'Marketing'; UPDATE employees SET salary = salary * 1.10 WHERE department = 'Marketing'; -- PATTERN 4: Safe batch UPDATE for large tables — update in chunks. -- Never run a multi-million-row UPDATE in a single transaction. -- This example updates 1000 rows per batch, releasing locks between commits. -- In a real script this would be a loop with a LIMIT and OFFSET or cursor. UPDATE employees SET salary = salary * 1.05 WHERE department = 'Engineering' AND employee_id BETWEEN 1 AND 1000; -- batch boundaries -- COMMIT; -- release locks, then run next batch -- Audit the changes after all patterns. SELECT employee_id, full_name, department, salary FROM employees ORDER BY employee_id;
SQL DELETE — Removing Records Safely and Responsibly
DELETE removes entire rows from a table. Not a column. Not a value. The entire row, with all its data, removed from the storage engine. This distinction matters: if you want to clear a value in a specific column while keeping the row, you use UPDATE SET column = NULL. DELETE is for when the record itself should no longer exist.
DELETE is irreversible without a transaction or a backup. Unlike UPDATE, where the old values might be recoverable from audit logs or CDC streams, a committed DELETE removes the row from the table immediately. Vacuum processes in PostgreSQL and InnoDB compaction in MySQL will eventually reclaim the physical disk space, but from the application's perspective, the data is gone the moment the DELETE commits.
This permanence is why most production systems for user-facing data default to Soft Delete rather than Hard Delete. A Soft Delete doesn't use the DELETE command at all — it uses UPDATE to set an is_deleted flag to true or a deleted_at timestamp to the current time. The row remains in the table, queries that should exclude deleted records add WHERE is_deleted = false or WHERE deleted_at IS NULL, and the data can be restored by flipping the flag back. The trade-off is table bloat over time, which requires periodic archival jobs to move soft-deleted rows to cold storage.
Hard Delete has legitimate use cases: GDPR right-to-erasure requests (where soft delete doesn't satisfy the legal requirement to actually remove data), clearing temporary staging tables that are re-populated on each run, and removing log or event data past its retention window. In all three cases, the DELETE should be inside a transaction, preceded by a SELECT to verify the target rows, and — for large tables — executed in batches to avoid holding locks for an unacceptable duration.
The batching requirement for large DELETEs is more critical than for large UPDATEs. Every deleted row generates a transaction log entry proportional to the row size. A single DELETE of 50 million rows generates enough WAL or redo log volume to fill disk on many systems. Batching with COMMIT between chunks keeps the per-transaction log volume manageable and keeps the lock window short enough for other writes to proceed.
-- io.thecodeforge: Production DELETE patterns -- PATTERN 1: Targeted Hard Delete via Primary Key -- Primary Key in WHERE guarantees exactly one row is affected — the safest DELETE pattern. -- Step 1: Always verify the target row before deleting. SELECT * FROM employees WHERE employee_id = 2; -- Verify this is James Okafor. Confirm there is exactly one row. -- Step 2: Delete only after verifying the target. DELETE FROM employees WHERE employee_id = 2; -- Primary Key: guaranteed one row, no ambiguity -- PATTERN 2: Soft Delete — the default for user-facing data -- Requires an is_deleted column (ALTER TABLE employees ADD COLUMN is_deleted BOOLEAN DEFAULT false) -- Never physically removes the row — just hides it from application queries. -- This example is illustrative; it works once the column exists. -- UPDATE employees -- SET is_deleted = true, deleted_at = NOW() -- WHERE employee_id = 2; -- Restore: UPDATE employees SET is_deleted = false, deleted_at = NULL WHERE employee_id = 2; -- PATTERN 3: Batch delete by category -- Add temporary staging rows to demonstrate scoped batch delete. INSERT INTO employees (employee_id, full_name, department, salary) VALUES (7, 'Angela Foster', 'HR', 47000.00), (8, 'Ben Stafford', 'HR', 44000.00); -- Step 1: Count affected rows — verify before deleting. SELECT COUNT(*) FROM employees WHERE department = 'HR'; -- Expected: 2 rows (Angela and Ben — Carlos moved to Engineering in the UPDATE section) -- Step 2: Wrap in a transaction for safety. Verify row count inside BEGIN. BEGIN; DELETE FROM employees WHERE department = 'HR'; -- The database engine returns the rows-affected count here. -- If it matches your expectation from the COUNT above, proceed. COMMIT; -- If the count is wrong: ROLLBACK; instead of COMMIT; -- PATTERN 4: Batched DELETE for large tables (pseudocode structure) -- Never delete millions of rows in one transaction. -- Loop until no rows remain in the target set: -- DELETE FROM employees -- WHERE department = 'archived' -- AND ctid IN ( -- SELECT ctid FROM employees -- WHERE department = 'archived' -- LIMIT 10000 -- ); -- COMMIT; -- release locks between batches -- Final state after all deletes. SELECT * FROM employees ORDER BY employee_id;
NOW() WHERE id = X. Recoverable. Auditable. Does not break foreign key references.Why SELECT Is Your First Line of Defense (Not Just a Query Tool)
Before you INSERT, UPDATE, or DELETE, you need to know what data you're touching. SELECT is your reconnaissance tool. I've seen juniors blast 10,000 rows into the void because they assumed the table was empty—then spent three hours restoring from backup. Every destructive operation should start with a SELECT that mirrors your target condition. Run it. Check the row count. Verify the data. Then run your mutation. That SELECT is your safety net. It forces you to understand the shape of your data before you change it. In production, we call this a 'dry run' or 'pre-check query.' It's not optional. If you can't SELECT it, you shouldn't DELETE it. This habit alone prevents more production incidents than any fancy tooling ever will.
-- io.thecodeforge -- Always preview before mutation -- First: see what you'll touch SELECT * FROM employees WHERE department = 'Engineering'; -- Confirm: count matches expectation SELECT COUNT(*) FROM employees WHERE department = 'Engineering'; -- Only then: DELETE DELETE FROM employees WHERE department = 'Engineering';
Transactions: The Undo Button Production Engineers Swear By
Your database has a built-in safety mechanism that too many devs ignore: transactions. When you wrap your INSERT, UPDATE, or DELETE in BEGIN and COMMIT, you can ROLLBACK if something goes wrong. This is your get-out-of-jail-free card. I've watched a junior run an UPDATE without a WHERE clause and lock the entire orders table for six minutes while 50,000 rows recalculated. With a transaction, that's a one-second ROLLBACK. Without it, you're restoring from snapshot. The pattern is simple: BEGIN, run your mutation, verify the impact with a SELECT, then COMMIT. If the result looks wrong, ROLLBACK immediately. Do NOT treat this as optional only for risky operations. Every single write operation should live inside a transaction in production code.
-- io.thecodeforge -- Always use transactions for mutating state BEGIN; UPDATE employees SET salary = salary * 1.1 WHERE department = 'Engineering'; -- Verify the impact SELECT id, name, salary FROM employees WHERE department = 'Engineering'; -- Looks wrong? ROLLBACK; -- Looks right? COMMIT; COMMIT; -- or ROLLBACK;
Missing WHERE Clause Zeros All Employee Salaries — 14,000 Rows, 6-Hour Data Loss Window
- Always write the WHERE clause before writing the SET clause — physically type WHERE first, even if you fill it in after
- Disable autocommit in production database sessions — the default behavior in most clients is the unsafe one
- Run SELECT with the identical WHERE clause to verify target rows and confirm the count before executing UPDATE or DELETE
- Require peer review for any DML touching more than 100 rows — a second pair of eyes costs minutes, a recovery costs hours
- Add a rows-affected check after DML execution — if the count is unexpected, ROLLBACK immediately while the transaction is still open
psql -c "SELECT * FROM employees WHERE employee_id = 7;" yourdbpsql -c "\d employees" yourdbpsql -c "BEGIN; SELECT COUNT(*) FROM employees WHERE department = 'HR';" yourdbpsql -c "ROLLBACK;" yourdbpsql -c "SELECT pid, usename, application_name, state, query_start, query FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY query_start ASC;" yourdbpsql -c "SELECT pg_terminate_backend(<pid>);" yourdb| Aspect | INSERT | UPDATE | DELETE |
|---|---|---|---|
| What it does | Adds new rows to the table — does not modify existing data | Changes specific column values in existing rows — row count stays the same | Removes entire rows from the table — column values in those rows are gone |
| Requires WHERE? | No — INSERT always targets new rows, WHERE has no role | Yes — omitting WHERE updates every row in the table simultaneously | Yes — omitting WHERE deletes every row in the table simultaneously |
| Affects row count? | Increases by the number of rows successfully inserted | No change — the same rows exist before and after | Decreases by the number of rows matched by the WHERE clause |
| Can target multiple rows? | Yes — multi-row VALUES syntax inserts many rows in one statement | Yes — WHERE clause can match thousands of rows; all are updated atomically | Yes — WHERE clause can match thousands of rows; all are removed atomically |
| Reversible without a transaction? | Only by running a matching DELETE — which requires you to know what was inserted | Only by running another UPDATE with the old values — which requires you to know what they were | Not easily — the data is gone. Requires point-in-time recovery or a backup restore. |
| Safe execution pattern | Name columns explicitly. For batch: multi-row VALUES. For idempotent: ON CONFLICT clause. | SELECT-first to verify targets. Wrap in BEGIN/COMMIT. Check rows-affected count before committing. | SELECT-first to verify targets. Wrap in BEGIN/COMMIT. Batch large deletes. Prefer Soft Delete for user data. |
| Typical real-world trigger | New user signup, new order placed, log event recorded, configuration entry created | User updates profile, price adjusted, order status changes, permission modified | Account closed, record archived, GDPR erasure request, staging table cleared |
| File | Command / Code | Purpose |
|---|---|---|
| io | CREATE TABLE employees ( | Setting the Scene |
| io | INSERT INTO employees (employee_id, full_name, department, salary) | SQL INSERT |
| io | UPDATE employees | SQL UPDATE |
| io | SELECT * FROM employees WHERE employee_id = 2; | SQL DELETE |
| pre_check.sql | SELECT * FROM employees | Why SELECT Is Your First Line of Defense (Not Just a Query T |
| safe_update.sql | BEGIN; | Transactions |
Key takeaways
Common mistakes to avoid
5 patternsRunning DELETE or UPDATE without a WHERE clause
Omitting column names in INSERT statements
Confusing DELETE, TRUNCATE, and DROP TABLE
Single-row INSERT in a loop for bulk data loading
Fitting the scaler on full data — wrong transaction isolation for concurrent reads during DML
Interview Questions on This Topic
What is the difference between DELETE and TRUNCATE? Cover DML vs DDL, transaction logging, trigger behavior, and performance.
Explain Soft Delete vs Hard Delete. Why would a production banking system prefer one over the other, and what are the operational trade-offs?
NOW(). The row remains in the table. Application queries add WHERE is_deleted = false to exclude deleted records. The data can be restored by flipping the flag back.
Banking systems prefer Soft Delete for customer and transaction data because: regulatory compliance (SOX, PCI-DSS) requires audit trails proving what data existed and when; accidental deletions can be reversed in seconds by a support engineer without a backup restore; referential integrity is preserved — foreign keys pointing to a soft-deleted row don't break; and historical reporting can include deleted records by removing the is_deleted filter.
The trade-offs: table bloat over time (deleted rows never physically leave), index bloat (indexes include soft-deleted rows that don't participate in live queries), and queries must consistently include the is_deleted filter — missing it in one query shows deleted records as if they're active. Mitigation: periodic archival jobs that move soft-deleted rows older than the retention window to a cold storage table, and database views or row-level security that enforce the is_deleted filter automatically.LeetCode challenge: Write a SQL query to delete all duplicate emails from a table named 'Users', keeping only the row with the smallest ID for each email.
sql
DELETE FROM Users
WHERE id NOT IN (
SELECT MIN(id)
FROM Users
GROUP BY email
);
`
For MySQL (requires a derived table alias because MySQL cannot reference the DELETE target table directly in a subquery):
`sql
DELETE FROM Users
WHERE id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id
FROM Users
GROUP BY email
) AS rows_to_keep
);
`
Alternative using a self-join (works across most databases):
`sql
DELETE u1
FROM Users u1
INNER JOIN Users u2
ON u1.email = u2.email
AND u1.id > u2.id;
``
The self-join approach deletes any row where another row exists with the same email and a smaller ID — effectively keeping only the minimum-ID row per email group.
Before running either in production: SELECT MIN(id), email, COUNT() FROM Users GROUP BY email HAVING COUNT() > 1 to identify the duplicates and verify the scope.You accidentally run an UPDATE without a WHERE clause and your session is still open. What do you do immediately, and what are your options if autocommit was enabled?
How does the database engine handle an INSERT that violates a UNIQUE constraint? How do you write an upsert that handles both the insert and update case in a single statement?
sql
INSERT INTO employees (employee_id, full_name, salary)
VALUES (3, 'Priya Nair', 75000.00)
ON CONFLICT (employee_id)
DO UPDATE SET
salary = EXCLUDED.salary,
full_name = EXCLUDED.full_name;
-- EXCLUDED. references the values that were attempted in the INSERT.
-- ON CONFLICT DO NOTHING silently skips if the row already exists.
`
MySQL:
`sql
INSERT INTO employees (employee_id, full_name, salary)
VALUES (3, 'Priya Nair', 75000.00)
ON DUPLICATE KEY UPDATE
salary = VALUES(salary),
full_name = VALUES(full_name);
`
SQL Server:
`sql
MERGE employees AS target
USING (VALUES (3, 'Priya Nair', 75000.00)) AS source (employee_id, full_name, salary)
ON target.employee_id = source.employee_id
WHEN MATCHED THEN UPDATE SET salary = source.salary
WHEN NOT MATCHED THEN INSERT (employee_id, full_name, salary) VALUES (source.employee_id, source.full_name, source.salary);
``
Upsert is particularly valuable for idempotent data pipelines where retrying a failed operation should not produce duplicate rows.Explain how a missing index on the WHERE clause column affects the performance of UPDATE and DELETE on a large table. How would you diagnose and fix it without blocking production traffic?
Frequently Asked Questions
It depends on whether the change has been committed and whether your database has recovery mechanisms in place.
If you ran BEGIN (or START TRANSACTION) before the DML and haven't committed yet, execute ROLLBACK; immediately — this reverts all changes made since BEGIN and releases the locks. This is the clean undo path.
If autocommit was enabled (the default in most clients), the change was committed the moment it completed. ROLLBACK has no effect. Your options are: point-in-time recovery using PostgreSQL's WAL archiving or MySQL's binary log (requires the feature to have been configured before the incident), a backup restore from the most recent snapshot (with data loss equal to the time since the last backup), or reconstruction from an audit log or CDC stream if your system has one.
The practice that ensures you always have the clean undo path: disable autocommit in production database sessions, always wrap DML in explicit BEGIN/COMMIT blocks, and never COMMIT until you have verified the rows-affected count matches your expectation.
DELETE FROM employees is a data operation. It removes rows matching the WHERE clause (or all rows if no WHERE is provided), but the table structure — column definitions, indexes, constraints, permissions, sequences — remains intact. The table exists and is ready for new INSERTs.
DROP TABLE employees is a schema operation. It removes the entire table entity: every row, every column definition, every index, every constraint, every permission granted on the table, and any sequences associated with it. After DROP TABLE, the table does not exist. Recovery requires recreating the schema from migration scripts and restoring data from a backup.
TRUNCATE TABLE occupies the middle ground: removes all rows efficiently (like DROP + recreate but preserving the structure), resets auto-increment counters, and does not require a WHERE clause because it always empties the entire table.
Rule: DELETE to remove specific rows, TRUNCATE to empty a table while keeping its structure, DROP to decommission the table permanently.
Standard SQL does not support inserting into multiple tables in a single INSERT statement — one INSERT targets one table. For writing to multiple tables as part of one logical operation, use multiple INSERT statements inside a single transaction: BEGIN; INSERT INTO table_a ...; INSERT INTO table_b ...; COMMIT;. The COMMIT ensures both writes succeed or both are rolled back — the operation is atomic from the application's perspective.
Oracle's INSERT ALL provides a non-standard extension for multi-table insert in a single statement, useful for ETL-style fan-out operations. For most PostgreSQL, MySQL, and SQL Server applications, the transaction-wrapped multiple INSERT pattern is the standard approach.
Use Soft Delete (UPDATE SET is_deleted = true, deleted_at = NOW()) when: you need an audit trail proving the record existed and when it was removed; regulatory compliance requires data retention for a defined period; accidental deletions need to be reversible without a backup restore; or foreign key relationships reference the record and hard deletion would require cascading changes.
Use Hard Delete (DELETE FROM table WHERE ...) when: GDPR right-to-erasure requires the data to be physically removed; you're clearing temporary staging or work tables that are re-populated on each run; data past a retention window needs to be purged to manage storage costs; or the table is not user-facing and audit requirements don't apply.
The operational discipline for Hard Delete: always inside a transaction, always preceded by a SELECT to verify targets, always batched for large row counts to manage WAL/redo log volume and lock duration.
The three most common causes, in order of frequency:
- String case mismatch: WHERE department = 'hr' doesn't match rows stored as 'HR'. SQL string comparisons are case-sensitive in PostgreSQL by default (and case-insensitive in MySQL with the default collation). Verify with SELECT department, LENGTH(department) FROM employees WHERE employee_id = X to see the exact stored value including any trailing spaces.
- NULL comparison: WHERE manager_id = NULL never matches any row, including rows where manager_id is NULL. NULL is not equal to NULL in SQL. Use WHERE manager_id IS NULL instead.
- Trailing whitespace: values stored with trailing spaces (common with CHAR columns and some data imports) don't match string literals without the spaces. Verify with SELECT department, LENGTH(TRIM(department)) = LENGTH(department) AS has_trailing_space FROM employees.
Diagnosis: run SELECT COUNT(*) FROM employees WHERE [exact condition from your UPDATE] — if it returns 0, the WHERE clause is the problem, not the UPDATE syntax.
20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.
That's SQL Basics. Mark it forged?
7 min read · try the examples if you haven't