Answers — say these out loud
The questions this ad is most likely to produce, with each answer written the way you would say it. The sentences are short on purpose: they are for speaking, not skimming.
Anything in [brackets] is a fact only you know. Fill it in before the call —
a real number is what makes an answer sound senior.
The three rules. 60 to 90 seconds per answer, in the shape context → options → decision → trade-off. Never stop at “I don’t know” — bridge to the nearest thing you have done. Slower English sounds more fluent, not less.
The one-line version of every answer is in Last hour — read this, then stop. The self-intro, the project story and the behavioural questions are in The call — everything except the technical answers.
Python — “5+ years of commercial experience”
Threads, processes or asyncio — and is the GIL still a thing?
It depends on where the time goes, so I start there.
For I/O-bound work — calling APIs, talking to the database — asyncio is my default in a service. One event loop can hold thousands of waiting connections cheaply. Threads also work for I/O, because the GIL is released around blocking calls, and they are the right choice when the library I need is synchronous.
For CPU-bound Python, processes. The GIL lets only one thread run Python bytecode at a time, so threads add contention without adding throughput. The exception is libraries like NumPy that release the GIL inside their C code — there, threads do run in parallel.
Is the GIL still a thing? Free-threaded CPython became officially supported in 3.14, under PEP 779. But it is a separate build you opt into, not the default. It costs some single-thread speed, and an extension that has not declared support turns the GIL back on at import. So in production today I still use processes for CPU work, and I treat free-threading as the direction of travel.
If they push — “what’s the simplest option people skip?”: more single-threaded worker processes. No shared state, no GIL question, and one crash takes out one request, not the whole service.
Go deeper: The GIL and free-threaded Python
What happens if you block the event loop?
Everything in that worker stops, not just the one request. The event loop is
a single thread. A call that does not yield — a sync database driver,
requests, bcrypt, parsing a huge payload — stalls every other coroutine
until it returns.
The symptom is distinctive. p99 latency rises on every endpoint at once, including endpoints that do not touch the slow thing, while the CPU sits idle. People usually misdiagnose it as a database or network problem.
In FastAPI the fix depends on the code. If a route uses a sync library, I
declare it with plain def, and FastAPI runs it in a threadpool. If one call
inside an async route blocks, I push that call to a thread with
run_in_threadpool. If the work is CPU-heavy, it goes to a worker process or
a queue, not a thread.
One detail matters under load: that threadpool has forty threads by default, shared by every sync route and every sync dependency. Past forty concurrent requests, they queue for a thread rather than for the database.
If they ask how to prove it: measure loop lag — a coroutine that sleeps for 100 milliseconds and records how late it wakes up. Lag is the direct measure of a blocked loop.
Go deeper: FastAPI and the event loop
How does Python manage memory?
Two layers. The main one is reference counting. Every object counts the references to it, and when the count reaches zero it is freed immediately. That is why a file closes deterministically when the last reference goes away.
Reference counting cannot free cycles — two objects pointing at each other never reach zero. So there is a second layer, the cycle collector. It tracks only container objects, and it periodically finds groups that are reachable only from each other.
Underneath, small objects — up to 512 bytes — come from CPython’s own
allocator, pymalloc, which carves memory into arenas, pools and blocks instead
of calling malloc every time.
The practical consequence: memory often does not go back to the operating system after you delete a big structure, because an arena is released only when everything in it is free. So a process is sized by its peak. For a service, that is an argument for streaming large data instead of loading it, and for recycling workers that process big batches.
If they ask about __slots__: it removes the per-instance dictionary. I
use it when there are millions of small objects, not by default.
Go deeper: Memory model and GC
What’s new in recent Python?
3.14 is current — it came out last October. The headline is that free-threaded Python is officially supported, under PEP 779, as an opt-in build rather than the default.
Alongside it: multiple interpreters in the standard library, each with its own GIL, which gives real parallelism with isolated state. Template strings — t-strings — which keep the literal text and the interpolated values apart, so a library can escape or parameterise them; that is the safe-SQL story an f-string can never give you. And annotations are now evaluated lazily, so forward references work without quotes.
From 3.12, I use the new generics syntax every day — type parameters written
directly on the function or class instead of declaring a TypeVar, and
type aliases.
And 3.15 lands this week, on the 9th — the final slipped by a week. The
headlines are explicit lazy imports for faster startup, a frozendict
built-in, and UTF-8 as the default encoding everywhere.
Go deeper: What’s new in Python 3.12-3.14
Web framework — “modern Python web frameworks”
FastAPI or Django?
For a new API service, FastAPI. It sits on Starlette and Pydantic, so I get async, request validation and an OpenAPI contract from the type hints, plus dependency injection that doubles as the seam for tests. For a service that mostly waits on other things — the database, other services, a model — that is the right shape.
Django when the product needs what Django gives for free: the ORM with migrations, the admin, auth, and a very mature ecosystem. For a back office or a data-heavy CRUD product, the admin alone saves weeks. Django’s async support has improved, but the ORM still runs a synchronous driver underneath, so for high-concurrency I/O I would still choose FastAPI.
In both, I keep the framework at the edge. Thin route handlers validate input and call a service. Services hold the business logic. Repositories own the queries. Then the logic is testable without the framework, and the framework choice matters less than people expect.
If they ask how Depends works: at startup FastAPI reads each route’s
signature and builds a dependency tree. Per request it walks the tree and
caches each dependency for that request — so the database session dependency
runs once, and the whole request shares one transaction. yield dependencies
give setup and guaranteed teardown. And app.dependency_overrides is how I
swap the real database or client for a fake in tests.
Go deeper: Dependencies and injection
REST APIs — “build and maintain REST APIs”
How do you design a REST API?
I start from resources, not actions. Plural nouns, one URL per resource, and
the HTTP method carries the verb — GET /patients/{id}/appointments,
POST /appointments. An action that does not map cleanly becomes a
sub-resource or a state change, not a verb in the URL.
Then the contract around it. Status codes that mean something to the client:
201 with a Location header on create, 409 for a conflict, 422 for
validation, and 404 rather than 403 for a resource in another tenant, so I do
not confirm it exists. One error shape everywhere — Problem Details, RFC 9457.
Cursor pagination for anything that grows, because offset gets slower the
deeper you go and drifts when rows are inserted or deleted. Versioning in the
path, but I work hard to make only additive changes, so a new version is rare.
And two things that matter at this scale. Endpoints that create things accept an idempotency key, so clients can retry safely. And the OpenAPI spec is a contract — reviewed like code, with a diff in CI so a breaking change fails the build.
If they ask about HATEOAS: most production APIs are Richardson level 2 — resources, methods and status codes — and I would defend that as a deliberate trade-off. Hypermedia pays off for public APIs with many unknown clients, and rarely for internal ones.
Go deeper: REST principles
A POST timed out on the client. Is it safe to retry?
Only if the API was designed for it. A timeout is exactly the case where the client does not know whether the first attempt applied. A blind retry can create two appointments, or charge twice.
The fix is an idempotency key. The client generates a key per logical operation — a UUID it stores before the first attempt — and sends it in a header. The server stores the key together with the response. If the same key arrives again, it returns the stored response, status code included, without running the operation again.
Two details separate a real implementation from a sketch. First, the race —
two retries arriving at the same moment. Check-then-insert lets both through,
so I let the database decide: INSERT ... ON CONFLICT DO NOTHING RETURNING on
the key, and only the request that inserted the row proceeds. Second, the
same key with a different body is a client bug. I return 422, never a silent
replay.
GET, PUT and DELETE are idempotent by definition, so this machinery is for POST, and for side effects that cannot be keyed naturally.
Go deeper: Idempotency keys
Databases — “databases, migrations, and query optimization”
How do you optimise a slow query?
The same order every time, because guessing is expensive.
First, find what actually costs the most. pg_stat_statements ranks queries
by total time, and often the problem is a fast query called ten thousand
times, not the slowest single one. For the real production plan I use
auto_explain, because a local plan describes my laptop’s data.
Second, read the plan with EXPLAIN (ANALYZE, BUFFERS). I look for three
things: a sequential scan on a big table, an estimated row count far from the
actual one — usually stale statistics — and pages read from disk instead of
cache. ANALYZE really executes the statement, so anything that writes goes
inside a transaction that I roll back.
Third, one fix at a time, then measure again. Usually it is an index, and
column order matters: equality columns first, the range column last.
Sometimes it is the application — an N+1 from lazy loading, which I fix with
selectinload, or prefetch_related in Django.
[A real example: the endpoint took X seconds; the plan showed Y; I changed Z; it went to N milliseconds.]
If they push — “you added an index and it is still slow”: confirm it is
unused with EXPLAIN first. Then check the predicate: a function, a cast or a
leading wildcard on the column defeats a B-tree. Then a type mismatch, then
column order. And if the query matches more than a few percent of the table, a
sequential scan really is cheaper, and the planner is right.
Go deeper: Reading a query plan
Two requests update the same record at once. What happens?
In Postgres, by default, you can lose an update. The default isolation level
is Read Committed. If two transactions each read a row, compute a new value in
the application and write it back, the second write silently overwrites the
first. An atomic UPDATE ... SET count = count + 1 is safe; read-modify-write
in Python is not.
I choose the fix by how contended the row is. If conflicts are rare — editing
a patient’s profile, say — optimistic locking: a version column, and an
UPDATE whose WHERE checks the version I read. If no row changed, someone
else won, and I return 409 or retry. Nothing stays locked while a user thinks.
If conflicts are frequent, or the operation has to queue — booking the last
slot in a schedule — pessimistic: SELECT ... FOR UPDATE in a short
transaction, so the second request waits its turn.
And where the invariant can be a constraint, that is the strongest option. A unique constraint on the slot makes double-booking impossible, whatever the application code does.
If they push on isolation levels: Repeatable Read in Postgres is snapshot isolation — a concurrent update to the same row fails with a serialization error, so I retry. Serializable also catches write skew, at the cost of more retries.
Go deeper: Transactions and isolation levels
How do you change the schema of a big table without downtime?
Two rules. During a deploy the old and new code run at the same time, so every migration must work with both. And no statement may hold a heavy lock on a busy table for long.
So I use expand and contract. To rename a column: add the new one, write to both, backfill, switch the reads, stop writing the old one, and drop it in a later release. Never rename in place.
To add a NOT NULL column: add it nullable, backfill in small batches — short
transactions, so I do not bloat the WAL or lag the replicas — and then enforce
it in two steps: a constraint added as NOT VALID, then VALIDATE, which
checks existing rows without blocking writes.
For indexes on a live table, CREATE INDEX CONCURRENTLY. It cannot run
inside a transaction, so in Alembic it needs an autocommit block, and if it
fails it leaves an invalid index that I have to drop and rebuild.
And I always set lock_timeout for migrations. Even a fast ALTER TABLE
waits behind a long-running query, and every query that arrives after it waits
behind the ALTER. That is how a one-millisecond migration takes a site down.
Go deeper: Zero-downtime migrations
Integrations — “integrate internal and third-party services”
How do you integrate a third-party API that is slow or unreliable?
I design for it failing, because it will.
Structurally: one adapter per provider, behind an interface my service depends on. The adapter maps their data into my internal model and their errors into my exceptions, so their quirks never leak inward, and changing provider is one file.
The client layer owns resilience, in this order. Timeouts first — connect,
read and pool — set from their p99 and my own latency budget. Retries only for
transient failures: connection errors, timeouts, 502, 503, 504, and 429 with
Retry-After respected. Never other 4xx, and never a write without an
idempotency key. Exponential backoff with jitter and a low cap, because a
retry is load — three attempts at each of three layers turn one failure into
twenty-seven requests. And a circuit breaker for a sustained outage, so a dead
dependency fails fast instead of holding every worker for the full timeout.
If nobody needs the result inside the request, I take the call off the request path entirely: a queue, retries, and a dead-letter queue.
For tests, recorded real responses for the adapter, and a scheduled check against their sandbox to catch the day their API changes. Plus per-provider metrics, so I know they are degrading before support tells me.
[Your example: Salesforce and the OAuth2 services — Microsoft Graph, Slack — on the Automation Platform, or the auth, CRM and messaging clients on Invest Planning: what failed, and what you built.]
Go deeper: Designing an external API integration
How do you receive webhooks reliably?
Four rules.
Verify the signature — an HMAC over the raw bytes of the body, compared in constant time, plus a timestamp window so a captured request cannot be replayed. Raw bytes matter: re-serialising the JSON reorders keys and breaks the hash.
Acknowledge fast. Validate, store, enqueue, return 200 in milliseconds. If my handler takes eight seconds and they time out at five, they retry while I am still processing, and I get concurrent duplicates.
Deduplicate on their event id with a unique constraint — not check-then-insert, which races when retries arrive together.
And reconcile. A webhook missed during my downtime is gone silently, so I add a periodic sweep against their API. Webhooks for latency, polling for correctness.
One more for healthcare or money: a valid signature proves who sent it, not that the content is right. For anything consequential, I treat the webhook as a notification and fetch the record from their API.
Go deeper: Webhooks
How should internal services talk to each other?
Synchronously only when the caller needs the answer to respond — REST for most things, gRPC where we own both ends and latency or streaming matters.
Everything that is a side effect — notifying, updating a read model, calling a slow model — goes asynchronously through a broker. A synchronous chain multiplies tail latency, and one slow service takes down the user’s request.
The trap with async is the dual write. I change my database and publish an event, and there is no transaction across both. Write then publish, and a crash in between loses the event. Publish then write, and consumers act on something that never happened. The fix is the transactional outbox: the event goes into an outbox table in the same transaction as the business change, and a relay publishes from that table.
That gives at-least-once delivery, not exactly-once, so every consumer is idempotent and deduplicates on the event id. And I alert on the age of the oldest unpublished outbox row. A dead relay makes nothing look unhealthy, so otherwise a downstream team is the one who notices.
Go deeper: The transactional outbox
How do you make background jobs reliable?
Background jobs fail in boring ways, so the design is about surviving them.
Every task is idempotent, because delivery is at-least-once. A worker can die after doing the work and before acknowledging it, and the message comes back. I key the side effect on something stable — the record id, or an idempotency key — so a second run changes nothing.
In Celery I set acks_late, so a task is acknowledged only after it
finishes; otherwise a crash loses it. With a Redis or SQS broker that comes
with a visibility timeout longer than the longest task, or the task is
redelivered while it is still running. Retries with exponential backoff and
jitter, a maximum attempt count, and then a dead-letter queue that someone
actually looks at.
Separate queues for separate workloads, so a flood of slow jobs cannot starve the fast, important ones. And I scale workers on queue depth or the age of the oldest message, not on CPU — in Kubernetes, that is what KEDA is for.
If they ask about FastAPI’s BackgroundTasks: fine for sending an email
after the response. But it runs in the same process with no durability — if
the pod restarts, the job is gone.
Go deeper: Consumer idempotency
Testing — “write unit and integration tests”
What do you mock, and what do you not?
I mock the boundaries I do not own, and nothing inside them.
Business logic gets fast unit tests — services tested with a fake repository, no framework, no database.
The database I never mock. A mocked query tests my mock; the real bugs live in the SQL, the constraints and the migrations. So integration tests run against real Postgres in a container — testcontainers, the same locally and in CI — and each test runs inside a transaction that is rolled back, so tests stay isolated and fast.
For the API layer, FastAPI’s dependency_overrides swaps in test versions of
the dependencies while the whole real request path still runs.
Third-party APIs I stub at the HTTP transport — respx for httpx — with
fixtures recorded from real responses rather than written by hand, because
the real ones contain the nulls and odd fields that break parsing. Most of
those tests are failure paths: a 4xx mapped to my exception, a 5xx retried, a
timeout handled. And a scheduled contract check against their sandbox catches
the day they change their API.
Coverage I use to find untested code, not as a target. On new code I would rather gate on diff coverage.
[On the Invest Planning Platform you worked test-first with pytest — one bug a test caught before production.]
Go deeper: Integration and contract testing
Architecture — “scalable Python backend services”
Monolith or microservices?
For most products, a modular monolith first, and a split when a concrete pain appears.
Microservices solve organisational and scaling problems — teams blocking each other’s deploys, or one component needing very different scaling or isolation. But they are expensive. You lose ACID across the system, so every multi-step operation becomes eventual consistency with sagas and compensations. And you need tracing, contract tests and per-service operations.
The worst outcome is a distributed monolith: services separated physically but sharing a database, calling each other in synchronous chains, and deploying in lockstep. All of the cost and none of the benefit.
So I start with one deployable and strict module boundaries — each module owns its tables, and others go through its interface. That is the seam that makes a later split cheap. When I do split, it is strangler fig: route through a gateway, move one bounded capability at a time, and move its data last, because the data is the hard part.
At your scale I expect several services already exist, so the real question I would ask is where the boundaries hurt today.
Go deeper: Monolith vs microservices
How would you scale this service ten times?
First I find the real bottleneck, because it is rarely the application servers.
The application tier is the easy part if it is stateless — no sessions in memory, configuration from the environment, files in object storage. Then it scales out: more workers and instances behind a load balancer, autoscaling on a metric that reflects load.
The database is usually the ceiling. In order of cost: fix the queries and indexes. Put a pooler like PgBouncer in front, because ten times the instances means ten times the connections, and Postgres degrades with too many. Cache hot reads — cache-aside, with TTLs and jitter. Send read-heavy traffic to replicas, accepting replication lag where that is safe. Only then partition or shard.
Slow work leaves the request path — queues and workers, scaled on queue depth.
And I load-test before and after, watching p99 rather than the average, because at ten times the traffic the tail is where users feel it.
Go deeper: Connection pooling
Decisions and review — “architectural decisions and code reviews”
What do you look for in a code review?
Correctness first — edge cases, error paths, what happens on a retry or under concurrency. Then design: does this belong here, does it respect the boundaries. Then operations: is this migration safe on a big table, is this query an N+1, does this external call have a timeout, could I debug this at three in the morning from the logs.
In healthcare I would add one check explicitly: is any patient data reaching a log, an error tracker or a URL.
Tests are part of the review. Do they test behaviour, and would they fail if the code were wrong?
What I do not spend review on is formatting and style. Ruff, mypy and pre-commit own that, and CI is the gate, so human attention goes to judgement.
How I say it matters as much as what I say. I label comments — blocking, suggestion, nit — so the author knows what stops the merge. I ask questions instead of assuming. I explain why, because review is where most mentoring actually happens. And I push for small pull requests: two hundred lines get a real review; two thousand get a rubber stamp.
Go deeper: Pre-commit hooks and code review
How do you make an architectural decision?
I start from the constraint, not the technology: what problem, at what scale, what we are optimising for — and how expensive the decision is to reverse.
That last part sets how much process it deserves. A library choice is cheap to undo, so I decide quickly. A datastore, a service boundary or a public API is a migration to reverse, so it gets real analysis — two or three options, a spike if there is a genuine unknown, and the trade-offs written down.
I write those as an ADR in the repository: context, decision, consequences — including what we are giving up. The context is the valuable part. A year later, someone can see the pressure behind the decision and judge whether it still holds. ADRs go through review in the same pull request as the change, and they are never edited afterwards; a new one supersedes the old one.
And I involve the people who will live with it. In a distributed team, a written proposal discussed asynchronously gets better input than a meeting.
[Your example: the decision, the options, what you gave up, how it turned out.]
Go deeper: Architecture decision records
Production — “support production services”
Walk me through how you handle a production incident.
Stop the bleeding before diagnosing. The first question is what changed — a deploy, a config change, traffic, a dependency. The first action is a safe mitigation: roll back, turn off the feature flag, scale up, shed load. Root cause comes after users are no longer affected.
For anything big I split the roles: one person coordinates and communicates, one investigates. Short updates on a fixed rhythm, even when the update is “no news yet”.
To investigate I go top-down: the metric that alerted, then traces of the slow or failing requests, then the logs for those trace ids. That only works if the groundwork exists — structured logs with a request id that follows the request across services and queues, RED metrics per endpoint, and alerts on symptoms users feel, tied to SLOs.
Afterwards, a blameless postmortem: a timeline that separates how long until we knew from how long until we fixed it, and action items with owners and dates. Detection time is usually the biggest lever, and the one nobody lists.
[Your incident: what broke, how you found out — Sentry, an alert, a user — the mitigation, and what changed afterwards.]
Go deeper: Incident response, runbooks and postmortems
Healthcare — “a digital healthcare project”
What changes when the data is patient data?
The controls are mostly things you should do anyway. What changes is that they become obligations you have to be able to prove.
Encryption in transit everywhere, including between internal services, and at rest with managed keys — plus field-level encryption for the most sensitive fields. Authorisation per request and per record, enforced on the server. And an audit log of every access to patient data — who, what, when — append-only and kept for years. That is a functional requirement, not observability: a 14-day log retention fails an audit.
Minimisation: select only what the task needs, never copy production data into staging, keep identifiers out of URLs. And logging discipline, which is where engineers fail most often. An f-string with a patient object puts health data into the log aggregator, and a traceback with local variables puts it into Sentry. So logging works from an allow-list of fields, not a deny-list.
Then the regulatory frame: HIPAA in the US, GDPR in Europe, where health data is a special category. Every vendor that touches patient data needs a BAA in the US or a data processing agreement under GDPR — which limits tool choice before you write any code. And for an international product, data residency: where each patient’s data is allowed to live.
The bridge to your experience: [on the Invest Planning Platform, audit and consent records were part of the data model because the advice was regulated — say how they were stored and who could read them]. Same discipline, higher stakes.
Go deeper: PHI, privacy and secure coding
Have you worked with FHIR or HL7?
[If you have: which integration, which resources, what volume, what broke.]
If not, say so plainly, then show you know the shape:
“Not in production. My understanding is that FHIR is the modern standard — REST and JSON, organised into resources like Patient, Observation, Encounter and MedicationRequest. I would build against R4, because that is what certified EHR systems actually implement; R5 has limited adoption and R6 is still in ballot. HL7 version 2 is the older pipe-delimited format that hospitals still run on, sent over MLLP.
In practice a platform speaks both, with an adapter layer that normalises into one internal model — the same integration pattern I used for the third-party systems on my last project.
Two things I know matter. Conformance is against a profile, not the base spec, because the base spec makes almost everything optional. And HL7 v2 interfaces resend when an acknowledgement times out, so processing must be idempotent on the message control id.“
Go deeper: Healthcare data standards
Would you send patient data to an LLM?
Only in one of three ways, and I would decide which before designing anything else.
One: a hosted model under a BAA with zero data retention, so the provider is contractually inside our compliance boundary. Two: de-identify before the call — though redaction is imperfect, and free text leaks identifiers in ways a regex will not catch. Three: run inference inside our own boundary — a model in our own cloud account, or self-hosted.
Then I treat the model like any other unreliable third-party dependency: timeouts, retries on transient errors, a fallback, a pinned model version, and cost per request tracked.
And because it is healthcare: a human in the loop for anything clinical, an evaluation set so we know whether a model or prompt change made things worse, and the inputs and outputs in the audit trail, under the same rules as every other piece of patient data.
[Your bridge: where the AI extraction on the Automation Platform ran, and whether documents left your AWS account; and the guardrails and consent flows of the LangGraph assistant on Invest Planning. One sentence each.]
Go deeper: PII, privacy and the EU AI Act