Foundation · Prepare
SQL & Transactions for Spring Developers
Model tasks, query joins, inspect plans, and make completion plus its audit event one PostgreSQL transaction.
Primary source: PostgreSQL 17 Documentation: Concurrency Control & Isolation Levels
Lesson 7 of 7
1d streakThe Architectural Question
ORM annotations translate to SQL; developers who cannot read that SQL cannot debug slow queries, deadlocks, or lost updates.
What this guide proves
- Tables, joins, and keys
- INSERT, UPDATE, DELETE
- ACID transactions
A relational schema stores rules as well as data
A relational database organizes data into tables whose rows share declared columns and types. The schema is an executable contract: primary keys establish identity, foreign keys preserve relationships, unique constraints prevent duplicates, checks reject invalid values, and nullability makes required data explicit. Application validation improves feedback, but database constraints protect the data from every writer, including scripts and future services.
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL CHECK (length(trim(title)) > 0),
status text NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('OPEN', 'COMPLETED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE TABLE task_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id bigint NOT NULL REFERENCES tasks(id),
event_type text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now()
);PostgreSQL is the canonical database for Module 0 and the later persistence path. timestamptz stores an instant and displays it using a session time zone. The event table references a valid task, while the status check prevents arbitrary strings. These rules exist before JPA; later annotations and migrations must represent the same database contract rather than invent a second one.
Active Recall Checkpoint
If the Java request validator rejects blank titles, is the database CHECK constraint redundant?
Show answer & explanation
No. The constraint protects the invariant from every database writer and from application defects. Boundary validation and storage integrity serve different failure points.
Read and change rows deliberately
SELECT chooses columns and rows; WHERE filters before projection is returned; ORDER BY makes output order explicit. Without ordering, row order is not a contract. Parameterize values in application code instead of concatenating SQL, both for correctness and to prevent injection. During the lesson, literal values make the query visible, but production Java should bind parameters.
INSERT INTO tasks(title)
VALUES ('Read Module 0')
RETURNING id, title, status, version;
SELECT id, title, status, created_at
FROM tasks
WHERE status = 'OPEN'
ORDER BY created_at DESC, id DESC
LIMIT 20;
UPDATE tasks
SET title = 'Finish Module 0',
version = version + 1
WHERE id = 42
RETURNING *;INSERT ... RETURNING retrieves database-generated values without a separate lookup. An UPDATE or DELETE should normally include a precise predicate; checking its affected-row count distinguishes “changed one row” from “matched nothing.” A missing predicate can modify an entire table, which is why the Watch exercise should make that risk observable inside a disposable database.
Preview destructive statements
Join related facts instead of duplicating them
A foreign key stores the relationship between facts. A join reconstructs a result across those tables. The database does not automatically include related rows merely because a foreign key exists; the query chooses the join type and condition. An inner join keeps matching pairs, while a left join preserves every row from the left even when no event exists.
SELECT
t.id,
t.title,
t.status,
count(e.id) AS event_count,
max(e.occurred_at) AS last_event_at
FROM tasks t
LEFT JOIN task_events e ON e.task_id = t.id
GROUP BY t.id, t.title, t.status
ORDER BY t.id;Aggregation changes the result’s grain. The query above returns one row per task, so every selected non-aggregated column participates in the grouping. Before writing a join, state the desired grain in words: one row per task, one row per event, or one row per status. Many incorrect reports are grain mistakes rather than syntax mistakes.
Active Recall Checkpoint
Why use a LEFT JOIN rather than INNER JOIN for a task summary?
Show answer & explanation
A new task may have no events yet. LEFT JOIN preserves that task and produces an event count of zero; INNER JOIN would hide it.
Use indexes to support measured access paths
An index is an additional data structure that can reduce the work required to find or order rows, at the cost of storage and extra work on writes. Primary keys and unique constraints normally create supporting indexes, but foreign-key columns and common filters may need deliberate indexes. “Add indexes everywhere” is not a strategy; inspect representative queries and data volume.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title
FROM tasks
WHERE status = 'OPEN'
ORDER BY created_at DESC
LIMIT 20;
CREATE INDEX tasks_open_created_idx
ON tasks (created_at DESC)
WHERE status = 'OPEN';EXPLAIN shows the planner’s chosen strategy; EXPLAIN ANALYZE actually executes the query and reports measured timing and row counts. Use it carefully for writes because the statement still executes unless you protect it with a rollback. Tiny tables often prefer a sequential scan even when an index exists, so interpret the estimate and observed rows instead of treating “Index Scan” as the only acceptable output.
A transaction defines one atomic state change
Completing a task and recording its audit event form one business operation. If one statement commits without the other, the database tells two incompatible stories. A transaction groups the statements so commit makes all changes visible as a unit and rollback makes none of them durable. Atomicity does not mean concurrent work never conflicts; isolation controls which intermediate effects transactions can observe.
BEGIN;
UPDATE tasks
SET status = 'COMPLETED',
completed_at = now(),
version = version + 1
WHERE id = 42
AND status = 'OPEN'
AND version = 3
RETURNING id, version;
-- Insert only after the UPDATE returned exactly one row.
INSERT INTO task_events(task_id, event_type)
VALUES (42, 'TASK_COMPLETED');
COMMIT;The status and version predicates turn the update into a guarded transition. Zero affected rows can mean the task is missing, already complete, or stale; the application may issue a focused read to distinguish those public outcomes. If the event insert fails, rollback must undo the task update. Spring’s @Transactional will later define this boundary declaratively, but PostgreSQL still provides the guarantee.
| ACID property | Question for this operation |
|---|---|
| Atomicity | Can task and event become durable separately? |
| Consistency | Do constraints hold before and after commit? |
| Isolation | What can another transaction observe or change concurrently? |
| Durability | Does committed state survive a process restart? |
Active Recall Checkpoint
The UPDATE returns zero rows. Should the event INSERT still run?
Show answer & explanation
No. No guarded transition occurred, so recording TASK_COMPLETED would create a false audit event. Roll back or skip the insert and translate the actual outcome.
Break it deliberately: Race conditions & the concurrency lab
Without explicit atomic guards or row locks, concurrent read-modify-write cycles silently destroy data via the Lost Update Anomaly.
-- Thread 1 reads task (version = 1)
SELECT id, title, version FROM tasks WHERE id = 42;
-- Thread 2 reads the same task concurrently (version = 1)
SELECT id, title, version FROM tasks WHERE id = 42;
-- Thread 1 modifies title and writes back:
UPDATE tasks SET title = 'Title A', version = 2 WHERE id = 42;
-- Thread 2 overwrites Thread 1 without checking version:
UPDATE tasks SET title = 'Title B', version = 2 WHERE id = 42;
-- Thread 1's write is silently lost forever!Why Application-Layer Locks Fail
synchronized or ReentrantLock) only protects a single JVM instance. In any distributed environment with multiple container replicas, only database-level atomic guards or database row locks (SELECT FOR UPDATE) can prevent lost updates.To prevent this, production applications use Optimistic Locking (UPDATE tasks SET ... WHERE id = 42 AND version = 1). If the version has advanced, the update returns 0 affected rows, triggering a Spring Data OptimisticLockingFailureException and prompting a safe retry.
Now verify this in practice: open two psql sessions against the same disposable PostgreSQL database. In both sessions, begin a transaction and read the same open task. Let session A perform the guarded update but pause before commit. Run the same update in session B and observe its wait. After A commits, B rechecks the predicate and affects zero rows. The final status is valid and only one event should exist.
-- Session A
BEGIN;
UPDATE tasks
SET status = 'COMPLETED', version = version + 1
WHERE id = 42 AND status = 'OPEN' AND version = 0;
-- pause, then COMMIT
-- Session B, while A is paused
BEGIN;
UPDATE tasks
SET status = 'COMPLETED', version = version + 1
WHERE id = 42 AND status = 'OPEN' AND version = 0;
-- after A commits: UPDATE 0
ROLLBACK;Session A: UPDATE 1, COMMIT\nSession B: UPDATE 0, ROLLBACK
PostgreSQL’s default Read Committed level begins each statement with a fresh command snapshot and rechecks an update predicate after waiting on a concurrent updater. Stronger levels can reject a transaction with a serialization failure, which the application must retry as a whole. Isolation is therefore a contract involving both the database and the retry behavior of its caller.
Active Recall Checkpoint
Why is optimistic locking preferred over pessimistic locking in high-throughput web applications?
Show answer & explanation
Optimistic locking avoids holding database row locks during network round-trips and business logic execution. It validates consistency at the exact moment of write, maximizing read concurrency.
Completion evidence
Guided Hands-On Lab
Code Walkthrough
Create users and tasks tables, join them, then move a row between statuses inside a transaction and roll it back deliberately.
-- 1. Schema Definition with Database Constraints
CREATE TABLE IF NOT EXISTS tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL CHECK (length(trim(title)) > 0),
status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN', 'COMPLETED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE TABLE IF NOT EXISTS task_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id bigint NOT NULL REFERENCES tasks(id),
event_type text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now()
);
-- 2. Atomic Guarded State Transition using Common Table Expression (CTE)
BEGIN;
WITH completed AS (
UPDATE tasks
SET status = 'COMPLETED',
completed_at = now(),
version = version + 1
WHERE id = 42
AND status = 'OPEN'
AND version = 0 -- Optimistic lock guard: must match read version
RETURNING id
)
INSERT INTO task_events(task_id, event_type)
SELECT id, 'TASK_COMPLETED'
FROM completed;
-- If completed matched 0 rows (stale update), 0 event rows are inserted.
COMMIT;
-- 3. Concurrency Proof: Second updater affecting 0 rows
-- Concurrent Session B running the same UPDATE:
-- UPDATE tasks SET status = 'COMPLETED', version = version + 1 WHERE id = 42 AND version = 0;
-- Result: UPDATE 0 (Application detects race condition and triggers rollback / retry)Guided LabBuild & Verify Step by Step
PostgreSQL 17 with tasks and task_events tables, a guarded task update, and two psql sessions open for the race.
- 01
Put the rules in the schema
Add primary keys, the task_events foreign key, nonblank-title and valid-status checks, and a version column.
Check: Invalid status and blank title writes are rejected by PostgreSQL without application code.
- 02
Make completion atomic
Run the guarded task UPDATE and task_events INSERT in one transaction; treat an update count of zero as missing, stale, or already complete.
Check: Either both rows change or neither does.
- 03
Force a race
From two sessions, complete the same version of the same task before either session commits.
Check: One session commits, the other reports UPDATE 0, and exactly one completion event exists.
- 04
Look at the plan
Run EXPLAIN ANALYZE for the open-task query before and after adding its partial index.
Check: The measured plan, not a guess, decides whether the index stays.
Command
psql "$DATABASE_URL" -f schema.sql psql "$DATABASE_URL" -v task_id=42 -v expected_version=0 -f complete_task.sql
Expected Output
BEGIN UPDATE 1 INSERT 0 1 COMMIT
[INFO] Scanning for projects... [INFO] -------------------< com.example:order-service >------------------- [INFO] Building order-service 0.0.1-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-surefire-plugin:3.2.5:test (default-test) @ order-service --- [INFO] Running com.example.OrderServiceTest [ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.284 s <<< FAILURE! -- in com.example.OrderServiceTest [ERROR] com.example.OrderServiceTest.testOrderCreation -- Time elapsed: 0.041 s <<< ERROR! java.lang.NullPointerException: Cannot invoke "com.example.OrderRepository.save(Object)" because "this.repository" is null at com.example.OrderService.createOrder(OrderService.java:18) at com.example.OrderServiceTest.testOrderCreation(OrderServiceTest.java:24) [INFO] Results: [INFO] [ERROR] Errors: [ERROR] OrderServiceTest.testOrderCreation:24 » NullPointer Cannot invoke "com.example... [INFO] [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test on project order-service: There are test failures. [ERROR] -> [Help 1] [ERROR] BUILD FAILURE
Synthesis & Production Verification
Deliberate Failure Condition
- Break: Update two related tables without a transaction and fail halfway
- Observe: Half-applied data that no single query explains
- Repair: A transaction groups statements into one all-or-nothing unit—the same guarantee @Transactional will provide declaratively.
Production Acceptance Criteria
- Query across joins with filters and ordering
- Mutate data with INSERT, UPDATE, and DELETE confidently
- Demonstrate commit and rollback around multi-statement work
Key Takeaway
A transaction makes multi-statement writes all-or-nothing, and every UPDATE or DELETE needs a WHERE clause you can defend. JPA will generate SQL later, but the database still enforces these contracts.
Architectural & Technical Review
Core Interview Questions
Q01How are transaction atomicity and isolation different?Show answer
Atomicity decides whether the transaction’s writes become durable together or not at all. Isolation governs what concurrent transactions can observe and how their operations interact. A transaction can be atomic yet still suffer a lost update under insufficient concurrency control.
Q02Why can an index improve reads but hurt writes, and why might PostgreSQL ignore it?Show answer
An index offers an additional access path but consumes storage and must be maintained on writes. PostgreSQL may prefer a sequential scan when the table is small or the predicate matches many rows. Use representative data and EXPLAIN ANALYZE instead of assuming every index helps.
Q03How does optimistic concurrency control prevent a lost update?Show answer
The UPDATE includes the version previously read, such as WHERE id = ? AND version = ?, and increments the version. Exactly one affected row means success; zero means the row is missing or stale. The application must handle that conflict instead of silently overwriting newer state.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Updating related tables without a transaction — A mid-sequence failure leaves half-applied data no single query explains. Wrap multi-statement work explicitly.
- Pitfall: Trusting the ORM without reading its SQL — Generated queries are still SQL — developers who cannot read joins and filters cannot debug slow queries or lost updates.
- Pitfall: Treating primary keys as an implementation detail — Identity drives relationships, caching, and API design. Choose stable identifiers deliberately instead of accepting defaults blindly.
Module Certification Quiz
Prove Module Mastery
Comprehensive question set covering all lessons in this module.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources