Interview guides · EnergyHub
EnergyHub Software Engineer Interview Questions & Guide 2026
Take-home style practice for EnergyHub software engineer candidates: a small service, a fixed budget, a written tradeoff.
The 60-second brief
As a Software Engineer at EnergyHub, you will build the core technology that enables a cleaner, more reliable energy grid. EnergyHub is a pioneer in the Distributed Energy Resource (DER) landscape, developing enterprise-grade software that aggregates and manages millions of connected grid-edge devices like smart thermostats, electric vehicle chargers, and home batteries. By orchestrating these devices, your code directly prevents blackouts, reduces reliance on fossil fuels, and helps utilities transition to 100% renewable energy.
Seniority moves the scope further than the words in the title do. An earlier-career loop mostly checks that you implement something correctly and can reason about its cost, while a senior loop checks that you can pick between two defensible designs and say what you gave up.
Focus areas
- Fold late, out-of-order scan events by event time
- Make every external side effect safe to retry
- Reserve inventory without overselling under concurrent allocation
Overview
The engineering challenges here are distinct and complex. You will work on distributed systems that must process massive streams of real-time telemetry data, maintain high availability, and execute precise load-shedding commands during critical grid events. This is not just about building standard APIs; it is about writing highly performant, scalable, and resilient software that interacts with the physical world.
Whether you are working on the Commercial & Industrial (C&I) team or as a, your contributions will directly impact grid stability. You will collaborate with cross-functional teams of product managers, data scientists, and hardware partners to solve some of the most pressing engineering problems in the climate tech space today.
The EnergyHub interview process
EnergyHub candidates report 5 rounds · ≈ 4-6 weeks. The stages below are what candidates describe, not a published process.
Recruiter Screen
Half of this call is the part candidates treat as small talk: start date, notice period, work authorisation and its timing, location and time zone, on-call, and the number. Those are what kill offers late, after several engineers have each spent a day. Surfacing a hard constraint now costs you nothing and occasionally buys you something, since a loop compressed to fit a competing deadline can usually only be arranged if it is asked for early. The common failure is deflecting the compensation question twice, then discovering at offer stage that the band never reached your number.
What to demonstrate
- Whether your hard constraints are compatible with the role before a loop gets booked: earliest start, notice period, what authorisation you hold and when it needs action, days on site, willingness to carry a pager
- Whether you give a compensation range with something behind it, such as current total compensation or a competing timeline, rather than leaving the band untested
- Whether your stated timeline is real, since a competing deadline raised now is something scheduling can sometimes work around and the same deadline raised at offer stage usually is not
How to prepare
- Write each constraint down in one line before the call and state them as facts rather than negotiating them live under a question you were not expecting
- Set your range from two or three current data points for that level and location, and name the structure you are quoting in, so the number is comparable to the one they are holding
- If another process is running, say where it stands and by when, and ask directly whether this loop can be scheduled inside that window
Technical Screen
Most of the time lost in this format is not lost to thinking. It goes to a standard-library call you half-remember, an off-by-one in a loop bound, and a debugging loop that mutates code at random until something passes. When output is wrong, stop re-reading the whole function: take the smallest input that reproduces it and walk the state through by hand, printing intermediates if the environment allows. Guessing at a fix without a failing case you understand is how a five-minute bug becomes twenty, and the clock does not pause while you do it.
What to demonstrate
- Whether you reach the right structure without a detour, and can write it from memory rather than only recall that one exists
- Whether overflow is considered where the language has fixed-width integers, since a signed 32-bit value stops at 2,147,483,647 and then wraps in Java, is undefined behaviour in C++, and does not arise in Python, whose integers grow instead
- Whether recursion depth is treated as a constraint on large inputs, given that CPython's default limit is 1000 frames and a deep recursion can exhaust the stack in any language where an iterative version would not
- Whether a failing case is isolated and explained before any edit is made to the code
How to prepare
- From an empty file and with no references open, implement the pieces you lean on most: a heap push and pop, an iterative DFS with an explicit stack, and a binary search whose midpoint is written lo + (hi - lo) / 2, which avoids the overflow that (lo + hi) / 2 can hit in a fixed-width integer type
- Time yourself on the ten library calls you look up most, such as sorting with a custom comparator, splitting and joining strings, and finding the next key at or above a value in an ordered map, until the lookup is gone
- Take a solution you know is broken and, before touching it, write one sentence naming the input, the expected value and the actual value. Repeat until you do it without deciding to.
Take-Home Coding Assessment
A test suite is read as a statement about what you believe can break, which is why the selection matters far more than the count. Assertions that repeat the happy path with different numbers tell a reviewer nothing. Cases at the boundaries tell them where you looked: empty input, one element, duplicates, a malformed row, a value at the edge of the accepted range. The sharpest question to ask of a test you have just written is whether it would fail if the logic were wrong. If deliberately breaking the function leaves the suite green, that test is documentation rather than a check.
What to demonstrate
- Which cases you chose, and whether the suite reaches empty, single, duplicate and malformed inputs instead of the prompt's example with the numbers changed
- Whether the suite is deterministic and runs offline, so it produces the same result on a reviewer's machine as on yours
- Whether assertions are written against behaviour rather than internals, so a reasonable refactor does not force the tests to be rewritten
- Whether a failure is readable on its own, naming the input and the expected value rather than reporting a bare assertion
How to prepare
- Mutate your own code by hand before submitting: change a < to <=, drop the last iteration of a loop, return the input unchanged. Anything that stays green is a missing test, and this takes minutes
- Remove nondeterminism before writing assertions. Pass the clock in rather than calling now() inside the logic, seed any random generator explicitly, and do not assert on the iteration order of a Go map or a Python set, since Go randomises that order deliberately and Python salts string hashing per process unless PYTHONHASHSEED is pinned
- Time a full run of the suite and trace anything slow to its cause, usually a sleep or a real network call, then replace it with a fake so the reviewer is not waiting
Final Panel Interview
Nobody in the room with you decides this. Interviewers typically write their rounds up separately, often before seeing anyone else's, and the outcome is settled later from those write-ups. A split panel gets resolved by whichever note carries specific evidence, so what you want out of each room is one concrete thing that person could write down: a bug you caught yourself, a trade-off you named, a decision you owned. The rest is arithmetic. The project you describe in a behavioural conversation is often the same system you sketched an hour earlier, and the two accounts have to agree.
What to demonstrate
- Whether the scale, team size and timeline you attach to a project hold steady when that project resurfaces in a different round
- Whether each interviewer leaves with a specific thing to cite rather than a general impression of competence
- Whether a trade-off you defended in one round survives a challenge in another, instead of being quietly swapped for the answer the new interviewer seemed to want
- Whether a question you have already answered earlier in the day gets the same answer at the same depth, without visible impatience
How to prepare
- Write a one-page sheet per project fixing the figures you will quote — request volume, data size, team size, elapsed time, what broke — and say them aloud from the sheet until they come out identical every time
- For each round on the schedule, decide in advance the one sentence you want in that person's notes, then check in a mock that you said it outright instead of leaving it to be inferred
- Have someone ask you the same project question twice, an hour apart, and diff the two answers for numbers that moved or a trade-off that reversed
Informal Leadership Conversation
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 to demonstrate
- 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
Where candidates lose points
Retrying an irreversible external call without an owned idempotency key
Timeouts and connection resets are ambiguous by construction - the partner may have committed before the response was lost - so a generic retry policy around an HTTP client is, in this domain, a machine for buying two labels and dispatching two trucks. Relying on the partner's deduplication is not a substitute, because their window is usually short, their key is often derived from fields you may legitimately change on retry, and many older integrations have no such concept at all. The workable pattern is to generate the key yourself, persist it with an explicit unknown status before the call, and resolve ambiguity by querying the partner for that key rather than re-issuing; the sweeper that does this is the component that has to be correct, not the call site. Teams get this right for payments and then forget that a carrier tender, a warehouse work release and an EDI shipping notice have the same shape.
Ordering events by arrival rather than by event time
Offline handhelds, batch partner feeds and store-and-forward gateways deliver observations out of order as a matter of course, so a pipeline that folds whatever arrived last will flap a delivered shipment back to in transit and recompute on-time performance from the wrong facts. Message-broker ordering guarantees do not rescue this: per-partition ordering only holds within a partition, so unless the producer keys by the entity being tracked, two events for one leg can land on different partitions and be processed concurrently. The defence has two halves that are often confused - deduplicate on a stable key, then fold with a monotonic status lattice ordered by occurred_at - and both are needed, because deduplication alone still lets a stale event win. Note also that occurred_at is device-reported and therefore sometimes wrong, which is why storing received_at and a measured clock offset beside it is what makes event-time logic auditable instead of merely plausible.
Tests that assert on the implementation rather than the behaviour
Assert on what a caller can observe, not on the number of internal calls or the shape of a private field. A test that breaks on every refactor but still passes when the answer is wrong costs more than it protects.
Choosing a schema before the access patterns are known
Write the queries first, with their filters, sort orders, cardinalities and which ones sit on the latency-critical path, then design tables and indexes to serve them. An index nothing queries still costs write throughput and storage, and a hot query with no supporting index becomes a full scan that only hurts once the table is big.
Questions & practice
Trace a suspect lot forward to every shipped order line
Medium · graph traversal · genealogy · indexing
lot_edge(parent_lot_id, child_lot_id) has 400 million rows with btree indexes on both (parent_lot_id, child_lot_id) and (child_lot_id, parent_lot_id). lot_shipment(lot_id, order_line_id) maps lots to shipped lines. Given one suspect received lot, return every downstream order_line_id. The reachable subgraph can reach 2 million lots and 12 levels; the full edge table does not fit in memory. Give the traversal, its complexity in terms of the reachable subgraph, and what you do if the data contains a cycle.
Approach
- Genealogy is a directed acyclic graph, not a tree: a production run consumes several lots and one lot splits across many shipments, so a node is reached by several paths and a tree traversal revisits it combinatorially. Breadth-first from the suspect lot over the
(parent_lot_id, ...)index, with a hash set of visited lot ids, is O(V' + E') where V' and E' count only the reachable subgraph — 2 million 8-byte ids is 16 MB of payload and a few tens of MB with open-addressing overhead, which is why the visited set is affordable and the edge table's 400 million rows never enter memory. - Batch each level into one indexed read. Issue
WHERE parent_lot_id = ANY($1)with a few thousand parents per call instead of a query per node: at 12 levels and 2 million nodes, per-node round trips dominate the wall clock entirely, and the recall's deadline is a real-world response time, not a query-plan aesthetic. The batch size is the tuning knob between round-trip count and per-statement planning cost. - Keep the visited set even though the schema says DAG. Rework and repack loops — material returned into an earlier lot — do occur, and an unguarded traversal on a cyclic edge set does not terminate; a set membership test is O(1) and turns a hang into a correct answer plus a data-quality alert. Also cap the traversal by level and by node count, and fail loudly on the cap rather than returning a partial set silently.
- Join to
lot_shipmentonce at the end over every visited lot id, not over the leaves and not per node. A lot is routinely both shipped and consumed: half a lot goes out on an order line while the other half is repacked into a child lot, so it has alot_shipmentrow and outgoing edges at the same time and is not a leaf. Restricting the join to nodes with no outgoing edges drops exactly those order lines, which is under-recall — the one failure mode this trace is not allowed to have. One bulk join against an index onlot_idover the whole visited set is a single pass; per-node lookups multiply the round-trip problem by the number of visited lots. - State the semantics honestly: reachability answers which shipped lines could contain material from the suspect lot, and without propagating edge quantities it is a superset of physical containment. For a recall that is the correct direction of error — over-recall, never under-recall — and saying so is the point, because a trace that returns quickly and incompletely will be believed. The upstream question, which received lots are in a suspect shipped unit, is the same traversal over the reverse index, which is why both indexes exist.
Follow-up
- A recursive CTE would express this in ten lines. What is its cost here if the index on the traversal column is missing, and how would you notice?
- You need the quantity of suspect material in each downstream line, not just reachability. What has to be on the edge, and what does that do to the traversal?
- The traversal must run while a production run is writing new edges. What does a consistent answer mean here, and what do you cut it at?
Find the hottest write keys under a fixed memory budget
Medium · top-k · heap · streaming sketches
One day of writes to inventory_movement is 200 million rows; each carries (item_id, node_id). Distinct pairs reach 60 million. Return the 200 pairs with the most writes so they can be moved behind a single writer before the next peak. First give the exact answer assuming you may hold 60 million counters, then give an answer under a 200 MB budget, and state the error guarantee that second answer carries. Both must be one pass over the day's rows unless you argue for a second.
Approach
- Exact version: one hash pass to build
(item_id, node_id) -> int64, then a single scan of the map maintaining a min-heap of size K = 200, pushing and popping when the incoming count exceeds the heap root. O(n + d log K) with n = 2e8 and d = 6e7, versus O(d log d) if you sort all 60 million entries — a factor of roughly log d / log K, about 3.4x, for no benefit. The heap must be a min-heap: the root is the weakest survivor, so eviction is O(log K). - Cost the map honestly. 60 million entries at a 16-byte key, an int64 count and per-entry overhead is 2-3 GB in most runtimes, which is the whole reason for the second version rather than a theoretical aside.
- Bounded version: Misra-Gries with m counters. Keep at most m keys with counts; on a key already present increment it; on a new key when fewer than m are held, insert at 1; otherwise decrement every held counter and drop those reaching zero. With m = 1,000,000 at roughly 40 bytes per counter, that is about 40 MB, comfortably inside 200 MB alongside the heap.
- State the guarantee as a number, not as 'approximate'. Misra-Gries returns an estimate satisfying f - n/(m+1) <= f_hat <= f, so with n = 2e8 and m = 1e6 every count is an undercount by at most 200. If the 200th hottest pair has hundreds of thousands of writes, a 200-count error cannot reorder the top of the list — and if the counts near rank 200 are within 200 of each other, the ranking there was never meaningful for the decision being made. The sketch also merges: run one per shard, combine, prune back to m, and the bound holds with n the combined length.
- If exactness is genuinely required, argue for two passes rather than more memory: the Misra-Gries pass yields a candidate superset of size m, and a second pass counts only those candidates exactly in O(m) memory. That is the standard way to get an exact top-K under a memory bound, and the cost is one extra read of the day's rows.
Worked solution (25 min)
- Implement the exact path first with a hash map and a size-200 min-heap, and record its peak resident memory on a scaled-down input.
- Implement Misra-Gries with an explicit decrement step, and assert after every insert that the held-counter count never exceeds m.
- Generate a synthetic stream with a known Zipf-ish head: 200 planted heavy keys plus a long tail, so the true top-K is known.
- Run both, diff the two top-200 lists, and measure the largest per-key undercount against the n/(m+1) bound.
- Shard the stream into eight parts, run eight sketches, merge and prune, and confirm the merged result still respects the bound.
Expected result: Both paths return the same 200 keys on the planted input; every Misra-Gries estimate is at or below the true count and never short by more than n/(m+1); the merged eight-shard sketch matches the single-stream sketch's guarantee.
Verify it yourself
- No estimate ever exceeds the true count — an overestimate means the decrement step is wrong.
- Peak memory of the bounded path is flat as the number of distinct keys grows, while the exact path's grows linearly.
- Shuffling the stream changes individual estimates but never violates the bound.
Follow-up
- The decision downstream is which keys get a single writer. Does a rank error at position 190 change that decision, and what does the answer tell you about how much accuracy to buy?
- Write volume on one key is bursty — a promotion concentrates it into twenty minutes. Does a daily top-K find it, and what window would?
- How do you keep the sketch across a worker restart without replaying the day?
Re-sum a movement ledger and report positions that disagree
Easy · aggregation · reconciliation · append-only ledger
inventory_movement holds 3 billion immutable rows: item_id, node_id, lot_id (NULL when the item is lot-untracked), state, delta_qty (signed int64 in the item's smallest transacting unit), uom_code, and reversed_by. inventory_position holds one row per (item_id, node_id, lot_id, state) with qty, version and last_movement_id, using lot_id = 0 as the sentinel for untracked. Up to 80 million distinct keys. Report every key whose summed movements disagree with the stored qty, while writes continue. State your time and space bounds and how you bound the comparison.
Approach
- Fix the key first.
inventory_movement.lot_idis NULL for untracked items andinventory_position.lot_idis 0, so the group key is(item_id, node_id, COALESCE(lot_id, 0), state). Getting this wrong does not error — it silently produces two groups that each look like a variance, and the report becomes noise nobody reads. - Cut the ledger at a watermark, but do not take
W = max(movement_id)at the start of the scan. A sequence hands out an id before the inserting transaction commits, so at the instant you read that maximum there are ids below it still in flight and invisible to your snapshot. Summingmovement_id <= Wmisses them on this run, and advancingreconciled_through_movement_idtoWmakes the miss permanent: every later incremental run starts above those ids and they are never summed again. That is a hole in the ledger's own re-derivation, not a transient skew. - Take a watermark that is provably settled instead. Bound write transactions with a statement or transaction timeout so 'the longest write' is a number T, record
(observed_at, max_movement_id)samples periodically, and use asW_safethe largest sampled id whoseobserved_atis older than T — every id at or below it has committed or rolled back. Sum rows withmovement_id <= W_safe, compare only against position rows withlast_movement_id <= W_safe, treat anything newer as a write that raced you rather than a variance, and advancereconciled_through_movement_idonly toW_safeso the next run starts there instead of re-reading 3 billion rows. If the ledger carries a commit timestamp, cutting on that is the same guarantee without the sampling table. - Sum
delta_qtyas int64, and include reversal rows.reversed_byis a back-pointer for audit, not an exclusion filter: an original of +10 and its reversal of -10 must both be summed to reach 0. Excluding the original while keeping the reversal produces -10 and a false variance on every corrected key. - Guard the denomination rather than trusting it.
uom_codeis stored per row because pack factors change over time, so reject — do not sum — any row whoseuom_codeis not the item's smallest transacting unit, and report those keys separately. A mixed-denomination sum is arithmetically meaningless and looks exactly like a real variance. - Hash aggregation is O(n) time and O(distinct keys) space: 80 million entries at roughly 40-56 bytes each in a typical runtime is 3-5 GB, so quote the number. The bounded-memory alternative is an external sort-merge on the group key — O(n log n) comparisons, resident memory bounded by the merge fan-in rather than by key count, and it streams — or hash-partition by
hash(item_id) % Pand run P independent passes for 1/P of the peak.
Follow-up
- A key shows a variance of exactly one pick, repeatedly, at one node. What do you look at first, and what would distinguish a duplicate movement from a missed one?
- The job takes six hours and the variance report is stale by the time anyone reads it. How would you make it incremental without losing the guarantee that it re-derives from the ledger?
- Who writes the correcting movement, and what reason code does it carry?
Decide whether available-to-promise is derived or stored
Hard · denormalisation · read path · cache staleness
Available to promise at one node equals inventory_position.qty in state on_hand minus the sum of allocation.qty in status held or committed for that key. The read sits inside checkout: 4,000 reads per second at peak, p99 budget 20 ms. inventory_position holds 40 million rows; during a promotion a single hot item-node pair carries about 2,000 open allocations. Decide whether to compute this per read or to maintain available_qty on the position row. Give both paths, the failure mode of each, and what the browse read may do that the allocate read may not.
Approach
- Cost the derived read in tuples, not in plans. One index probe into the position is trivial; the aggregate over allocation touches one tuple per open claim on that key. Even if only a tenth of peak lands on the hot pair, 400 reads per second times 2,000 rows is 800,000 index tuples per second for one item at one node, and it gets worse exactly as the key gets hotter. A partial index on status IN ('held','committed') does not help, because the row count is the cost, not the selectivity.
- Maintaining available_qty on the position row is nearly free on the contention axis, which is the point most candidates miss. The allocator already takes a row lock or a compare-and-set on that same position row, so the extra column costs no lock that was not already held, and the read collapses to a single primary-key probe well inside 20 ms.
- It is not free on the correctness axis. available_qty becomes a second derived value that can drift, so it joins qty under the same reconciliation job with its own re-sum against the allocation table, and a drift alert on that comparison is the operational price of the read speed.
- Split the two reads rather than arguing about one. The browse read may serve from a replica or a short-TTL cache and must err low, showing less than is there. The allocate read must be on the primary and inside the same transaction as the conditional update, because replication lag peaks during the write burst that made the key hot. Conflating them is what makes a replica read look safe in a benchmark.
- Pick the TTL against the drain rate, with a number. If a promotion burns 2,000 units in a minute, a 30-second cache is a thousand units stale, so the displayed figure should be a banded signal such as in stock or low stock rather than an exact count that the allocate step will then contradict.
- Do not denormalise availability across nodes into a single network number. A yes-somewhere answer cannot be allocated against, so sourcing will disagree with the page, and the customer sees a promise retracted after checkout rather than an honest out-of-stock before it.
Worked solution (40 min)
- Load a position with 2,000 open allocations and measure p99 for the derived read at 400 requests per second against the same read from a maintained available_qty column.
- Add available_qty, backfill it per key under the same lock the allocator takes, and write the re-sum query that proves it equals qty minus held and committed.
- Run both read paths in shadow for a fixed window, logging every disagreement with its key and magnitude.
- Point the browse read at a replica with a 30-second TTL and the allocate read at the primary, then measure how often the browse figure is higher than the allocatable truth.
Expected result: The maintained column serves the read in a single primary-key probe inside the 20 ms budget at 4,000 reads per second, the derived path misses it on the hot key, and shadow comparison shows zero disagreements on the primary path while the replica path is stale in the low-availability direction only.
Verify it yourself
- p99 for the maintained read is flat as open allocations on the key grow from 10 to 2,000; the derived read's is not.
- The re-sum finds zero drift after a sustained allocate-and-release workload.
- Every observed browse-versus-primary disagreement is an under-report, never an over-report.
- Killing an allocator mid-transaction leaves available_qty and the allocation rows consistent after the expiry sweeper runs.
Follow-up
- Roll this out on a live system. How do both paths run side by side, what do you compare, and what result gates the cut-over?
- The reconciliation job finds available_qty drifted by 3 on one key. What do you do, and what must you not do?
- Lot-tracked items multiply the position rows per item-node. Does your answer change, and where does the read now spend its time?
Model the movement ledger so a retried write is a no-op
Easy · append-only ledger · idempotency · audit history
inventory_movement is append-only: movement_id, item_id, node_id, lot_id, state, delta_qty (signed BIGINT), uom_code, reason_code, ref_type, ref_id, idempotency_key, occurred_at, recorded_at, reversed_by. inventory_position is keyed (item_id, node_id, lot_id, state) with qty, version, last_movement_id. Give the DDL constraints and the exact statements a pick confirmation runs so that a client which times out after the commit and retries changes nothing. Then say how you correct a pick recorded against the wrong lot two days ago, and why the position carries CHECK (qty >= 0) rather than clamping at zero.
Approach
- Put the guarantee in the schema, not the handler: UNIQUE (idempotency_key) on inventory_movement. A SELECT-then-INSERT cannot work, because two concurrent retries both read nothing before either commits and both then insert.
- Claim with INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING movement_id, and branch on rowcount. Zero rows means the first attempt already committed, so the handler returns success without touching the position. This branch is the whole point: applying the position update on the conflict path is what makes the ledger and the shelf disagree by one pick forever.
- Keep the insert and the position update in one transaction: UPDATE inventory_position SET qty = qty + :delta, version = version + 1, last_movement_id = :id WHERE item_id = :i AND node_id = :n AND lot_id = :l AND state = :s AND version = :v, then test the rowcount and retry the transaction on zero.
- Use lot_id BIGINT NOT NULL DEFAULT 0 rather than NULL. In a default unique index NULLs are distinct from each other, so a NULL lot would let two position rows exist for the same untracked item and split the running sum in half.
- Correct the mis-keyed pick with two compensating movements, never an UPDATE or a deleted_at flag: one reversal restoring the wrong lot, one fresh pick against the right lot, both with reason_code = 'reversal' or 'pick' and ref pointing at the original movement_id. The original row keeps its values; reversed_by is a back-pointer written in the same transaction so a reader does not need a reverse scan to see that the row was undone.
- CHECK (qty >= 0) aborts the transaction and surfaces a real defect. GREATEST(qty + :delta, 0) writes a plausible number instead, so the missing receipt that caused the negative is never investigated and the first symptom is a picker at an empty location.
Follow-up
- Two retries of the same pick arrive concurrently on different application instances. Walk through both transactions statement by statement and say which one commits a position change.
- occurred_at from a handheld is five minutes ahead of recorded_at because the device clock drifted. Which of your queries break, and which column should each of them actually be using?
- How long do you keep idempotency_key unique for, given the table grows by tens of millions of rows a month?
How would you design a rate-limiting service to protect our APIs from being overwhelmed by third-party partner integrations?
Medium · system design and architecture
Approach
- Choose a partition key and say what query it makes expensive.
- Name the read and write paths separately; they rarely have the same bottleneck.
- Name the failure you are designing for, then the recovery path.
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 a system that ingests telemetry data from millions of smart thermostats and triggers an alert if a device goes offline.
Medium · system design and architecture
Approach
- Choose a partition key and say what query it makes expensive.
- Name the failure you are designing for, then the recovery path.
- Name the read and write paths separately; they rarely have the same bottleneck.
Follow-up
- What breaks first when traffic grows ten times?
- How does this behave when that dependency is down for an hour?
What are some basic Unix commands you would use to find a specific string within a large directory of log files?
Medium
Approach
- Clarify what is being asked and what a complete answer contains.
- State your assumptions explicitly before working the problem.
- Say what you would check first and why it is the highest-information step.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
Describe a situation where you had to refactor a legacy codebase. What strategies did you use to ensure zero downtime?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- Work from the requirement backwards to the design.
- State your assumptions explicitly before working the problem.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
Paginate an append-only movement ledger under continuous writes
Easy · pagination · keyset · append-only
A partner's nightly reconciliation job pulls every inventory_movement row for its nodes since its last run, while the table is being appended to throughout. The columns available are movement_id BIGINT (sequence-assigned), node_id, occurred_at (device-reported) and recorded_at (insert time). Specify the cursor format, the ordering key, the page-size policy and the delivery guarantee you promise across job restarts. State explicitly what happens to a row inserted today whose occurred_at is eighteen hours in the past, and how the client resumes after a crash mid-page.
Approach
- Use a keyset cursor, not OFFSET: WHERE node_id = ANY($1) AND movement_id > $2 ORDER BY movement_id LIMIT n, served by an index on (node_id, movement_id). Each page costs O(log n + k); OFFSET re-walks the skipped prefix at O(offset + k), so page 900 of a nightly pull is the page that times out.
- Order by insert order, never by occurred_at. A handheld that reconnects after a partition inserts movements stamped hours earlier; a cursor advanced past that instant will never return them. occurred_at is a filter and a display field, recorded_at and movement_id are the feed order - conflating the two is how a reconciliation silently misses a day of picks.
- Name the sequence-gap hazard and close it. The sequence hands out movement_id at INSERT, but transactions do not commit in id order, so a reader that takes max(id) from a page can step over a row that commits afterwards with a lower id. Either hold the cursor behind a watermark older than the longest writer transaction (bounded by statement_timeout plus idle_in_transaction_session_timeout) or read a commit-ordered source such as logical decoding or a transactional outbox, and say which you chose.
- Make the cursor opaque - encode the ordering key plus a schema tag - so you can change the key later without a coordinated client release. Return next_cursor and has_more; do not return a total count, which is a second full scan and is stale before it is serialised.
- State the guarantee honestly: at-least-once across restarts, because a client that crashes after processing a page and before persisting the cursor will re-request it. Delivery is idempotent on the partner side through movement_id, and exactly-once over a network is not on offer.
Worked solution (20 min)
- Write the page query and the index it requires, then state the cost per page in terms of index height and page size.
- Trace a late insert: a device buffers at 09:00, reconnects at 15:00, the row lands with occurred_at 09:00 and a current movement_id. Confirm it is delivered under your ordering key.
- Trace two interleaved writers to show the sequence-gap skip concretely: txn A takes id 100 and commits late, txn B takes 101 and commits first, a reader pages past 101.
- Pick the watermark lag or the commit-ordered feed, and write the number or the mechanism down rather than leaving it as an assumption.
- Write the resume test: page the table while a writer inserts continuously, kill the client mid-page, resume from the persisted cursor, and assert no row is missing.
Expected result: A keyset cursor over movement_id backed by an index on (node_id, movement_id), a stated lag or commit-ordered source that makes a skipped row impossible, an explicit at-least-once guarantee, and occurred_at excluded from the ordering key.
Verify it yourself
- A full pass taken while inserts continue returns every committed row at least once and skips none.
- A row inserted with occurred_at eighteen hours old still appears in a later page.
- p95 page latency is flat in page number rather than growing with it.
Follow-up
- The partner asks for 10,000-row pages and still times out. What do you change before raising the limit?
- They ask for a changes-since-timestamp parameter instead of a cursor. What breaks, and what do you offer instead?
- They need to re-read a three-day window to settle a dispute. How does that coexist with the cursor contract?
Duplicate bookings and second trailers after a partner slowdown
Hard · idempotency · partial failure · retries
A partner's booking API ran at 45 s p99 for ninety minutes. Afterwards 214 shipment_leg rows carry two booking references, 41 stops received two trailers, and finance holds duplicate invoices. booking_idempotency_key is generated and persisted on the leg before the call and is sent to the partner, whose documented dedupe window is 60 seconds. The HTTP client retries timeouts three times, and a sweeper re-tenders any leg still in status 'planned' after ten minutes. Explain how duplicates happened despite the key, and give the corrected flow.
Approach
- Establish that the duplicates are timeouts rather than double-clicks. Join the gateway request log to shipment_leg for the 214 legs and check whether each duplicate pair carries the same idempotency key and how far apart the calls landed. Same key, calls minutes apart, first attempt ending in a client timeout is the ambiguous-outcome signature; different keys would mean the key is being regenerated and is a different bug.
- Name why the key did not help. The key was correct and persisted, but deduplication was delegated to the partner, whose window is 60 seconds. A retry at 45 s may land inside it; the third retry does not, and the ten-minute sweeper re-tender certainly does not, so the partner sees a new request and books a second truck. A short partner window plus a long retry horizon is an at-least-once system wearing an at-most-once label.
- Identify the missing state. The leg goes planned to tendered only on a 2xx, so a timeout leaves it exactly as if nothing was sent, which is false: the partner may have committed before the connection dropped. The state machine needs an explicit in-doubt status between requested and confirmed, and nothing may issue a new tender for a leg in that state.
- Replace re-issuing with asking. On any ambiguous outcome, move the leg to in-doubt and let a sweeper resolve it by querying the partner for that key or for bookings against our shipment reference, and only tender again when the query definitively returns nothing. Where the partner offers no query, the manifest or the status feed is the authority and resolution is slower, so the in-doubt state must be allowed to persist rather than being timed out into a retry.
- Move retry ownership out of the process. A retry decorator around an HTTP client disappears on deploy or crash, so the persisted state is the only thing that can drive recovery: a sweeper reading legs in requested or in-doubt, with bounded attempts, backoff and an alert on in-doubt depth. The call site should not retry an irreversible effect at all.
- Deal with the 214 that already exist, since code cannot undo them. Reconcile against the partner's manifest, cancel the duplicate where the cancellation window still allows it, and for legs already executed record the second cost as a compensating entry and dispute the invoice. State plainly that this is a business process with a cost, which is exactly why the guard belongs before the call.
Follow-up
- The partner offers no lookup by your key and no search by reference. What is your resolution path and how long may a leg stay in doubt?
- Which other effects in this system have the same shape and the same missing state?
- How do you test this without a degraded partner, and what does the fault injection have to simulate that a plain timeout does not?
Suggested practice checklist
For someone fluent in a dynamic language who has shipped real work but has never had to say what the runtime is doing underneath. The week is built on measuring and deliberately breaking things, because the questions that expose this background are the ones where the interviewer asks why a second time.
Day 1 — Measure before reasoning
- Take a slow piece of your own code, write down in advance where you believe the time goes, then profile it and record how wrong the guess was. The cost is usually an allocation you did not notice or an accidental quadratic membership test.
- Replace one list membership test inside a loop with a set and measure at a thousand, ten thousand and a hundred thousand elements, confirming the shape of the curve rather than only that it got faster.
- Write down the three quantities you can now measure instead of assert: wall time, peak memory, and call count for the function you suspected.
Deliverable: A before-and-after profile of real code plus a written note on the size of the gap between the guess and the measurement.
Day 2 — References, copies, and the bugs they produce
- Write the function with a mutable default argument, call it three times, and explain the accumulating result: the default is evaluated once when the function is defined, so every call shares one object.
- Build a nested structure, take a shallow copy, mutate an inner element, and show that both views changed, because a shallow copy duplicates the container and not the elements. Then fix it with a deep copy and state the cost you just accepted.
- Write two functions, one mutating its argument in place and one rebinding the local name, and predict the caller's view of each before running it. That single distinction produces most of the bugs that pass their tests.
Deliverable: Three small programs whose output you predicted correctly before running, each with a one-line statement of the rule underneath.
Day 3 — Types, once, in a language that checks them
- Port one module you have already written, roughly a hundred lines, into a statically typed language, and record every place the compiler demanded an answer your original had left implicit: a value that can be absent, a numeric width, a case never handled.
- Write the same signature in both languages and state what the static one guarantees before the program runs and what it does not, since it will not save you from a wrong algorithm or an index out of range.
- Write the difference between an interface satisfied by declaration and one satisfied structurally, with one case each where the other approach would miss the mistake.
Deliverable: One module in two languages plus a list of the questions the type checker forced you to answer.
Day 4 — Concurrency, starting with what actually runs at the same time
- Run the same CPU-bound function across four threads and four processes and measure both. Under the default CPython build the threaded version will not speed up, because only one thread executes bytecode at a time; the process version will. Check which build you are on first, since free-threaded builds remove that lock and change the result.
- Then run a blocking I/O workload across four threads and measure it speeding up, because the interpreter releases that lock around blocking calls, which is why treating threads as useless is wrong as a general claim.
- Build the lost update: two threads each incrementing a shared counter a hundred thousand times, and show a final value below the expected sum, because an increment is a load, an add and a store and the thread can be suspended between them. Fix it with a lock and then measure what the lock costs.
Deliverable: Three measurements, threads against processes on CPU work, threads on I/O work, and a demonstrated lost update, each with the mechanism written underneath.
Day 5 — Debugging as a procedure rather than an instinct
- Work one real failure as a bisection: find a revision or an input size where it is good and one where it is bad, halve repeatedly, and state the two assumptions bisection needs, that the property changes exactly once across the range and that the test is reliable.
- Minimise one failing input to the smallest version that still fails, and record how many rounds it took.
- Keep a hypothesis log for one bug in three columns, what I believe, what would disprove it, what I observed, and stop yourself the first time you are about to change two things at once.
Deliverable: One bug worked to root cause with a written hypothesis log and a minimised reproducing input.
Day 6 — Tests that catch the bug you are about to write
- Implement an LRU cache with a capacity bound, then write the three test cases that would catch an off-by-one in eviction: insert exactly capacity items and assert nothing was evicted, insert one more and assert the least recently used key is the one gone, and read an old key just before that insert so the eviction victim changes.
- Add a property test comparing your implementation against a deliberately slow reference, an ordered list scanned linearly, over a few thousand random operation sequences, because a slow reference finds the cases you would not have thought to write.
- Write one numeric test that fails under exact equality and passes with a tolerance, and state why the tolerance has to be relative rather than absolute once the magnitudes grow.
Deliverable: An LRU implementation with three boundary tests, one property test against a slow reference, and one tolerance-based numeric test.
Day 7 — Debug something broken, out loud
- Have someone plant three defects in a two-hundred-line program, an off-by-one, a shared mutable state bug, and a wrong error-handling path, then find them while narrating, under a fixed rule: state the hypothesis before touching anything.
- Time each one and record which tool found it, reading, a printed value, a debugger, or a test, because the question asked in interviews is how you would find it rather than what it was.
- Write the sentence you will use when you do not yet know the cause, one that names the next measurement instead of offering a guess.
Deliverable: A recorded debugging session with time-to-find per defect and the method that found each.
Outage answers are graded on instruments
Nobody is scoring your stamina at three in the morning. What carries weight is which signal told you something was wrong, what you measured before touching anything, what you rolled back versus what you fixed forward, and why you picked one. 'We restarted it and it went away' is a story about not knowing.
How do you balance the need to deliver features quickly with the desire to maintain high code quality and minimize technical debt?
Medium · behavioural and engineering judgement
Approach
- Name the disagreement and how you resolved it with evidence.
- Close with what you would do differently, concretely.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- What would you do differently if you ran that again?
- How did you know your change caused the improvement?
Tell me about a time you had to debug a critical issue in production. How did you identify the root cause, and what did you do to prevent it from happening again?
Medium · behavioural and engineering judgement
Approach
- State the situation in two sentences and spend the rest on the reasoning.
- Pick a story where you made the decision, not one where you watched it.
- Name the disagreement and how you resolved it with evidence.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
How do you handle message delivery guarantees (e.g., at-least-once vs. exactly-once) in a distributed event-driven system?
Medium · behavioural and engineering judgement
Approach
- State the situation in two sentences and spend the rest on the reasoning.
- Give the blast radius: what could have broken, and what you measured.
- Pick a story where you made the decision, not one where you watched it.
Follow-up
- How did you know your change caused the improvement?
- What did you decide not to do, and why?