cswork / libraryOpen practice workspace

Interview guides · Anthesis Consulting Group

Anthesis Consulting Group Software Engineer Interview Questions & Guide 2026

Anthesis Consulting Group software engineer prep: algorithm drills where every complexity claim has to name the assumption under it.

The 60-second brief

As a Software Engineer at Anthesis Consulting Group, you are at the intersection of high-level sustainability strategy and technical execution. The role is critical because Anthesis Consulting Group relies on robust, scalable software to help global organizations measure, track, and reduce their environmental impact. Your work directly influences the tools that enable clients to navigate complex carbon and energy markets, making your technical contributions a cornerstone of the firm’s mission to drive systemic change.

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

  • Version an API you cannot force clients to upgrade
  • Bound credential lifetime by the engagement end date
  • Evolve schemas expand-contract across un-upgradable client deployments

Overview

This position is inherently collaborative and intellectually demanding. You will move beyond standard feature development, often working on projects that require translating abstract environmental data into actionable insights for diverse stakeholders. Because the firm operates as a consultancy, the "product" is often deeply intertwined with client success. You will find this role highly rewarding if you are motivated by solving problems that have a tangible, positive impact on the planet, all while working in an environment that prioritizes domain expertise alongside engineering excellence.

The Anthesis Consulting Group interview process

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

Initial Screening

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 Discussions

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

Behavioral Discussions

This round is deciding whether a change you make without supervision can be allowed to reach production. It is scored on what you knew at the moment you decided, not on how it turned out, so a story that opens with the result and works backwards reads as luck retold as judgement. Say what the options were, what you did not know, what you did to shrink the unknown before committing, and what you accepted as the worst plausible case. The detail that separates answers is a bound: how many users, how much data, and for how long, if you had been wrong.

What to demonstrate

  • Whether the reasoning you give was available at the time you decided rather than after the result came in, since a story whose deciding evidence arrived later describes an outcome and not a judgement
  • Whether you can put units on the exposure (users, rows, minutes of degraded service) and whether the containment you chose actually bounded it: a canary bounds the request path it fronts, while a background job writing to a shared table reaches every user regardless of which version served their requests
  • Whether the reversal path existed before you shipped or was improvised during the incident, and whether it restores state or only stops further damage

How to prepare

  • For your three largest changes, write down the one thing you would have had to be wrong about for it to fail, and what your best estimate of it was on the day you shipped. If you never held an estimate, that is the gap the follow-up questions will find
  • Write the undo procedure for one of those changes as it existed at the time, then mark which steps restore data and which only stop new damage. Turning a flag off or reverting a deploy ends the new writes; rows already written come back only from a copy you kept, and a dropped column comes back empty unless something outside the schema holds the values
  • Rehearse one story from the decision point forward and stop before the outcome, then have someone ask what you would do next. If the story only works with the ending attached, it is an anecdote rather than a decision you can defend

Where candidates lose points

Validating against a client sandbox and assuming production parity

Sandboxes typically carry smaller data, looser or absent rate limits, a schema version behind production, and sometimes synchronous behaviour where production is asynchronous. Code that passes there fails first in the client's production, during a change window you do not control and often cannot get a second one of. The mitigations are specific: assert the observed schema version at the boundary of every run, measure the production rate limit empirically rather than reading it from a document, and design the first production run to be a bounded, reversible slice rather than a full backfill.

Depending on a client field because it is always populated

It is populated by their operational process, not by a constraint in their database, and their process changes without telling you. When it stops being populated, a permissive pipeline writes nulls or defaults into derived data and the damage spreads for weeks before anyone notices. Validate at the boundary and fail the run with failure_class='schema_mismatch' plus a redacted sample, so the failure is loud, attributable to the client's change, and cheap to diagnose. A run that fails clearly is far cheaper than a backfill that has to reconstruct which rows were silently wrong.

Going silent while thinking

Narrate the candidates and why you are discarding them, even in fragments: sorting first would make this a two-pointer scan, but it destroys the original indices, which the output needs. From the other side of the table, a candidate thinking hard and a candidate stuck are indistinguishable until one of them speaks.

Assuming the bug is in the framework

Suspect your own code first: read the stack trace top to bottom, check which versions are actually installed rather than which ones you believe are, and reproduce in isolation before blaming a library that thousands of people run daily. When the fault really is upstream, you need that minimal reproduction to say so credibly anyway.

Questions & practice

How do you approach evaluating different technical solutions for a single problem?

Medium · data structures and algorithms

Approach

  1. Choose the data structure from the access pattern, not from familiarity.
  2. State the target complexity and say which constraint rules the naive version out.
  3. Restate the input: its shape, its size, and what is guaranteed about it.

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?

How do you balance the need for rapid delivery with the need for long-term code quality?

Medium · data structures and algorithms

Approach

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

Follow-up

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

How do you ensure your code remains performant as user demand scales?

Medium · data structures and algorithms

Approach

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

Follow-up

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

What is your process for debugging a system when the root cause is unclear?

Medium · data structures and algorithms

Approach

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

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?

Measure credential exposure past engagement close without double counting

Medium · interval merge · sweep line · credential ttl

For one engagement you have credential_grant rows: (grant_id, principal_id, target_system_id, issued_at, not_after, revoked_at which may be NULL). The cutoff instant T is the engagement's ends_on plus access_grace_days. A grant is live over the half-open interval [issued_at, min(not_after, revoked_at)). There are up to 200,000 grants across target systems. For each target_system_id, return the total wall-clock time after T during which at least one grant was live, plus the disjoint segments that make it up. Grants that overlap must be counted once.

Approach

  1. Clip each grant to [max(issued_at, T), min(not_after, coalesce(revoked_at, +inf))) and discard any interval whose start is not strictly before its end. O(n), and it removes every grant that already expired inside the engagement window.
  2. Bucket the survivors by target_system_id and sort each bucket by start. O(n log n) overall, and sorting dominates the whole algorithm.
  3. Sweep each bucket once, holding one open segment [s,e): if the next start is greater than e, emit [s,e) and open a new segment, otherwise set e = max(e, next_end). O(n) after the sort, O(1) working state, O(number of emitted segments) output.
  4. Total exposure is the sum of emitted segment lengths, which is the measure of the union. Summing per-grant durations instead answers a different question and is unbounded above by the elapsed wall clock.
  5. Produce a second, conservative figure that ignores revoked_at and uses not_after alone. revoked_at records that you asked for revocation; whether the client's identity provider honoured it is not something this table knows, and a self-contained token validated offline stays valid to its own expiry regardless.
  6. If a bucket does not fit in memory, sort externally and sweep the stream, or push end timestamps into a min-heap keyed by end and pop those below the current start. Same O(n log n), memory proportional to the maximum number of concurrently live grants.

Worked solution (25 min)

  1. Set T = 2026-06-30T00:00Z for one target system with four grants: g1 not_after 2026-06-29T12:00Z; g2 not_after 2026-07-01T00:00Z, revoked_at NULL; g3 not_after 2026-07-01T06:00Z, revoked_at 2026-06-30T18:00Z; g4 issued 2026-07-01T06:00Z, not_after 2026-07-02T06:00Z.
  2. Clip: g1 becomes empty and drops out. g2 -> [Jun30 00:00, Jul1 00:00). g3 -> [Jun30 00:00, Jun30 18:00). g4 -> [Jul1 06:00, Jul2 06:00).
  3. Sort by start and sweep: g2 and g3 merge into [Jun30 00:00, Jul1 00:00); g4 starts after that end, so it is emitted separately.
  4. Union = 24h + 24h = 48h = 2,880 minutes, in two segments with a 6-hour uncovered gap between them. Summing the same three clipped intervals per grant instead gives 24 + 18 + 24 = 66h; the 18h excess is exactly the [Jun30 00:00, Jun30 18:00) stretch where g2 and g3 overlap.
  5. Recompute ignoring revoked_at: g3 now runs to Jul1 06:00, which bridges the gap, and the union becomes the single segment [Jun30 00:00, Jul2 06:00) = 54h.

Expected result: Exposure after T = 2,880 minutes in segments [Jun30 00:00, Jul1 00:00) and [Jul1 06:00, Jul2 06:00). The conservative figure that ignores revoked_at is 3,240 minutes in one segment. Naively summing the clipped per-grant durations (24 + 18 + 24) would have reported 66 hours, more than the 54 hours that elapsed between T and the last expiry.

Verify it yourself

  • Union (48h) <= the clipped per-grant sum (66h) always, with equality only when no two clipped intervals overlap; here the 18h difference is the single g2/g3 overlap.
  • Union (48h) <= last expiry minus T (54h), since the union cannot exceed the elapsed span it lives in.
  • The emitted segments are strictly increasing and pairwise disjoint, and their count is 2, less than the 3 surviving grants.
  • g1 contributes nothing, confirming the clip ran before the sort rather than after it.

Follow-up

  • Three grants overlap and one of them belongs to an offboarded contractor. What must the sweep emit so exposure can be attributed per principal?
  • Which of your two numbers is the real bound on access if the client system validates tokens offline, and what does that imply about issuing TTLs in the first place?
  • Run this across 20,000 engagements as a nightly job with a fixed memory budget. What changes in the shape of the computation?

Return the latest attempt per connector idempotency key

Easy · distinct-on · indexing · idempotency

connector_run stores one row per attempt: run_id, connector_id, idempotency_key, attempt_no SMALLINT, status (queued, running, succeeded, failed, ambiguous, cancelled, abandoned), failure_class, watermark_from, watermark_to, started_at, finished_at, unique on (connector_id, idempotency_key, attempt_no). For one connector_id, write the query returning the highest-numbered attempt per idempotency_key, keeping only those keys whose latest attempt is not succeeded, ordered by watermark_from. Give the index that serves it, and say what the plan does without that index.

Approach

  1. Use DISTINCT ON (idempotency_key) with ORDER BY idempotency_key, attempt_no DESC. PostgreSQL requires the ORDER BY to lead with exactly the DISTINCT ON expressions, and that requirement is what makes the row picked deterministic rather than whichever the scan happened to reach first.
  2. Apply the not-succeeded filter outside, in a wrapping SELECT. Putting it in the inner WHERE changes the question from 'keys whose latest attempt failed' to 'keys that have any non-succeeded attempt', which reports a key that failed twice and then succeeded, and is the single most common wrong answer here.
  3. Know the portable alternative: row_number() OVER (PARTITION BY idempotency_key ORDER BY attempt_no DESC) in a subquery, filtered = 1 outside. A window function cannot be filtered in WHERE because WHERE is evaluated before the window, and PostgreSQL has no QUALIFY clause, so the subquery is mandatory rather than stylistic.
  4. Index (connector_id, idempotency_key, attempt_no DESC). With connector_id fixed by equality, the index supplies the exact order DISTINCT ON needs and the Sort node disappears. It does not reduce rows read: through PostgreSQL 17 there is no loose index scan, so the executor still walks every attempt in range rather than skipping to each next distinct key.
  5. Give the complexity honestly: without the index the plan is a scan plus an O(n log n) sort over the connector's attempts; with it, O(n) ordered reads and constant working memory, where n is attempts in range, not distinct keys. If the distinct keys are a tiny fraction of attempts, a recursive skip-scan is the next step.

Follow-up

  • attempt_no is not monotonic in time because a retry was queued out of order. Does your query still pick the right row, and which column should it actually order by?
  • How would you return the latest attempt per key across all connectors in one environment without the query degrading to a full sort?
  • An ambiguous latest attempt means the target may or may not have been written. What does this query feed, and should ambiguous be treated as failed here?

Soft-delete environments without breaking uniqueness or audit history

Easy · soft deletes · partial indexes · audit history

environment holds one row per provisioned deployment: environment_id, client_id, engagement_id, env_name, region, status, destroyed_at TIMESTAMPTZ NULL. Decommissioned environments stay readable for seven years, and env_name must be unique per client among live environments only, because a client may reuse a retired name. Design the constraint set and the history table, write the query that lists a client's live environments, and say what your design does about connector_run rows whose foreign key points at a destroyed environment.

Approach

  1. Keep the tombstone as a nullable timestamp rather than a boolean: destroyed_at carries when as well as whether, and enforce live-only uniqueness with a partial index, CREATE UNIQUE INDEX env_live_name ON environment (client_id, env_name) WHERE destroyed_at IS NULL, which indexes only the live subset and so stays small.
  2. Get the NULL semantics right before you pick a spelling. In a B-tree unique index two NULLs compare distinct by default, so plain UNIQUE (client_id, env_name, destroyed_at) enforces nothing at all among live rows — every live row is unique on the third column purely by virtue of being NULL. PostgreSQL 15's UNIQUE NULLS NOT DISTINCT (client_id, env_name, destroyed_at) does satisfy the requirement: the NULL live rows now compare equal and collide with each other, while retired rows carry distinct destroyed_at timestamps and stay distinct, so a name can be retired and reused any number of times.
  3. Prefer the partial index anyway, and be able to say why rather than asserting it. It indexes only the live subset — tens of rows per client — instead of seven years of tombstones; it needs no version floor; and it keeps destroyed_at out of the key, so it cannot be defeated by two rows retired at the same instant. That last case is reachable: now() is transaction-start time, so retiring a name, recreating it and retiring it again inside one transaction writes two identical destroyed_at values and raises a duplicate-key error under the constraint form. clock_timestamp() avoids it if you take that route.
  4. Put history in a separate append-only table written by an AFTER INSERT OR UPDATE OR DELETE trigger holding environment_id, changed_at, changed_by, op and the before and after row images. PostgreSQL has no system-versioned tables, so this is a trigger or a logical-decoding consumer, not a DDL flag, and the soft delete arrives at that trigger as an UPDATE rather than a DELETE.
  5. State the foreign-key consequence rather than leaving it implicit: a soft delete leaves every referencing connector_run row resolvable, which is exactly what the audit requirement wants, but it means no read is protected by the database. Give the predicate one home, a view or an RLS-backed view that every caller uses, instead of repeating destroyed_at IS NULL across hundreds of call sites.
  6. Size the retention: seven years of history plus tombstoned rows in the hot table. Partition the history table by month so expiry is a DETACH PARTITION rather than a DELETE that has to be vacuumed, and decide separately whether a residency-driven erasure request can be honoured against an append-only store at all.

Worked solution (20 min)

  1. Write the DDL with destroyed_at TIMESTAMPTZ NULL plus the partial unique index on (client_id, env_name) WHERE destroyed_at IS NULL.
  2. Insert two live rows sharing (client_id, env_name) and confirm the second is rejected; set destroyed_at on the first and confirm the insert now succeeds.
  3. Build the same table a second time under UNIQUE NULLS NOT DISTINCT (client_id, env_name, destroyed_at) and confirm it passes the same two tests, so you are choosing between two working designs rather than rejecting a broken one.
  4. Add environment_history (history_id, environment_id, changed_at, changed_by, op, row_before JSONB, row_after JSONB) and the AFTER trigger that populates it.
  5. Write the live-listing query: SELECT environment_id, env_name, region, status FROM environment WHERE client_id = $1 AND destroyed_at IS NULL ORDER BY env_name.

Expected result: A partial unique index covering only live rows, a separate append-only history table with its own partitioned retention, and a listing query whose predicate matches the index predicate exactly so the planner can use it.

Verify it yourself

  • Run retire-and-recreate three times for the same name: three tombstoned rows and one live row coexist without violating the index.
  • On the NULLS NOT DISTINCT variant, retire and recreate the same name twice inside one transaction and watch it fail on duplicate destroyed_at, since now() does not advance within a transaction; the partial index has no such failure mode.
  • EXPLAIN the listing query and see the partial index; widen the predicate to destroyed_at IS NULL OR destroyed_at > now() and watch the index drop out, because that predicate no longer implies the index's.
  • Soft-delete a row and confirm the history trigger fired; a DELETE-only trigger records nothing, which is the common way this audit trail ends up empty.

Follow-up

  • What stops the next endpoint from forgetting the destroyed_at IS NULL predicate, and does that mechanism survive a developer writing raw SQL?
  • The history table stores row images as JSONB and the base table gains a column. What do old rows now mean, and how do you read them six years later?
  • A residency erasure request requires a genuine hard delete for one client. What breaks, and what do you keep?

Can you walk us through a significant project from your past experience?

Medium

Approach

  1. Work from the requirement backwards to the design.
  2. State your assumptions explicitly before working the problem.
  3. Clarify what is being asked and what a complete answer contains.

Follow-up

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

Tell us about a challenging team dynamic you navigated and the outcome.

Medium

Approach

  1. State your assumptions explicitly before working the problem.
  2. Say what you would check first and why it is the highest-information step.
  3. Clarify what is being asked and what a complete answer contains.

Follow-up

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

Expose the month-end billing run as a retry-safe resource

Medium · async-jobs · idempotency · partial-failure · billing

Timekeeping and Billing generates invoice_line rows per engagement, billing period and line_type under a unique generation_key derived from (engagement_id, period_start, period_end, line_type, generator_version). A run covers thousands of engagements, takes tens of minutes, and is triggered both by a scheduler that retries on any non-2xx and by an operator who may click twice. Late approvals keep landing while it runs. Design the HTTP contract: how a run is started, how progress and per-engagement outcomes are read, what a duplicate trigger returns, and what a partially failed run does when it is triggered again.

Approach

  1. Model the run as a resource rather than a synchronous call. POST /billing-runs returns 202 with a Location pointing at /billing-runs/{id} and a Retry-After telling the poller how long to wait. A request that holds a connection open for tens of minutes dies at some intermediary timeout you do not control, and the caller then cannot distinguish a timed-out run from a failed one.
  2. Key the run itself, not only its output. The run's identity is (period, generator_version, scope), so a second trigger for the same period collides and returns 200 or 303 pointing at the existing run instead of starting a second one. The operator's double-click and the scheduler's retry then become the same handled case rather than two separate ones.
  3. Inside the run, the generation_key unique violation is the per-engagement idempotency mechanism: catch 23505, read the existing invoice_line back, and count it as already-generated. Letting that violation escape as a 500 makes a run that already did the work report failure, and the scheduler retries it indefinitely — which is precisely the failure the unique key was added to prevent.
  4. Give partial failure a shape. The run resource carries counts by outcome plus a paginated child collection of per-engagement results, each with engagement_id, outcome (generated, already_present, skipped, failed), a stable failure code and a correlation id. A single aggregate status hides twelve failures inside four thousand successes and leaves an operator diffing invoices to find them.
  5. Make re-triggering a resume rather than a restart. Already-generated engagements collide on generation_key and do nothing; only the failed ones do work. That property comes from the key, so it survives the run resource being lost, the process being replaced, and the operator triggering from a different tool.
  6. Answer the late-approval question explicitly, because the key makes it sharp: since a second line for the same engagement, period and line_type cannot exist, an approval landing after generation cannot simply be picked up by a re-run. Pin a cut-off timestamp on the run and generate from work approved at or before it, then route later approvals to the next period or to an explicitly keyed supplementary line. Silently dropping them is the third option and it is the one that shows up as a revenue query six weeks later.

Worked solution (30 min)

  1. Write the three endpoints with their status codes, including the duplicate-trigger response and what it points at.
  2. Write the per-engagement result record's fields, then check that an operator can act on each outcome without opening the database.
  3. Trace one engagement through a first run that crashed after committing its line, and say exactly what the second run does at the insert.
  4. Write the cut-off rule, then work out what happens to an approval entered one minute after it.
  5. Name what the scheduler does for each run-level outcome: succeeded, succeeded-with-failures, failed.

Expected result: 202 with Location and Retry-After on start; a run keyed on (period, generator_version, scope) so duplicate triggers return the existing run; per-engagement outcomes as a paginated child collection; generation_key collisions counted as already_present and treated as success; a pinned cut-off that makes re-triggering a no-op; and late approvals routed to the next period or an explicitly keyed supplementary line rather than dropped.

Verify it yourself

  • Triggering the run twice in the same second yields one run and two 2xx responses.
  • Re-running a completed period writes zero new rows and reports every engagement as already_present.
  • The per-outcome counts sum to the number of engagements in scope, with none unaccounted for.

Follow-up

  • A run failed halfway, and by the time it is triggered again forty more timesheets have been approved. Which lines does the second run produce, and which does it refuse to?
  • An operator wants a run cancelled mid-flight. What does cancel mean for the engagements whose lines already committed?

Timesheet writes freeze during a month-end migration window

Medium · lock contention · ddl · migrations

At 23:40 on the last day of the month, every query touching work_record starts timing out: submissions, approvals and the billing generator. Database CPU is near zero and nothing is doing work. pg_stat_activity shows 60 backends with wait_event_type = 'Lock', an ALTER TABLE work_record ADD COLUMN issued at 23:38, and an analytics SELECT over work_record that started at 23:26 and is still running. Explain the mechanism, state precisely what you cancel first and why, and give the permanent guard.

Approach

  1. Read the wait states before changing anything. Grouping pg_stat_activity by wait_event_type and seeing 60 backends in 'Lock' with idle CPU says queueing, not load: nothing is slow, everything is waiting, and no amount of capacity helps.
  2. Walk the chain to its root with pg_blocking_pids(pid) rather than inferring from start times. You will find ordinary reads and writes blocked by the ALTER, and the ALTER blocked by the analytics SELECT.
  3. State the mechanism exactly. ADD COLUMN takes ACCESS EXCLUSIVE, which conflicts with the ACCESS SHARE the running SELECT holds, so the DDL waits. Lock requests queue, so every request that arrives afterwards queues behind the pending ACCESS EXCLUSIVE, including plain SELECTs that would never have conflicted with the analytics query. One waiting DDL statement freezes the table without ever executing a byte of work.
  4. Act in the correct order: cancel the DDL first with pg_cancel_backend on that backend. The queue drains immediately and the writes behind it proceed while the analytics query finishes harmlessly. Cancelling the analytics query instead lets the ALTER take the lock and rewrite or at minimum lock the table during the busiest write window of the month, which is worse than the outage you are in.
  5. Guard permanently at the migration session: SET lock_timeout to a couple of seconds so a DDL that cannot take its lock fails fast instead of queueing the world behind it, and retry the migration later rather than waiting. Know the version-specific escapes too: from PostgreSQL 11, ADD COLUMN with a non-volatile DEFAULT avoids the table rewrite but still requires ACCESS EXCLUSIVE, so no rewrite is not no lock; and where validation needs a scan, ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT keeps the long phase at SHARE UPDATE EXCLUSIVE.
  6. Cap the other side as well: statement_timeout on the reporting role, so a long read can never become the head of an estate-wide stall, and keep DDL out of the month-end billing burst entirely.

Follow-up

  • The migration has to ship this week and month-end runs for three days. What sequence gets it in without a quiet window?
  • Now you need a NOT NULL column with a check constraint on the same table. Which step takes which lock, and for roughly how long?
  • What signal would have paged you within 30 seconds of 23:38, given that CPU, error rate and query duration all looked normal at first?

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.

Scope is read from what you decided

Team size, service count and tickets closed say very little. Seniority shows in the decision you owned: what you chose not to build, which constraint you traded away, whose objection you had to resolve before anything could move. A large project where you executed someone else's plan is a small story.

Why do you want to apply your engineering skills at Anthesis Consulting Group specifically?

Medium · behavioural and engineering judgement

Approach

  1. Give the blast radius: what could have broken, and what you measured.
  2. Pick a story where you made the decision, not one where you watched it.
  3. Close with what you would do differently, concretely.

Follow-up

  • What would you do differently if you ran that again?
  • How did you know your change caused the improvement?

What draws you to the sustainability sector specifically?

Medium · behavioural and engineering judgement

Approach

  1. State the situation in two sentences and spend the rest on the reasoning.
  2. Pick a story where you made the decision, not one where you watched it.
  3. Close with what you would do differently, concretely.

Follow-up

  • What would you do differently if you ran that again?
  • How did you know your change caused the improvement?

Own a cross-tenant read that reached a client

Hard · tenant isolation · incident response · blast radius

Prepare a five-minute account of an isolation or data-exposure incident you owned: a query that returned another tenant's rows, a cache keyed without a tenant, or a report that crossed a boundary. State the mechanism precisely, how you established blast radius (which tenants read which tenants' rows, over what window), the containment step, and the fix that made the class impossible rather than the instance. Finish with the notification decision and who made it. If you have never owned one, use the closest near-miss and say so.

Approach

  1. Open with the mechanism in one sentence rather than the symptom. The canonical version here: a session-scoped SET app.tenant_id on a connection returned to a transaction-mode pool with that value still attached, so the next checkout inherited it and row-level security then enforced the previous tenant's policy flawlessly.
  2. Separate containment from fix. Containment is what you did in the first twenty minutes (drain the pool, switch the pool to session mode, disable the endpoint); the fix is structural (SET LOCAL, which dies with the transaction, plus an assertion that the setting equals the request's tenant immediately before the first statement).
  3. Give the blast-radius method, not an adjective: reconstruct from the query log joined to request context on a correlation id, count distinct (reading tenant, row tenant) pairs and rows, and state what you could not reconstruct and why.
  4. Name the class-level fix and its cost. FORCE ROW LEVEL SECURITY so the table owner is not exempt, separate migration and application roles because a role with BYPASSRLS defeats every policy, and a test that drives two tenants' requests concurrently over one pooled connection, which is the load profile a serial integration suite never produces.
  5. Close with the notification call: it is a contractual question, not only an engineering one, so say who decided, how long the decision took, and what you would not repeat.

Follow-up

  • Your suite ran one request at a time and passed. What test would have caught this, and what does it cost to run on every change?
  • The same defect inside a dedicated deployment touches one client. Does that change your severity, your containment, or only your disclosure?
  • How do you know today that no other endpoint in the estate has the same defect?