cswork / libraryOpen practice workspace

Interview guides · Revolut

Revolut Software Engineer Interview Guide

Revolut Software Engineer interview guide: the four reported rounds, concurrency and PostgreSQL theory questions, banking system design, and a 7-day plan.

The 60-second brief

Revolut runs banking, trading, crypto and payment services. Software Engineers there are reported to work on core banking features, risk and fraud detection pipelines, distributed ledgers and payment gateways. Reported backends are mostly Java, Kotlin and Scala on transactional databases such as PostgreSQL. The reported interview questions follow that stack closely: thread-safe money movement, transaction isolation and locking, and designs that must stay correct when a dependency fails.

This guide covers the four stages candidates report for the Revolut Software Engineer role (Initial Screening, Hands-on Technical Validation, Theory Questions, System Design Round) and the four question categories behind them: concurrency and multi-threading, database mechanics and SQL, system architecture and design patterns, and coding fundamentals with test-driven development. The practice material leans toward backend correctness problems, such as idempotent writes, lock contention and ledger design, because that is what the reported questions ask for.

Focus areas

  • Reconcile the ledger against processor settlement files
  • Make every money-moving endpoint idempotent by key
  • Model money movement as balanced double-entry postings

Overview

A Software Engineer at Revolut builds backend systems for banking, trading, crypto and payment products. Candidate reports put the work in core banking features, risk and fraud detection pipelines, distributed ledgers and payment gateways. The reported stack is Java, Kotlin and Scala backends on transactional databases such as PostgreSQL. The reported questions focus on thread safety, ledger correctness and data isolation.

The reported technical questions lean toward practical backend problems rather than abstract puzzles. Coding examples include an in-memory banking service that rejects invalid transactions, a thread-safe money transfer, and a load balancer with Random and Round Robin selection. The reported database questions go deep on PostgreSQL isolation levels, optimistic versus pessimistic locking, and index choice. The reported design questions are about banking systems: booking against unreliable third-party APIs, distributed transactions, and reliable event publishing.

Reported rounds expect working code, tests and modular design, not pseudocode. Aim your preparation there: finished, tested solutions to money-movement problems, and theory answers that name the exact anomaly or failure a mechanism prevents.

The Revolut interview process

Revolut candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.

Initial Screening

This stage is only described as an initial screening to assess candidate fit, so the reports do not say what it covers. Prepare for the two things a screen can end on. The first is logistics that surface late: start date, notice period, location and work authorisation. The second is a clear, short account of your backend experience. The later stages are described as live coding, theory questions on databases and concurrency, and banking-domain design, so use this call to confirm which language you will code in and what the live coding environment looks like. The hands-on stage is described as coding in your own IDE, and knowing that early leaves time to get it ready.

What to demonstrate

  • Whether your background and the role's backend focus line up. Be ready to name the languages, databases and concurrency work you have actually shipped.
  • Whether hard constraints such as start date, notice period, location and authorisation are compatible before technical rounds are scheduled

How to prepare

  • Write a short summary of one backend system you built where correctness under concurrency or failure mattered. Cover what it did, what could go wrong, and what you did to prevent it.
  • Ask the recruiter which language you can use for live coding, whether you code in your own IDE, and whether tests are expected. Write down the answers.
  • List your constraints and your compensation range as one-line facts before the call, so you are not working them out live.

Hands-on Technical Validation

This stage is described as live coding on real-world backend scenarios, writing code in your own IDE. The sources do not say which questions are asked here, but the reported coding-category questions are the right practice set for it: an in-memory banking service with deposits, withdrawals and transfers that rejects invalid transactions atomically, a Minesweeper-style board generator, a custom Set built without Java collections, hash tables or trees, and a load balancer with Random and Round Robin selection. The source's tip for live rounds is complete, working code with tests (TDD) and modular design, not pseudocode. If the interviewer asks whether you want to add more validations or tests, treat it as a prompt to go looking for edge cases. Avoid two time sinks: coding before a failing test exists, and making random fixes to a failing case you have not isolated.

What to demonstrate

  • Whether the code runs and is covered by unit tests you wrote as you went, not added at the end
  • Whether the design stays modular, with the domain (accounts, transactions, balances) kept apart from input handling
  • Whether invalid inputs are rejected explicitly and atomically, such as a negative amount, overdraft, unknown account or transfer to the same account
  • Whether randomness is testable, such as a seeded or injected random source so the output is deterministic under test

How to prepare

  • Before the round, configure your IDE with a test runner and a project template that compiles and runs one passing test, so setup costs you nothing
  • Build the in-memory banking service test-first: write a failing test for each rejection rule before the code that enforces it, and keep amounts in BigDecimal or integer minor units
  • For the mine grid, inject the random source and assert invariants (exactly k mines, every count equals its mined neighbours) rather than exact layouts. Place k mines with selection sampling so no extra array is needed.
  • Rehearse a thread-safe Round Robin selector using an AtomicInteger counter and Math.floorMod, so the index stays valid after the counter overflows

Theory Questions

This stage is described as targeted theory questions on databases and concurrency. The sources do not say which questions belong to it, so prepare from the two reported categories that match. The reported database questions cover PostgreSQL isolation levels (Read Committed, Repeatable Read, Serializable) and the anomalies each allows, optimistic versus pessimistic locking, B-Tree versus BRIN indexes on high-volume ledger tables, and sharding, partitioning and replication; the source also lists MVCC as an area to be ready on. The reported concurrency questions cover Java thread safety beyond global synchronized blocks, race conditions on shared caches, and deadlock-free transfers, and each can be answered as theory as well as code. Short answers that name a mechanism without its failure mode do not hold up to follow-up questions. For each mechanism, say what it prevents, what it still allows, and what it costs.

What to demonstrate

  • Whether you can map each PostgreSQL isolation level to the anomalies it prevents and allows, including that its Repeatable Read is snapshot isolation that still permits write skew, and that Serializable needs a retry on SQLSTATE 40001
  • Whether you can say when SELECT ... FOR UPDATE beats a version-column optimistic check, and what each does under high contention
  • Whether you know the JVM primitives (synchronized, ReentrantLock, ReadWriteLock, atomic variables, concurrent collections) and when each is the cheapest correct choice
  • Whether index answers are tied to access patterns, for example BRIN relies on physical order matching the column, such as an append-only timestamp

How to prepare

  • Build a grid with isolation levels as rows and anomalies (dirty read, non-repeatable read, phantom, serialization anomaly) as columns, fill it in for PostgreSQL, and explain each cell aloud
  • Reproduce a lost update in two psql sessions under Read Committed with SELECT then UPDATE, then fix it three ways: an atomic conditional UPDATE, SELECT ... FOR UPDATE, and a version column
  • Write a two-account transfer that deadlocks with naive locking, then fix it by locking in ascending account id order. Be ready to explain tryLock with a timeout as the alternative.
  • Explain B-Tree versus BRIN for a ledger table in three sentences: size, which queries each serves, and why BRIN cannot back a uniqueness constraint

System Design Round

This round is described as focused on real-world banking domain challenges rather than abstract concepts. The sources do not tie specific questions to it, but the reported design-category questions are the practice set: an apartment booking system that integrates with unreliable third-party APIs without double bookings, a URL shortener with distributed caching and high availability, a card ordering feature, a high-throughput ledger with strict ordering and idempotency, multi-region banking data synchronisation, and conceptual questions on SAGA versus Two-Phase Commit, the Transactional Outbox with Kafka, and DDD, CQRS and Event Sourcing for a ledger. The same themes recur: every external call can fail or repeat, so correctness has to come from idempotency keys, local transactions and compensation rather than hoping the call succeeds once.

What to demonstrate

  • Whether you pin down the invariant first, for example no double booking or no double spend, and show where in the design it is enforced
  • Whether failures of third-party calls are handled explicitly: timeouts, retries with idempotency keys, compensating actions and reconciliation
  • Whether you can compare SAGA and 2PC, and explain why an outbox row written in the same local transaction avoids the dual-write problem
  • Whether trade-offs are stated and justified, for example consistency versus availability across regions or read models split from write models under CQRS

How to prepare

  • Walk the apartment booking design end to end: local reservation hold, idempotent call to the partner API, confirmation or compensation, and a reconciliation job for unknown outcomes
  • Explain the Transactional Outbox from write to consumer: business row and outbox row in one transaction, a relay that publishes to Kafka, at-least-once delivery, consumer deduplication by event id
  • Compare an orchestrated saga with 2PC on the same transfer, covering who holds locks, what happens when the coordinator dies, and what a compensation looks like
  • Work through the drill 'Raise the write ceiling on one hot clearing account' to practise stating a throughput ceiling as a number before proposing a design

Where candidates lose points

Writing the implementation first and adding tests at the end, or not at all

Reported live rounds expect test coverage written TDD-style. Start with one failing test for the simplest rule, make it pass, then add a failing test for each rejection rule (negative amount, overdraft, unknown account, self-transfer) before the code that enforces it. For random output, inject a seeded source and assert invariants rather than exact values.

Naming an isolation level without the anomalies it still allows

PostgreSQL defaults to Read Committed, where every statement takes a fresh snapshot, so a SELECT-then-UPDATE on a balance loses updates under concurrency. Its Repeatable Read is snapshot isolation: it aborts the conflicting writer with SQLSTATE 40001 but still permits write skew across two rows, which only Serializable closes. Both stronger levels need a bounded retry loop on 40001. Say which anomaly each level stops and which it does not.

Locking the two accounts of a transfer in call order

Transfers A to B and B to A running together each hold one lock and wait for the other. Always acquire locks in a fixed order, such as ascending account id, in Java code and in SQL alike (on the database side, PostgreSQL reports the deadlock as SQLSTATE 40P01). Mention tryLock with a timeout as the alternative and what it costs: retries and possible starvation.

Holding money in double or a hard-coded two-decimal format

Binary floating point cannot represent 0.1 exactly, so repeated arithmetic drifts and equality checks fail. Use BigDecimal with an explicit scale and rounding mode, or integer minor units with the currency's exponent carried alongside it, since ISO 4217 exponents are 0 for JPY, 2 for most currencies and 3 for BHD or KWD. State the one place rounding happens.

Designing as if a third-party or cross-service call happens exactly once

In the booking, ledger and outbox questions, assume every external call can time out with an unknown result and every message can arrive twice. Show the idempotency key, the local transaction that records intent, the compensation path, and the reconciliation job that resolves unknown outcomes. Name where the no-double-booking or no-double-spend invariant is enforced.

Questions & practice

How do you structure unit tests using TDD to test functions that generate non-deterministic or random output?

Medium · data structures and algorithms

Approach

  1. Choose the data structure from the access pattern, not from familiarity.
  2. State the target complexity and say which constraint rules the naive version out.
  3. Name the brute-force solution and its complexity before improving on it.

Follow-up

  • Which test case would catch an off-by-one here?
  • How does this change if the input no longer fits in memory?

How do you choose between using primitive data types versus arbitrary-precision types like BigDecimal when handling multi-currency balances?

Medium · data structures and algorithms

Approach

  1. Name the brute-force solution and its complexity before improving on it.
  2. State the target complexity and say which constraint rules the naive version out.
  3. Walk one small example through your approach before writing the whole thing.

Follow-up

  • How does this change if the input no longer fits in memory?
  • What is the worst case, and how likely is it on real data?

Write a program to generate a grid matrix filled with randomly placed mines while maintaining an optimal space complexity.

Medium · data structures and algorithms

Approach

  1. State the target complexity and say which constraint rules the naive version out.
  2. Walk one small example through your approach before writing the whole thing.
  3. Restate the input: its shape, its size, and what is guaranteed about it.

Follow-up

  • Which test case would catch an off-by-one here?
  • What is the worst case, and how likely is it on real data?

What mechanisms would you use to handle race conditions when writing to a shared cache or state across multiple worker threads?

Medium · languages, concurrency and fundamentals

Approach

  1. Name what is shared across threads and what owns each piece of state.
  2. Say what the runtime actually does before reasoning about the code.
  3. Reach for the cheapest primitive that closes the race, not the broadest lock.

Follow-up

  • How would you prove the race exists rather than suspect it?
  • Where could this allocate more than you expect?

How do you ensure thread safety in Java without relying entirely on global synchronized blocks?

Medium · languages, concurrency and fundamentals

Approach

  1. Say what the runtime actually does before reasoning about the code.
  2. Reach for the cheapest primitive that closes the race, not the broadest lock.
  3. Identify the window where an invariant is briefly untrue.

Follow-up

  • Where could this allocate more than you expect?
  • What happens if two callers reach this at the same time?

Compute peak held exposure from overlapping authorisation holds

Medium · sweep line · intervals · authorisation holds

An account has up to 2 million authorisations on one business date: (auth_id, amount_minor, created_at, expires_at) with the hold live over the half-open interval [created_at, expires_at), plus capture events (auth_id, captured_minor, captured_at) that reduce the hold at captured_at, and explicit reversals that drop the remainder to zero. Timestamps are microsecond-precision timestamptz. Return the maximum total held amount across the day and the earliest instant it is reached, with complexity. Then say what changes if the deliverable is the peak per minute instead.

Approach

  1. Expand each authorisation into signed delta events rather than reasoning about intervals: +amount at created_at, -remaining at expires_at, -captured_minor at each captured_at, -remaining at reversed_at. The problem collapses to a running sum over a sorted event list.
  2. Sort the 2n to 4n events by (timestamp, sign) with negative deltas ordered first on a tie. The half-open convention forces that: at exactly expires_at the hold is already gone, so a - must land before a + at the same instant or you report a one-microsecond peak that never existed. O(n log n) time, O(n) space.
  3. Sweep once, tracking running, best and best_at, taking the first instant that attains the maximum. Say out loud which tie rule you are using — 'the peak' is ambiguous when the same level is reached twice, and the caller needs to know which instant they are being handed.
  4. If timestamps are bucketed (1,440 minute buckets for the per-minute variant), drop the sort for a difference array: add the delta at the start bucket, subtract at the end bucket, prefix-sum once. O(n + B) time and O(B) space, strictly better, at the cost of answering only at bucket resolution.
  5. Assert the invariant during the sweep: running must never go negative. A negative total means a capture exceeded its authorisation, which is an invariant violation upstream rather than a sweep bug — fail loudly instead of clamping at zero and reporting a plausible number.
  6. Handle carry-in: a hold created before the window contributes its remaining amount as the sweep's initial value, not as a + event inside the window. Omitting that is the off-by-a-day that makes the first minute of every day look artificially quiet.

Worked solution (25 min)

  1. Write the event expansion and the comparator first; the comparator is the part that is wrong in most first attempts.
  2. Fixture A: two holds of 10,000 minor units where the first's expires_at equals the second's created_at.
  3. Fixture B: one hold of 10,000 with a partial capture of 4,000 at t+1 and expiry at t+2, so the held series is 10,000 then 6,000 then 0.
  4. Fixture C: a hold opened the previous day and still live at the window start; seed running with its remaining amount.
  5. Shuffle the events in all three fixtures before sorting and re-run, proving the answer depends only on the comparator.

Expected result: A peaks at 10,000 at the first hold's created_at, not 20,000. B peaks at 10,000 at t. C's peak includes the carried-in hold. running is never negative in any fixture and returns to 0 at the end of A and B.

Verify it yourself

  • The peak matches a naive O(n^2) interval scan recomputed independently on a 1,000-event fixture.
  • Flipping the tie rule changes fixture A's answer to 20,000 — that fixture is the test for the comparator.
  • The sum of all + deltas equals the sum of all - deltas whenever every hold settles inside the window.

Follow-up

  • An incremental authorisation raises an existing hold after the fact. Where does that event go, and does it disturb the tie rule?
  • You now need the peak for 10 million accounts inside a nightly window. What changes, and what must the partitioning key be?
  • The peak sizes a funding transfer. Does the business date or the timestamp decide which day that transfer lands on?

Version a loan schedule instead of soft-deleting posted instalments

Medium · audit history · soft deletes · temporal queries

loan_instalment is keyed by (loan_id, schedule_version, instalment_no) and carries due_date, principal_minor, interest_minor, fee_minor, paid_principal_minor, paid_interest_minor, status, days_past_due, effective_from (date) and superseded_at (timestamptz). A borrower defers two payments on 2026-03-14, and instalments 1 to 6 already have allocations posted against them. Model exactly what the deferral writes, and write the query that returns the schedule as the borrower saw it on an arbitrary date. Say why stamping the old rows with deleted_at, or updating them in place, fails an audit.

Approach

  1. Treat the deferral as an insert, not an edit: write a complete new schedule_version with effective_from = the deferral instant on 2026-03-14, and in the same transaction stamp superseded_at on every row of the outgoing version with that identical instant, so the two version windows are half-open and adjacent rather than overlapping. Instalments 1 to 6 are reproduced unchanged, because they are what the borrower was told and what was posted to the ledger.
  2. Write the as-of read as a version selection, not a row filter: WHERE loan_id = $1 AND effective_from <= $2 AND (superseded_at IS NULL OR superseded_at > $2), then assert that exactly one schedule_version comes back - COUNT(DISTINCT schedule_version) = 1, not one row - so an overlapping window raises rather than silently returning two interleaved schedules under one instalment_no.
  3. Fix the type mismatch before writing that predicate: effective_from is declared date and superseded_at timestamptz, so comparing them casts the date at the session TimeZone and two readers in different zones select different versions near midnight. Put both columns in one domain, timestamptz, keep the window half-open with effective_from inclusive and superseded_at exclusive, and resolve a bare as-of date to an instant once, in the loan's booking timezone, at the edge of the system.
  4. Say what deleted_at loses. It records that a row stopped being current but not what replaced it or from when, it leaves every downstream query obliged to remember deleted_at IS NULL, and one query that forgets double-counts the schedule. Versioning puts the same information in the primary key where it cannot be forgotten.
  5. Close the arithmetic: under the loan's stated day-count convention the new version must still sum to outstanding principal plus scheduled interest to the minor unit, with the per-period rounding residual placed in one named instalment, conventionally the last, rather than smeared across the tail.
  6. Index (loan_id, effective_from DESC) for the as-of lookup, keep superseded versions online rather than archiving them, and enforce with a trigger that no UPDATE touches a row whose paid_principal_minor or paid_interest_minor is non-zero.

Follow-up

  • What does days_past_due mean for an instalment that exists in two versions with different due_dates?
  • A payment arrives allocated to an instalment_no that exists only in the superseded version. What do you do with it?
  • How do you prove the ledger postings made under version 1 still reconcile once version 2 exists?

Make a charge endpoint safe under concurrent duplicate retries

Easy · idempotency · unique constraints · read committed

idempotency_key holds id, scope, key, request_fingerprint (SHA-256 over the canonicalised body), status (in_progress, completed, failed), response_status, response_body, locked_at, completed_at, expires_at, created_at. Fifty identical create-payment requests carrying the same scope and key reach four application instances inside the same 20 ms. Give the DDL constraint and the exact statements the handler runs so that exactly one payment_intent is created and all fifty callers receive the same response body. State what you return when that key arrives with a different fingerprint, and what an arrival after expires_at means.

Approach

  1. Put the concurrency control in the schema: UNIQUE (scope, key). A SELECT-then-INSERT cannot work because both transactions can read nothing before either commits, so the check passes twice and the constraint then surfaces as an error on a payment that succeeded.
  2. Claim the key with INSERT ... ON CONFLICT (scope, key) DO NOTHING RETURNING id. A conflict returns zero rows rather than the existing row, so branch on rowcount: the winner proceeds, the loser reads the stored row.
  3. Keep that path on READ COMMITTED deliberately. The loser's follow-up SELECT takes a fresh statement snapshot and therefore sees the winner's committed row; under REPEATABLE READ the transaction snapshot predates that commit, the row stays invisible and the loser concludes the key does not exist.
  4. Split the work across two transactions because the processor call cannot sit inside one: commit the in_progress row with locked_at first so losers can see a claim, perform the effect, then write payment_intent plus status=completed with response_status and response_body in a single second transaction.
  5. Handle the crash window explicitly: a row stuck in_progress past its lease is an unknown outcome, not a failure, so the reaper queries the processor for that key before deciding. A loser that sees in_progress returns 409 and retries rather than repeating the effect.
  6. Compare request_fingerprint before replaying anything. Same key with a different body is 409, never the cached response, because replaying confirms a payment the caller did not request; and set expires_at beyond the client's and the processor's maximum retry horizon, since a replay after it is a genuinely new request.

Worked solution (20 min)

  1. Create idempotency_key with UNIQUE (scope, key) and write the claim statement as INSERT ... ON CONFLICT DO NOTHING RETURNING id, plus the fallback SELECT on zero rows.
  2. Drive it with 50 threads sending the identical body, and assert one payment_intent row and fifty byte-identical response bodies.
  3. Re-send the same key with amount_minor changed by one unit and assert 409 plus no new payment_intent row.
  4. Kill the process between the processor call and the second commit, restart, and show the reaper resolves the in_progress row by querying the processor rather than by re-charging.

Expected result: One payment_intent row, one idempotency_key row at status=completed, fifty responses with identical response_status and response_body, zero 5xx. A 51st request with the same key and a changed amount returns 409 and leaves payment_intent untouched.

Verify it yourself

  • Repeat the 50-way race 20 times; COUNT(*) FROM payment_intent for that key is 1 on every run.
  • Assert the fifty bodies are byte-identical, not merely all 2xx.
  • Drop the UNIQUE constraint and confirm the test now fails; if it still passes, the race is not being exercised.
  • Re-run the loser path under REPEATABLE READ and observe it failing to find the winner's row, which is why the level is pinned.

Follow-up

  • The handler dies after the processor call and before the local commit. What does the next retry with that key observe, and how does the system converge on exactly one charge?
  • Does the downstream processor honour an idempotency key of its own? Who mints it, and what breaks if a fresh one is generated per attempt?
  • How do you purge rows past expires_at without the delete contending with the insert path?

How do the SAGA and Two-Phase Commit (2PC) patterns handle distributed transactions across microservices?

Medium · system design and architecture

Approach

  1. State the consistency you need, and where you are willing to be stale.
  2. Name the read and write paths separately; they rarely have the same bottleneck.
  3. Fix the scope first: who calls this, how often, and what they do when it fails.

Follow-up

  • How does this behave when that dependency is down for an hour?
  • What would you drop to keep the system up under load?

Design an apartment booking system that integrates with unreliable third-party APIs while preventing double bookings.

Medium · system design and architecture

Approach

  1. Name the read and write paths separately; they rarely have the same bottleneck.
  2. State the consistency you need, and where you are willing to be stale.
  3. Fix the scope first: who calls this, how often, and what they do when it fails.

Follow-up

  • How does this behave when that dependency is down for an hour?
  • What breaks first when traffic grows ten times?

How does the Transactional Outbox pattern guarantee message delivery to an event bus like Kafka?

Medium · system design and architecture

Approach

  1. Name the read and write paths separately; they rarely have the same bottleneck.
  2. Choose a partition key and say what query it makes expensive.
  3. Fix the scope first: who calls this, how often, and what they do when it fails.

Follow-up

  • What would you drop to keep the system up under load?
  • How does this behave when that dependency is down for an hour?

What are PostgreSQL transaction isolation levels, and how does Read Committed differ from Repeatable Read and Serializable?

Medium

Approach

  1. Work from the requirement backwards to the design.
  2. Say what you would check first and why it is the highest-information step.
  3. Clarify what is being asked and what a complete answer contains.

Follow-up

  • How would you know your answer was wrong?
  • What assumption would you test first?

Raise the write ceiling on one hot clearing account

Hard · lock contention · isolation levels · sharding · write-heavy

The ledger posts balanced entry sets at 10,000 entries/s into append-only ledger_entry(entry_id, transaction_id, account_id, direction, amount_minor>0, currency, source_type, source_id, business_date, posted_at), roughly three entries per transaction. A materialised row per account carries the floor check available_minor >= floor_minor and is updated in the posting transaction. One pooled clearing account appears in 40 percent of transactions. Compute the throughput ceiling on that one row, then raise it. Name the isolation level each version requires and state precisely what sharding the balance costs the floor check.

Approach

  1. Compute the ceiling before designing anything. Throughput against a single row is one divided by the lock hold time, where the hold runs from first acquiring the row to commit, including the WAL flush. At a 2 ms hold that is about 500 commits/s on that row, and adding cores does not move it. Demand is 10,000 entries/s at three entries per transaction, so roughly 3,300 transactions/s, of which 40 percent touch this account: about 1,300/s against a 500/s ceiling. You are 2.6x over before any code exists.
  2. Shorten the hold before sharding, because it is free. Acquire the hot row last in the transaction and hold it for exactly one statement: UPDATE balance SET available_minor = available_minor - $1 WHERE account_id = $2 AND available_minor - $1 >= floor_minor. This is correct at READ COMMITTED, because an UPDATE that blocks on a concurrent writer re-evaluates its WHERE clause against the newly committed row version rather than its original snapshot.
  3. Name what the broken version needs. SELECT the balance, compute in application code, then UPDATE loses updates at READ COMMITTED, since the second statement takes a fresh snapshot. Making that shape safe requires REPEATABLE READ or SERIALIZABLE plus a bounded retry loop on SQLSTATE 40001, which is the part most implementations omit; SELECT ... FOR UPDATE also works but is simply the same lock held for longer. Note that the identical application code changes behaviour on a different engine: InnoDB's REPEATABLE READ does not abort the loser.
  4. Order locks deterministically on any two-account movement, always ascending by account_id. Concurrent opposing transfers without that ordering deadlock with SQLSTATE 40P01, and the deadlock rate rises with exactly the concurrency you added to gain throughput.
  5. Shard the hot balance into N sub-rows keyed (account_id, shard_no), writers choosing a shard by hash of transaction_id. Per-shard hold time is unchanged, so capacity rises roughly N-fold, and at N=16 you clear 1,300/s with headroom. The cost is precise: the floor is now a predicate over the sum of N rows and is no longer expressible as a single-row CHECK, so it must be paid for deliberately. Either give each shard its own floor, which rejects postings while other shards hold headroom, or add a slow path that locks all shards in ascending shard_no under SERIALIZABLE, rebalances and retries on 40001. For a clearing account whose floor is effectively unreachable, dropping the per-shard floor and enforcing it as a monitored invariant is a defensible stated choice; for a customer deposit account it is not.
  6. Keep the entries immutable throughout: no UPDATE or DELETE grant on ledger_entry plus a trigger, corrections as reversing transactions, and partitioning by business_date so index writes stay in the current partition and retention is a DETACH.

Worked solution (45 min)

  1. Run two concurrent sessions doing SELECT then UPDATE to a computed value against the same account under READ COMMITTED and observe one update lost.
  2. Replace it with the single atomic conditional UPDATE and re-run; then run the original read-modify-write under REPEATABLE READ with a bounded retry on 40001.
  3. Measure committed transactions per second against the single pooled row at 1, 8, 32 and 128 concurrent writers, recording mean lock hold time.
  4. Shard into 16 sub-rows hashed on transaction_id, re-measure, and write the query that answers the true available balance.
  5. Run concurrent opposing two-account transfers with and without ascending account_id lock ordering.

Expected result: The read-modify-write loses an update at READ COMMITTED and aborts with 40001 at REPEATABLE READ; the single atomic statement is correct at READ COMMITTED with no retry loop. Single-row throughput flattens near one divided by the lock hold time, about 500 commits/s at a 2 ms hold, against the roughly 1,300 transactions/s this account needs. Sixteen shards clear that, and the floor check becomes a predicate over sixteen rows. Unordered transfers produce 40P01 deadlocks; ascending order produces none.

Verify it yourself

  • Past the plateau, adding writers raises latency without raising throughput; that is the ceiling, not a tuning problem.
  • After sharding, a floor breach is reproducible when each shard enforces the floor independently; construct it deliberately rather than assuming it cannot happen.
  • ledger_entry has no UPDATE or DELETE path throughout, and every correction in the test appears as a new reversing transaction.

Follow-up

  • While the balance is sharded, what query answers the account's true available balance, and what does it cost?
  • A transaction has legs in two currencies. What does the balanced-to-zero check mean now, and where does the rounding residual go?
  • What does the nightly reconciliation compare the sharded balance against?

Authorisation p99 tripled overnight with no deploy

Medium · index bloat · autovacuum · long-running transaction · latency

Payment orchestration serves about 3,000 authorisations per second against a 150 ms p99 budget. Since 02:00, p99 is 460 ms and rising about 8 ms per hour, while p50 is unchanged at 11 ms. There was no deploy, no traffic change and no processor degradation. The hot path does one INSERT into idempotency_key, which has UNIQUE (scope, key), then two UPDATEs on that row: locked_at, then status and response_body. A nightly reconciliation job started at 01:50 and is still running. Give the ordered diagnostic checklist and the cause.

Approach

  1. Read the shape first. p50 flat with p99 rising and no deploy is a resource or data-volume effect, not a code path, because a code change moves the median too. A tail that climbs monotonically at fixed workload means something monotonically grows.
  2. Ask what started at 01:50. In PostgreSQL an open transaction holds back the xmin horizon cluster-wide, so autovacuum can reclaim no dead tuple newer than that snapshot. Confirm with pg_stat_activity (state, now() - xact_start, backend_xmin) and with pg_stat_all_tables (n_dead_tup, last_autovacuum) for idempotency_key.
  3. Connect it to the write pattern. Three writes per key produce up to two dead tuples each, so at 3,000 rps the table sheds roughly 6,000 dead tuples per second. Heap-only tuple updates would keep those out of the index, but only when no indexed column changes and the page has room, and appending response_body grows the tuple enough to force a new page. So the unique index on (scope, key) grows too.
  4. Explain why only the tail suffers. A larger index means more pages per lookup and a rising fraction of them missing shared_buffers; the median request still hits cache while the tail pays physical I/O. This is exactly the p50-flat, p99-rising signature, and it is worth stating before acting.
  5. Verify before fixing rather than after. n_dead_tup in the millions and rising, last_autovacuum stale since about 01:50, and pg_relation_size on the unique index measured twice fifteen minutes apart showing growth at constant workload. Index bloat cannot be inferred from row count alone; use the size series or pgstattuple.
  6. Fix in two moves and prevent separately. End or chunk the long transaction so the reconciliation job commits per batch instead of holding one snapshot over 50M lines, then let autovacuum catch up or run REINDEX CONCURRENTLY. Add a transaction-age alert and a statement timeout on the reporting role. Collapsing the two UPDATEs into one and lowering fillfactor halves dead-tuple production, but that is an optimisation, not the cause.

Follow-up

  • The reconciliation job legitimately needs a consistent view of 50M lines. How do you give it one without pinning the xmin horizon?
  • Why did p50 not move at all?
  • You also run an expires_at cleanup job that DELETEs old idempotency keys. During this incident, does running it help or hurt, and what design avoids the question entirely?

Suggested practice checklist

For someone who has spent the last few years shipping features and reading other people's code, and who has not solved a timed problem from a blank file in a long time. Five days rebuild the primitives and the patterns that sit on them, working from invariants rather than remembered solutions, and the last two attach that back to the rest of the loop.

Day 1 — Set up the IDE and build the banking service test-first

  • Configure your IDE with a test runner and a template project that compiles and runs one passing test, so live-coding setup costs nothing
  • Build an in-memory banking service with create, deposit, withdraw and transfer, writing a failing test for each rejection rule before the code that enforces it
  • Decide between BigDecimal and integer minor units for balances, and write two sentences defending the choice for multi-currency accounts

Deliverable: A tested banking service where every invalid transaction is rejected atomically and leaves balances untouched.

Day 2 — Coding fundamentals: random output, custom structures, load balancing

  • Generate a mine grid with an injected random source, placing k mines by selection sampling, and test invariants (exactly k mines, correct neighbour counts) rather than layouts
  • Implement a Set of integers without Java collections, hash tables or trees, for example a sorted array with binary search, and state the cost of add, contains and remove
  • Implement a load balancer with Random and Round Robin selection behind one interface, with a seeded random source for tests

Deliverable: Three solutions with tests, each with its time and space complexity written above the main method.

Day 3 — Concurrency: transfers, shared state and lock choice

  • Make the Day 1 transfer thread-safe with per-account locks acquired in ascending id order, then run a stress test of opposing transfers and assert total money is conserved
  • Make the Round Robin selector thread-safe with an AtomicInteger and Math.floorMod, and explain why a synchronized method would also be correct but slower under contention
  • For a shared cache written by several worker threads, compare a synchronized map, ConcurrentHashMap.compute and a ReadWriteLock, and say when each is the cheapest correct choice

Deliverable: A stress test that fails with naive locking and passes with ordered locking, plus a one-page note on JVM concurrency primitives.

Day 4 — Database theory: isolation, locking and indexes

  • Fill in a PostgreSQL grid of isolation levels against anomalies and explain each cell aloud
  • Reproduce a lost update in two psql sessions, then fix it with an atomic conditional UPDATE, SELECT ... FOR UPDATE and a version column
  • Work the drill 'Make a charge endpoint safe under concurrent duplicate retries' and explain B-Tree versus BRIN for a ledger table in three sentences

Deliverable: Session transcripts showing the lost update and each fix, and short spoken answers on isolation, locking and index choice.

Day 5 — Databases under load: contention, scaling and incidents

  • Work the drill 'Raise the write ceiling on one hot clearing account', stating the throughput ceiling as a number before any design
  • Compare sharding, partitioning and replication for a write-heavy banking database: what each scales and what each costs in consistency
  • Work the debugging drill on authorisation p99, writing the diagnostic checklist in order before reading the cause

Deliverable: A written ceiling calculation, a scaling comparison table, and an ordered diagnostic checklist.

Day 6 — System design: external APIs, distributed transactions and events

  • Design the apartment booking system with unreliable third-party APIs end to end, marking where double booking is prevented
  • Explain the Transactional Outbox with Kafka from local write to deduplicating consumer
  • Compare an orchestrated saga with 2PC on one transfer, and sketch a ledger using CQRS and Event Sourcing

Deliverable: One full booking design and two short written explanations you can deliver aloud in five minutes each.

Day 7 — Timed rehearsal of all four stages

  • Solve one coding question from Day 2 in 45 minutes with tests, working in your own IDE and narrating as you go
  • Answer ten database and concurrency theory questions aloud, one or two minutes each, naming the anomaly or failure each mechanism prevents
  • Run one 45-minute banking design from Day 6 on a new prompt, such as a card ordering feature, and rehearse your screening summary and constraints

Deliverable: A timed coding solution with tests, a list of theory answers that needed a second attempt, and one recorded design walkthrough.

Disagreement needs an outcome you did not choose

The sources do not describe a separate behavioural stage, but the reported question bank includes ownership and planning questions, such as preventing double-withdrawal incidents and planning an observability overhaul. Prepare stories where you owned a correctness problem end to end, with the specific change that followed.

  1. Tell me about a payment-critical or data-integrity incident you owned. What broke, how did you find the cause, who did you bring in, and what did you change so it could not happen again?
  2. You have nine months to overhaul observability for a set of backend services. What do you deliver first, what do you defer, and how do you show progress along the way?
  3. Describe a design trade-off you made under time pressure. Which alternative did you reject, and what would have made you choose it instead?
  4. Tell me about a test you wrote that caught a bug before production. What made you write that test?
  5. Describe a technical decision you later reversed. What did you believe at the time, and what measurement changed your mind?

Reverse a sharding decision after production contradicted it

Medium · sharding · lock contention · reversibility

To raise throughput past a single row's lock ceiling, you shard a hot settlement account balance into 16 sub-rows. Two weeks later the floor check has to sum all 16 under a stronger isolation level, contention has moved rather than gone, and operations cannot explain the balance to an auditor. Describe a decision you reversed. State what you believed when you made it, the measurement that changed your mind, how you unwound it without causing a second incident, and how long you waited before concluding the data was real rather than noise.

Approach

  1. The probe is whether you can hold a belief loosely and unwind your own work without ego. Begin with the reasoning that was correct at the time: a single balance row commits at roughly one write per lock hold, so at a 4 ms hold you get about 250 writes per second regardless of core count, and sharding is the standard answer to that ceiling.
  2. Name what the original reasoning missed rather than calling it a mistake in general. The floor predicate was a single-row CHECK before sharding and became a cross-row predicate after it, so every write now either sums the shards under SERIALIZABLE with a bounded retry on 40001 or locks them in a fixed order to avoid 40P01. The throughput gain is real but smaller than 16x, and the auditability cost was never priced.
  3. Give the measurement that decided it, with a before and an after: committed writes per second, p99 write latency, retry rate on 40001, and the time an analyst needs to reconstruct one balance. A reversal justified by feel is the generic answer.
  4. Describe the unwind as a migration, not a revert: shadow the consolidated balance, reconcile it against the sum of shards over a full business day including the cutoff, cut reads over first, then writes, keeping the shards readable until one full reconciliation cycle has passed clean.
  5. State the waiting rule you used before acting. Two weeks of a moving p99 can be a deploy or a traffic shift; a strong answer names the signal that separated a trend from noise, such as the retry rate persisting across a low-traffic weekend.
  6. Close with what you would keep. Some of the work is usually salvageable (the instrumentation, the lock ordering, the measured ceiling), and saying which parts survived shows the reversal was analysed rather than abandoned.

Follow-up

  • You still need the throughput. What is the next thing you try, and what does it cost the floor check?
  • How do you reconcile the sharded balance against the consolidated one during the migration without double counting entries posted mid-cut?
  • What would you have measured before the original change that would have made the answer obvious?

Force an implicit timeout behaviour into an explicit decision

Medium · fail open · risk decisioning · decision records

The risk decision service has an 80 ms p99 budget inside a roughly 2 s caller timeout. Today, when its feature store is unavailable, the timeout handler returns approve. Nobody chose that; it is what the code does. You need a real decision: fail open, fail closed, or refer, potentially differing by amount band. Describe a time you turned an accidental behaviour into an owned decision. State who had to be in the room, the data you brought, what you did when nobody wanted to own it, and where the decision was recorded so it outlived you.

Approach

  1. The probe is whether you can drive a cross-functional decision rather than escalating and waiting. Lead with the framing that makes it undeniable: this is already a product decision, it is currently being made by an exception handler, and the only question is whether anyone reviews it.
  2. Bring the two losses side by side instead of arguing a principle. Fail open costs expected fraud loss on approved-but-should-have-declined volume during the outage; fail closed costs declined good payments, which is lost revenue plus customer harm and a support queue; refer costs manual review capacity, which is a headcount number and saturates within minutes at 3,000 decisions per second. Give each as a rate per minute of outage using real volume.
  3. Propose the banded answer as the default, because the two losses cross over at an amount: below some threshold the expected fraud loss is smaller than the expected decline loss, above it the reverse, and the crossover is computable from observed fraud rate by band. That converts a values argument into an arithmetic one.
  4. Name the attendees by the decision they own, not by title: whoever carries fraud loss, whoever carries approval rate, and whoever staffs manual review. Three people who can each say yes is a decision; eight people who can each say no is a meeting.
  5. Say what you did when ownership was contested. A strong answer has a forcing function: propose a default in writing with a review date and state that it ships unless someone objects, which converts inaction into consent rather than into another meeting.
  6. Record it where the code can find it: the decision, its date, its owner, the amount thresholds, and a test asserting the fallback behaviour, so the next engineer reading the timeout handler learns it was chosen. A wiki page nobody links from the code is the generic answer.

Follow-up

  • The feature store is degraded rather than down and the model is scoring on stale features. Is that the same decision?
  • How do you stop the banded thresholds from silently rotting as fraud patterns shift?
  • Nobody objects to your written default, and six months later there is an outage and a loss. Who owns it?

Unblock an engineer on double-posted interest accrual

Easy · mentoring · idempotent batch · accrual

An engineer two years into their career has a nightly accrual job that double-posts interest for some accounts whenever the batch is partially re-run after a failure. They have spent two days on it and are now rewriting the batch runner. You have 30 minutes. Describe how you have unblocked someone without taking the keyboard: the question you asked first, how you chose between handing over the answer and handing over the method, what you left behind so the next person does not get stuck here, and how you knew they were unblocked rather than deferring to you.

Approach

  1. The probe is whether you grow people or absorb their work. Open with the diagnostic question rather than the solution: ask what identifies one unit of work, because the answer reveals immediately that accrual is keyed by (account_id, accrual_date) and that the job has no uniqueness on it.
  2. Redirect from the runner to the write. The rewrite is aimed at never re-running, which is unachievable; the property needed is that re-running posts nothing new, enforced by a unique index on (account_id, accrual_date) or by passing the same idempotency key to the ledger posting operation so the second attempt is a no-op rather than a second transaction.
  3. Choose deliberately between answer and method and say why. Two days in and blocked on the wrong layer is usually the moment to hand over the framing (restartable at account granularity, idempotent per unit) and let them write the code, because the lesson is the framing and the code is the easy part.
  4. Leave an artefact, not a conversation: a test that re-runs one account twice and asserts one posting, plus two lines in the runbook stating that per-account work must be idempotent because the batch is always partially re-run.
  5. Check that they are unblocked by asking them to predict the failure that the fix does not cover, such as a mid-run rate change producing two different correct amounts for the same key. If they can find the next edge themselves, they own it; if they ask you to confirm each step, they are deferring and you have hidden the block rather than removed it.
  6. Say what you deliberately did not do. Not fixing it yourself before the standup is the whole exercise, and a strong answer names the pressure it resisted.

Follow-up

  • The unique index rejects the re-run, but the first run posted the wrong amount. How should the job behave now?
  • How do you tell whether you taught them or just unblocked them, a month later?
  • The same engineer is blocked again next week on a similar problem. What does that tell you about your first intervention?