Interview guides · Capital One
Capital One Software Engineer Interview Guide
Code reading practice for Capital One software engineer prep: work out what unfamiliar code does before judging it.
Interviewing at Capital One
- Practice bank: 68+ questions
- Rounds: 4
- Typical prep: 2–4 weeks
- Interview reports: 13
Interview rounds
| Round | Questions |
|---|---|
| HR Screen | 3 |
| Online Assessment | 35 |
| Technical Screen | 13 |
| Onsite | 17 |
How hard is the Capital One Software Engineer interview?
| Difficulty | Share | Questions |
|---|---|---|
| Easy | 0% | 0 |
| Medium | 91% | 62 |
| Hard | 9% | 6 |
What Capital One actually tests for
| Topic | Share | Questions |
|---|---|---|
| Coding & Algorithms | 51% | 35 |
| System Design | 13% | 9 |
| Software Engineering Fundamentals | 12% | 8 |
| Behavioral & Leadership | 10% | 7 |
| Analytics & Experimentation | 4% | 3 |
| Data Manipulation (SQL/Python) | 3% | 2 |
| Machine Learning | 3% | 2 |
| Product Design & Strategy | 1% | 1 |
| Statistics & Math | 1% | 1 |
The 60-second brief
A Software Engineer at Capital One plays a vital role in powering one of the nation's largest data-driven financial institutions. Rather than operating as a traditional banking enterprise, Capital One builds technology-first solutions that handle billions of transactions annually. As a engineer here, you will contribute directly to microservice ecosystems, enterprise cloud infrastructure on AWS, real-time fraud detection pipelines, and flagship consumer products like Capital One 360 checking and savings, virtual credit card systems, and personalized cash-back reward engines.
Capital One candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.
What a Software Engineer does at Capital One
The work you do balances high-throughput performance with bank-grade security and reliability. Software Engineers are responsible for building modular, resilient backend services and intuitive user interfaces that operate under strict compliance and zero-downtime requirements. You will work in cross-functional engineering pods alongside product managers, data scientists, and cybersecurity specialists to modernize financial technology, move critical workloads to serverless and containerized environments, and deliver seamless digital experiences to millions of retail and commercial customers.
Joining Capital One as a Software Engineer offers a unique blend of financial domain challenge and modern engineering scale. Candidates must demonstrate not only deep core computer science fundamentals and solid coding abilities, but also strong architectural sense and business logic reasoning. The organization places high value on clean object-oriented code, thoughtful trade-off analysis, and proactive collaboration.
The Capital One interview process
Capital One candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.
Automated Coding Assessment
Input bounds are the part of the prompt most often skimmed, and they usually contain the answer. They tell you which complexity class is admissible, which narrows the search before you have thought about the problem itself. As a rough planning figure, a compiled language does on the order of 10^8 simple operations per second and an interpreted one roughly an order of magnitude less. So n up to about twenty admits enumerating subsets, a few thousand admits a quadratic pass, and a million admits neither: you need near-linear, or linear with a log factor. If the bounds are missing, ask for them.
What it evaluates
- Whether the approach is justified by the stated input size rather than by whichever pattern you recognised first
- Whether you ask about the properties that change the algorithm: whether the input arrives sorted, whether duplicates occur, whether values are bounded integers, whether it all fits in memory
- Whether you can name the bottleneck in your own solution and what would remove it, even when you deliberately leave it in place
- Whether a claimed speedup is real, since memoising a recursion only helps when subproblems genuinely overlap and the state can be keyed cheaply
How to prepare
- For each algorithm you rely on, write down the largest n it handles in roughly a second, then check two of those figures by timing them in the language you will actually type in
- For two weeks, write one line naming your target complexity and the bound that justifies it before you write any code, then compare that line with what you ended up submitting
- Practise the conversion backwards: given a required O(n log n), list the mechanisms that get you there (sorting, a heap, an ordered map, divide and conquer) and choose by what the problem needs to query, not by what you used last
Recruiter Conversation
Before anything technical happens, someone has to decide which rung of the ladder your loop is calibrated to, and that decision sets the bar for every round after it. It comes from how you describe scope, not from your title, because titles do not convert cleanly between companies. The weak version of the answer is team size and years. The strong version names the largest change you shipped where nobody reviewed the design, what would have broken if you had been wrong, and what you were paged for. Get the level said out loud on this call, because the range and the loop both follow from it.
What it evaluates
- Whether the scope in your own account maps onto a level the team actually has an opening at, so a mismatch ends the process cheaply rather than after four interviewers have spent a day
- Whether your title needs re-mapping: the same word describes very different amounts of independent decision-making at a twenty-person company and a ten-thousand-person one
- Whether your compensation expectation can be filled at that level in the structure the role pays in, which is why the number gets asked for before any engineer is scheduled
How to prepare
- Write down two changes from the last two years: the largest one you designed with nobody reviewing the design, and the largest one where someone more senior did. Lead with the first when scope comes up, and be ready to say which parts of the second were yours
- Ask which level the loop is calibrated to and what changes at the level above it, then plan your weeks from that answer rather than from the posting
- Settle a total-compensation range beforehand with the split named, base against bonus against equity and its vesting period, so a question about numbers gets a number instead of the word market
Power Day
Because the format is not fixed, the first job in the room is classification. Listen to the opening question and decide what it is: a probe into work you have already described, a fresh problem to solve now, or a conversation about how you operate. Each wants a different register, and the common failure is forcing a rehearsed structure onto a question that did not ask for it. Running a full design ritual on a ten-minute debugging question reads as not listening. When you cannot tell which it is, ask how long they want to spend and answer at that depth.
What it evaluates
- Whether the shape of your answer matches the question, so a yes-or-no gets answered before it is justified and an open prompt gets a direction before a detour
- Whether you check how much depth is wanted instead of deciding for them, and whether you stop when the answer is complete rather than continuing until someone interrupts
- Whether you can be redirected in the middle of an answer without restarting it from the beginning
- Whether a question outside your experience gets an honest boundary followed by reasoning from what you do know, instead of a confident answer with nothing behind it
How to prepare
- Rehearse one project at three lengths, roughly thirty seconds, three minutes, and a full walkthrough at the depth of a design review, and practise switching between them when someone interrupts mid-telling
- Have someone ask you five questions of deliberately mixed type in one sitting without telling you the types, and score only whether you identified each one correctly before you started answering
- Draft the sentence you will use to check depth, along the lines of asking whether the short version is useful here or they want the detail, and use it in a real conversation this week so the day of the round is not its first outing
Team-Matching Phase
An unlabelled round is first an information problem, and the cheapest information is free. Whoever schedules it can usually tell you how long it runs, who will be in the room and what they work on, whether you will be writing code and in what environment, and whether anything is being sent beforehand. Ask in writing so the answer is on record, then prepare for the two or three formats those answers still leave open instead of betting on one. What separates a strong candidate is not guessing right; it is having an opening that works whichever one it turns out to be.
What it evaluates
- Whether you can start work from an ambiguous brief, since tolerating a vague scope without stalling is the same thing the job asks for
- Whether the questions you asked beforehand were ones that change your preparation, such as duration, medium and who is joining, rather than ones whose answers you could not have acted on
- Whether you adapt when the round turns out to be something other than what you were told, instead of spending the first ten minutes visibly recalibrating
How to prepare
- Send one short scheduling message asking four things: how long, who is joining and what they work on, whether you will be writing code and where, and whether to prepare anything in advance. Treat a vague reply as real information, since it means the round is loosely structured and you will be shaping it yourself.
- Write one opening that works in any of the formats still open: restate in your own words what you have been asked to do, then ask which of two directions is more useful to them. Say it aloud until it stops sounding recited.
- Set up for the two most likely formats before the call starts, with a blank editor in the language you would choose and a shared document you can type into, so a format surprise costs you nothing in the first minutes
Questions Capital One candidates report
Data structures and algorithms
State the bound, then read your own code against it, because the cost you forgot is usually inside a call you did not write. A membership test against a list is linear while the same test against a set is expected constant, so one of those inside a loop turns a linear scan quadratic. Taking a slice copies the elements wherever slices are values rather than views. Repeated string concatenation is quadratic wherever strings are immutable, unless you accumulate into a builder or a list and join once. None of that is visible in the shape of the algorithm, and all of it is visible in the runtime.
- Given a list of integers and target values, identify all valid pair combinations that satisfy specific offset mathematical conditions.
- Given a matrix representing grid boundaries, extract numbers along the outer border and rearrange them in a clockwise sequence.
- Write a function that processes string command sequences to manage virtual folder directory structures and identify the path containing the highest file count.
System design and architecture
The design is the data model and the interface; the boxes follow from them. Write the entities, the key each row is stored under, and then, for every read the product needs, the path from a key to the rows returned. Do the same for each write, including whatever else must change in the same operation. Most designs fail here rather than at scale, when a read the feature depends on turns out to need a scan or a fan-out across every partition. The strongest answers state the queries first and let the storage choice fall out of them.
- Build an object-oriented transaction validator that checks account balances, enforces daily spending limits, and manages concurrent balance updates.
- Extend a core banking transaction engine to record operation activity and retrieve the Top N most active accounts within a rolling time window.
- Design an end-to-end virtual credit card issuance system, focusing on single-use card generation, merchant locking, and tokenization security.
Behavioural and engineering judgement
Every question in this category has a number in it somewhere, and engineers routinely tell the story without one. What the outage cost, what the delay cost, what the rewrite consumed in engineer-weeks against what it saved, and who absorbed it when it was not users: the on-call rotation, another team's quarter, the support queue. You will rarely have an exact figure, and an estimate with its method stated beats both a precise-sounding invention and silence. Say how you got to it and what it leaves out. That is the sentence that makes the follow-up about whether it was worth doing answerable at all.
- Describe your experience mentoring junior developers or leading an initiative through shifting business priorities.
- Discuss a time when you experienced a major conflict with a manager or teammate regarding architectural direction and how you resolved it.
Other topics
These reward mechanism over vocabulary. Whatever the topic, the move that works is to describe what the machine does one layer below the name you just used: what a lookup actually touches, or what the operating system did with the write you believed went to disk. Attach the precondition to every performance claim, since most are conditional. A hash lookup is constant time on average given a hash that spreads the keys and a load factor kept bounded by resizing, and linear in the worst case when either fails. Knowing where your explanation stops is part of the answer, so stop there out loud.
- Analyze a transaction rule matrix and trace execution flows across edge cases involving refunded purchases and disputed charges.
Practice exercises with worked solutions
The prompts above tell you what gets asked.
Stop the capture-and-refund join from double-counting merchant money
Report captured_minor, refunded_minor and net_minor per merchant per business_date. payment_intent holds intent_id, merchant_id, amount_minor, captured_minor, refunded_minor. ledger_entry holds transaction_id, account_id, direction, amount_minor, source_type in (capture, refund, fee, chargeback), source_id = the intent_id as text, and business_date; every capture posts one debit and one credit. A draft joins payment_intent to ledger_entry twice, once filtered to captures and once to refunds, and sums both. On intents with two partial captures and one refund the totals are wrong. Name both multiplications precisely and write the correct query.
How a strong candidate works it
- Name the first multiplication by cardinality: intent to capture entries is one-to-N and intent to refund entries is one-to-M, so joining both yields N times M rows per intent. Every capture row is then counted M times and every refund row N times, and the two columns are inflated by different factors, which is why the totals look almost right rather than obviously broken.
- Name the second, which is easier to miss: double-entry means each capture posts two legs, so summing every entry of a capture counts the amount twice regardless of joins. Select the single leg by ACCOUNT - the merchant's settlement payable, which carries exactly one leg of every capture and of every refund - and not by direction. Direction is not a substitute for that filter: a capture credits the payable and a refund debits it, so adding direction = 'credit' keeps the captures and silently drops every refund leg, leaving refunded_minor at exactly zero on a query that otherwise reads as correct.
- Reject the reflexive fixes: SELECT DISTINCT and SUM(DISTINCT ...) de-duplicate values, not rows, so two genuine captures of equal amount collapse into one and the total is wrong in the other direction while looking tidier.
- Prefer a single pass with conditional aggregation over ledger_entry, restricted to that one account: SUM(amount_minor) FILTER (WHERE source_type = 'capture') and the same for refunds, grouped by merchant and business_date, joining payment_intent only to reach merchant_id. One scan, no intent-level fan-out, and fees or chargebacks are added as another FILTER rather than another join. amount_minor is a positive magnitude, so net_minor is captured minus refunded; the signed form over the same rows, SUM(CASE WHEN direction = 'credit' THEN amount_minor ELSE -amount_minor END), returns the same net in one column and is the check on the sign convention.
- Keep aggregate-then-join in reserve for when per-intent detail is required: aggregate each side to intent grain in its own CTE, then join the two aggregates one-to-one. LATERAL works too and reads better when the right side needs the left's key.
- Get the account predicate, the join column and the date right. Each merchant has its own settlement payable, so the predicate is a join to the account dimension (a.account_type = 'merchant_payable' AND a.merchant_id = pi.merchant_id); a single $settlement_account literal is only correct for a one-merchant report. source_id is text and intent_id is uuid, and PostgreSQL has no implicit cast between them, so one side must be cast and the side decides which index stays usable - pi.intent_id = e.source_id::uuid probes payment_intent's primary key per entry row, e.source_id = pi.intent_id::text probes an index on ledger_entry(source_id), and if neither fits the driving table the planner hashes both sides, which is fine for a day's report and not for a single-intent lookup. Group on ledger_entry.business_date rather than posted_at::date, because they differ across the cutoff, and decide explicitly whether a later-dated refund reduces its own day or the original capture's.
Follow-ups
- A refund lands on a later business_date than its capture. Which day does net_minor move, and what does that do to a daily merchant payout?
- Add scheme fees, which arrive netted into a batch total rather than per transaction. How does the query change, and what can it no longer claim?
- How would you reconcile this output against payment_intent.captured_minor, and which one is authoritative when they disagree?
Explain why the outbox relay stopped using its partial index
outbox_event holds event_id, aggregate_type, aggregate_id, aggregate_version, event_type, payload jsonb, published_at, attempts, last_error, created_at, with index ix_unpub ON outbox_event (created_at) WHERE published_at IS NULL. The relay runs SELECT ... WHERE published_at IS NULL ORDER BY created_at LIMIT 500 FOR UPDATE SKIP LOCKED, then marks each row by setting published_at. Unpublished rows hold steady near 400, but the query has gone from 3 ms to 900 ms. Explain what EXPLAIN (ANALYZE, BUFFERS) will show, why it happens, and the fix.
How a strong candidate works it
- Read the plan for the gap between rows returned and work done: an index scan on ix_unpub returning 500 rows while touching tens of thousands of buffers is the signature. Rows Removed by Filter and the buffer counts name it; wall-clock alone does not, because a warm cache hides it.
- Explain the mechanism: marking a row published is an UPDATE, which writes a new tuple version. The new version fails the index predicate and leaves ix_unpub, but the dead old version's index entry stays until vacuum removes it, so the scan walks dead entries and discards them. PostgreSQL can hint an entry LP_DEAD once a scan has proved it dead, which cheapens repeat visits, but the index pages themselves still have to be read and are not reclaimed.
- Ask why vacuum is not reclaiming. Anything holding the xmin horizon back prevents removal: a long-running query, an idle-in-transaction session, an abandoned prepared transaction, or an inactive replication slot. Check the oldest xact_start in pg_stat_activity, pg_replication_slots, pg_prepared_xacts, and n_dead_tup with last_autovacuum in pg_stat_all_tables.
- Fix in order of leverage: delete or archive published rows instead of leaving them in place, so a queue table stays a queue; keep the transaction horizon short and alert on it; then tune autovacuum on this one table with an aggressive scale factor rather than changing the global setting.
- Rule out the other failure with the same symptom: a partial index is usable only when the planner can prove the query predicate implies the index predicate, so rewriting the filter as coalesce(published_at, 'epoch') = 'epoch' or wrapping the column in a function disqualifies the index entirely and produces a sequential scan instead of a bloated index scan.
- Verify by re-running EXPLAIN (ANALYZE, BUFFERS) after the horizon is released and a VACUUM completes, comparing shared buffer reads rather than elapsed time, and confirm the relay keeps per-destination ordering after the change.
Expected result (25 min): Before: Index Scan using ix_unpub, 500 rows returned, tens of thousands of shared buffers read and a large Rows Removed by Filter, in the hundreds of milliseconds. After releasing the horizon and vacuuming: the same plan node, buffer reads down by orders of magnitude, back to low single-digit milliseconds.
Check yourself
- pg_stat_all_tables.n_dead_tup for outbox_event falls to near zero after the VACUUM.
- The plan node is unchanged across both runs, which proves the regression was cost of work rather than plan choice.
- Rewriting the predicate with coalesce produces a Seq Scan, confirming the separate predicate-matching failure.
- pg_relation_size of ix_unpub shrinks only after the vacuum, not after the rows are published.
Follow-ups
- SKIP LOCKED means two relay workers never block each other. What else does it change about ordering guarantees for a single destination?
- You archive published rows to a second table. What does that do to the relay's crash recovery and to duplicate delivery?
- The relay batches 500 rows and publishes them, then marks them. Where exactly can it crash, and what does the consumer see?
Detect duplicate-charge bursts in an out-of-order authorisation stream
Authorisations arrive as (instrument_token_id, amount_minor, currency, event_time, arrival_time) at roughly 3,000 per second, up to 60 seconds late and out of order. Flag any token with three or more authorisations of identical (amount_minor, currency) inside any 10-minute window of event time. Report each flag once, as early as correctness allows. State memory per key and in total, how late an event you will accept, and what you do with one that arrives after you have already reported — or already declined to report — that window.
How a strong candidate works it
- Key state by
(instrument_token_id, amount_minor, currency), not by token: the predicate is about identical amounts, so the window belongs to the triple. Each key holds its own event-time-ordered deque. - In-order, this is two pointers: on insert, pop from the front while
front <= new - 10 min, then flag if the deque reaches length 3. Amortised O(1) per event, memory proportional to that key's window occupancy. - Out-of-order arrival breaks append-only monotonicity, so insert in position instead. With lateness bounded at 60 seconds the insertion point is always near the tail, so a short sorted vector or a 600-bucket per-second ring keeps it O(w) with tiny w; a balanced tree per key is correct but over-built for a one-minute reorder.
- Drive decisions off a watermark of
max(event_time seen) - 60 s, never off wall clock. Anything older than the watermark is too late to change an answer and is counted in alate_droppedmetric. Without an explicit watermark you have still chosen a lateness policy — you just cannot state it or test it. - Report-once needs its own state: per key, a set of already-flagged 10-minute window ids. A later event inside an already-flagged window must not re-flag, and a late event that completes a window you never flagged must flag — which is why state is retired at window close plus the lateness bound, not at window close.
- Size it: state lives 660 seconds, so at 3,000 events per second there are about 1.98 million in flight, plus one flagged-window set per active key. Bound total memory explicitly and shed by key age, and say what shedding costs — a shed key can miss a flag, making the threshold a product decision rather than a tuning knob.
Expected result (40 min): Fixture 1 flags exactly once, at the t+9 event; the t+11 event does not flag under the half-open rule and does under the closed one, which is the boundary the fixture exists to pin. Fixture 2 yields the same single flag with the same window id. Fixture 3 yields no flag and late_dropped == 1. All twenty shuffles produce an identical flag set.
Check yourself
- Replaying the same events in any arrival order within the lateness bound produces identical flags — the only property that makes this job re-runnable.
- Per-key state never exceeds that key's events within 660 seconds; assert it rather than hope.
- Every flag's window id derives from event time alone;
arrival_timeappears only in the watermark computation and the lateness metric.
Follow-ups
- Make the threshold and window configurable without rebuilding all in-flight state on every change. What does that constrain in the data structure?
- Two stream partitions hold events for the same token. What does that force the partitioning key to be?
- An event arrives three hours late. Does any answer change, and who is told?
Serve balances and statements without re-summing the ledger
Balances are derived from an append-only ledger_entry table: entry_id, transaction_id, account_id, direction (debit|credit), amount_minor, currency, source_type, source_id, business_date, posted_at. Reads run at 50,000/s from customer apps and merchant dashboards; postings run at 10,000 entries/s. Large accounts hold tens of millions of entries, so summing per read is not viable. Design the read path: where the current balance lives, when it is written relative to the entries, what a caller sees immediately after its own posting, and how a statement for a closed business date stays byte-identical on every future read. State your invalidation rule.
How a strong candidate works it
- Split the request into two reads with different requirements rather than one 'balance API'. Current balance is a single row that must be read-your-writes for the party who just posted. A statement is a bounded range over entries for one business_date window, and once that date's cutoff has passed the range is immutable, so it is cacheable indefinitely rather than for a guessed TTL.
- Write the materialised balance inside the same database transaction as the entries: UPDATE account_balance SET amount_minor = amount_minor + $delta, version = version + 1 WHERE account_id = $1, alongside the INSERTs. An asynchronous updater fed from the entry stream is the tempting alternative and is wrong here, because it makes the balance disagree with the system of record for exactly as long as the consumer lags, which is exactly when someone is refreshing the screen.
- Serve the balance as a primary-key lookup, O(1) and sub-millisecond, and serve the statement as an index range scan on (account_id, business_date, entry_id), O(k) in rows returned rather than O(n) in account history.
- Invalidate by writing through on commit, keyed by account_id and carrying the version, not by TTL. A TTL on a balance is a defined interval during which you knowingly display a stale number; a version lets a reader detect staleness instead of hoping.
- Price the design honestly: the extra balance UPDATE serialises postings on that account row, and that lock hold is what sets the per-account write ceiling. Measure it before deciding the read path is free.
Expected result (20 min): The full-history SUM costs hundreds of milliseconds to seconds and grows with history; the single-date SUM is bounded by the index range; the balance lookup is sub-millisecond and constant in account size. The second session sees the old balance until the posting commits and the new one immediately after, never a state where entries exist without the balance move. The closed-date statement is identical on both runs.
Check yourself
- SUM of entries signed by direction equals the materialised balance for every account, asserted on a schedule rather than once.
- The application role holds no UPDATE or DELETE privilege on ledger_entry.
- A backdated posting into an already-closed business date either fails or changes that date's statement; decide which, and have a test that proves which one happens.
Follow-ups
- A customer disputes a balance shown three months ago. Reproduce that exact number from the data you kept.
- The materialised balance and the sum of entries differ by one minor unit. How do you find that before the customer does, and what do you do about it?
- What changes if the balance must also be readable from a second region with a 70 ms round trip?
Settlement postings plateau at 310 per second and deadlock
Ledger postings against one pooled merchant settlement account plateau at about 310 committed transactions per second. Adding workers beyond 24 raises latency linearly and leaves throughput flat. Separately, about 0.4% of two-account transfers abort with SQLSTATE 40P01. Each posting takes SELECT ... FOR UPDATE on the materialised balance row, validates a floor, inserts the entries, then updates the balance. Explain both numbers, give the fix for each, and state precisely what sharding the hot balance would cost the floor check.
How a strong candidate works it
- Compute the ceiling instead of guessing. A row-level exclusive lock serialises every transaction touching that row, so the maximum committed writes per second is 1 divided by the lock hold time, where hold runs from FOR UPDATE to COMMIT and includes the entry inserts, the WAL flush and anything else inside the transaction. 310 per second implies about 3.2 ms held. Measure it via pg_locks joined to pg_stat_activity rather than inferring it.
- Recognise what the flat-throughput, rising-latency curve proves. Past the ceiling, extra workers only lengthen the wait queue; that is serialisation, not saturation, and no amount of CPU, replicas or pool size changes it. Establishing this rules out the three most common wrong fixes before proposing anything.
- Shorten the critical section before sharding anything. Take the lock last, never hold it across an application round trip or a processor call, and replace SELECT-then-UPDATE with one conditional statement: UPDATE balance SET amount_minor = amount_minor - $1 WHERE account_id = $2 AND amount_minor - $1 >= floor_minor. It needs no prior read and holds the row only for that statement, so halving hold time doubles the ceiling for free.
- Treat the 40P01 as a separate defect with a separate fix. Two transfers moving money in opposite directions between accounts A and B acquire the two row locks in opposite orders and wait on each other until the detector aborts one. Acquiring locks in a deterministic order, such as ascending account_id, makes the cycle impossible rather than merely rarer; a bounded retry on 40P01 remains prudent but is no longer the mechanism.
- Only then shard, and price it honestly. N sub-rows multiply the ceiling by roughly N, but the floor becomes a predicate over N rows that a single-row conditional UPDATE cannot express. Either each sub-row carries its own floor, which over-restricts by refusing a payment while funds sit on another shard, or you move to SERIALIZABLE with a bounded retry on 40001 across the sub-rows. Establish first whether this pooled clearing account has a floor at all, because if it does not, sharding is nearly free and the whole trade-off disappears.
Follow-ups
- At what N does rebalancing funds between sub-rows cost more than the throughput it buys?
- How would you measure lock hold time in production without attaching a profiler?
- Does moving to SERIALIZABLE eliminate the 40P01 aborts?
Reverse a sharding decision after production contradicted it
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.
How a strong candidate works it
- 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.
- 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.
- 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.
- 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.
- 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.
- 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-ups
- 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?
Where candidates lose points
Calling a compensating action a rollback
A saga's compensation is a new, externally visible business event, not an undo. A refund after a capture leaves both movements on the customer's statement, may not return the scheme fee, and lands days later rather than immediately. Designing a multi-service flow as though the compensation restores the prior state produces flows that turn out to be unimplementable at the final step, when the thing that needs undoing has already left the building. The sequence has to be ordered so the irreversible step is last and the reversible ones precede it, with an explicit pending state shown to the customer while a compensation is in flight.
Assuming the default isolation level enforces the invariant you wrote down
PostgreSQL defaults to READ COMMITTED, where every statement takes a fresh snapshot, so a read-modify-write on a balance loses updates under concurrency. Its REPEATABLE READ is snapshot isolation, which blocks that particular anomaly by aborting the loser with SQLSTATE 40001 but still permits write skew across two different rows; only SERIALIZABLE closes that, and both levels therefore require a bounded retry loop on 40001 that many implementations simply never write. MySQL's InnoDB REPEATABLE READ behaves differently again — it does not abort on a conflicting write, so the identical application code silently changes behaviour when the engine changes. Two-sided transfers add a second failure mode on top: without a deterministic lock ordering, such as always locking account ids in ascending order, concurrent opposing transfers deadlock (SQLSTATE 40P01).
Hardcoding to the sample inputs
Solve the stated problem rather than the two examples; special-casing a literal to make a sample pass is obvious immediately and reads as either a misunderstanding or an attempt to fake progress. If you genuinely cannot generalise yet, say which part is a stub and what would replace it.
A queue or buffer with no bound
Every producer-consumer boundary needs a capacity and a policy for reaching it: block the producer, shed load, or drop the oldest entry. Unbounded buffering converts a temporary slowdown into memory exhaustion and hides the backpressure signal that would have revealed the consumer was falling behind.
Design first, for a senior candidate
For a candidate senior enough that the loop turns on design and judgement rather than on whether the coding round gets finished. Five days build one system properly and then stress it; coding gets a single maintenance day, on the assumption that the risk at this level is an unexamined tradeoff rather than a missed algorithm.
Day 1 — Numbers before diagrams
- Build your own reference card of the figures you will re-derive all week: bytes for a realistic record, requests per second implied by a given daily active count, and the storage that a year at a given write rate produces. Derive each one rather than copying it, because the derivation is what survives a follow-up.
- Turn one product statement into capacity requirements. From ten million daily users at four writes and forty reads each, state the peak-to-average factor you are assuming and why, then produce peak write QPS, peak read QPS and a year of storage.
- Write the two numbers whose order of magnitude changes the design, the read-to-write ratio and the working-set size against memory per node, and state the threshold at which each one flips your answer.
Deliverable: A one-page numbers card and one worked capacity estimate with every assumption written down.
Day 2 — One system, from requirements to schema
- Spend the first ten minutes producing only functional requirements, non-functional targets with numbers attached, a p99 latency, a durability expectation, a consistency requirement, and an explicit out-of-scope list.
- Define the interface before the boxes: the three or four endpoints, their parameters, what each returns, and which of them are idempotent.
- Write the data model, then write the single access pattern that justifies it, and state what the schema would have to become if the dominant access pattern were the other one.
Deliverable: One design carried to endpoint-and-schema depth, with non-functional targets expressed as numbers and a written out-of-scope list.
Day 3 — The consistency you are actually buying
- Write out what a client sees under asynchronous replication when its write commits on the leader and its next read is served by a lagging follower, then write the two fixes, pinning that session's reads to the leader for a bounded window or carrying a version token the replica must reach, and the cost of each.
- Work the quorum arithmetic on paper for N of three with W and R of two, and separate what R + W > N does guarantee, that any read set intersects any write set, from what it does not: on its own it is not linearizability, and a sloppy quorum that accepts writes on nodes outside the preference list breaks even the intersection.
- Take two storage choices with different defaults, a single-leader relational store committing synchronously and a quorum-replicated store that converges eventually, and write the specific product behaviour that would be wrong under each, rather than a general statement about which is stronger.
Deliverable: A page separating what quorum overlap guarantees from what it does not, with one concrete product misbehaviour attached to each gap.
Day 4 — Failure is the design
- For one write path, work through the case where the client times out after the server has already committed, then design the idempotency key: who generates it, how long it is retained, and what the duplicate request returns.
- Express the retry policy as parameters rather than as a word: maximum attempts, base delay, backoff factor, jitter, and which error classes are retried at all. Then state why retrying a non-idempotent write without a key is a correctness bug and not merely waste.
- Compute the fan-out effect on tail latency. If a request waits on ten backends and each independently exceeds its p99 one percent of the time, the chance at least one is slow is 1 - 0.99^10, about ten percent. Then write why independence is the optimistic assumption and what correlates them in practice.
- Name the backpressure mechanism for one queue or one dependency in the design, a bounded queue with shedding or a concurrency limit, and write what the caller is told when it engages.
Deliverable: One write path with an idempotency design, a parameterised retry policy, and a written tail-latency calculation with its assumption named.
Day 5 — Scaling the hot path
- Choose cache-aside or write-through for one read path and write the staleness window each produces, then name the invalidation event and what the system does when that event is lost.
- Design against the stampede: either coalesce requests so only one recomputes a missing key, or refresh early with jittered expiry, and write why identical TTLs on keys populated in the same moment produce a synchronised expiry and a thundering herd.
- Shard one table by a key you choose, then answer the two questions that break the choice: which queries now require a scatter-gather, and what happens to the distribution when one tenant is a hundred times larger than the median.
- Write the cost of adding a node under plain modulo placement, where nearly every key moves, against consistent hashing, where roughly one key in n+1 moves, and state what virtual nodes are for.
Deliverable: A caching and sharding decision for one path, each with its failure mode and its rebalancing cost written beside it.
Day 6 — Keep the coding hand in, at the bar that applies to you
- Solve one medium problem in thirty minutes, then spend twenty more making it production-shaped: named invariants, validation at the boundary, and errors that distinguish a caller mistake from an internal fault.
- Write the tests you would require of a colleague's version of that function: one for empty input, one for the boundary, and one for the case the implementation is most likely to get wrong.
- Read a piece of your own code from six months ago and write the change you would ask for, phrased as you would actually phrase it in review.
Deliverable: One problem hardened to review standard, with its test list and one written review comment.
Day 7 — Defend it while being interrupted
- Run a forty-five-minute design mock with an interviewer briefed to change a requirement halfway, a tenfold traffic increase or a new strict consistency requirement, and to push on one number you estimated.
- Rehearse the two sentences a senior loop is listening for: naming the tradeoff you are choosing against and why, and saying what you would measure to learn that the choice was wrong.
- Prepare the design you regret: a real decision, the constraint that produced it, what it cost, and what you changed afterwards.
Deliverable: Mock notes recording how the design changed under the new requirement, plus a written account of one regretted decision.