SQL INSERT, UPDATE, DELETE Explained — Add, Change and Remove Data
- INSERT adds new rows — always name your columns explicitly in every INSERT statement. Position-based inserts are schema bombs that detonate silently when someone adds a column to the table later.
- UPDATE changes existing rows — the WHERE clause is the only thing preventing every row in the table from being modified simultaneously. Write WHERE before SET, every time, without exception. No WHERE equals every row.
- DELETE removes entire rows permanently — the row and all its column values are gone. Without WHERE, the entire table is emptied. Run SELECT with the same WHERE clause first, verify the row count, then DELETE inside a BEGIN/COMMIT block.
- 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
Duplicate key error on INSERT — operation rejected, row not written
psql -c "SELECT * FROM employees WHERE employee_id = 7;" yourdbpsql -c "\d employees" yourdbUPDATE or DELETE affected wrong number of rows — too many or too few
psql -c "BEGIN; SELECT COUNT(*) FROM employees WHERE department = 'HR';" yourdbpsql -c "ROLLBACK;" yourdbTransaction blocking other queries — API timeouts increasing, write queue backing up
psql -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>);" yourdbProduction Incident
Production Debug GuideSymptom-driven diagnosis for SQL data modification failures — start with the symptom, not the assumption
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.
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;
Table "public.employees"
Column | Type | Nullable
--------------+------------------------+---------
employee_id | integer | not null
full_name | character varying(100) | not null
department | character varying(50) |
salary | numeric(10,2) |
Indexes:
"employees_pkey" PRIMARY KEY, btree (employee_id)
-- SELECT * FROM employees;
employee_id | full_name | department | salary
-------------+-----------+------------+--------
(0 rows)
- 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;
employee_id | full_name | department | salary
-------------+----------------+-------------+-----------
1 | Sarah Mitchell | Engineering | 72000.00
2 | James Okafor | Marketing | 55000.00
3 | Priya Nair | Engineering | 72000.00 <- updated via upsert
4 | Carlos Mendes | HR | 48000.00
5 | Lisa Thornton | Engineering | 76000.00
6 | Derek Huang | Marketing | 59000.00
(6 rows)
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;
employee_id | full_name | department | salary
-------------+----------------+-------------+-----------
1 | Sarah Mitchell | Engineering | 84000.00 <- 80000 * 1.05 (Engineering batch)
2 | James Okafor | Marketing | 64900.00 <- 59000 * 1.10 (Marketing 10% raise)
3 | Priya Nair | Engineering | 75600.00 <- 72000 * 1.05 (Engineering batch)
4 | Carlos Mendes | Engineering | 65100.00 <- 62000 * 1.05 (Engineering batch)
5 | Lisa Thornton | Engineering | 79800.00 <- 76000 * 1.05 (Engineering batch)
6 | Derek Huang | Marketing | 64900.00 <- 59000 * 1.10 (Marketing 10% raise)
(6 rows)
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;
employee_id | full_name | department | salary
-------------+--------------+------------+---------
2 | James Okafor | Marketing | 64900.00
(1 row)
-- Confirmed: this is the correct target. Proceeding with DELETE.
-- SELECT COUNT(*) FROM employees WHERE department = 'HR';
count
-------
2
(1 row)
-- Two rows will be deleted. Confirmed. COMMIT.
-- Final state:
employee_id | full_name | department | salary
-------------+----------------+-------------+-----------
1 | Sarah Mitchell | Engineering | 84000.00
3 | Priya Nair | Engineering | 75600.00
4 | Carlos Mendes | Engineering | 65100.00
5 | Lisa Thornton | Engineering | 79800.00
6 | Derek Huang | Marketing | 64900.00
(5 rows)
NOW() WHERE id = X. Recoverable. Auditable. Does not break foreign key references.| 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 |
🎯 Key Takeaways
- INSERT adds new rows — always name your columns explicitly in every INSERT statement. Position-based inserts are schema bombs that detonate silently when someone adds a column to the table later.
- UPDATE changes existing rows — the WHERE clause is the only thing preventing every row in the table from being modified simultaneously. Write WHERE before SET, every time, without exception. No WHERE equals every row.
- DELETE removes entire rows permanently — the row and all its column values are gone. Without WHERE, the entire table is emptied. Run SELECT with the same WHERE clause first, verify the row count, then DELETE inside a BEGIN/COMMIT block.
- The SELECT-before-DML habit is one of the highest-value practices in database work. It costs 10 seconds and prevents recoveries that cost 10 hours. Build it as a reflex, not a procedure you follow when you remember.
- Bulk INSERT with multi-row VALUES syntax is 10-50x faster than single-row INSERT in a loop — the difference is network round-trips. For very large loads, COPY (PostgreSQL) or LOAD DATA INFILE (MySQL) outperforms even batched INSERT by another order of magnitude.
- Soft Delete (UPDATE SET is_deleted = true) is the correct default for user-facing data in regulated or audit-sensitive systems. Hard Delete is a deliberate choice for specific scenarios — GDPR erasure, staging tables, data past retention windows — not the casual path of least resistance.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between DELETE and TRUNCATE? Cover DML vs DDL, transaction logging, trigger behavior, and performance.JuniorReveal
- QExplain Soft Delete vs Hard Delete. Why would a production banking system prefer one over the other, and what are the operational trade-offs?Mid-levelReveal
- QLeetCode 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.Mid-levelReveal
- QYou 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?JuniorReveal
- QHow 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?Mid-levelReveal
- QExplain 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?SeniorReveal
Frequently Asked Questions
Can I undo a DELETE or UPDATE after it's been executed?
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.
What is the difference between DELETE FROM employees and DROP TABLE employees?
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.
Can an INSERT statement add rows to multiple tables at the same time?
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.
When should I use Soft Delete vs Hard Delete?
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.
Why does my UPDATE affect 0 rows even though the data visually appears to match?
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.