Backend — Python & FastAPI
"What's the difference between async and sync in Python? When would you use each?"
Backend
Suggested Answer

Async in Python is cooperative multitasking — an event loop runs coroutines, and while one is waiting on I/O (a DB query, an HTTP call), the loop can run another. It's not true parallelism; it's concurrency through yielding.

Use async when the bottleneck is I/O-bound: web APIs, database queries, file reads. A FastAPI app handling 1,000 concurrent requests benefits enormously from async — each request awaits I/O while others run.

Use sync / threads / multiprocessing for CPU-bound work — image processing, computation, ML inference. Async gives you nothing there; you're never actually waiting on I/O.

The critical gotcha: you cannot call blocking sync code inside an async context. A single blocking call freezes the event loop for every request. In production, that usually means using an async driver such as asyncpg for PostgreSQL and offloading unavoidable sync calls with asyncio.to_thread().

→ Follow-up you might get: "What's the GIL?" — The GIL prevents true thread parallelism for CPU work in CPython, which is why async (single-threaded concurrency) works well for I/O and multiprocessing works for CPU. asyncio sidesteps the GIL debate entirely.
"How does FastAPI's dependency injection work? Why use it?"
Backend
Suggested Answer

FastAPI's DI system resolves declared function parameters at request time. You declare a dependency with Depends() and FastAPI handles instantiation, lifecycle, and cleanup.

A common production FastAPI app uses it for three things:

Database sessions — a generator function that yields an AsyncSession and closes it after the request, regardless of success or failure:
async def get_db() → AsyncGenerator[AsyncSession, None]: async with SessionLocal() as s: yield s

Authentication — a get_current_user dependency that extracts the Bearer token, validates the JWT, and returns the User object. Protected routes declare it; the auth logic lives in one place.

Composability — dependencies can depend on other dependencies. get_current_active_user depends on get_current_user, which depends on get_db. FastAPI builds the graph and resolves it efficiently per request.

The real benefit is testability: in tests I override dependencies with app.dependency_overrides — swap a real DB session for a test transaction, or a real auth user for a fixture.

✓ This is a FastAPI-specific question but the underlying pattern (DI) applies everywhere. Connect to testability — that's the senior signal.
"How do you implement authentication and authorization in a FastAPI app?"
Backend
Suggested Answer

I separate authentication (who are you?) from authorization (what can you do?) deliberately.

Authentication: JWT-based, stateless. Login endpoint validates credentials, returns a short-lived access token + longer-lived refresh token. The access token is signed (HS256 or RS256), contains user_id + scopes + expiry. No server-side session — any instance can validate it.
The refresh token is stored in an HttpOnly cookie, not localStorage — eliminates XSS as an attack surface for token theft.

Authorization: Role or scope-based. The JWT payload carries the user's scopes. A dependency checks the required scope for each endpoint. I avoid mixing authorization logic into business logic — it belongs at the routing layer.

In higher-risk systems, I may also require step-up verification for sensitive mutations — delete account, export all data, changing payout details. Even with a valid JWT, those endpoints can require a TOTP confirmation or recent re-authentication.

What I explicitly avoid: storing passwords in plain text or MD5, using JWTs for session replacement without a refresh strategy, and trusting user-provided data in the token payload without re-validating from DB when stakes are high.

→ The HttpOnly cookie detail + MFA mention signals production-grade thinking. Most candidates describe JWT at a surface level. The "stateless but when to re-validate from DB" nuance is what separates senior answers.
"What is Pydantic and how does it work in FastAPI?"
Backend
Suggested Answer

Pydantic is a data validation library built on Python type hints. You define a model class, declare fields with types, and Pydantic validates and coerces incoming data — raising a structured error if anything doesn't match.

In FastAPI, Pydantic models serve three roles:
Request body validation — FastAPI deserializes the JSON body into a Pydantic model automatically. Invalid data → 422 with field-level error detail, before your handler ever runs.
Response serialization — declaring response_model=TransactionResponse strips any fields not in the model and serializes correctly. Prevents accidentally leaking internal fields like password hashes.
Settings managementBaseSettings from the companion package pydantic-settings (separate install in v2: pip install pydantic-settings) reads from environment variables with type coercion and validation. One class replaces all the os.getenv() calls.

In Pydantic v2, the core was rewritten in Rust, which makes validation much faster than v1. One migration gotcha is stricter type coercion: v2 is less permissive about silently accepting the wrong type.

→ Mentioning v2's Rust core + the speed improvement signals you're current. The response_model note (preventing data leaks) is a security detail that mid-level candidates often miss.
"What are Python decorators? How do you write one?"
Backend
Suggested Answer

A decorator is a callable that takes a function and returns a new function — wrapping behavior around the original without modifying it.

Minimal example:
def timer(fn): @wraps(fn) def wrapper(*a, **kw): t0=time.time(); r=fn(*a,**kw); print(time.time()-t0); return r; return wrapper

@wraps(fn) from functools copies the original function's __name__ and __doc__ to the wrapper — without it, introspection tools and FastAPI's OpenAPI schema generator break.

Decorators with arguments need a third layer:
def retry(times=3): def decorator(fn): @wraps(fn) def wrapper(*a,**kw): … return wrapper; return decorator

Real-world use in my stack: @api.depends() in Odoo is a decorator. FastAPI's @router.get() is a decorator. I've written retry decorators for external API calls and timing decorators for profiling slow methods. Class-based decorators (implementing __call__) work too, and are easier to reason about when state is involved.

→ If asked to write one live: start with the three-line version (no wraps, no args), get it working, then add @wraps. Don't start by memorising the three-layer pattern — reason your way to it.
"What is Python's GIL? When does it matter?"
Backend
Suggested Answer

The GIL (Global Interpreter Lock) is a mutex in CPython that allows only one thread to execute Python bytecode at a time. It exists because CPython's memory management (reference counting) is not thread-safe; the GIL protects it from race conditions.

When it matters: CPU-bound multithreading. Two threads doing heavy computation won't truly run in parallel — one blocks while the other holds the GIL. Solution: use multiprocessing (separate processes, each with their own GIL) or offload to native extensions (NumPy releases the GIL during operations).

When it doesn't matter: I/O-bound work. When a thread blocks on I/O (network, disk), it releases the GIL voluntarily — other threads can run. This is why threading works fine for I/O-bound tasks. Async (asyncio) sidesteps the GIL entirely since it's single-threaded.

Current state (2026): Python 3.13 shipped an experimental "free-threaded" build (PEP 703) that removes the GIL. It's not the default yet, and many C extensions don't support it yet — but the direction is clear. Worth knowing if an interviewer digs into Python internals.

→ The Python 3.13 free-threaded mention is a strong 2026-current signal. Most candidates are still describing the GIL as if it's permanent and unaddressed.
"What are generators in Python? How are they related to async?"
Backend
Suggested Answer

A generator is a function with yield — it returns an iterator that produces values lazily, one at a time, without computing and storing the entire sequence upfront.

Why it matters: memory efficiency. (x**2 for x in range(10_000_000)) uses constant memory; a list comprehension allocates 10 million items at once. For streaming large datasets — CSV exports, DB cursor iteration — generators are essential.

yield from delegates to a sub-generator, enabling composition of pipelines:
def pipeline(source): yield from filter_step(transform_step(source))

Connection to async: async def with yield = async generator. This is exactly what FastAPI's DI system uses for lifecycle management:
async def get_db() → AsyncGenerator[AsyncSession, None]: async with SessionLocal() as s: yield s
The yield is the suspend point — setup runs before it, teardown after. FastAPI drives the generator's lifecycle per request.

Generators also underpin asyncio internally — coroutines were originally built on generator protocol before async/await syntax existed.

→ Connecting generators to FastAPI's Depends() pattern is the bridge between theory and your actual project. Interviewers asking this at senior level want to see you understand the underlying mechanism, not just the syntax.
"How do you handle errors and exceptions properly in a FastAPI app?"
Backend
Suggested Answer

I separate three categories of errors with different handling strategies:

Expected HTTP errors: raise HTTPException(status_code=404, detail="Expense not found"). FastAPI converts these to JSON responses automatically. Use specific status codes — 422 is not 400, 403 is not 401.

Business logic violations: I define custom exception classes and register global handlers:
@app.exception_handler(InsufficientFundsError) async def handle(req, exc): return JSONResponse(status_code=409, content={"code": "INSUFFICIENT_FUNDS", "message": str(exc)})
This keeps business error codes consistent across the API surface and decouples error handling from route handlers.

Unexpected exceptions (bugs/infra): catch-all handler returns a generic 500 with a correlation ID — the real error is logged internally with full traceback. Never expose stack traces to clients. In financial apps, the correlation ID lets support trace the incident in logs.

Validation errors (422): Pydantic raises RequestValidationError automatically. I override the default handler to normalise the error shape to match the rest of the API contract. Clients shouldn't need to handle two different error formats.

→ The "never expose stack traces" + correlation ID design is what separates production-minded engineers. The custom exception + handler pattern shows architectural thinking vs just try/except everywhere.
"How do you write and structure tests for a FastAPI application?"
Backend
Suggested Answer

I use pytest + httpx's AsyncClient for async route testing. The key tool is app.dependency_overrides — it lets me swap real dependencies for test doubles per test.

DB strategy: I create a test engine pointing to a separate test database, wrap each test in a transaction, and roll back after. No test data leaks between tests, no manual cleanup. SQLite in-memory can be useful for very isolated tests, but for PostgreSQL apps I prefer the real engine for integration coverage.

async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/expenses/")

Structure: I layer tests in two levels:
Unit tests on service/business logic functions directly — fast, no HTTP overhead, no DB.
Integration tests on routes — test the full request pipeline including auth, validation, and DB interaction.

Auth in tests: override get_current_user dependency to return a fixture user, bypassing JWT validation. This keeps auth tests isolated from route logic tests.

I avoid mocking the DB for integration tests — I learned from the webhook incident that mock/prod divergence is a real danger. Real DB calls in a rolled-back transaction is the best of both worlds.

→ "Rolled-back transaction per test" is the right pattern and many candidates don't know it. The "don't mock the DB for integration tests" stance signals production experience.
"When do you use FastAPI background tasks vs a task queue like Celery or ARQ?"
Backend
Suggested Answer

FastAPI's BackgroundTasks runs a function after the response is sent, inside the same process and event loop. Simple, zero infra, no setup.

Use BackgroundTasks when: lightweight post-response work — sending a welcome email, logging an analytics event, updating a cache entry. If the server restarts or the task throws, the work is silently lost. Acceptable for non-critical side effects.

Use a task queue (Celery, ARQ, Dramatiq) when:
— The task must not be lost if the server crashes (durability)
— Retry logic with backoff is needed (external API calls, webhook delivery)
— The task is CPU-heavy and shouldn't share the event loop
— You need scheduling (cron-style), task routing, or monitoring (Flower for Celery)
— Task volume requires horizontal scaling of workers independently from the API

My recommendation for 2026: ARQ is a good fit for async-first FastAPI projects because it avoids some of Celery's sync-vs-async friction. Celery remains dominant for mature ecosystems, complex pipelines, and teams that already have operational familiarity with it.

A practical rule: use BackgroundTasks for low-stakes side effects, and move to a real queue once you need durability, retries, scheduling, or independent worker scaling.

→ Naming ARQ as an alternative and explaining why it fits async FastAPI better than Celery shows you're thinking about 2026 tool choices, not just cargo-culting the most popular option.
Backend — Database & PostgreSQL
"What are ACID properties? Why do they matter for financial systems?"
Database
Suggested Answer

Atomicity: a transaction is all-or-nothing. If you're transferring money between two accounts and the debit succeeds but the credit fails, the whole transaction rolls back. No partial state.

Consistency: the database moves from one valid state to another. DB constraints (foreign keys, check constraints, unique indexes) enforce the rules. For a financial app: account balance can never go below zero — that's a DB constraint, not just application logic.

Isolation: concurrent transactions don't see each other's in-progress changes. The default in PostgreSQL is READ COMMITTED — you won't see uncommitted rows. For financial operations where you need to prevent double-spending or race conditions, you may want SERIALIZABLE isolation or explicit row-level locking (SELECT FOR UPDATE).

Durability: once a transaction commits, it survives crashes. PostgreSQL's WAL (Write-Ahead Log) guarantees this.

In practice: I always use explicit transaction boundaries in SQLAlchemy, never autocommit for multi-step financial operations. And I use Decimal, never float, for monetary values — floating point arithmetic cannot represent 0.10 exactly.

→ The Decimal vs float detail always impresses. If they dig deeper: mention isolation levels (READ COMMITTED vs REPEATABLE READ vs SERIALIZABLE) and when phantom reads are actually a problem.
"What is an N+1 query problem and how do you diagnose and fix it?"
Database
Suggested Answer

An N+1 problem occurs when you fetch a list of N records and then fire an additional query for each one to get related data — totaling N+1 queries instead of 1 or 2.

Classic ORMs make this easy to trigger accidentally. Example: for order in orders: print(order.customer.name) — if customer is a lazy relationship, that's one query per order.

Diagnosis: I log all queries in development (SQLAlchemy's echo=True) and look for repeated identical queries with different IDs. In production I use EXPLAIN ANALYZE on slow endpoints to see what's actually hitting the DB.

Fix options:
Eager loading: selectinload() or joinedload() in SQLAlchemy — fetch related records in a second optimized query upfront.
Single JOIN query: join everything at the SQL level and build the in-memory structure from the result set.
Batch loading: collect all IDs first, fetch in one WHERE id IN (...) query.

Real example from my Odoo work: a financial report was taking 30+ seconds. N+1 on account lines — one query per line. Refactored to a single JOIN query with an in-memory dict for aggregation. Dropped to under 3 seconds, same dataset, no infra change.

✓ Always anchor to the real example. The "30s → 3s" number makes this concrete and memorable.
"How do PostgreSQL indexes work? When do you add them and when do you not?"
Database
Suggested Answer

Indexes let PostgreSQL find rows without a full table scan. The default is B-tree — efficient for equality (=), range (<, >, BETWEEN), and ordering. Works for 90% of cases.

Other types for specific problems:
GIN: for JSONB columns, full-text search (tsvector), and array containment. Can't use B-tree for "does this JSONB field contain key X?"
GiST: geometric data, pg_trgm similarity searches (LIKE '%pattern%' with trigrams).
Partial index: index only a subset — e.g., WHERE deleted_at IS NULL. Much smaller, much faster for queries that always filter on that condition.
Composite index: column order matters — put equality-condition columns before range-condition columns. Example: (user_id, created_at) for WHERE user_id = X AND created_at > Y — equality first, then range.

When NOT to add an index: every index slows down writes (INSERTs and UPDATEs must maintain the index). Don't index columns with very low cardinality (e.g., a boolean). Don't add indexes speculatively — add them when you have a slow query and EXPLAIN shows a sequential scan you can avoid.

My workflow: always run EXPLAIN ANALYZE before and after. The planner should switch from Seq Scan to Index Scan — if it doesn't, the planner decided the index isn't worth it (usually correct).

→ The "when NOT to index" part is what separates a senior answer. GIN + JSONB is a detail that signals real PostgreSQL experience, not just ORM usage.
"How do you handle database schema migrations safely in production?"
Database
Suggested Answer

I use Alembic (with SQLAlchemy) and follow a few strict rules to keep migrations safe:

Never drop or rename in a single step. If you rename a column, the old code still reads the old name. The safe approach: add the new column → backfill data → deploy new code → drop the old column in a later migration after the old code is gone.

Always review auto-generated migrations. Alembic's --autogenerate catches most changes but misses things like CHECK constraints, trigger-based defaults, or index changes on existing columns. I never commit an auto-gen migration without reading every line.

Test the rollback path when practical. Prefer a working downgrade() for reversible migrations, but some destructive changes are safer with a forward-fix plan, backup, or phased rollout. I want a clear recovery strategy either way.

Dangerous operations need special handling:
— Adding a column with a NOT NULL constraint on a large table requires a default or a multi-step approach (PostgreSQL will rewrite the table otherwise, locking it).
— Adding an index: use CREATE INDEX CONCURRENTLY — avoids a full table lock.
— Large backfills: do them in batches, not a single UPDATE that locks the entire table.

In Odoo, migrations are trickier because the ORM manages the schema — we use post_init_hook and pre_init_hook in the module manifest for data migrations.

→ "CREATE INDEX CONCURRENTLY" and the two-step rename are the most-impressive details here. Both are real production gotchas that juniors learn the hard way.
"What's the difference between INNER, LEFT, and FULL OUTER JOIN?"
Database
Suggested Answer

INNER JOIN: returns only rows where both tables have a matching key. The default JOIN. If an expense has no category, it won't appear.

LEFT JOIN (LEFT OUTER): returns all rows from the left table, with NULL columns from the right where there's no match. Use case: "show all users, whether or not they have expenses" — users with zero expenses still appear, with NULL expense columns.

RIGHT JOIN: mirror of LEFT JOIN. In practice, just flip the tables and use LEFT JOIN — easier to reason about.

FULL OUTER JOIN: all rows from both tables. Rows with no match on either side get NULLs for the other table. Use case: finding records that exist in one table but not the other — "users with no expenses AND expenses with no valid user".

A pattern I use often: LEFT JOIN + WHERE right_table.id IS NULL — finds all rows from the left that have no match on the right. Example: users who have never made an expense:
SELECT u.* FROM users u LEFT JOIN expenses e ON u.id = e.user_id WHERE e.id IS NULL

CROSS JOIN (cartesian product) exists but is rarely intentional — usually a mistake when you forget the ON clause.

→ The "LEFT JOIN + WHERE IS NULL" anti-join pattern is what separates SQL fluency from just knowing the join types. Showing a concrete example from your domain makes it memorable.
"What are window functions in PostgreSQL? When are they useful?"
Database
Suggested Answer

Window functions perform calculations across a set of rows related to the current row, without collapsing the rows like GROUP BY does. You keep all rows; you just add a computed column per row.

Syntax: FUNCTION() OVER (PARTITION BY col ORDER BY col)

Common functions:
ROW_NUMBER(): sequential number within partition. Use: paginate per-user, deduplicate.
RANK() / DENSE_RANK(): ranking with/without gaps on ties.
LAG(col, 1) / LEAD(col, 1): access previous/next row's value. Use: month-over-month change.
SUM() OVER (PARTITION BY ... ORDER BY ...): running total.

Real use case from Expense Tracker: running total of expenses per user per month, without a self-join or subquery:
SELECT date, amount, SUM(amount) OVER (PARTITION BY user_id ORDER BY date) AS running_total FROM expenses

This would require a correlated subquery without window functions — much slower and harder to read.

vs GROUP BY: GROUP BY collapses N rows to 1. Window functions keep all N rows and add a computed column alongside them.

→ The running total example from the Expense Tracker domain makes this concrete. The "vs GROUP BY" distinction is what most people miss when first learning window functions.
"What is connection pooling and why does it matter for async apps?"
Database
Suggested Answer

Each PostgreSQL connection is expensive: it involves a TCP handshake, authentication, and a dedicated backend process on the Postgres side (~5–10 MB RAM per connection). Opening a new connection per request at scale is a performance and resource problem.

Connection pooling maintains a pool of already-open connections and reuses them across requests. A request borrows a connection, uses it, and returns it to the pool.

Critical for async apps: a FastAPI app with async SQLAlchemy can handle 1,000 concurrent requests — but that doesn't mean you need 1,000 DB connections. Most of those requests are awaiting I/O at any given moment. A pool of 20–30 connections is often enough for hundreds of concurrent requests.

SQLAlchemy async engine config:
create_async_engine(url, pool_size=20, max_overflow=10, pool_timeout=30)
pool_size: maintained connections
max_overflow: temporary extra connections under spike load
pool_timeout: how long to wait if pool is exhausted before raising

PgBouncer: external connection pooler between app and Postgres. Used at high scale when multiple app instances would exceed Postgres's max_connections. Can pool in transaction mode — even more efficient but incompatible with some PostgreSQL features.

→ Knowing PgBouncer exists and when you'd need it distinguishes someone who's operated at scale from someone who's only built on a single server. The pool_size calculation logic is a senior signal.
"What's the difference between optimistic and pessimistic locking?"
Database
Suggested Answer

Pessimistic locking: lock the row when you read it — no one else can write to it until your transaction commits. PostgreSQL: SELECT FOR UPDATE. High contention scenario: guaranteed consistency, but reduced concurrency. If two transactions compete for the same row, one waits.

Optimistic locking: don't lock on read. Add a version integer column. When updating: UPDATE ... WHERE id = X AND version = Y. If another transaction updated first, the version changed, your UPDATE affects 0 rows → detect and retry. Higher concurrency (no waiting), but requires retry logic in the application.

When to use pessimistic (SELECT FOR UPDATE): financial operations where conflicts are expected and correctness is mandatory. Example: deducting from an account balance — two concurrent transactions both reading balance=500, both thinking they can spend 400, would both succeed without locking. With FOR UPDATE, the second one waits until the first commits.

When to use optimistic: read-heavy workloads where conflicts are rare — updating a user profile, editing a document. The optimistic assumption is: "by the time I write, no one else has changed this."

Odoo uses optimistic locking internally via write_date — concurrent writes to the same record will raise a UserError on the second writer.

→ The "two transactions both reading balance=500" scenario is the clearest illustration of why pessimistic locking exists. Connecting to Odoo's internal mechanism shows depth.
"What are database views and materialized views? When to use each?"
Database
Suggested Answer

Regular view: a stored SQL query — no data is physically stored. Every query against the view re-executes the underlying SQL. Acts like a virtual table.
Use for: encapsulating complex joins, hiding base table complexity from application code, security (grant SELECT on view but not on base tables).
Cost: if the underlying query is expensive, every view access is expensive. No persistent storage.

Materialized view: runs the query and stores the result set on disk. Queries against it read cached data — very fast. But data is stale until you refresh it.
Use for: expensive aggregations queried frequently where slightly stale data is acceptable — dashboards, reports, analytics summaries.

Refreshing:
REFRESH MATERIALIZED VIEW mv_name — blocks reads during refresh.
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_name — non-blocking, but requires a unique index on the view. Preferred for production.

Real use case: monthly expense totals per category in the Expense Tracker. Computing this on every dashboard request hits expenses + categories with aggregation. Materialized view refreshed every hour — dashboards read cached results instantly.

Alternative: Redis cache. Tradeoff: Redis = flexible TTL, but you maintain cache invalidation. Materialized view = SQL-native, simpler, but less flexible refresh control.

→ CONCURRENTLY + the "requires a unique index" detail is what separates someone who has used materialized views in production from someone who read the docs. The Redis tradeoff shows systems thinking.
"How do you read and use EXPLAIN ANALYZE output?"
Database
Suggested Answer

EXPLAIN shows the query plan without executing; EXPLAIN ANALYZE executes it and shows both the planner's estimates and actual timings.

Key things I look for:

Seq Scan on a large table → missing index. If rows= is large and it's scanning the whole table, add an index on the filter column.

Estimated vs actual rows divergence → stale statistics. If the planner estimated 10 rows but got 50,000, run ANALYZE table_name to update statistics. Bad estimates lead to bad plans (wrong join order, wrong index choice).

Nested Loop with many iterations → potential N+1 or bad join. A nested loop scanning 10,000 rows per outer row is a problem. Hash Join or Merge Join is usually better for large sets.

cost=X..Y → startup cost .. total cost in arbitrary planner units. Useful for comparing two plans, not as absolute numbers.

My workflow: capture the slow query from application logs (request_id → log trace), run EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) in psql, add BUFFERS to see cache hits vs disk reads. Add the index, run again — confirm the plan changed from Seq Scan to Index Scan. If it didn't switch, the planner decided seq scan is cheaper (often correct for very small tables).

→ BUFFERS option is the detail most candidates don't know. Cache hits vs disk reads tells you whether performance will degrade in production after the buffer cache cools down.
Frontend — Next.js & React
"What's the difference between SSR, SSG, and CSR? How does the App Router change this?"
Frontend
Suggested Answer

CSR (Client-Side Rendering): HTML is minimal, JS runs in the browser and builds the page. Good for private dashboards (no SEO needed, user-specific data). First paint is slow until JS loads.

SSG (Static Site Generation): HTML is built at compile time, served from CDN. Fastest TTFB, cacheable. Only works for data that doesn't change per request — landing pages, docs, blog posts.

SSR (Server-Side Rendering): HTML is built on each request on the server. Fresh data, SEO-friendly, but server bears the rendering cost. Good for pages that need both fresh data and SEO.

App Router (stable since Next.js 13, now the primary model in Next.js 16) changes the model significantly. The default is now Server Components — components that render on the server and send HTML + zero JS to the client. No state, no effects, no browser APIs — but you can fetch data directly, even from a DB. You add "use client" only for components that need interactivity.

A common pattern is: marketing pages are SSG, SEO-sensitive pages with fresh data are SSR, and private dashboards often lean heavily on client-side interactivity. In App Router, I push "use client" as deep as possible so only the interactive sub-components opt into the client bundle.

→ In Next.js 16, the Pages Router still exists for migration compatibility but App Router is the primary model. Candidates who answer this question in Pages Router terms are signalling they haven't kept up since 2022.
"When do you use useState vs useReducer vs Context vs an external store?"
Frontend
Suggested Answer

I think of state management as a ladder — use the simplest tool that handles the problem, and move up only when needed.

useState: simple, local, independent state. A form field, a toggle, a loading flag. Default choice.

useReducer: when local state has multiple sub-values that change together based on the same action — like a form with validation state, dirty fields, and submission status. Keeps transition logic in one place and makes it testable.

Context: for global state that doesn't change often — auth user, theme, locale. The gotcha: every consumer re-renders when the context value changes. Use separate contexts for separate concerns. Don't put frequently-changing data (like a live count) in Context.

External store (Zustand, Jotai): when Context's re-render behavior becomes a problem at scale, or when state needs to be shared between components that aren't in a clean parent-child tree. I prefer Zustand for its simplicity — it's just a store with selectors, no provider needed.

A common split is: auth state in Context because it changes rarely, local filtering state in useState, and a move to useReducer once transitions become complex enough to deserve explicit actions.

→ The Context re-render gotcha is what separates senior from mid-level answers. The "simplest tool that handles the problem" framing shows architectural thinking, not framework preference.
"What is React hydration? What causes hydration errors?"
Frontend
Suggested Answer

Hydration is the process of attaching React's event handlers and state to server-rendered HTML. The server sends static HTML — fast first paint. Then the client JS loads, React "hydrates" the existing DOM by matching its virtual DOM tree against the real DOM, and makes it interactive.

A hydration error occurs when the HTML rendered on the server doesn't match what React would render on the client. React expects them to be identical on the first render.

Common causes:
Date/time rendering: new Date() on the server returns UTC; on the client it may use the user's timezone. The strings differ → mismatch.
Random values: Math.random(), UUID generation — different values server vs client.
Browser-only APIs: accessing window or localStorage during render on the server throws; wrapping in useEffect fixes it because effects only run client-side.
Incorrect nesting: a <p> tag containing a <div> — invalid HTML — gets corrected differently by server vs client parsers.

Fix: use suppressHydrationWarning only for values that are intentionally different (timestamps). For the rest, ensure server and client render paths produce identical output.

→ The specific causes (especially the timezone one) are real gotchas. The suppressHydrationWarning nuance shows you've hit this in practice, not just read about it.
"What are common mistakes with useEffect? How do you avoid them?"
Frontend
Suggested Answer

Missing dependency array: useEffect(() => {...}) with no array runs after every render. Almost always a bug. Add [] for once-on-mount or [dep] for specific deps.

Stale closure: declaring a dep but not adding it to the array means the effect captures an old value from the closure. ESLint's exhaustive-deps rule catches this — always enable it.

Infinite loop: an object or array created inline as a dep ([{}] or [[]]) creates a new reference on every render → triggers effect → re-render → repeat. Fix: memoize with useMemo or move the value outside the component.

Async in useEffect: you can't declare the callback async directly (useEffect's return value must be a cleanup function, not a Promise). Correct pattern:
useEffect(() => { let active = true; async function fetch() { const d = await getData(); if (active) setData(d); } fetch(); return () => { active = false; }; }, []);
The active flag prevents setting state on an unmounted component — a common "Can't perform a React state update on an unmounted component" warning.

Missing cleanup: subscriptions, timers, and event listeners created in effects must return a cleanup function — otherwise memory leaks and duplicate listeners accumulate.

→ In development, Strict Mode still helps expose unsafe effects by re-running render/effect paths. Mentioning eslint-plugin-react-hooks exhaustive-deps is a practical credibility signal.
"What is React's key prop and when does it matter beyond lists?"
Frontend
Suggested Answer

key is React's hint to the reconciler about which component instance corresponds to which list item across re-renders. Without stable unique keys, React can't tell if an item moved, was added, or removed — it guesses, and gets it wrong for stateful components.

Why index as key is usually wrong: if you reorder or filter a list and use index as key, React thinks item at index 0 is still the "same" component — it reuses the DOM node and state. An input at position 0 that was previously filled with "Alice" will keep showing "Alice" even after reordering puts "Bob" there.

Rule: use a stable, unique ID from the data — not index, not Math.random() (new key every render = full remount every render).

Beyond lists — the reset trick: key on any component forces a full unmount + remount when it changes. This is useful for resetting a controlled form: instead of imperatively resetting every field, just change the key:
<ExpenseForm key={formResetId} />
Increment formResetId → React destroys the old instance and mounts a fresh one with default state. Clean and declarative.

In newer React form patterns: APIs like useActionState can reduce some cases where key-reset was previously the simplest escape hatch, but key-based remounting is still a valid tool.

→ The "reset trick" with key is something most developers discover by accident. Knowing it deliberately, and connecting it to newer form primitives, signals you're current.
"How do you optimize performance in a React/Next.js 16 application?"
Frontend
Suggested Answer

Measure first. React DevTools Profiler shows which components render and how long they take. Chrome's Performance tab shows actual frame time. Never optimize speculatively.

Reducing re-renders:
React.memo: wraps a component, skips re-render if props are shallowly equal. Useful when a parent re-renders frequently but the child's props haven't changed.
useCallback: stable function reference. Without it, a new function is created each render — if passed as a prop to a memoized child, the memo is broken.
useMemo: memoizes an expensive computation. Don't use it for cheap operations — the overhead of memoization isn't worth it for simple values.

Bundle size: @next/bundle-analyzer to identify heavy dependencies. Dynamic imports for heavy, non-critical components: const Chart = dynamic(() => import('./Chart'), { ssr: false }).

Next.js 16-specific: Server Components send zero client JS by default — the biggest performance win is architectural. Use Server Components for anything that doesn't need interactivity. Good caching and revalidation strategy at the route/data layer often saves more work than memoizing individual components.

Images: always use Next.js <Image> — automatic lazy loading, WebP/AVIF conversion, size hints that prevent layout shift (CLS).

The biggest gains in React apps are almost always: fewer re-renders or smaller bundles. Fix those before reaching for exotic optimizations.

→ "Measure first" as the opening line signals seniority immediately. Referencing Next.js 16's Server Components as the primary performance lever (not just memo/callback) shows architectural thinking over micro-optimization.
"What are TypeScript generics? When do you actually need them?"
Frontend
Suggested Answer

Generics let you write type-safe code that works with multiple types without sacrificing the type information. Instead of any, you parameterise the type.

Classic example — typed API wrapper:
async function apiFetch<T>(url: string): Promise<T> { const r = await fetch(url); return r.json() as T; }
const expenses = await apiFetch<Expense[]>('/api/expenses');
The caller decides the return type — the wrapper stays generic and reusable.

Constrained generics: <T extends object> — T must be an object. <T extends { id: number }> — T must have an id field. Useful for generic list components that need to access a key.

TS5 utility types built on generics you should know:
Partial<T>: all fields optional (patch/update payloads)
Pick<T, 'name' | 'email'>: subset of fields
Omit<T, 'password'>: exclude fields (response schemas)
Record<K, V>: typed dictionary

When you don't need generics: for a one-off function with a specific type, just type it directly. Generics add cognitive overhead — only introduce them when the same logic genuinely applies to multiple different types.

→ The "when you don't need them" section is the senior answer. Juniors apply generics everywhere. The utility types are practical because they show where generics actually earn their complexity.
"How do you fetch data in Next.js 16 App Router? What are the tradeoffs?"
Frontend
Suggested Answer

Server Components (preferred for initial data): fetch directly inside the component with await fetch() or query the DB/ORM directly. No useEffect, no loading state, no client JS. Next.js 16 extended native fetch with caching semantics:
{ cache: 'force-cache' }: cached indefinitely (SSG-like)
{ next: { revalidate: 60 } }: revalidate after 60s (ISR-like)
{ cache: 'no-store' }: always fresh (SSR-like)

Client-side data fetching: for interactive, user-specific, or post-hydration data — use TanStack Query (React Query) or SWR. Both handle caching, deduplication, background revalidation, and loading/error states. Don't roll your own with useEffect + useState for this.

Server Actions (Next.js 16): async functions marked "use server" — called from forms or Client Components but executed on the server. Great for mutations when you want to avoid a separate API route for the UI layer.

A pragmatic split:
— Initial page data: Server Component fetch when SEO or first-load performance matters
— Interactive filters/search: client-side fetching via TanStack Query or SWR when you need responsive refetching
— Form submission: Server Actions or API routes, depending on team conventions and integration needs

What I avoid: mixing patterns inconsistently — fetch strategy should follow a clear rule, not be decided per-component whim.

→ Knowing Server Actions as a first-class pattern (not just fetch + API routes) is a Next.js 16 signal. The "don't roll your own with useEffect" advice saves juniors weeks of debugging race conditions and stale data.
"Why does Tailwind CSS work well with Next.js? How does it compare to CSS-in-JS?"
Frontend
Suggested Answer

Tailwind is utility-first CSS — you style via class names directly in JSX. At build time, Tailwind scans your files and emits only the CSS classes you actually used. Zero runtime overhead.

Why it pairs well with Next.js + Server Components: Server Components render on the server and Tailwind classes are static strings, so the styling model stays simple. CSS-in-JS libraries like styled-components and Emotion can still work, but they need more careful SSR/App Router integration and usually add more runtime and bundle complexity.

Emotion/styled-components support SSR, but the setup and runtime cost are typically higher. Tailwind has no such issue — it's just a stylesheet generated at build time.

When CSS-in-JS still makes sense: highly dynamic styles driven by runtime values (animation states, theme tokens computed at runtime). For most UI work, Tailwind + CSS custom properties handles this cleanly.

A common modern stack: Tailwind + shadcn/ui. shadcn gives accessible, headless components built on Radix UI with Tailwind classes — no CSS-in-JS runtime, and full control because you own the component source.

→ The important point isn't "Tailwind is better by default"; it's that utility CSS keeps the Server Components/App Router integration simple. Mentioning shadcn as part of that ecosystem shows you're current.
"What are the rules of React hooks and why do they exist?"
Frontend
Suggested Answer

Two rules:
1. Only call hooks at the top level — not inside conditions, loops, or nested functions.
2. Only call hooks from React function components or custom hooks — not from regular JS functions.

Why rule 1 exists: React tracks hooks by their call order. On every render of a component, React expects the same hooks to be called in the same sequence — it uses this order to match state and effect data to the right hook. If a hook is inside an if statement, it might be called on some renders and skipped on others → the order shifts → React associates the wrong state with the wrong hook → bugs that are extremely hard to debug.

Why rule 2 exists: React only manages the hooks list for component renders. Calling a hook inside a regular function has no render lifecycle to attach to — the state would have nowhere to live.

Custom hooks: any function prefixed with use that calls other hooks. useExpenses(), useAuth(). Convention signals to the linter and to readers that this function uses hooks. They're just functions — no special magic — but they let you extract and reuse stateful logic cleanly.

eslint-plugin-react-hooks enforces both rules at compile time. Always include it — it catches hooks violations before they reach review.

→ Explaining the "call order" mechanism is the answer most people can't give. The majority say "just because" or "React requires it." The mechanism reveals you understand how React works internally, not just its API.
System Design & Architecture
"Walk me through how you'd design a REST API for a financial application."
Design
Suggested Answer

I start with the data model and business rules, not the framework. For financial apps, correctness and auditability are non-negotiable from day one.

API layer: versioned endpoints (/api/v1/), consistent error contracts (same shape for all 4xx/5xx), request/response validation via Pydantic. Auth: stateless JWT for access, HttpOnly cookie for refresh token. Authorization: scope-based per endpoint, declared at the router layer.

Data layer: PostgreSQL with strict constraints — no nullable FKs that shouldn't be nullable, explicit NOT NULL, CHECK constraints for business rules. Decimal types for money, never float. Explicit transaction boundaries around any multi-step write. Alembic for migrations with safe rollback.

Idempotency: any write operation a client might retry (payment submission, transfer initiation) gets an idempotency key. Server stores processed keys with results — retries return the original response, not a duplicate mutation.

Observability: structured JSON logs with a request_id injected by middleware into every log line. This means any failed transaction can be traced end-to-end from a single ID in the logs.

That's the baseline architecture I'd describe in an interview, then adapt to the domain's actual regulatory, traffic, and data-consistency requirements.

→ The idempotency key detail is what separates a senior answer from a mid-level one. Most candidates cover auth and validation. Idempotency shows you've thought about clients retrying and what that does to your data.
"Monolith vs microservices — when would you choose one over the other?"
Design
Suggested Answer

Start with a monolith. Always. The default should be a well-structured monolith until you have a specific, measurable reason to split.

Reasons to stay monolith: team is small, domain isn't fully understood yet, deployment coupling isn't actually painful, you don't have independent scaling needs.

Reasons to consider splitting:
— Different components need to scale independently (payment processing vs reporting)
— Team boundaries are clear and cross-team coupling is causing deployment friction
— A part of the system has different reliability requirements (e.g., real-time messaging vs batch processing)
— The domain boundary is well-understood and stable

The cost of microservices people underestimate: network latency between services, distributed transaction management, observability across service boundaries, eventual consistency instead of ACID, operational overhead (service discovery, circuit breakers, independent deployments).

I've worked on Odoo, which is a large monolith that successfully handles extremely complex business domains across thousands of installations. The problem with most premature microservices is that you get all the distributed systems complexity without the scale or team size to justify it.

→ "Start with a monolith" is a defensible senior opinion. The Odoo angle is an original data point — use it. The distributed transaction / eventual consistency cost point separates someone who has read about microservices from someone who has lived with the consequences.
"How do you approach an unfamiliar codebase?"
Design
Suggested Answer

First, I find the entry points — the app factory, router setup, or main file. This tells me how the system is assembled and what the top-level structure is.

Then I study the data models. The schema is the most honest documentation in any codebase — it reveals the real business domain, the relationships, and usually the constraints that matter. A well-named schema tells you what the business does faster than any README.

Next, I trace a single end-to-end request through the full stack: route → handler → service or repository → database and back. This forces me to understand the patterns, naming conventions, and where business logic actually lives.

I look for tests — if they exist and are well-written, they're the best living documentation of intended behavior. I read them before reading the implementation.

Then I make a small, intentional first contribution — fix a documented bug, add a missing test. This builds context, reveals the development workflow, and establishes credibility before I touch anything critical.

This process I developed specifically from working on Odoo, where a new module might inherit from 6 different existing models across different modules. There's no shortcut — you have to trace the chain.

→ The Odoo context adds credibility — navigating Odoo's inheritance chain is genuinely complex and demonstrates real experience with large, unfamiliar codebases.
"How do you design a system for observability from the start?"
Design
Suggested Answer

Observability has three pillars, and you want all three from day one — retrofitting them is painful.

Logs: structured JSON, always. A request_id generated per request and injected into every log line via middleware. With this, any failed transaction can be traced end-to-end from a single ID. I use loguru or structlog for Python — both serialize context automatically. Never concatenate strings for log messages: logger.info("user {id} did action", id=user_id), not f-strings.

Metrics: application-level counters and histograms — request count, error rate, latency percentiles (p50, p95, p99). Prometheus + Grafana is standard. A health endpoint (/healthz) that checks DB connectivity, not just that the process is alive.

Traces: for distributed systems, distributed tracing (OpenTelemetry) connects a single user request across service calls. For a monolith, structured logs with request_id achieve 80% of this at zero infra cost.

My personal rule: if I can't answer "what happened to request X at time T?" from my logs alone, observability is incomplete. The webhook incident at XippTech (where payments were silently lost) happened partly because we had no structured logging — we couldn't trace what had happened to which events.

→ Connecting observability to the webhook incident story gives it narrative weight. The "p50/p95/p99" language signals production experience. The "structured JSON vs f-strings" detail is a real engineering preference, not just theory.
"What are webhooks and how do you build a reliable webhook receiver?"
Design
Suggested Answer

A webhook is an HTTP callback — instead of polling for changes, the source POSTs an event payload to your endpoint when something happens. Payment confirmed, subscription cancelled, file processed.

Reliability challenges: your server might be down when the event arrives. The source retries — which means you might receive the same event multiple times. You must handle both.

My pattern for a reliable receiver:
1. Ack fast — return 200 immediately on receipt. Don't process inline. If you take >5s, most sources retry.
2. Persist first — write the raw payload to a "pending_events" table before doing anything else.
3. Process asynchronously — a worker reads from the table and processes. If it fails, it retries from the DB, not from the source.
4. Idempotency — use the event ID from the source as a unique key. Before processing, check if this ID was already processed. Skip duplicates.
5. Validate the signature — most sources send an HMAC signature in a header (e.g., Stripe's Stripe-Signature). Always verify it before trusting the payload.

This is the architecture I built after the XippTech payment webhook incident — where we were processing inline and acking before committing, causing silent data loss.

→ This is a real system design question with a real story behind it. The five-step pattern is complete and defensible. HMAC signature validation is the detail most candidates omit — it's the difference between a webhook endpoint and an open injection vector.
"How does caching work? What strategies exist and what are the tradeoffs?"
Design
Suggested Answer

Caching layers from closest to the user to closest to the data:

Browser (HTTP cache): Cache-Control: max-age=3600 tells the browser to serve the response from its cache for 1 hour. stale-while-revalidate serves stale while fetching fresh in background — great UX. no-store for sensitive data.

CDN: caches at network edges worldwide. Static assets (JS, CSS, images) should always be CDN-cached with long TTLs + content-hash filenames (changed file = new URL = immediate cache bust).

Application cache (Redis): for expensive DB queries or computed aggregations. Three patterns:
Cache-aside (lazy): check cache → miss → query DB → store → return. Most common. Cache only has what was actually requested.
Write-through: write to cache and DB simultaneously. Always fresh. Double write cost.
Write-behind: write to cache immediately, async write to DB. Fast writes, risk of loss on crash.

The hard part is invalidation. Options: TTL (simple, may serve stale), explicit eviction on write (consistent but complex), event-driven (pub/sub). Cache invalidation bugs are among the hardest to debug in production.

When not to cache: user-specific sensitive financial data should be cached only behind auth, with very short TTLs or explicit invalidation — stale balance data is worse than slow balance data.

→ "Cache invalidation is the hard part" is the insight that separates someone who adds Redis from someone who understands caching. Knowing all three write strategies (not just cache-aside) shows depth.
"What is a message queue and when would you use one?"
Design
Suggested Answer

A message queue decouples producers (who generate work) from consumers (who process it). The producer enqueues a message and moves on — it doesn't wait for processing. The consumer processes at its own pace, independently.

Why this matters:
Reliability: if the consumer is down, messages wait in the queue. When it comes back, it processes them. No work is lost.
Scalability: add more consumers to handle load without changing producers.
Rate limiting: control how fast you hit an external API by controlling consumer throughput.
Durability: message persisted until acknowledged — if processing fails, it goes back to the queue (or dead letter queue).

Use cases in a FastAPI app: email sending, PDF generation, webhook delivery, data pipeline steps, bank sync jobs, report generation.

Technologies (2026):
Redis (ARQ/Celery): simple, sufficient for most web apps, low ops overhead.
RabbitMQ: AMQP, more features (routing, exchanges, priority queues), heavier ops.
Kafka: high-throughput event streaming, durable log, replay. Overkill for most web apps — right for data pipelines and event sourcing at scale.

Queue vs Pub/Sub: queue delivers to exactly one consumer; pub/sub fans out to all subscribers. Different problem, different tool.

→ The "reliability + scalability + rate limiting" framing explains why you'd use a queue beyond just "async processing." The dead letter queue mention shows you've thought about failure modes.
"How do you implement rate limiting in an API? What algorithms exist?"
Design
Suggested Answer

Where to implement it: ideally at the infrastructure layer — reverse proxy (nginx), API gateway (Kong, AWS API Gateway), or CDN (Cloudflare). Application-level rate limiting is per-instance and adds latency; unless using a shared store (Redis), multiple instances have separate counters.

If implementing at the application level: use Redis with atomic operations. All instances share the same Redis counter → consistent limits across the fleet.

Algorithms:
Fixed window: count requests in a time bucket (per minute). Simple. Weakness: allows bursts at window boundaries — 100 at 23:59, 100 at 00:00.
Sliding window: count requests in the rolling last N seconds. More accurate, no boundary burst. Slightly more complex (sorted sets in Redis).
Token bucket: a bucket refills at rate R, max capacity C. Each request consumes a token. Bursts are allowed up to C, sustained rate capped at R. Most commonly implemented algorithm — allows legitimate bursts while enforcing average rate.
Leaky bucket: requests processed at a constant rate — smooths bursts. Good for output rate limiting.

HTTP response: 429 Too Many Requests + Retry-After: 60 header.
Granularity: rate limit by API key > by user ID > by IP. IP-based is least reliable (NAT, proxies). API key is most actionable.

→ "Infrastructure layer first, then application layer" is the senior answer — not "just add slowapi middleware to FastAPI." The token bucket explanation, including why it's preferred (burst-friendly), shows algorithm understanding.
"What is the CAP theorem? How does it actually affect system design decisions?"
Design
Suggested Answer

CAP states that a distributed system can guarantee at most 2 of 3: Consistency (all nodes see the same data at the same time), Availability (every request gets a response), and Partition Tolerance (the system continues operating when network partitions occur).

The key insight: Partition Tolerance is not optional in real distributed systems — network failures happen. So the real choice is: when a partition occurs, do you prioritize Consistency (some nodes go unavailable to avoid serving stale data) or Availability (serve potentially stale data rather than go down)?

CP systems (Consistency + Partition Tolerance): return an error or wait during a partition rather than serve stale data. Examples: etcd, ZooKeeper, HBase. Right choice for financial ledgers, leader election.

AP systems (Availability + Partition Tolerance): serve potentially stale data during a partition, reconcile later. Examples: Cassandra, DynamoDB in eventual consistency mode. Right choice for social feeds, shopping carts, analytics.

Practical implication for my work: PostgreSQL with a single primary is not a distributed system — no CAP tradeoff. When I add read replicas, I choose: read from primary only (CP-like) vs read from replica (AP-like, potentially stale). For financial data in the Expense Tracker, I always read from primary for balance queries. For historical reports, stale data from a replica is acceptable.

Most modern databases are "tunable" — they let you choose per-operation consistency level.

→ "Partition Tolerance is not optional" is the insight that resolves the misconception that you can choose all three. The PostgreSQL primary vs replica read example makes this concrete and relatable.
"How do you approach security in API design? What are the most critical vulnerabilities?"
Design
Suggested Answer

I use the OWASP API Security Top 10 as a framework. The most critical for a backend engineer building in FastAPI:

BOLA — Broken Object Level Authorization (IDOR): the most common. GET /expenses/123 — does the server verify that expense 123 belongs to the authenticated user? Always authorize at the row level, not just the route level. Pydantic validates the shape; your handler must validate ownership.

Broken Authentication: weak JWT secrets (use long random keys, not "secret"), no token expiry, no refresh token rotation, tokens in localStorage (XSS-vulnerable — use HttpOnly cookies for refresh tokens).

Excessive Data Exposure: returning full DB model objects when only 3 fields are needed. FastAPI's response_model is the fix — it whitelists exactly what goes out. Never return a User object that includes a password hash.

Injection: SQL injection (always use parameterised queries or ORM — never string-concat SQL), command injection (never pass user input to shell commands).

Security Misconfiguration: DEBUG=True in production (exposes stack traces), CORS allow_origins=["*"] for authenticated endpoints (allow only known origins), secret keys in git repositories.

My default checklist per endpoint: Is the user authenticated? Does the user own this resource? Am I returning only what's needed? Is input validated by Pydantic? Is the error message safe (no internals exposed)?

→ BOLA is the most common API vulnerability and most candidates don't know its name. Leading with it signals you've studied API security specifically, not just generic security. The "5-question checklist" is a memorable close.
Odoo & ERP — Your Differentiator
"How does Odoo's ORM and inheritance model work?"
Odoo
Suggested Answer

Odoo's ORM maps Python model classes to PostgreSQL tables. Each field is a descriptor (CharField, Many2one, etc.) that generates SQL automatically. The ORM handles create/read/write/unlink with built-in access control — every operation is filtered through record rules and model-level ACLs before hitting the DB.

The three inheritance types are where Odoo gets interesting:

Classical (Extension): _inherit = 'res.partner' with no new _name. Modifies the original model in-place — same table, same registry entry. Used to add fields or override methods in an existing model without creating a new one. Most common.

Prototype (Copy): _name = 'my.model'; _inherit = 'res.partner'. Creates a new model with a new table that starts with all the fields of res.partner, but is fully independent. Changes to one don't affect the other.

Delegation: _inherits = {'res.partner': 'partner_id'}. Creates a new model with its own table, but transparently delegates field access to the parent via a FK. Reading my_record.name transparently hits partner.name. Used when you need a new record type that is also a partner (e.g., employees, companies).

Understanding which to use and why is a large part of Odoo module design — the wrong choice creates either data duplication or tight coupling.

→ Most developers who "know Odoo" can only describe classical inheritance. Knowing all three types and articulating when each applies is a genuine differentiator in Odoo Partner interviews.
"What are computed fields in Odoo and what are their performance implications?"
Odoo
Suggested Answer

Computed fields are fields whose value is calculated by a Python method rather than stored directly. You declare them with compute='_compute_field_name' and use @api.depends() to declare what triggers recomputation.

store=False (default): computed on-the-fly every time the field is read. Fast for writes, but expensive at scale — reading 1,000 records with a complex computed field fires the compute method 1,000 times. Can trigger N+1 patterns if the compute method reads related records.

store=True: stored in the DB column. Fast reads. Triggers recomputation when any field listed in @api.depends() changes. The ORM maintains a recomputation queue and processes it at commit time.

Performance pitfalls I've seen in production:
— Missing dependencies in @api.depends(): stale cached values that look correct until an edge case.
— Over-declaring dependencies: every change to an unrelated field triggers unnecessary recomputation across thousands of records.
— Stored computed fields on large tables with complex dependencies can cause cascade recomputation that locks tables.
compute_sudo=True: runs as superuser, bypassing record rules. Necessary for some cases, but over-use is a security smell.

Rule of thumb: use store=True for fields that are frequently read and infrequently recalculated. Use store=False for fields that are only relevant in specific views or are cheap to compute.

→ The "missing vs over-declaring depends()" detail is the real-world gotcha. Any experienced Odoo developer has hit both. Mentioning compute_sudo + security is a sign of production thinking, not just tutorial knowledge.
"How do you approach an Odoo module migration across major versions?"
Odoo
Suggested Answer

Version migrations in Odoo are substantial engineering work because each major version changes the ORM, the views, and often the JS framework. My approach is version-by-version, never big-bang.

Key changes to check across versions (v14→v18):
— v14→v15: @api.multi was fully removed (already deprecated in v13). Methods that received recordsets need to be updated. Various field attribute changes.
— v15→v16: OWL became the standard JS framework. Legacy Widget classes still worked but were deprecated.
— v16→v17: Legacy Widget fully removed. JS that used the old widget system breaks entirely. ir.actions.act_window attribute changes.
— v17→v18: remaining legacy widget compatibility APIs removed. Changes in the account module: reconciliation flow refactored, adjustments to account.move. Improved conditional view inheritance in ir.ui.view. Check the official changelog for field deprecations in specific modules (especially sale, purchase, and stock).

My migration process:
1. Install the module on the new version with -u and read every error. Errors are your migration TODO list.
2. Fix Python first: ORM API changes, renamed methods, deprecated decorators.
3. Fix XML: XPath selectors that referenced renamed fields or views, view attributes that changed.
4. Migrate JS last — OWL migration from legacy widgets requires rewriting components.
5. Run existing tests after each phase. Write tests for any behavior you're unsure about.
6. Never migrate and add features simultaneously — isolate migration commits for clean rollback.

→ The version-by-version principle + "migration commits are isolated from feature commits" are the real lessons from production migrations. This answer signals you've done it, not just read about it.
"What are Odoo record rules and ACLs? How do they differ?"
Odoo
Suggested Answer

Access Control Lists (ir.model.access.csv): model-level permissions. Define which groups can create/read/write/delete records of a given model. Coarse-grained — "group Sales can read sale.order" with no condition on which orders.

Record Rules (ir.rule): row-level filters. A domain expression that limits which records within a model a user can see or write. Example: "salespeople can only read their own orders" → domain: [('user_id', '=', user.id)].

How they interact: ACL blocks access to the model entirely — if you fail ACL, record rules are never evaluated. If ACL passes, record rules filter the visible rows. Both must be satisfied.

Multiple rules for the same model+group: they are OR'd — the user sees the union of all matching records. Rules across different groups are AND'd.

sudo(): bypasses both ACL and record rules. Necessary for system-level operations (cron jobs, automated actions that must access all records). Overuse is a security smell — every sudo() in production code should have an explicit justification comment.

In tests: record rules are enforced. Use self.env['model'].with_user(other_user) to test access from a different user's perspective — verify that salespeople can't see HR records, etc.

→ The "OR within group, AND across groups" behavior is the nuance that trips up experienced Odoo devs. The sudo() + justification comment stance shows production discipline.
"How does OWL (Odoo Web Library) work? How does it differ from the legacy widget system?"
Odoo
Suggested Answer

OWL is Odoo's custom reactive UI framework, introduced in v14 and mandatory from v17 (legacy Widget system fully removed). It's Odoo's answer to React/Vue — a component model with reactive state and declarative templates.

Component model: class-based components with a setup() method (similar to Vue 3's Composition API). Lifecycle hooks: onWillStart, onMounted, onWillUnmount.

Reactivity: useState() from OWL creates a reactive proxy — mutations to it trigger re-renders automatically. useStore() for shared state.

Templates: QWeb XML with t-if, t-foreach, t-on-click. Unlike JSX, templates are separate from the component class.

Services: OWL uses a service registry pattern. useService('rpc'), useService('notification'), useService('action'). Replaces the legacy this._rpc() and this.do_action().

Key migration challenges from legacy Widget:
events / custom_events dict → t-on-* attributes and env signals
this._rpc()useService('rpc')
this.trigger()env.services.bus_service or parent-child prop passing
— No more start() / init() — use onWillStart for async setup

Mental model shift: from imperative jQuery-style (mutate DOM) to declarative reactive (describe what UI should look like given state).

→ Knowing the specific API changes (rpc migration, event migration) signals you've actually written OWL code, not just read the docs. The "imperative → declarative" framing is the conceptual bridge for someone coming from the legacy system.
"How do you integrate an external API or receive webhooks in Odoo?"
Odoo
Suggested Answer

Outbound (Odoo → external API): use Python's requests library inside a model method or server action. Store API keys in ir.config_parameter (Settings → Technical → System Parameters) — never hardcode. Always handle exceptions: network errors, 4xx/5xx responses, timeouts. For retries, wrap in a cron job that processes a queue table.

Inbound (external → Odoo via webhook):
1. Create an HTTP controller: @http.route('/webhook/payment', type='json', auth='none', methods=['POST'], csrf=False)
2. Validate the HMAC signature from the request header before trusting the payload.
3. Return 200 immediately — don't process inline.
4. Write the raw payload to a staging table.
5. A cron job or server action processes the staging records asynchronously.

Idempotency: the external source may deliver the same event multiple times. Store the event ID from the source as a unique key in your staging table — use ON CONFLICT DO NOTHING or check before insert.

Real example: I integrated a Brazilian payment gateway (Gerencianet) into Odoo. Webhook receiver created account.move records on payment confirmation. The idempotency key prevented duplicate invoices when the gateway retried after our brief downtime.

→ The Brazilian payment gateway example grounds this in production reality. The controller + staging table + cron pattern is the correct production architecture — not processing inline in the controller.
"How do you write tests in Odoo? What test types exist?"
Odoo
Suggested Answer

Odoo has built-in test infrastructure based on Python's unittest. Main base classes:

TransactionCase (most common): each test method runs inside a transaction that is rolled back after the test. Fast, isolated, no cross-test contamination. Use for model logic, computed fields, business rule validation.

SavepointCase: uses PostgreSQL savepoints for finer isolation within a single test. Less common.

HttpCase: for testing controllers and routes. Spins up an HTTP server and makes real requests. Use for testing API endpoints and web controllers.

Practical test patterns:
— Test a business rule: create a record, try an invalid action, assert it raises UserError or ValidationError.
— Test a computed field: set the trigger fields, verify the computed value updates correctly.
— Test record rules: switch user with self.env['model'].with_user(restricted_user), verify access is denied or filtered.
— Test a method: call it directly on a recordset, assert outputs.

Run tests: odoo -d test_db --test-enable -u my_module --stop-after-init --log-level=test

What I avoid: testing only at the controller level. Business logic tests should be on the model — they're faster, more isolated, and don't require an HTTP server. Test the unit of logic, not the full stack, unless integration is specifically what you need.

→ The "with_user() for record rule testing" pattern is what most Odoo devs learn late. Knowing to test at the model level (not just controller level) is an architectural signal.
"How is a Many2many field stored in PostgreSQL? What are the ORM write commands?"
Odoo
Suggested Answer

A Many2many creates a junction (bridge) table in PostgreSQL. Default table name: model1_model2_rel. You can declare a custom name with relation='my_junction_table' to avoid collisions. The table has two columns: one FK to each model's primary key.

PostgreSQL automatically creates indexes on both FK columns. For performance-critical M2M filtering (many records, frequent search), verify those indexes exist with \d table_name in psql.

ORM write commands (the "magic tuples"):
(0, 0, {vals}): create a new record and link it
(1, id, {vals}): update an existing linked record
(2, id): delete and unlink a record
(3, id): unlink a record (no delete)
(4, id): link an existing record without removing others
(5,): unlink all (clear the M2M)
(6, 0, [ids]): set the M2M to exactly this list of IDs

Common mistake: using (6, 0, ids) inside a compute method — this replaces the entire relationship on every recompute, which can wipe manually-set links if the compute logic doesn't include them. Always verify the intended behavior before using (6, 0, ...) in automated contexts.

→ The full tuple list is something most Odoo devs look up every time. Knowing them — especially the difference between (3) unlink and (2) delete — signals real ORM fluency. The "don't use (6,0) in compute" warning is the production gotcha.
"How do you debug slow performance in an Odoo instance?"
Odoo
Suggested Answer

I start by narrowing down whether the problem is Python, SQL, or both.

1. Enable SQL logging: set log_level = debug_sql in odoo.conf — logs every SQL query with timing. Look for: high query count (N+1), repeated identical queries, long-running individual queries.

2. Odoo built-in profiler (v16+): in debug mode, go to Settings → Technical → Profiler. Attach it to a specific user session and reproduce the slow action. Shows Python call stack + SQL breakdown in a flamegraph. This is the fastest path to the root cause.

3. PostgreSQL EXPLAIN ANALYZE: capture the slow query from logs, run EXPLAIN (ANALYZE, BUFFERS). Look for Seq Scans on large tables → add an index. Look for bad row estimates → run ANALYZE on the table.

4. Computed field chains: a simple UI action can cascade-trigger recomputation on thousands of records. Check which _compute_ methods are called and whether their @api.depends() is accurate. Over-declared dependencies trigger unnecessary recomputes.

5. N+1 in compute methods: if a _compute_ reads a related field inside a loop on a recordset, each iteration hits the DB separately. Fix: use .mapped('field') or .read(['field']) on the whole recordset — Odoo prefetches in a single query.

6. RPC timing: if browser Network tab shows slow XHR responses, check if a non-stored computed field is being recalculated on every read. Adding store=True (with care) can cut response time dramatically.

→ The Odoo built-in profiler (v16+) is what most devs don't know about. The "mapped() vs loop" prefetch pattern is the Odoo-specific N+1 fix that applies to dozens of real-world performance issues.
"How do you create safe Odoo cron jobs? What can go wrong?"
Odoo
Suggested Answer

Cron jobs in Odoo are ir.cron records that call a model method on a schedule. You define them in XML data files and they run in a separate thread managed by Odoo's scheduler.

What can go wrong — and how I prevent it:

Silent failures: unhandled exceptions in cron methods are caught by Odoo, logged, and silently discarded in some versions. Always wrap the main logic in try/except and log failures explicitly with enough context to debug.

Table lock from large operations: never do env['model'].search([]) on 100k records in one call. Process in paginated batches — search(domain, limit=1000, offset=0) in a loop. Commit between batches to release locks and reduce transaction size.

Runs as superuser: cron executes as the admin user by default, bypassing record rules. Be careful — the cron can access data the current user would normally not see. Document this explicitly.

Long-running crons blocking scheduler: for truly long jobs, use OCA's queue_job module — the cron enqueues jobs, workers process them independently. Avoids blocking the main scheduler thread.

Testing: never wait for the scheduler to fire during development. Call the method directly from the Odoo shell or trigger it via the "Run Manually" button in Technical → Automation → Scheduled Actions.

→ The "batched processing with mid-batch commits" pattern is something most developers learn only after causing a production table lock. The "runs as superuser" note is the security detail most miss.
Production Debugging & Scaling
"A user reports the monthly report endpoint takes 30+ seconds. Walk me through your debugging process."
Backend
Suggested Answer

I work from the outside in: start at the network layer, drill down to the query layer.

Step 1 — Reproduce and measure. Hit the endpoint with curl -w "%{time_total}" or check the server access log. Confirm the 30 s is real and not a client-side timeout or CDN issue.

Step 2 — Check slow query log. In PostgreSQL, log_min_duration_statement = 1000 logs anything over 1 s. A monthly aggregation over a large transactions table is the usual suspect. I'd also look at pg_stat_activity to see if the query is blocked waiting on a lock.

Step 3 — EXPLAIN ANALYZE. Run the actual query with EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT). I'm looking for: sequential scans on a large table (missing index on user_id or created_at), poor row estimates (stale statistics — run ANALYZE), nested loops on large sets (should be a hash join or merge join).

Step 4 — Application profiling. If the DB looks fine, the problem is Python. Wrap the endpoint in a profiler (cProfile + pstats, or py-spy for zero-instrumentation sampling). For this kind of report endpoint, I'd check whether the aggregation is being done efficiently in SQL or accidentally expanded into Python/ORM loops.

Step 5 — Fix and verify. Add the missing index, rewrite the ORM query as a single aggregation, or cache the result if the data doesn't change within the day.

→ The senior signal here is the structured debugging order — not jumping to "add an index" without evidence. Mentioning pg_stat_activity for lock detection shows production awareness.
"You deploy and memory usage climbs steadily over 6 hours. Where do you look first?"
Backend
Suggested Answer

Steady climb over time is a classic memory leak pattern — something is accumulating without being released.

First check: is it the process or the OS? If RSS grows but Python's internal allocator shows flat usage via tracemalloc, the issue may be in a C extension (SQLAlchemy's connection pool, an HTTP client keeping sockets open). If Python's heap is growing, I'm looking for references that aren't being released.

Common culprits in a FastAPI app:
Unbounded caches: a module-level dict used as a cache with no eviction. Every cache miss adds a key that never leaves.
Background tasks accumulating: if tasks are queued faster than they're consumed, the queue grows in memory indefinitely.
DB connection pool leak: connections not returned to the pool because a generator dependency raised before yield. Check with pool.status() from SQLAlchemy.
Circular references with __del__: rare, but they prevent CPython's reference-counting GC from collecting objects.

Tool of choice: memray for Python memory profiling — produces a flamegraph of allocations by call site. I'd run it against a load test and look for the allocation that keeps growing.

→ Mentioning tracemalloc and memray by name, and distinguishing between C-extension vs Python-heap growth, signals production experience rather than guesswork.
"Database is locking under load. What are the possible causes and how do you diagnose?"
Database
Suggested Answer

PostgreSQL has different lock levels — most queries use row-level locks, but certain operations (ALTER TABLE, VACUUM FULL, some FK operations) take table-level locks that block everything else.

Diagnosis — start with pg_locks:
SELECT pid, relation::regclass, mode, granted FROM pg_locks WHERE NOT granted;
This shows which queries are waiting. Then find what's blocking them:
SELECT pg_blocking_pids(pid) FROM pg_stat_activity WHERE wait_event_type = 'Lock';

Common causes in a FastAPI/SQLAlchemy app:
Long-running transactions: a transaction opened but not committed holds row locks for its duration. A background task that starts a transaction early and does slow work inside it is a common culprit. Fix: keep transactions short, open them as late as possible.
Missing index on FK column: a DELETE on a parent row causes PostgreSQL to do a sequential scan on the child table to check FK constraints. With large tables this holds a lock for seconds. Fix: index the FK column.
Deadlocks: two transactions each waiting for a lock the other holds. PostgreSQL detects and resolves them (one transaction is rolled back), but the error surfaces in logs. Fix: ensure all transactions acquire locks in a consistent order.

Monitoring: set deadlock_timeout = 500ms and log_lock_waits = on in postgresql.conf to log any lock wait over 500 ms.

→ The specific SQL queries against pg_locks and pg_stat_activity are what separates someone who has debugged this in production from someone who has only read about it.
"Your API needs to scale from 100 to 10K req/sec. What breaks first and in what order?"
Design
Suggested Answer

I'd think through the stack from the request path inward:

1. Database connection pool (first to break). A single PostgreSQL instance handles ~100–200 concurrent connections well; beyond that, connection overhead degrades performance. You hit this before running out of CPU. Fix: PgBouncer in transaction-mode pooling in front of the DB.

2. Single DB write bottleneck. At 10K req/s, if most requests write, a single primary becomes a bottleneck. Fix: read replicas for read-heavy queries; CQRS if writes are the bottleneck; partition tables to distribute I/O.

3. Rate limiting and abuse. At higher traffic, a small percentage of bad actors can dominate. Fix: rate limiting at the load balancer or API gateway, not in the app (avoids hitting the Python process for dropped requests).

4. In-process caches fill memory. Module-level dicts or memoized results that work fine at low traffic can eat available memory at high traffic. Fix: move cache to Redis; set TTLs.

5. CPU saturation on compute paths. Heavy serialization (Pydantic validation of large payloads), JWT verification on every request, or synchronous middleware can peg CPU. Fix: async middleware, cache decoded JWTs short-term, use background tasks for expensive post-processing.

For the Expense Tracker specifically, the bottleneck would be the transaction report aggregation — a full-table GROUP BY that runs on every dashboard load. First fix would be a materialized view refreshed on a schedule, or a cache layer in Redis.

→ Saying "the DB connection pool breaks first" is the answer most experienced engineers give — it's counterintuitive but correct. Knowing PgBouncer is the fix shows practical production knowledge.
"When would you use async job queues instead of responding synchronously? Give a concrete example from your Expense Tracker."
Backend
Suggested Answer

The rule: if a user action triggers work that takes longer than ~200ms and the user doesn't need the result immediately, that work belongs in a queue.

The criteria I use:
— Work is slow: sending an email, calling a third-party API, generating a PDF, running a data export
— Work is retriable: if the external API is down, you want to retry later, not fail the user's request
— Work is decoupled from the response: the user only needs "request received", not the final result inline

Concrete Expense Tracker example: when a user requests a monthly expense report export (PDF), the response is "Your report is being generated. You'll receive a notification when it's ready." The PDF generation — which involves a DB query, data aggregation, and rendering — runs in a background worker. The user's request returns in under 100ms instead of waiting 5–10 s.

If I were adding this to the Expense Tracker today, I'd use Celery + Redis as the broker: simple to set up, battle-tested, integrates cleanly with FastAPI via a shared async task dispatcher. For simpler cases, FastAPI's built-in BackgroundTasks works for fire-and-forget when retries aren't needed.

✓ The key insight to demonstrate: you understand the trade-off, not just the tool. "User doesn't need the result immediately + work is retriable" is the correct framing.
"Database read replicas — how do you implement them? What are the consistency trade-offs?"
Database
Suggested Answer

A read replica is a PostgreSQL standby that receives a continuous stream of WAL (write-ahead log) changes from the primary and applies them. Reads can be served from replicas; all writes go to the primary.

Implementation in a FastAPI/SQLAlchemy app:
Configure two SQLAlchemy engine instances — one pointing to the primary, one to the replica. Use a router: write operations (INSERT, UPDATE, DELETE) always use the primary engine; GET endpoints use the replica engine. This routing can be a FastAPI dependency that reads from a request-scope context.

Consistency trade-offs:
Replication lag: replicas are eventually consistent. A write on the primary may not be visible on the replica for 50–500 ms. For a financial app, this is a real problem — if a user creates a transaction and immediately loads the list, they might not see it.
Read-your-own-writes: the pattern to fix this is "read-your-own-writes consistency" — after a write, route the user's next read to the primary for a short window (e.g., 2 s), or use a session token that tells the replica "don't serve reads older than this LSN."
Long-running queries on replicas: a slow analytics query on a replica can block WAL application, causing lag to grow. Isolate analytics replicas from app replicas.

→ Replication lag is the answer most candidates skip. The "read-your-own-writes" pattern and the LSN-based consistency check show genuine distributed systems thinking, not just "add a replica."
"Explain cache invalidation. Why is it considered hard? How did you handle it in the Expense Tracker?"
Design
Suggested Answer

Cache invalidation is hard because a cache entry represents a point-in-time snapshot of derived data. When the underlying data changes, the cache doesn't automatically know — you have to explicitly tell it, which requires tracking every possible dependency.

Why it fails in practice:
— You cache user:42:monthly_total. The user creates a new transaction. Which cache keys need to be invalidated? If it's only that one key, fine. But if the total is also part of a category breakdown cache, a yearly summary cache, and a dashboard aggregate cache, you need to invalidate all of them — and missing one means stale data.
— Distributed systems make this worse: two app servers might have different in-memory cache states if invalidation is event-driven and an event is lost.

Strategies I use:
TTL-based invalidation (simplest): cache expires after N seconds. Stale for up to N seconds, but never permanently wrong. For the Expense Tracker dashboard totals, a 60 s TTL is acceptable — users don't need real-time accuracy on aggregate numbers.
Write-through invalidation: on every write that touches a resource, explicitly delete the cache key. Requires discipline to keep the invalidation code colocated with the write path.
Cache by version: include a version token in the cache key (user:42:v{version}:monthly_total). Bump the version on any write; old keys naturally become unreachable and expire via TTL.

→ "There are only two hard things in computer science: cache invalidation and naming things" — Phil Karlton. The versioned key pattern is the cleanest answer to the "what if I miss an invalidation?" problem.
"HTTP caching headers: Cache-Control, ETag, Last-Modified. When does each apply?"
Backend
Suggested Answer

Cache-Control — tells clients and intermediaries (CDN, proxy) how long to cache and under what conditions. Most important directives:
max-age=3600: cache for up to 1 hour, serve without hitting the origin
no-cache: must revalidate with the server before serving (does NOT mean "don't cache")
no-store: never cache — use for sensitive financial data, auth tokens
private: only the browser can cache, not CDN nodes — user-specific responses
public: CDN can cache this response

ETag — a fingerprint (hash) of the response body. Server sends ETag: "abc123". On the next request, client sends If-None-Match: "abc123". If the resource hasn't changed, server responds 304 Not Modified (no body, saves bandwidth).
Use when: content changes unpredictably and you want conditional fetching. Good for API responses where content-based caching makes sense.

Last-Modified — timestamp of last change. Same conditional request pattern: client sends If-Modified-Since: <date>. Weaker than ETag because timestamps have 1-second resolution and don't handle simultaneous edits well.

In the Expense Tracker: static assets (JS, CSS) use Cache-Control: max-age=31536000, immutable — they're fingerprinted at build time. API responses that are user-specific use Cache-Control: private, no-cache — we don't want CDN caching user financial data.

→ The distinction between no-cache (revalidate) and no-store (never store) is the most common misconception in HTTP caching. Naming it shows real HTTP fluency.
"Rate limiting: token bucket vs leaky bucket vs sliding window. Which did you use with slowapi and why?"
Backend
Suggested Answer

Token bucket: a bucket has N tokens. Each request consumes one token. Tokens refill at a fixed rate. Allows short bursts up to the bucket size, then enforces the rate. Most permissive — allows natural request patterns.

Leaky bucket: requests enter a fixed-size queue; they leave at a constant rate regardless of input rate. Smooth output, but bursty traffic queues or gets dropped. Good for downstream systems that can't handle spikes.

Sliding window: count requests in the last N seconds as a sliding window (not a fixed clock-aligned window). Fixed windows have a "burst at boundary" problem — a client can fire 100 req at 11:59:59 and 100 more at 12:00:01, getting 200 in 2 seconds against a 100/minute limit. Sliding window prevents this. More accurate but slightly more expensive (requires storing per-request timestamps or an approximation).

In practice with libraries like slowapi: many frameworks implement fixed-window or moving-window style limits rather than a pure token bucket. So in interviews, I separate the conceptual algorithms from the concrete library implementation. For API design, I usually prefer burst-tolerant behavior plus per-endpoint limits: stricter on auth (5/minute), more permissive on reads (60/minute). The key design choice is often the identifier: API key or user ID is usually better than raw IP, because IP-based limits break shared NAT environments.

→ The "burst at boundary" problem with fixed windows is the detail that shows you've thought about rate limiting beyond the happy path. "Rate limit by user ID, not IP" is the production lesson.
"Design the pagination for the Expense Tracker's transaction list. Offset/limit vs cursor — trade-offs?"
Database
Suggested Answer

Offset/limit is simple: SELECT ... LIMIT 20 OFFSET 100. The problems at scale:
Performance: PostgreSQL scans and discards the first 100 rows to serve offset 100. At offset 10,000 this is expensive even with an index.
Consistency: if a new transaction is inserted between page 1 and page 2 fetches, every subsequent page shifts by one. Users see duplicates or miss records. For a transaction list this is a real UX problem.

Cursor-based (keyset) pagination solves both:
SELECT ... WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20
— Uses the index efficiently — no scanning of skipped rows
— Stable: new inserts don't affect already-seen pages
— Downside: can't jump to "page 5" — you must traverse sequentially. Also requires a consistent sort order (usually by created_at DESC, id DESC to break ties).

My default recommendation: use offset/limit for small admin tables and low-scale internal tools; switch to cursor pagination for user-facing feeds, ledgers, or any list where insertions happen frequently and consistency between pages matters. A typical API returns a next_cursor carrying the last created_at + id.

→ Knowing the "page shift on insert" problem with offset pagination is what makes this a senior answer. The tie-breaking (created_at, id) compound cursor detail is the implementation nuance that matters.
"API versioning strategies. Which would you choose for a financial API and why?"
Design
Suggested Answer

The main strategies:

URL versioning (/v1/transactions, /v2/transactions) — explicit, easy to route at the infrastructure level, easy to document. The downside: maintaining two full codepaths simultaneously is expensive. Clients must update the base URL when migrating.

Header versioning (Accept: application/vnd.myapi.v2+json) — cleaner URLs, but versioning is invisible in browser address bars and harder to test manually. Requires clients to set custom headers.

Query parameter versioning (?version=2) — easy for clients to use but feels like an afterthought and conflicts with caching (query params often bypass CDN cache).

Additive changes without versioning — the best default for a stable API: never remove or rename fields, only add. Clients that don't know about new fields safely ignore them. Breaking changes get a version bump.

For a financial API I'd choose URL versioning for two reasons: (1) financial clients (banks, payment processors) are conservative — explicit versioning in the URL is easy to audit and routes are immediately distinguishable in logs; (2) compliance requirements often require proving which API version was called for a given transaction. URL versioning makes this trivial.

→ "Additive changes without versioning" as the default is the pragmatic senior answer — you version when you have to, not preemptively. The financial API justification ties technical reasoning to business context.