ArchitectMind
  1. Home
  2. Tutorials
  3. Book 6 · Databases, APIs & Integration
  4. Volume II
  5. Data Modification
Book 6 · Volume II — SQL, Transactions, Concurrency & Database Performance · Topic 1.2

Data Modification

INSERT, UPDATE and DELETE

9 min readStage 3 · Systems8 interview questions
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. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally — MVCC and Modifications
  5. 4A. Deep Dive — Upserts and MERGE
  6. 5. APIs / Features / Syntax
  7. 6. Practical Examples
  8. 7. Common Problems & Mistakes
  9. 8. Performance & Production Considerations
  10. 9. Architecture & Design Considerations
  11. 10. Interview Questions & Answers

1Concept / Definition

Data modification language (DML) changes the contents of tables:

Data modification statements
Figure 1 — Data modification statements
StatementEffect
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/NOTHINGUpsert (PostgreSQL)
MERGE INTO t USING s ON … WHEN MATCHED/NOT MATCHED …Standard conditional insert/update/delete
… RETURNING colsReturn affected rows (generated keys, new values)
Table 1 — DML statements

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

SQL
-- 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 techniqueUse
RETURNINGGet identity values, defaults, computed columns in the same round trip
Multi-row VALUES / JDBC batchMany rows efficiently
INSERT … SELECTCopy/transform within the database
ON CONFLICT DO NOTHINGIdempotent inserts (retries, duplicate events)
COPY FROM STDINBulk loads (fastest)
Table 2 — Insert techniques

3.2 UPDATE

SQL
-- 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 patternWhy
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 countDetect conflicts and missing rows
Table 3 — Update patterns
What UPDATE really does (PostgreSQL MVCC)
Figure 2 — What UPDATE really does (PostgreSQL MVCC)

3.3 DELETE

SQL
-- 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';
Ways to remove data
Figure 3 — Ways to remove data
DELETE cautionMitigation
Missing WHERE deletes everythingCode review, sql_safe_updates-style tooling, test on copies
Huge deletes = long transactions, bloat, replication lagBatch by key range; partition drop for retention
FK checks on childrenIndex child FKs; understand CASCADE/RESTRICT
IrreversibilitySoft delete or archive when history matters
Table 4 — Deleting safely

4How It Works Internally — MVCC and Modifications

OperationPostgreSQL implementationConsequence
INSERTNew tuple with xmin = inserting transactionVisible to others after commit
UPDATENew tuple version + old version marked xmaxDead tuples; HOT if no indexed column changes and space exists
DELETETuple marked xmaxSpace reclaimed later by VACUUM
All DMLWAL records for durability/replicationBulk changes produce large WAL
ConstraintsChecked per row (or deferred)Violation aborts the statement
Table 5 — DML internals

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

SQL
-- 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());
ChoiceConcurrency behaviourBest for
INSERT … ON CONFLICTAtomic, race-free with unique constraintSingle-row/idempotent upserts from applications
MERGECan raise unique violations under concurrent insertsBatch synchronisation from staging tables
Table 6 — Upsert vs MERGE

5APIs / Features / Syntax

FeatureUse
RETURNINGGenerated keys, audit, results of changes
ON CONFLICT … DO UPDATE/NOTHINGUpserts
MERGEConditional sync
UPDATE … FROM, DELETE … USINGJoin-based modifications
COPY / CopyManagerBulk loading
JDBC addBatch/executeBatch, reWriteBatchedInserts=trueBatched DML
Hibernate jdbc.batch_size, order_inserts/updatesORM batching
Spring JdbcClient.update() → row countDetect conflicts
Table 7 — DML features
YAML
spring:
  datasource:
    url: jdbc:postgresql://db:5432/shop?reWriteBatchedInserts=true
  jpa:
    properties:
      hibernate.jdbc.batch_size: 50
      hibernate.order_inserts: true
      hibernate.order_updates: true

6Practical 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

Loading 1 million rows: round trips matter
Figure 4 — Loading 1 million rows: round trips matter
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

SQL
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 stepPurpose
Run a SELECT with the same WHERE firstKnow the expected count
Explicit transactionAbility to roll back
RETURNING / row count checkVerify before commit
Ticket + reviewerAccountability
Table 8 — Safe data-fix procedure

6.5 Worked example — batched delete for retention

SQL
-- 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;
ApproachImpact on 400 M-row table
Single DELETE … WHERE created_at < …Hours-long transaction, massive WAL, replica lag, bloat
Batched deletes with pausesControlled load; vacuum keeps up
Monthly partitions + DROP/DETACH PARTITIONSeconds; best for time-based retention
Table 9 — Retention strategies

6.6 Worked example — race-free stock decrement

JAVA
@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 correctExplanation
Condition and change in one statementNo gap between check and update
Row lock serialises concurrent reservationsSecond waits, then re-evaluates condition
CHECK (available >= 0) backs it upIntegrity even if code changes
Table 10 — Atomic conditional update

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.

SQL
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;
PropertyWhy it matters
Single statement = single transactionRows are either moved or not — never in both or neither
Batched with LIMITShort transactions, bounded WAL and locks
Re-runnableEach run moves the next batch; safe after interruption
archived_at added on the wayAudit metadata without a second pass
Table 11 — Atomic move pattern
JAVA
@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:

SQL
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 checkWith version check
Last writer wins silentlySecond writer gets a conflict
Data loss discovered by customersAgent sees current data and decides
Table 12 — Optimistic check in plain SQL (JPA `@Version` generates the same)

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

AspectNote
BatchingCuts round trips by 100–1000×
WAL volumeBulk DML → replica lag; throttle
Dead tuplesUpdates/deletes need vacuum; avoid no-op updates
HOT updatesDon't index frequently updated columns
LocksKeep modifying transactions short
Table 13 — Operational notes

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

Q1BasicConcept

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.

Q2BasicConcept

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.

Q3IntermediateTricky

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.

Q4IntermediateCode

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.

Q5Senior EngineerInternals

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.

Q6Senior EngineerScenario

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.

Q7Technical ArchitectTrade-off

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.

Q8Solution ArchitectDesign

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.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.
Keep going in Book 6

Databases, APIs & Integration

Volume I · Topic 1.1

Why Databases Exist

Database Fundamentals & Relational Data Modeling

Volume I · Topic 1.2

Database Management Systems

Database Fundamentals & Relational Data Modeling

Volume I · Topic 1.3

Database Architecture

Database Fundamentals & Relational Data Modeling

Volume II · Topic 1.1

Data Retrieval

SQL, Transactions, Concurrency & Database Performance

Volume II · Topic 1.3

Aggregation

SQL, Transactions, Concurrency & Database Performance

Volume III · Topic 1.1

JDBC Architecture

Java Persistence & Data Access Architecture

Volume III · Topic 1.2

PreparedStatement

Java Persistence & Data Access Architecture

Volume III · Topic 1.3

JDBC Transactions

Java Persistence & Data Access Architecture

Volume IV · Topic 1.1

Why NoSQL Exists

NoSQL, Caching & Polyglot Persistence

Volume IV · Topic 1.2

Categories

NoSQL, Caching & Polyglot Persistence

Volume IV · Topic 1.3

Trade-offs

NoSQL, Caching & Polyglot Persistence

Volume V · Topic 1.1

HTTP Architecture

API Design, HTTP & REST

Volume V · Topic 1.2

Methods

API Design, HTTP & REST

Volume V · Topic 1.3

Status Codes

API Design, HTTP & REST

Volume VI · Topic 1.1

Gateway Responsibilities

API Management, GraphQL & Distributed API Design

Volume VI · Topic 1.2

Gateway Patterns

API Management, GraphQL & Distributed API Design

Volume VI · Topic 1.3

Gateway Risks

API Management, GraphQL & Distributed API Design

Volume VII · Topic 1.1

Why Systems Integrate

Enterprise Integration & Messaging

Volume VII · Topic 1.2

Integration Styles

Enterprise Integration & Messaging

Volume VII · Topic 1.3

Point-to-Point Integration

Enterprise Integration & Messaging

Volume VIII · Topic 1.1

Integration Boundaries

Integration Architecture & Data Consistency

Volume VIII · Topic 1.2

Synchronous vs Asynchronous

Integration Architecture & Data Consistency

Volume VIII · Topic 1.3

Coupling

Integration Architecture & Data Consistency