skip to content

You must import 100,000 rows and a small unknown number of them will violate constraints. Describe how you would structure the transaction scope so that bad rows are rejected and recorded while good rows still land, and explain the tradeoffs of the approach you choose.

level: seniorimportance: should knowfreq 36%

answer

  1. Requirement is per-row atomicity, not per-file
  2. Chunk + commit per chunk + resumable offset
  3. Savepoint before the risky row, or replay dirty chunks row-wise
  4. Rejects table written outside the rolled-back section
  5. Staging table + set-based filter for big volumes

basics

~20 s

Do not run the import as one transaction. Process in chunks with a commit per chunk; inside a chunk, take a savepoint per item (or re-run a failed chunk item-by-item), roll back to the savepoint on error, record the row as rejected, and continue. That bounds lock hold time, redo cost and rollback loss.

solid answer

~60 s

Three decisions. **Chunking.** One transaction for 100k rows means a huge rollback on any unhandled failure, long lock hold time, and no visible progress. Commit every N rows (typically 500–5000, tuned by measurement) so each chunk is a small unit of redo, and record the last committed offset so a crashed run resumes rather than restarts. **Per-item recovery.** After a statement error, many engines will not accept further work in that transaction until it is unwound — so a single bad row kills the chunk unless you took a savepoint before the row. The efficient pattern is optimistic: run the chunk without per-row savepoints; if it fails, roll back the chunk and replay it row-by-row with a savepoint each, isolating and rejecting only the offenders. When failures are rare, this pays the savepoint cost only on bad chunks. **Rejects as data.** Write rejected rows with their error into a reject/dead-letter table so the run is reportable and re-runnable. Also validate cheaply before writing, and make the import idempotent — a natural key with an upsert — so a resumed run does not duplicate.

code

text · 12 lines
text
for chunk in chunks(rows, N):
    begin
      try: insert all rows in chunk; commit; continue
      except: rollback
    # dirty chunk: isolate offenders
    begin
      for row in chunk:
          savepoint sp
          try: insert row
          except: rollback to savepoint sp; record reject(row, error)
      commit
    record progress(offset)

go deeper

for a junior

Say the import should be chunked with a commit per chunk rather than one giant transaction, and that bad rows get skipped and logged.

for a middle

Explain savepoint-per-row recovery, why a failed statement blocks further work in the transaction, and the chunk-size tradeoff.

for a senior

Add the optimistic chunk with row-wise fallback, reject tables that survive the rollback, resumability and idempotent upserts, and abort thresholds.

for a principal

Weigh staging-plus-set-based loading against row-by-row recovery, and decide what atomicity the business truly needs before choosing the transaction shape.

## Why one big transaction is the wrong default The instinct is "the import must be atomic", so wrap all 100,000 rows in one transaction. That buys a guarantee nobody asked for and costs a lot: - **All-or-nothing failure.** Row 99,998 violating a constraint destroys hours of work. - **Lock hold time and cleanup pressure.** Every lock is held for the whole run, and every version written stays as garbage the engine must reclaim afterwards. - **A gigantic rollback**, which is often slower than the insert itself and cannot be interrupted. - **No progress signal**, so nothing is resumable and nothing is observable. The requirement here is not "all rows or none"; it is "every good row lands, every bad row is reported". That is a *per-item* atomicity requirement, and the scope should match it. ## The structure that works **1. Validate before writing what you cheaply can.** Format, required fields, referential lookups that can be batched. Rejecting a row before it reaches an INSERT is far cheaper than writing it, hitting a constraint, and rewinding. Constraint checks that only the database can do — unique keys, foreign keys under concurrency, exclusion rules — necessarily fail at write time, which is what the recovery machinery is for. **2. Chunk and commit.** Process in chunks of N rows, one transaction per chunk. N trades round-trip and commit overhead (small N) against redo cost and lock hold time (large N); a few hundred to a few thousand is typical, and it should be measured, not guessed. Persist the last successfully committed offset or a per-row imported marker so a killed run resumes. Note the consequence honestly: mid-run, other sessions can see a partially imported dataset. If that is unacceptable, import into a staging table and make the final switch a single small transaction — that is how you keep atomicity without holding one transaction across the whole load. **3. Recover inside the chunk with savepoints.** The mechanism matters: after a statement error, engines commonly put the transaction into a state where every subsequent statement is refused until it is rolled back — to a savepoint or entirely. So without a savepoint taken before the failing row, a single bad row costs the whole chunk. Two strategies: - *Savepoint per row*: set a savepoint before each row, roll back to it on error, log the reject, continue. Simple and precise, but you pay the savepoint cost on all 100,000 rows. - *Optimistic chunk with fallback* (usually better): run the chunk with no per-row savepoints; if it fails, roll back the chunk and replay those N rows individually with a savepoint each. Clean chunks cost nothing extra; only dirty chunks pay for isolation. With rare failures this is markedly faster. **4. Make rejects first-class.** Insert each rejected row plus its error into a reject table. Two subtleties: write it in a way that survives — either after the rollback to the savepoint (inside the still-open chunk transaction) or in a separate connection — because anything written inside the rolled-back section disappears with it. Rejects then become a report, a re-run input, and the basis for an abort threshold: if more than X% of rows fail, stop the run, since that usually signals a bad source file rather than bad rows. **5. Make the whole import idempotent.** Resumption and retries mean rows can be attempted twice. Import against a natural or source key with an upsert (insert-or-update on conflict) so a replayed chunk converges instead of duplicating. This also lets you fix and re-run rejects without a cleanup step. ## Alternatives worth naming - **Bulk-load into a staging table with no constraints**, then move data with set-based statements that filter out the invalid rows by joining against the constraint conditions. This turns row-by-row error handling into two SQL statements and is dramatically faster at large volumes: one INSERT ... SELECT for the valid rows, one for the rejects. The cost is expressing every rule as a predicate. - **Conflict-tolerant statements** (insert-ignore-on-conflict style) handle the single most common failure — duplicate keys — with no savepoints at all, but they skip silently unless you capture what was skipped. - **Deferred constraint checking** can move some failures to commit time, which is usually the *opposite* of what you want here: it converts a per-row failure into a whole-transaction failure. ## The judgment being tested The interviewer wants to hear that transaction scope is a design choice driven by the atomicity the business actually needs, that you know a failed statement can poison a transaction until it is unwound, and that savepoints are the tool for continuing after an expected error — while chunked commits, not savepoints, are what bounds lock hold time and rollback cost.

  • Why not simply run every row in its own transaction and skip savepoints entirely?
    It works and is simple, but you pay a commit per row — each with its own log flush and round trip — which is typically an order of magnitude slower at 100,000 rows. It also gives no batching benefit and makes progress tracking as expensive as the work. Chunked commits with in-chunk savepoint recovery get the same per-row rejection behaviour at a fraction of the cost.
  • The business insists the import must be all-or-nothing. How do you satisfy that without one enormous transaction?
    Load into a staging table over many small committed chunks, validate there, then make the cutover a single short transaction — an INSERT ... SELECT of the validated rows, or a partition/table swap where the engine supports it. The heavy work happens outside any long-lived transaction, and the atomic step touches metadata or a set-based statement that runs quickly. If the target must remain readable throughout, the swap approach also avoids exposing a half-imported state.

saying these in an interview costs you the question

  • Wrapping the entire 100,000-row import in one transaction for 'atomicity' nobody required
  • Not knowing that a failed statement can leave the transaction refusing further work until it is unwound
  • Writing reject records inside the section that then gets rolled back, so the evidence disappears
  • Believing savepoints reduce lock hold time or make the long transaction safe
  • Ignoring resumability and idempotency, so a killed run must start over or creates duplicates
  • Reaching for row-by-row processing when a set-based staging load would be far faster

context