- Chapter
- Chapter 1 — SQL Fundamentals
- Prerequisites
- Topic 1.1; Volume I Chapter 4 (constraints)
- You will be able to
- Write correct and safe INSERT, UPDATE and DELETE statements, use upserts, MERGE and RETURNING, understand how MVCC implements modifications, perform bulk changes efficiently from Java, and avoid common production accidents such as unbounded updates and long-running deletes
- Covers
- INSERT; UPDATE; DELETE
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — MVCC and Modifications
- 4A. Deep Dive — Upserts and MERGE
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
Data modification language (DML) changes the contents of tables:

| Statement | Effect |
|---|---|
INSERT INTO t (cols) VALUES (…) / SELECT … | Add rows |
UPDATE t SET col = expr WHERE … | Change matching rows |
DELETE FROM t WHERE … | Remove matching rows |
INSERT … ON CONFLICT (…) DO UPDATE/NOTHING | Upsert (PostgreSQL) |
MERGE INTO t USING s ON … WHEN MATCHED/NOT MATCHED … | Standard conditional insert/update/delete |
… RETURNING cols | Return affected rows (generated keys, new values) |
2Why It Exists
Applications must record new facts (orders), change state (status transitions) and remove data (cleanup, retention). Declarative DML lets the database apply changes atomically, enforce constraints and handle concurrency, while set-based statements make bulk changes efficient compared with row-at-a-time processing in application code.
Data modification is also where most production incidents in the data layer begin. A predicate that is slightly wrong changes thousands of rows in milliseconds; a read-modify-write in application code loses updates under concurrency; a retention job that deletes one huge range blocks replication for hours. Modern DML forms, RETURNING, ON CONFLICT, MERGE and data-modifying CTEs, exist so that an application can express a whole change, including its guard condition and its result, as one atomic statement instead of several round trips with gaps between them. Knowing these forms, and the procedures around running them, is what keeps a Java service correct and fast as data volume grows.
3Core Concepts
3.1 INSERT
-- Single row with generated key returned
INSERT INTO purchase_order (customer_id, placed_at, status, total)
VALUES (101, now(), 'PLACED', 1200.00)
RETURNING order_id;
-- Multi-row insert (one round trip)
INSERT INTO order_line (order_id, line_no, product_id, quantity, unit_price)
VALUES (5001, 1, 77, 2, 250.00),
(5001, 2, 91, 1, 700.00);
-- Insert from a query (set-based copy)
INSERT INTO order_archive
SELECT * FROM purchase_order WHERE placed_at < now() - interval '3 years';| INSERT technique | Use |
|---|---|
RETURNING | Get identity values, defaults, computed columns in the same round trip |
| Multi-row VALUES / JDBC batch | Many rows efficiently |
INSERT … SELECT | Copy/transform within the database |
ON CONFLICT DO NOTHING | Idempotent inserts (retries, duplicate events) |
COPY FROM STDIN | Bulk loads (fastest) |
3.2 UPDATE
-- Guarded state transition: only from PAID to SHIPPED, returns the updated row
UPDATE purchase_order
SET status = 'SHIPPED', shipped_at = now()
WHERE order_id = 5001 AND status = 'PAID'
RETURNING order_id, status, shipped_at; -- 0 rows → wrong state or unknown id
-- Update from another table (join update)
UPDATE product p
SET list_price = s.new_price
FROM price_import s
WHERE s.sku = p.sku AND s.new_price IS DISTINCT FROM p.list_price;| UPDATE pattern | Why |
|---|---|
Conditional (WHERE status = 'PAID') | Enforce allowed transitions atomically |
Relative (SET stock = stock - :q WHERE stock >= :q) | Race-free counters/stock |
Skip no-op updates (IS DISTINCT FROM) | Avoid dead tuples and WAL for unchanged rows |
| Check affected row count | Detect conflicts and missing rows |

3.3 DELETE
-- Delete with RETURNING for audit
DELETE FROM cart_item
WHERE cart_id = :cartId
RETURNING product_id, quantity;
-- Delete using another table (anti-orphan cleanup)
DELETE FROM session s
USING app_user u
WHERE s.user_id = u.user_id AND u.status = 'CLOSED';
| DELETE caution | Mitigation |
|---|---|
| Missing WHERE deletes everything | Code review, sql_safe_updates-style tooling, test on copies |
| Huge deletes = long transactions, bloat, replication lag | Batch by key range; partition drop for retention |
| FK checks on children | Index child FKs; understand CASCADE/RESTRICT |
| Irreversibility | Soft delete or archive when history matters |
4How It Works Internally — MVCC and Modifications
| Operation | PostgreSQL implementation | Consequence |
|---|---|---|
| INSERT | New tuple with xmin = inserting transaction | Visible to others after commit |
| UPDATE | New tuple version + old version marked xmax | Dead tuples; HOT if no indexed column changes and space exists |
| DELETE | Tuple marked xmax | Space reclaimed later by VACUUM |
| All DML | WAL records for durability/replication | Bulk changes produce large WAL |
| Constraints | Checked per row (or deferred) | Violation aborts the statement |
Row locks: UPDATE/DELETE lock the affected rows until the transaction ends; concurrent writers on the same rows wait (Chapter 6). Long transactions that modify many rows block others and delay vacuum.
4ADeep Dive — Upserts and MERGE
-- PostgreSQL upsert: concurrency-safe via unique index
INSERT INTO product_stock (sku, warehouse_id, quantity, updated_at)
VALUES (:sku, :wh, :qty, now())
ON CONFLICT (sku, warehouse_id) DO UPDATE
SET quantity = EXCLUDED.quantity, updated_at = now()
WHERE product_stock.quantity IS DISTINCT FROM EXCLUDED.quantity;
-- SQL-standard MERGE (PostgreSQL 15+; RETURNING in 17)
MERGE INTO product_stock t
USING stock_import s ON (t.sku = s.sku AND t.warehouse_id = s.warehouse_id)
WHEN MATCHED AND s.quantity = 0 THEN DELETE
WHEN MATCHED THEN UPDATE SET quantity = s.quantity, updated_at = now()
WHEN NOT MATCHED THEN INSERT (sku, warehouse_id, quantity, updated_at)
VALUES (s.sku, s.warehouse_id, s.quantity, now());| Choice | Concurrency behaviour | Best for |
|---|---|---|
INSERT … ON CONFLICT | Atomic, race-free with unique constraint | Single-row/idempotent upserts from applications |
MERGE | Can raise unique violations under concurrent inserts | Batch synchronisation from staging tables |
5APIs / Features / Syntax
| Feature | Use |
|---|---|
RETURNING | Generated keys, audit, results of changes |
ON CONFLICT … DO UPDATE/NOTHING | Upserts |
MERGE | Conditional sync |
UPDATE … FROM, DELETE … USING | Join-based modifications |
COPY / CopyManager | Bulk loading |
JDBC addBatch/executeBatch, reWriteBatchedInserts=true | Batched DML |
Hibernate jdbc.batch_size, order_inserts/updates | ORM batching |
Spring JdbcClient.update() → row count | Detect conflicts |
spring:
datasource:
url: jdbc:postgresql://db:5432/shop?reWriteBatchedInserts=true
jpa:
properties:
hibernate.jdbc.batch_size: 50
hibernate.order_inserts: true
hibernate.order_updates: true6Practical Examples
6.1 The missing WHERE
An admin script ran UPDATE customer SET status = 'BLOCKED' intended for one customer; 2 M accounts were blocked. Recovery took hours from PITR. The team introduced reviewed scripts, a "run in transaction, check count, then commit" procedure and restricted production write access.
6.2 Idempotent event handling
A Kafka consumer inserting payments occasionally received duplicates, causing unique violations and retries. INSERT … ON CONFLICT (payment_id) DO NOTHING made processing idempotent.
6.3 Worked example — bulk insert from Java

// JDBC batch via JdbcClient-compatible JdbcTemplate
jdbcTemplate.batchUpdate("""
INSERT INTO order_line (order_id, line_no, product_id, quantity, unit_price)
VALUES (?, ?, ?, ?, ?)""",
lines, 1_000,
(ps, l) -> {
ps.setLong(1, l.orderId());
ps.setInt(2, l.lineNo());
ps.setLong(3, l.productId());
ps.setInt(4, l.quantity()); ps.setBigDecimal(5, l.unitPrice());
});
// Fastest: COPY via PgJDBC
try (Connection c = dataSource.getConnection()) {
CopyManager copy = c.unwrap(PGConnection.class).getCopyAPI();
copy.copyIn("COPY order_line (order_id, line_no, product_id, quantity, unit_price) "
+ "FROM STDIN WITH (FORMAT csv)", new StringReader(csv));
}6.4 Worked example — safe manual data fix
BEGIN;
UPDATE purchase_order
SET status = 'CANCELLED', cancelled_reason = 'PAYMENT_TIMEOUT'
WHERE status = 'PENDING' AND placed_at < now() - interval '48 hours'
RETURNING order_id; -- inspect: expected ~1,240 rows
-- if the count matches the ticket's expectation:
COMMIT; -- otherwise ROLLBACK;| Procedure step | Purpose |
|---|---|
| Run a SELECT with the same WHERE first | Know the expected count |
| Explicit transaction | Ability to roll back |
| RETURNING / row count check | Verify before commit |
| Ticket + reviewer | Accountability |
6.5 Worked example — batched delete for retention
-- Delete 90-day-old audit events in 10k chunks (repeat until 0 rows)
WITH doomed AS (
SELECT event_id FROM audit_event
WHERE created_at < now() - interval '90 days'
ORDER BY event_id
LIMIT 10000
)
DELETE FROM audit_event a USING doomed d WHERE a.event_id = d.event_id;| Approach | Impact on 400 M-row table |
|---|---|
Single DELETE … WHERE created_at < … | Hours-long transaction, massive WAL, replica lag, bloat |
| Batched deletes with pauses | Controlled load; vacuum keeps up |
Monthly partitions + DROP/DETACH PARTITION | Seconds; best for time-based retention |
6.6 Worked example — race-free stock decrement
@Transactional
public void reserve(Sku sku, int qty) {
int updated = jdbc.sql("""
UPDATE product_stock SET available = available - :q
WHERE sku = :sku AND available >= :q""")
.param("q", qty).param("sku", sku.value())
.update();
if (updated == 0) throw new OutOfStock(sku, qty);
}| Why this is correct | Explanation |
|---|---|
| Condition and change in one statement | No gap between check and update |
| Row lock serialises concurrent reservations | Second waits, then re-evaluates condition |
CHECK (available >= 0) backs it up | Integrity even if code changes |
6.7 Worked example — archiving atomically with a data-modifying CTE
Move closed tickets older than a year to an archive table in one statement, so no row is lost or duplicated even if the job is interrupted.
WITH batch AS (
SELECT ticket_id FROM support_ticket
WHERE status = 'CLOSED' AND closed_at < now() - interval '1 year'
ORDER BY ticket_id
LIMIT 5000
), moved AS (
DELETE FROM support_ticket t USING batch b
WHERE t.ticket_id = b.ticket_id
RETURNING t.*
)
INSERT INTO support_ticket_archive
SELECT *, now() AS archived_at FROM moved;| Property | Why it matters |
|---|---|
| Single statement = single transaction | Rows are either moved or not — never in both or neither |
| Batched with LIMIT | Short transactions, bounded WAL and locks |
| Re-runnable | Each run moves the next batch; safe after interruption |
archived_at added on the way | Audit metadata without a second pass |
@Scheduled(cron = "0 */5 1-5 * * *") // 01:00–05:59, every 5 minutes
@SchedulerLock(name = "archive-tickets", lockAtMostFor = "PT4M")
void archiveTickets() {
int moved;
do {
moved = jdbc.sql(ARCHIVE_SQL).update();
metrics.counter("tickets.archived").increment(moved);
} while (moved > 0 && withinMaintenanceWindow());
}6.8 Worked example — detecting lost updates with row counts
A support tool let two agents edit the same ticket; the later save silently overwrote the earlier one. Adding a version predicate turned lost updates into detectable conflicts:
UPDATE support_ticket
SET priority = :priority, assignee = :assignee, version = version + 1
WHERE ticket_id = :id AND version = :expectedVersion;
-- 1 row → saved; 0 rows → someone else changed it → show "reload and retry"| Without version check | With version check |
|---|---|
| Last writer wins silently | Second writer gets a conflict |
| Data loss discovered by customers | Agent sees current data and decides |
7Common Problems & Mistakes
- UPDATE/DELETE without WHERE (or with wrong predicate).
- Read-modify-write in Java instead of atomic SQL updates.
- Row-by-row inserts with autocommit.
- Huge single-transaction deletes.
- Ignoring affected row counts.
- Using MERGE for highly concurrent single-row upserts (prefer ON CONFLICT in PostgreSQL).
8Performance & Production Considerations
| Aspect | Note |
|---|---|
| Batching | Cuts round trips by 100–1000× |
| WAL volume | Bulk DML → replica lag; throttle |
| Dead tuples | Updates/deletes need vacuum; avoid no-op updates |
| HOT updates | Don't index frequently updated columns |
| Locks | Keep modifying transactions short |
9Architecture & Design Considerations
- Prefer atomic, conditional SQL for state transitions and counters.
- Design idempotent writes (unique keys + ON CONFLICT) for retries and events.
- Use partitioning for time-based retention instead of mass deletes.
- Govern manual production changes with procedures and least privilege.
10Interview Questions & Answers
What does RETURNING do?
RETURNING makes an INSERT, UPDATE or DELETE return columns from the rows it affected, in the same statement and round trip. For an INSERT that typically means generated identity values, defaults or computed columns; for an UPDATE, the new values after the change; for a DELETE, the rows that were removed, which is useful for auditing or for moving data. It avoids a second SELECT, which would cost another round trip and could see a different state under concurrency. It also pairs well with guarded updates: zero rows returned tells you the state transition was not allowed. PostgreSQL 17 supports it on MERGE too. From JDBC, read it with executeQuery.
What is an upsert?
An upsert inserts a row, or updates the existing row if one with the same key already exists, as one atomic operation. In PostgreSQL it is INSERT ... ON CONFLICT (key columns) DO UPDATE, where EXCLUDED refers to the values you tried to insert; DO NOTHING turns it into an idempotent insert. The conflict target must match a unique index or constraint, because that index is what makes the operation safe under concurrency: two sessions inserting the same key cannot both succeed. MySQL offers INSERT ... ON DUPLICATE KEY UPDATE. Upserts are the natural tool for idempotent event consumers, synchronising reference data and retry-safe writes from services.
Why is UPDATE … SET stock = stock - 1 WHERE stock > 0 better than reading and writing in Java?
Because the check and the change happen in one statement under one row lock, there is no window for another transaction to slip in between. With read-modify-write in Java, two requests can both read stock = 1, both decide there is enough, and both write 0, so you oversell and lose an update. The single UPDATE locks the row; a concurrent update waits, then re-evaluates the WHERE condition against the newly committed value and updates zero rows. The application checks the affected row count to detect that failure. It also saves a round trip. A CHECK (stock >= 0) constraint is a useful safety net if someone later changes the code.
How do you insert many rows efficiently from Java?
Use JDBC batching, not one execute per row with autocommit. Prepare one INSERT, bind each row and call addBatch, then executeBatch every 500 to 5,000 rows inside a transaction, committing per chunk to bound memory and transaction size. With PgJDBC, set reWriteBatchedInserts=true so the driver rewrites the batch into multi-row VALUES statements, which typically gives another two to three times. In Spring, JdbcTemplate.batchUpdate does this; with Hibernate, set jdbc.batch_size and order_inserts, and avoid IDENTITY ids because they disable insert batching. For very large loads, millions of rows, PostgreSQL COPY through CopyManager is fastest because it streams rows without per-statement overhead.
What happens physically when PostgreSQL updates a row?
PostgreSQL never overwrites a row in place. An UPDATE writes a complete new tuple version, stamped with the updating transaction's id as xmin, and marks the old version with xmax, so concurrent readers with older snapshots still see the old row under MVCC. Every index normally gets a new entry pointing to the new tuple, and the change is written to the WAL. The old version becomes a dead tuple that VACUUM later reclaims. If no indexed column changes and the page has free space, a HOT update keeps the new version on the same page and skips index updates, which is why you avoid indexing frequently updated columns and avoid no-op updates.
How should you delete hundreds of millions of old rows?
Never as one DELETE. A single statement over hundreds of millions of rows runs as one huge transaction: it holds locks for hours, generates massive WAL that makes replicas lag, blocks vacuum and leaves the table full of dead tuples, and if it fails it all rolls back. If this is regular time-based retention, the right design is range partitioning by month or day, so removal becomes DETACH or DROP PARTITION, which takes seconds. For a one-off cleanup on an unpartitioned table, delete in batches of around 10,000 rows by key, commit each batch, pause between batches, and watch replication lag and autovacuum while the job runs.
ON CONFLICT or MERGE?
For concurrent single-row upserts from application code in PostgreSQL I choose INSERT ... ON CONFLICT. It uses the unique index to arbitrate, so two sessions inserting the same key cannot both insert; one inserts and the other updates, with no unique violation. MERGE is the SQL standard and more expressive: it can insert, update and delete in one statement based on a join with a source, which suits batch synchronisation from a staging table. But in PostgreSQL MERGE does not guarantee race-free behaviour under concurrent inserts and can raise unique violations, so callers need retries. Rule of thumb: ON CONFLICT for application upserts, MERGE for controlled batch jobs.
How do you make data changes safe in production operations?
I treat production data changes as deployments, not ad-hoc typing. Each change is a scripted, reviewed file linked to a ticket, ideally run by a pipeline rather than a person. The script first runs a SELECT with the same predicate to establish the expected count, then executes inside an explicit transaction with RETURNING or a row-count check, and commits only if the count matches. Humans have read-only access by default, with time-boxed elevated roles. Point-in-time recovery is configured and regularly tested so mistakes are recoverable. Large changes are batched, and routine retention uses partition drops. Together these turn a missing WHERE clause into a rolled-back transaction instead of an outage.