A nightly report runs a dozen separate SELECT statements inside one transaction at READ COMMITTED, and its section subtotals do not add up to its grand total. What is the likely cause, and what are your options for fixing it?
answer
- subtotals ≠ total → statements read different instants
- RC takes a fresh view per statement; BEGIN alone changes nothing
- single statement = single consistent view
- snapshot level fixes it but pins old versions → bloat
- heavy reports belong on a replica or an extract
basics
~20 sAt READ COMMITTED each statement takes a fresh view of the data, so every query in the report sees a different instant while writers keep committing. Fix it by giving the whole report one consistent view — a transaction-wide snapshot, one combined statement, or a point-in-time copy such as a replica or extract.
solid answer
~60 s**Cause.** READ COMMITTED promises only that each statement sees committed data; it takes a **new view per statement**. Query 3 and query 11 therefore run against different instants, and any commit in between lands in one but not the other. The report is internally inconsistent even though every number was true at some moment. Confirm it by checking whether the discrepancy correlates with concurrent write volume and whether re-running the report on a quiet system reproduces it. **Options, cheapest first.** 1. **Collapse the queries into one statement** — a common table expression or a single aggregate producing all sections. One statement always uses one consistent view, at any isolation level. 2. **Run the report transaction at REPEATABLE READ / snapshot isolation** so all twelve reads share the snapshot taken at the first statement. Watch the cost: a long snapshot holds back version cleanup and bloats storage. 3. **Report from a point-in-time copy** — a read replica pinned for the run, or an extract into staging. Best for heavy reports, at the cost of freshness. Re-running the same query twice in an application loop is not a fix; it just re-samples a moving target.
code
sql · 6 linesWITH base AS (
SELECT region, amount FROM orders WHERE created_at >= :from
)
SELECT region, SUM(amount) AS section_total FROM base GROUP BY region
UNION ALL
SELECT 'TOTAL', SUM(amount) FROM base;go deeper
Recognize that each statement sees a different, newer set of committed data and that the report needs one consistent view.
Explain per-statement views at READ COMMITTED and propose either one combined statement or a higher isolation level.
Diagnose it properly first, then weigh the options including replica or extract, and call out the version-retention cost of a long snapshot.
Set policy for reporting workloads — where they run, what consistency they promise, and how 'as of' semantics are communicated — rather than tuning one report's isolation level.
## Diagnosing it The symptom — parts that do not sum to the whole, or a detail listing that disagrees with a summary line — is the classic signature of statements reading different instants. Before changing anything, confirm the diagnosis: - **Does the discrepancy scale with concurrent write volume?** If the report is clean at 03:00 and wrong at 20:00, that is the tell. - **Is it reproducible on a quiescent copy of the data?** If the same input data always yields consistent output, the queries themselves are fine and the problem is concurrency, not logic. - **How long does the report take?** The exposure window is the wall-clock time between the first and last statement. A ten-minute report at a busy hour has a ten-minute window. - **Rule out the boring causes first**: different filter predicates between sections, rounding, `NULL` handling, a section that excludes a category the total includes. Concurrency is the interesting answer, not always the right one. ## Why READ COMMITTED does this READ COMMITTED makes exactly one promise about reads: you never see uncommitted data. It does **not** promise a stable view across statements. In a multiversion engine the implementation is literal — a **new snapshot is taken at the start of every statement**, so each `SELECT` sees everything committed up to that moment, including commits that happened after the previous statement in the same transaction. In a lock-based engine the shared read lock is released as soon as the statement finishes, leaving the data free to change. Crucially, wrapping the twelve queries in `BEGIN ... COMMIT` does not help at this level. The transaction gives atomicity for writes and a unit of work; it does not, by itself, give a stable read view. Many teams discover this exactly this way. One thing that *is* safe: a **single statement** is evaluated against one view from start to finish. A `SELECT` scanning for two minutes never sees half of a concurrent transaction's work. That fact is the basis for the cheapest fix. ## The fixes, with their trade-offs ### 1. One statement Rewrite the report so all sections come from one query — a common table expression computing the base set once and deriving every section and the total from it, or a single grouped aggregate the application pivots. Because it is one statement, it uses one view, and the totals cannot disagree. *Pros*: no isolation change, no long snapshot, usually faster because the base data is scanned once. *Cons*: a bigger, harder-to-read query; may need a temporary result or materialization if the base set is large; not always expressible when sections come from genuinely different sources. ### 2. A transaction-wide snapshot Run the report's transaction at REPEATABLE READ (or the engine's snapshot level). All statements then read from the snapshot established by the first statement, so the twelve queries describe one instant. *Pros*: minimal code change — a session or transaction setting. *Cons*: the snapshot must be retained for the report's whole duration, which prevents the engine from reclaiming superseded row versions **database-wide**, causing table and index bloat and, in some engines, an error if the retained history is exceeded. A ten-minute report is usually fine; an hour-long one on a write-heavy system is a known way to bloat production. Also, if the reporting transaction writes anything (a run log, a materialized result), it can now hit serialization failures and needs a retry. If you take this route, also consider explicitly declaring the transaction read-only, which lets the engine skip write-related bookkeeping and makes the intent obvious to operators. ### 3. A point-in-time copy Run the report against a read replica or an extract loaded into staging. The replica gives a consistent view without competing with the primary's write workload, and a staged extract removes the concurrency question entirely. *Pros*: the primary is untouched, the report can be as slow as it likes, and repeat runs are reproducible. *Cons*: the data is as of the copy, replication lag must be understood and stated on the report, and you now own a pipeline. ### 4. Lock the data Technically you could hold locks over everything the report reads, but for a reporting workload this blocks writers for the duration and is almost always the wrong answer. Mention it only to dismiss it. ## What does not work - **Re-running a query until two runs agree.** You are sampling a moving target; agreement is luck. - **Sorting or ordering the statements differently.** The window remains. - **Adding a transaction around the queries at READ COMMITTED.** As above, that is precisely what already failed. - **Rounding or reconciling the difference in the application.** This hides a correctness problem and makes the next investigation harder. ## Choosing For a report of a few seconds, the snapshot level is the simplest correct answer. For a long or heavy report, move it off the primary — a consistent view for an hour is a storage-retention problem, not a query problem. And whenever the whole answer can be expressed as one statement, prefer that: it is the only option that costs nothing extra and cannot be undone by someone changing a session setting later. Whichever you choose, record the intended semantics on the report itself — "as of 02:00", or "consistent snapshot of the run start" — because a report that is internally consistent but silently stale causes a different argument next quarter.
- The team wraps the twelve queries in BEGIN ... COMMIT and the totals still disagree. Why?Because the transaction alone does not change the read view. At READ COMMITTED each statement still takes a fresh view, so the queries continue to see different instants. Stability requires either a transaction-wide snapshot from a higher isolation level, or collapsing the reads into a single statement, which is evaluated against one view by definition.
- What is the operational risk of simply switching the report to REPEATABLE READ?Its snapshot must be preserved for the whole run, so the engine cannot reclaim any row version the snapshot might need — database-wide, not just for the report's tables. On a write-heavy system a long report therefore causes table and index bloat, slower scans for everyone, and in some engines an error when retained history is exceeded. Short reports are fine; long ones should move to a replica or an extract.
- How would you confirm concurrency is the cause rather than a bug in the queries?Re-run the report against a static copy of the data: if the sections and total agree there but disagree on the live system, the queries are correct and the difference comes from concurrent commits. Correlating the size of the discrepancy with write throughput during the run, and with the report's own duration, confirms it.
saying these in an interview costs you the question
- Believing that wrapping the queries in a transaction is itself enough at READ COMMITTED
- Re-running queries until two runs happen to agree
- Switching a long, heavy report to a transaction-wide snapshot on the primary without considering version retention and bloat
- Blaming rounding or the query logic without testing against static data
- Locking the reported tables to stabilize a reporting workload