cswork / libraryOpen practice workspace

Interview guides · GM Financial

GM Financial Software Engineer Interview Questions & Guide 2026

API design practice for GM Financial software engineer candidates: compatibility, versioning, and changes you cannot take back.

The 60-second brief

As a Software Engineer at GM Financial, you play a vital role in building, scaling, and maintaining the technological infrastructure that powers global automotive finance solutions. Your work directly impacts millions of customers, dealers, and internal business partners by ensuring seamless digital applications, robust data processing, and secure transaction systems. Whether you are developing cloud-native microservices, modernizing legacy enterprise applications, or integrating complex third-party platforms, your engineering contributions drive the core financial operations of the business.

If the seat owns a service boundary, scope your preparation toward failure behaviour rather than topology. Retrying over an at-least-once channel produces duplicates by construction, so a retry policy is only as safe as the idempotency key underneath it.

Focus areas

  • Name the isolation level each invariant requires
  • Consume webhooks duplicated, delayed and out of order
  • Store money as integer minor units

Overview

The problem spaces you will encounter at GM Financial require a balance of technical precision and domain understanding. You will collaborate closely with product managers, system architects, and operations teams to deliver high-performance software across various technology stacks, including C#/.NET, Azure Cloud, Java/Spring Boot, and modern frontend frameworks like React. The scale of enterprise financial data demands rigorous attention to security, reliability, and architectural scalability, making this an ideal environment for engineers who thrive on solving complex, real-world distributed challenges.

Expect a fast-paced environment where your code directly translates to business value. While the technical scope is broad—ranging from customer-facing web applications to backend data pipelines—you will be expected to write clean, maintainable code and participate actively in system design and architecture discussions. Success in this role requires not only sharp coding skills and a strong foundational grasp of computer science principles, but also the collaborative mindset needed to navigate a large, matrixed corporate environment.

The GM Financial interview process

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

Automated Coding Assessment

The same problem is scored by two different mechanisms depending on the format, and preparing for one does not cover the other. With a person watching, partial progress is visible and a hint is a correction you can absorb; silence is the expensive failure, because nobody can read a half-written function. With an automated grader there is no partial credit for what you were about to do, nobody to ask, and the worked examples in the prompt are the entire specification. Read them as a contract, down to whether an empty result should be an empty list or no output at all.

What to demonstrate

  • In a live session, whether your commentary tracks what your hands are doing, and whether a hint redirects you or gets defended against
  • In an automated one, whether you cover the cases the examples do not show, since the hidden cases are where the score moves
  • Whether you manage the clock on purpose: abandoning an approach that is not converging while there is still time to write something simpler that finishes

How to prepare

  • Have someone hand you a problem and feed you one deliberately wrong hint. Practise testing it against a concrete case instead of accepting or rejecting it on authority.
  • Do one timed run a week in a plain browser editor with autocomplete, linting and your own snippets switched off, which is closer to what these environments give you
  • For the automated format, write the harness before the solution: a main that feeds the worked examples plus an empty and a single-element case and prints expected against actual, so a wrong submission is caught by you first

Recruiter Pre-Screen

The title covers product work, platform work, infrastructure, mobile and frontend, and those are different jobs with different loops behind them. A screening call is the cheapest place to find out which one the seat is, and asking reads as experienced rather than fussy. The questions that separate them: what the team is on call for, what the last three projects were, and whether any round happens inside an existing repository instead of a blank file. Then say which of that you have done and which you have not. Claiming the whole posting is the fastest way to be found out one round later.

What to demonstrate

  • Whether you can locate your experience inside one flavour of the role honestly instead of claiming the entire requirements list
  • Whether you name what you have not done, which an experienced screener reads as a level signal and can plan the loop around
  • Whether what you want next matches what the seat is: someone who wants greenfield work landing on a team that mostly operates an existing system is a hire that leaves within the year

How to prepare

  • Mark every line of the posting as done, adjacent or new, and write one sentence for each adjacent line naming the closest thing you actually built
  • Split your last two years into rough percentages across feature work, operating and debugging live systems, and design or review, so a question about scope gets numbers rather than adjectives
  • Bring three questions that discriminate between seats: what the team is paged for, how much of the work is changing existing code versus standing up something new, and what shipped in the last quarter

Technical Interview

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 to demonstrate

  • 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

Panel Interviews

A day like this is several different games in a row, and the expensive mistake is carrying the previous one into the next room. Coding rewards narrow precision and finishing inside a timer. Design rewards breadth, stated assumptions and naming what you are deliberately not building. Behavioural rewards specificity about people and decisions. Candidates who over-engineer a coding problem they were supposed to finish, or who start sketching class hierarchies before anyone has agreed what the system has to do, are usually still playing the last round. Between rooms, name out loud which game the next one is.

What to demonstrate

  • Whether the coding round ends with something that runs and has been traced against a degenerate input, rather than an extensible design that was never finished
  • Whether a design discussion opens by agreeing on traffic shape, read-to-write ratio and what is allowed to be stale, instead of proceeding from an architecture you arrived with
  • Whether a behavioural answer names a person, a disagreement and what you did about it, rather than describing the system the story happened inside
  • Whether the opening habits still appear late in the day: restating the problem, asking for constraints, saying the plan before typing

How to prepare

  • Book three mocks of different types back to back on one afternoon and ask each interviewer afterwards which round you answered in the wrong mode
  • Write a three-line opening script per round type — coding: restate, name the approach and its cost, then type; design: ask for scale, read-write mix and what must not break; behavioural: name the person, the stakes and the decision — and run it off a card so the switch is mechanical rather than remembered
  • Practise coding with a timer you do not extend, stopping when it stops, so the trained reflex is to finish a correct solution rather than to keep improving one

Where candidates lose points

Treating money as a decimal with two places

ISO 4217 exponents are 0 for currencies such as JPY and KRW, 2 for most, and 3 for BHD, KWD, JOD, OMR and TND, so a hard-coded multiply-by-100 is off by a factor of 100 or 10 depending on the currency, in opposite directions. Floating point is worse: IEEE 754 binary64 cannot represent 0.1 exactly, so repeated accrual accumulates drift that appears as a handful of minor units in the daily reconciliation and then gets 'fixed' by widening the match tolerance, which is how a genuine break becomes invisible. The only forms that survive a reconciliation are integer minor units with the exponent carried alongside the currency code, or a fixed-scale decimal type with exactly one documented rounding point.

Retrying a charge after a timeout

A timeout is not a failure; it is an unknown outcome, and the request may have been processed in full with only the response lost. Re-sending it without an idempotency key that the processor itself honours produces a duplicate charge, which is a customer-visible incident and usually a dispute. The correct handling is to treat the state as unknown, query the processor for that key or client reference, and only then decide. The mechanism also depends on the key being generated once by the caller and reused across every attempt — generating a fresh key per retry turns the whole scheme into a no-op while leaving all the code that appears to implement it in place.

Writing code before the input contract is pinned down

Before the first line, state the types, the size bounds, whether duplicates, negatives or an empty input are possible, whether the input is sorted, whether you may mutate it, and what the function returns when nothing matches. Every one of those answers changes the code, and discovering one at minute twenty costs a rewrite you no longer have time for.

Issuing one query per row of a result set

Fetch related rows in a single batched query keyed by the ids you already hold, or join them into the original query. A per-row round trip multiplies network latency by the row count, and it looks perfectly fine against the ten rows in your development database.

Questions & practice

Write a function to reverse a linked list or solve a standard array manipulation problem in your preferred programming language.

Medium · data structures and algorithms

Approach

  1. Restate the input: its shape, its size, and what is guaranteed about it.
  2. Choose the data structure from the access pattern, not from familiarity.
  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?
  • Which test case would catch an off-by-one here?

Explain the core concepts of Object-Oriented Programming (OOP) and how you apply them in enterprise systems.

Medium · data structures and algorithms

Approach

  1. Walk one small example through your approach before writing the whole thing.
  2. Name the brute-force solution and its complexity before improving on it.
  3. Choose the data structure from the access pattern, not from familiarity.

Follow-up

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

Explain the purpose and inner workings of the Java Virtual Machine (JVM) or your primary runtime environment.

Medium · data structures and algorithms

Approach

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

Follow-up

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

What is a REST API, and how do you ensure it remains stateless and secure?

Medium · data structures and algorithms

Approach

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

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?

Parse settlement amounts into minor units without floating point

Easy · parsing · money representation · iso-4217

A settlement file carries amounts as text: 1234.56, -0.07, 1,234.5, 1234, 12 345,67, and for three-exponent currencies 1.234. Each line also carries an ISO 4217 alphabetic code and the file's sign convention. Write the function converting one amount string plus its currency into a signed amount_minor int64, scaling by the currency's ISO 4217 exponent — 0 for JPY and KRW, 2 for USD and EUR, 3 for KWD and BHD. Reject anything not convertible exactly, with a typed reason. Never raise, never use a float.

Approach

  1. Fix the contract first: return a result union of Ok(int64) or Err(reason) with reason drawn from unknown_currency, malformed, too_many_fraction_digits, overflow. A parser on this path that throws turns one bad line into a failed fifty-million-line run.
  2. Decide which character is the decimal mark from the file's format spec, not from a heuristic on the last separator, then strip grouping separators. 1,234 is genuinely ambiguous between 1234 and 1.234 exactly when the exponent is 3, so an unstated spec is a reject, not a guess.
  3. Split on the decimal mark. Right-pad the fraction with zeros to the currency's exponent e; if it is longer than e and any dropped digit is non-zero, return too_many_fraction_digits. Silently truncating is how half a minor unit becomes a reconciliation break nobody can source.
  4. Build the integer by digit accumulation on int64 with checked multiply and add, or equivalently concatenate the integer and padded-fraction substrings and parse once. Either way the value never passes through a float.
  5. Apply the sign last, from the convention the file declares — trailing CR/DR, a trailing minus as in 1234.56-, or a leading minus. Assuming a leading minus silently flips every credit in a file that uses the trailing form.
  6. Complexity is O(len) per amount with no allocation beyond a digit buffer, so the whole file is one O(total bytes) pass.

Worked solution (20 min)

  1. Write the exponent table {JPY:0, KRW:0, USD:2, EUR:2, KWD:3, BHD:3} and the result type before any parsing code.
  2. Implement integer-only scaling: split on the decimal mark, pad or reject the fraction, concatenate, parse once with checked arithmetic.
  3. Table-test: (1234.56, USD) to 123456; (1234, JPY) to 1234; (1.234, KWD) to 1234; (1234.56, JPY) to Err(too_many_fraction_digits); (0.005, USD) to Err; (-0.07, USD) to -7.
  4. Write the float implementation beside it and run both over ten thousand random amounts across all six currencies, printing every disagreement.
  5. Round-trip: format amount_minor back to text at the currency's exponent and assert equality with the normalised input.

Expected result: All six table cases pass. The float version agrees with the integer version on ordinary two-decimal USD amounts — which is exactly why it survives review — and disagrees on every JPY amount by 100x, every KWD amount by 10x, every input with more fraction digits than the exponent (which it rounds instead of rejecting), and under truncation, where int(1.15 * 100) yields 114.

Verify it yourself

  • For every input that parses, formatting the result back at the currency's exponent reproduces the normalised input string.
  • No code path returns a value when the fraction is longer than the exponent and a dropped digit is non-zero.
  • The function body contains no float, no double and no %f — grep is a legitimate test here.
  • The sum of parsed minor units over a whole file equals the file's own control total exactly, with no tolerance applied.

Follow-up

  • Exponent 4 exists (CLF). Does the exponent come from a compiled-in table or from the file header, and what happens when the two disagree?
  • A line has more fraction digits than the exponent allows. Is that a reject, a round, or a break — and who makes that call?
  • How do you fuzz this so that every failure is a typed rejection rather than a wrong number?

Stop the capture-and-refund join from double-counting merchant money

Medium · join fan-out · conditional aggregation · grain

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.

Approach

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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-up

  • 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?

Add and backfill business_date on a live ledger table

Hard · online migration · lock queue · backfill

ledger_entry holds 4 billion rows, is append-only, takes 10,000 inserts per second, and every reporting query currently derives the business date as posted_at::date. You must add business_date date NOT NULL, populated from the cutoff rule (17:00 in the account's own timezone), backfilled across all history, indexed, and cut over, with no write downtime and no long-held lock. Give the ordered migration steps with the lock each one takes, how you make the backfill restartable and throttled, and how you retire the old expression safely.

Approach

  1. Add the column nullable and with no default. ALTER TABLE ... ADD COLUMN takes ACCESS EXCLUSIVE but is a catalogue-only change held for microseconds. The hazard is the lock queue, not the statement: a blocked ALTER waits behind one long reader holding ACCESS SHARE, and every query arriving afterwards queues behind the ALTER's pending ACCESS EXCLUSIVE, so set lock_timeout to a couple of seconds and retry rather than letting a metadata change take the table down.
  2. Deploy the write path before the backfill, so new inserts populate business_date from the cutoff rule while reads stay on the old expression. The backfill then chases a closed set with a fixed upper bound instead of a moving target.
  3. Backfill in bounded batches keyed by entry_id range, on the order of 50,000 rows per statement, committing between batches and recording the high-water mark in its own table so a killed run resumes instead of restarting. WHERE business_date IS NULL makes each batch idempotent, and the pacing is set by replica lag and dead-tuple growth rather than CPU, since each UPDATE writes a new row version and the WAL volume is proportional to the rows touched.
  4. Install the constraint without a blocking scan: ALTER TABLE ... ADD CONSTRAINT ck_business_date CHECK (business_date IS NOT NULL) NOT VALID takes a brief ACCESS EXCLUSIVE and scans nothing, then VALIDATE CONSTRAINT takes SHARE UPDATE EXCLUSIVE and runs alongside reads and writes. On PostgreSQL 12 and later, SET NOT NULL can then use the validated CHECK and skip its own full scan; on 11 and earlier it always scans, so the CHECK is the migration on those versions.
  5. Build the index with CREATE INDEX CONCURRENTLY, which avoids ACCESS EXCLUSIVE at the cost of two table passes, cannot run inside a transaction block, and on failure leaves an INVALID index that must be dropped and rebuilt rather than reused.
  6. Cut over behind a flag: run the new and old expressions side by side for one reporting cycle and compare totals per day, since the cutoff rule will legitimately move entries near 17:00 across the boundary. Only once they reconcile do you retire the posted_at::date expression index, and you keep posted_at as the ordering key rather than repurposing it.

Worked solution (40 min)

  1. Write the migration as numbered SQL statements, annotating each with its lock mode and expected duration, and set lock_timeout plus a retry around every ALTER.
  2. Rehearse on a 10-million-row copy under a concurrent insert load, measuring per-batch duration, WAL generated and replica lag.
  3. Kill the backfill at a random point, restart it, and confirm it resumes from the high-water mark.
  4. Add the CHECK as NOT VALID, VALIDATE it while inserts continue, then SET NOT NULL and build the index CONCURRENTLY.
  5. Run old and new date expressions side by side for one cycle and diff the daily totals before dropping the old expression index.

Expected result: A migration in which no statement holds ACCESS EXCLUSIVE for more than a few milliseconds, every batch is bounded and resumable, VALIDATE and CREATE INDEX CONCURRENTLY run while writes continue, and the only rows whose daily bucket changes are those between 17:00 local and midnight UTC on their posting day.

Verify it yourself

  • SELECT count(*) FROM ledger_entry WHERE business_date IS NULL reaches exactly zero before the constraint is validated.
  • During the backfill, an INSERT from another session returns in single-digit milliseconds and no session waits on a relation lock longer than lock_timeout.
  • After a kill and restart, the count of rows updated equals the row count exactly once, with no batch re-applied and none skipped.
  • pg_index.indisvalid is true for the new index; an INVALID index means the CONCURRENTLY build failed and must be dropped, not left in place.

Follow-up

  • Reporting now wants ledger_entry partitioned by business_date. Why can this not be another ALTER, and what is the migration instead?
  • Nightly totals move for the days around the cutoff change. How do you tell a correct restatement from a backfill bug?
  • The backfill is halfway done when a replica falls 20 minutes behind. What do you throttle, and what do you refuse to throttle?

How would you design a high-availability microservices architecture hosted on Azure Cloud?

Medium · system design and architecture

Approach

  1. Name the read and write paths separately; they rarely have the same bottleneck.
  2. Fix the scope first: who calls this, how often, and what they do when it fails.
  3. Name the failure you are designing for, then the recovery path.

Follow-up

  • What breaks first when traffic grows ten times?
  • What would you drop to keep the system up under load?

What strategies do you use to ensure zero-downtime deployments in production environments?

Medium · system design and architecture

Approach

  1. State the consistency you need, and where you are willing to be stale.
  2. Choose a partition key and say what query it makes expensive.
  3. Name the failure you are designing for, then the recovery path.

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?

Authorise during a ledger outage with bounded exposure

Hard · consistency vs availability · failover · replay · bounded risk

Authorisations run at 3,000/s with a 150 ms p99, and a hold on a deposit account is what keeps that account above its floor, so an authorisation normally requires a ledger write. The ledger's primary becomes unreachable for 20 minutes while your orchestrator and the processor stay healthy; the scheme answers on your behalf if you take longer than 2 seconds. Decide what you authorise during the outage, bound the exposure in currency, design the replay that runs when the ledger returns, and state exactly what can be lost.

Approach

  1. State the two extremes so the middle is a choice rather than a drift. Declining everything is a 20-minute outage for every customer and hands the decision to the scheme anyway. Approving everything accepts unbounded overdraft, because the floor is unenforceable without the balance. The deliverable is a bounded stand-in whose exposure is a currency figure agreed in advance.
  2. Bound it per party rather than globally: a last-known available balance from a read replica or a periodically refreshed per-party snapshot, less a haircut for staleness; a per-party amount cap; a per-party approval count cap; a maximum snapshot age past which no stand-in is offered; and a global kill switch. Exposure is then parties active in the window times amount cap times count cap, computable before the incident.
  3. Record each stand-in approval durably outside the ledger, in a replicated append-only log carrying the same idempotency key the authorisation used, and treat it as an unposted obligation rather than a posting. Name the loss precisely: the snapshot is stale, so concurrent spend on one account during the window can breach the floor by at most the amount cap times the count cap per party. That is the loss being chosen, and it has to be quantified rather than discovered on the other side.
  4. Make recovery a replay, not a reconstruction. Replay the stand-in log into the ledger in idempotency-key order, posting each hold under its original key so a half-finished replay is safe to restart. Accounts the replay pushes below floor go to an exceptions queue for waiver, collection or write-off, because silently posting an overdraft nobody reviews converts a known risk into an unknown one.
  5. Treat the failover question as separate and answer it explicitly. Promoting an asynchronous standby to stay available accepts an RPO above zero on the system of record: acknowledged postings can simply cease to exist, and you cannot enumerate which ones. Prefer riding the outage under a bounded stand-in whose loss you can state. If instead you run synchronous cross-region replication, price it honestly against the budget: one inter-region round trip is added to every commit, tens of milliseconds against a 150 ms p99.

Worked solution (35 min)

  1. Write the decision table: eligible transaction types, per-party amount cap, per-party count cap, maximum snapshot age, global kill switch.
  2. Compute exposure as eligible parties in a 20-minute window times amount cap times count cap, and take that figure to a named owner before writing any code.
  3. Implement the stand-in log as a replicated append-only store keyed by the authorisation's own idempotency key.
  4. Simulate the outage: approve under stand-in for 20 minutes, restore the ledger, replay in key order, and count accounts driven below floor.
  5. Kill the replay halfway, restart it, and compare the posted set against the completed run.

Expected result: A stated currency bound on maximum overdraft exposure for the outage, a replay that posts every stand-in authorisation exactly once, and an exceptions queue containing precisely the accounts driven below floor rather than those postings being absorbed silently. The interrupted replay and the clean replay produce identical posted sets, because each posting is keyed by the original idempotency key.

Verify it yourself

  • Ledger postings after replay equal the stand-in log row count, with nothing posted twice.
  • Flipping the kill switch mid-outage stops new approvals without invalidating approvals already given.
  • The design states the loss in currency: at most amount cap times count cap per party and nothing beyond it. If it cannot be stated in currency, it is not bounded.

Follow-up

  • Someone learns stand-in is active and farms the cap across many accounts. What detects that during the outage rather than after?
  • Which transaction types do you never stand in for, and why those specifically?
  • Is turning stand-in on automatic or human, and what does the automatic version do during a network partition that only looks like a ledger outage?

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 — Rebuild the primitives by implementing them

  • Implement a dynamic array with doubling growth and an operation counter, then change the growth rule to add a fixed sixteen slots instead, and time both for n of ten thousand, a hundred thousand and a million. The fixed-increment version resizes n/16 times at O(n) each, so its total work is quadratic; doubling is what makes append amortised constant.
  • Implement a hash map with separate chaining and a load-factor resize, then insert ten thousand keys engineered to land in one bucket and record what happens to lookup time, so that average-case O(1) becomes a claim with a stated precondition rather than a reflex.
  • For dynamic-array append and hash-map insert, write down which cost is amortised rather than worst-case, which single operation pays the whole bill, and what a system with a hard per-operation deadline would have to do instead.

Deliverable: Two working implementations plus a timing table showing the input at which each structure's advertised complexity stops holding.

Day 2 — Arrays under an invariant: two pointers, sliding window, binary search

  • Solve longest-subarray-with-sum-at-most-K using a sliding window, then run it on an input containing negative numbers and watch it return the wrong answer: extending the window only moves the sum monotonically when every element is non-negative, and that precondition is the whole reason the technique works.
  • Write the binary search that finds the first index satisfying a predicate rather than an exact value, put the loop invariant above the loop in a comment, and verify termination on the two inputs that break careless versions: the empty range, and a range where every element satisfies the predicate.
  • Compute the midpoint as lo + (hi - lo) / 2 and write one line on why the obvious (lo + hi) / 2 is a genuine defect in a fixed-width integer type and a non-issue in a language with arbitrary-precision integers.

Deliverable: Three solved problems, each with its invariant written above the loop, plus one recorded input on which the sliding window is provably wrong.

Day 3 — Sorting, heaps, and the greedy argument that has to be proved

  • Solve one top-k problem three ways, by full sort, by a size-k heap, and by quickselect, then write the values of n and k at which each becomes the right choice, along with quickselect's quadratic worst case and why a randomised pivot makes that unlikely rather than impossible.
  • Implement bottom-up heapify and count sift-down steps to confirm it does linear work rather than n log n, because most nodes sit near the bottom of the tree and therefore move only a short distance.
  • Take interval scheduling by earliest finishing time and write the exchange argument out in full: given any optimal schedule, swapping in the earliest-finishing interval keeps it feasible and no smaller. Then construct the weighted variant where that same greedy fails and name what has to replace it.

Deliverable: A three-way top-k comparison with measured crossover points, one written exchange argument, and one counterexample to a greedy rule that looks almost identical.

Day 4 — Recursion, memoisation, and the step to a table

  • Take one problem with overlapping subproblems, such as edit distance or coin change, instrument the plain recursion with a call counter to show the blow-up, then add memoisation and re-count.
  • Convert the memoised version to a bottom-up table and state the two properties you relied on: each subproblem's result depends only on its arguments, and the dependencies form a DAG you can enumerate in order.
  • Rewrite one deep recursion with an explicit stack, then find the input length at which the original hits the interpreter's frame limit, which defaults to about a thousand frames in CPython, so you know when the rewrite is required rather than decorative.

Deliverable: One problem in three forms, naive, memoised and tabulated, with call counts for each and the input length at which recursion depth becomes the binding constraint.

Day 5 — Graphs, where most of the work is choosing the traversal

  • Implement BFS and DFS over one adjacency list, then answer for each which finds a shortest path in an unweighted graph and which you would use to detect a cycle in a directed graph, including why the in-progress versus finished distinction matters for the second.
  • Implement topological sort by in-degree, feed it a graph containing a cycle, and confirm the failure signature is that fewer than V nodes come out rather than an exception, then note that the order it produces is one of several valid ones.
  • Run a shortest-path search on a graph with a single negative edge weight and show the wrong answer, then write the precondition Dijkstra actually needs, non-negative weights, because it finalises a node's distance the first time that node is popped, and name the algorithm you would switch to and its own limit.

Deliverable: A small graph library with BFS, DFS and topological sort, plus two inputs that produce documented wrong answers under the wrong algorithm choice.

Day 6 — One day for everything that is not an algorithm

  • Sketch one system only to the depth a coding-heavy loop tends to reach: the endpoints, what the service stores, and the single query pattern that decides the schema. Stop at twenty-five minutes.
  • Prepare the project answer for an interviewer who codes, which means rehearsing the two levels they push to: the specific thing you built, and why you chose that approach over the alternative they will name. Open with a number and be ready to say what it excludes.
  • Prepare the answer to what you would do differently, choosing a real technical mistake with a specific fix rather than a complaint about process or staffing.

Deliverable: One design sketch at endpoint-and-schema depth, plus a project answer rehearsed to two levels of follow-up.

Day 7 — Solve out loud, under time

  • Do three timed problems at twenty-five minutes each in a plain editor with no autocomplete and no execution until the end, then tally separately the failures that were syntax and the ones that were approach, because those two numbers call for different fixes.
  • Narrate one solution from the first sentence, stating the approach and its complexity before writing any code, and rehearse the sentence you will use when you realise mid-solution that the approach is wrong.
  • Re-solve from blank the two problems you were slowest on this week and compare the times against the day they first appeared.

Deliverable: A recording of one fully narrated solution and a tally that separates syntax failures from approach failures.

Disagreement needs an outcome you did not choose

Conflict answers where you were right and everyone came round are the weakest ones. Stronger: the evidence you went and collected, what would have changed your mind, and what you did in the weeks after the call went against you. Implementing a design you argued against, properly, is a specific and checkable behaviour.

Explore your communication style, teamwork, past project experiences, and alignment with corporate expectations.

Medium · behavioural and engineering judgement

Approach

  1. State the situation in two sentences and spend the rest on the reasoning.
  2. Name the disagreement and how you resolved it with evidence.
  3. Close with what you would do differently, concretely.

Follow-up

  • What did you decide not to do, and why?
  • What would you do differently if you ran that again?

Give an example of a time when a production deployment failed. How did you diagnose and remediate the issue?

Medium · behavioural and engineering judgement

Approach

  1. State the situation in two sentences and spend the rest on the reasoning.
  2. Give the blast radius: what could have broken, and what you measured.
  3. Close with what you would do differently, concretely.

Follow-up

  • What did you decide not to do, and why?
  • How did you know your change caused the improvement?

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?