…
/
1. Nested loop and Big O
Algorithms and data structures· in roadmap
Question: A service runs every night and finds unpaid orders. In production we have about 100,000 orders and about 100,000 payment ids. The code is:
def find_unpaid(order_ids: list[int], paid_ids: list[int]) -> list[int]:
unpaid = []
for order_id in order_ids:
if order_id not in paid_ids:
unpaid.append(order_id)
return unpaid
The tests use 20 orders and they pass. You see this code in code review. What do you think? Is there a problem?
Short answer: The code is correct, but slow. The in operator on a list is a linear search: Python compares items one by one from the start. So the whole job is about n × m comparisons, which is O(n·m). If we turn paid_ids into a set, each lookup is O(1) on average, and the whole job becomes O(n + m).
Hint (if the candidate says there is no problem): In tests the job finishes in a few milliseconds. In production the same job takes several minutes, and the CPU stays at 100% the whole time. The data grew only 5,000 times, but the time grew much more than 5,000 times. Why?
Big O in simple words:
- Big O tells us how the amount of work grows when the input grows. It does not give the exact time.
- O(n) means: if the input doubles, the work roughly doubles.
- O(n²) means: if the input doubles, the work is roughly 4 times bigger.
- O(1) means: the work does not depend on the input size.
Why does this happen? (step by step)
- The for loop runs over 100,000 orders.
- For each order, the in operator runs on the paid_ids list. This is a hidden loop.
- For an unpaid order, the hidden loop goes to the end of the list: 100,000 comparisons.
- So in the worst case we do 100,000 × 100,000 = 10 billion comparisons.
- In the test, 20 × 20 is only 400 comparisons. That is why nobody noticed.
The fix:
- Turn the list into a set once. This costs O(m).
- A set lookup uses a hash, so it is O(1) on average.
def find_unpaid(order_ids: list[int], paid_ids: list[int]) -> list[int]:
paid = set(paid_ids) # build once: O(m)
return [order_id for order_id in order_ids if order_id not in paid]
- Now the whole job is about 200,000 operations, not 10 billion.
- The cost: a set uses more memory than a list. For 100,000 numbers this is usually small.
- The output keeps the order of order_ids, because we still loop over order_ids.
One more important point: If the data comes from a database, maybe this work should happen in the database itself, for example with a LEFT JOIN or a NOT EXISTS query. Then we do not move 100,000 rows into the application.
Follow-up question: Is a set always O(1)? When is it not worth building a set?
Follow-up answer:
- A set lookup is O(1) on average. In the worst case, for example with a bad hash function and many collisions, it can be slower. With int and str in Python this is rare in practice.
- Building the set costs O(m). If we search only once, building a set gives no benefit.
- If the list is tiny (for example 5 items), there is no visible difference. Readability matters more.
- Set items must be hashable. For example, you cannot put a list inside a set.
Red flag: Does not know that the in operator on a list is a loop, or only says “let’s get a bigger server” without looking at the algorithm’s complexity.
2. Finding duplicate emails
Algorithms and data structures· in roadmap
Question: We have a file with 1 million user emails. We must find the emails that appear more than once. A candidate first writes this code:
def find_duplicates(emails: list[str]) -> list[str]:
duplicates = []
for i in range(len(emails)):
for j in range(i + 1, len(emails)):
if emails[i] == emails[j] and emails[i] not in duplicates:
duplicates.append(emails[i])
return duplicates
What do you think about this code? What other options do we have, and what does each one cost?
Short answer: The code is correct, but it is O(n²). For 1 million emails that is about 500 billion comparisons, so in practice it never finishes. We have three options:
- Double loop: O(n²) time, almost no extra memory.
- Sort, then compare neighbors: O(n log n) time.
- Use a set or a dict (a hash): O(n) time, but O(n) extra memory.
Hint (if the candidate says there is no problem): With 100 emails the answer is instant. With 10,000 emails it takes a few seconds. With 1 million emails it is still running after an hour.
Why is the double loop slow? (step by step)
- Each email is compared with every email after it.
- The number of pairs is about n × n / 2. For 1 million, that is about 500 billion.
- On top of that, the in operator on the duplicates list is a hidden loop, which adds more work.
- If the input grows 10 times, the time grows about 100 times.
Option two: sort
- After sorting, equal emails sit next to each other.
- We only need to compare each item with the one before it.
- Sorting is O(n log n) and the neighbor check is O(n).
def find_duplicates_sorted(emails: list[str]) -> list[str]:
s = sorted(emails)
result = []
for i in range(1, len(s)):
if s[i] == s[i - 1] and (not result or result[-1] != s[i]):
result.append(s[i])
return result
- Good side: if the data is already sorted, or is too big for memory, an external sort on disk is possible.
- Bad side: we lose the original order of the input.
Option three: hash (the usual best choice)
from collections import Counter
def find_duplicates_hash(emails: list[str]) -> list[str]:
counts = Counter(emails)
return [email for email, count in counts.items() if count > 1]
- We read each email once and increase its counter in a dict. So it is O(n).
- The cost: we keep every unique email in memory.
- For 1 million emails this easily fits in the memory of a normal server.
One more important point: What does “duplicate” mean? Are Ali@Mail.com and ali@mail.com the same? Do spaces at the start and end matter? A good candidate asks this and normalizes the email before comparing (for example, lowercase and trim).
Follow-up question: What if we have 1 billion emails and they do not fit in the memory of one machine?
Follow-up answer:
- One way: split the data into smaller files by the hash of the email. Equal emails always go to the same file. Then check each file on its own with the hash method.
- Another way: an external sort on disk, then compare neighbors.
- If the data is in a database, the simplest way is a GROUP BY query with HAVING COUNT greater than 1.
- If an approximate answer is enough, structures like a Bloom filter use little memory, but they can give false positives.
Red flag: Knows only one option and cannot compare the time and memory cost of the options.
3. Rebase on a shared branch
Git and version control· in roadmap
Question: A team of 6 works on the shared develop branch. This morning one teammate wanted to clean up the history. They ran these commands on develop:
git checkout develop
git rebase -i HEAD~15 # squash and reorder old commits
git push --force
After this, when the others run git pull, they get strange conflicts. One person also says their commit from yesterday is not in develop anymore. What happened? What should the team rule about rebase be?
Short answer: Rebase rewrites history: old commits are replaced by new commits with new ids (hashes). When this happens on a shared branch and is sent with a force push, the history on the server no longer matches the history on everyone else’s machine. The simple rule: never rewrite the history of a shared branch. On your own personal branch, rebase is fine.
Why does this happen? (step by step)
- Every commit has an id. The id is made from its content and its parent commit.
- Rebase builds the commits again. Even if the content looks the same, the parent or the content changed, so the id is new.
- Teammates still have the old commits, and they built their work on top of them.
- When they pull, Git sees two different histories and tries to merge them. Result: conflicts and duplicate commits.
- A force push means “replace the server version with mine”. If someone pushed a new commit after the teammate’s last pull and before their push, that commit is removed from the server. That is the lost commit.
How to recover:
- No commit really disappears right away. The person who wrote the lost commit still has it on their own machine.
- With reflog you can find where the branch was before:
git reflog show develop # find the old position, e.g. develop@{1}
git branch rescue develop@{1} # keep it safe on a new branch
- Then the team decides together which version is right, and brings the lost commits back with cherry-pick or merge.
- Note: reflog is local. It only exists on the machine that saw those commits.
Team rules:
- On shared branches (develop, main), never rebase and never force push. Use merge to bring in changes.
- On your personal feature branch, rebasing to clean up commits before a pull request is good.
- Instead of a plain force push, use force-with-lease. It only pushes if the server still has what you saw last time. So it does not silently delete other people’s work.
- On the Git server (for example GitHub or GitLab), turn on branch protection for develop and main, so force push is blocked.
Merge vs rebase:
- Merge keeps the history as it really happened and adds a merge commit. It is safe, but the history is busier.
- Rebase makes the history straight and clean, but it rebuilds the commits.
Follow-up question: You rebased your own feature branch, and a normal push now fails. What do you do?
Follow-up answer:
- First, make sure nobody else works on this branch.
- Then push with force-with-lease, not a plain force.
- If someone else also committed on the branch, talk to them first, or use merge instead of rebase.
Red flag: Sees force push as the normal fix for any push error, or does not know that rebase changes commit ids.
4. HTTP status codes in an API
Question: We have a public API for a mobile app and several partner companies. In code review you see that every endpoint always returns HTTP 200. The result is in the body:
GET /api/orders/9999 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{"success": false, "error": "not found"}
A database error looks the same: status 200 and a body with success set to false. What do you think?
Short answer: This is not a good design. The HTTP status code is a standard contract. Many things look only at the status code and never read the body: client libraries, caches, load balancers, monitoring tools and retry logic. If we always return 200, all of them believe everything worked.
Hint (if the candidate says there is no problem): The monitoring dashboard shows a 0% error rate. But users complain about errors. A partner also says their code did not retry when our database had a problem. Why?
Why does it matter? (step by step)
- The monitoring tool counts the error rate from 5xx codes. If a server error is also 200, the alert never fires.
- Client retry logic usually retries on 5xx or 503, and not on 4xx. With 200 it does not know what to do.
- A cache or a CDN may store a 200 response. Then an error response may be served to other users too.
- Every client must read the body and check the success field. If one developer forgets, they treat an error as real data.
The main groups:
- 2xx means success. For example 200 for OK, 201 for “created”, 204 for “success, no body”.
- 4xx means the client made a mistake. Sending the same request again will not help.
- 5xx means the server has a problem. Trying again later may work.
Important 4xx and 5xx codes:
| Code | Meaning | Example |
|---|---|---|
| 400 | The request is broken | Invalid JSON |
| 401 | We do not know who you are | Token missing or expired |
| 403 | We know who you are, but you are not allowed | A normal user asks for an admin page |
| 404 | Not found | Order 9999 does not exist |
| 409 | Conflict with the current state | Duplicate email, or an old version of the record |
| 422 | The shape is valid, but the content is not accepted | End date is before start date |
| 500 | Unexpected server error | An exception in the code |
| 503 | Service is temporarily unavailable | The database is down |
The fix:
- Return the right status code, and keep an error body too, because the message helps humans and debugging.
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type": "about:blank", "title": "Not Found", "status": 404,
"detail": "Order 9999 does not exist"}
- For the shape of the error body, we can use the Problem Details standard (RFC 9457). Then every endpoint has the same error shape.
- Because the API is public, do not change this suddenly. Current clients depend on 200. Better to change it in a new API version and tell partners.
Follow-up question: What is the difference between 401 and 403? And why do some APIs return 404 instead of 403?
Follow-up answer:
- 401 means “I do not know who you are”. The client should log in or get a new token.
- 403 means “I know who you are, but you are not allowed”. Logging in again will not help.
- Sometimes a private resource returns 404, so the API does not even reveal that the resource exists. For example, a user should not learn that order 9999, which belongs to someone else, exists.
Red flag: Thinks status codes are only “decoration”, or cannot tell 401 from 403.
5. Building SQL with an f-string
Question: An online shop has a search box. The user types a product name. The server code is below (PostgreSQL with the psycopg library):
def search_products(conn, term: str):
sql = f"SELECT id, name, price FROM products WHERE name LIKE '%{term}%'"
with conn.cursor() as cur:
cur.execute(sql)
return cur.fetchall()
The application connects with the database admin user. You see this code in code review. What do you think?
Short answer: This code has SQL injection. The user’s text is pasted straight into the SQL text. So instead of a word, the user can send SQL code, and the database runs it. The fix: parameterized queries. The SQL text and the user’s value go to the database separately. Also, the application should not connect as the admin user.
Hint (if the candidate says there is no problem): If a user types a single quote in the search box (for example the name “O’Reilly”), the page returns a 500 error, and the log shows an SQL syntax error. Why does one simple character break the query?
Why is this dangerous? (step by step)
- Imagine the user types this text:
x' UNION SELECT id, email, 0 FROM users --
- The final SQL becomes:
SELECT id, name, price FROM products WHERE name LIKE '%x'
UNION SELECT id, email, 0 FROM users --%'
- The user’s quote closes the string. The rest of the user’s text is now an SQL command, not data.
- The two dashes turn the rest of the query into a comment.
- Result: the email of every user shows up in the search results. The same trick can read other columns too.
- Because the application connects as admin, the attacker may also be able to drop tables or change data.
The fix:
- Use a parameterized query. The user’s value is separate from the SQL text, so the database always treats it as data, never as code.
def search_products(conn, term: str):
sql = "SELECT id, name, price FROM products WHERE name LIKE %s"
with conn.cursor() as cur:
cur.execute(sql, (f"%{term}%",)) # value is sent as a parameter
return cur.fetchall()
- Note: the percent signs are now inside the value, not inside the SQL text. This is safe.
- Escaping characters by hand, or filtering words like UNION, is not the right way. Someone always finds a way around it.
- Least privilege: the application should connect with a database user that has only the rights it needs. For example, only SELECT, INSERT and UPDATE on its own tables. Then, if an injection bug is left somewhere, the damage is smaller.
- These also help: an ORM that builds parameters for you, and static analysis tools that find an f-string passed to execute.
One more important point: Parameters only work for values. You cannot use a parameter for a table name or a column name (for example, for sorting). For those, use an allowlist of known names.
Follow-up question: The user wants to sort the results by a column they choose (price or name). How do you build this safely?
Follow-up answer:
- Do not put the user’s input straight into ORDER BY.
- Build a fixed map from allowed options to column names. Reject anything else, or use a default.
SORT_COLUMNS = {"price": "price", "name": "name"}
column = SORT_COLUMNS.get(user_sort, "name") # unknown value -> default
sql = f"SELECT id, name, price FROM products ORDER BY {column}"
- This f-string is safe, because column can only be one of our own fixed values, never the user’s text.
Red flag: The fix is “remove quotes from the input”, or thinks the server is safe because the search box is validated in the frontend.
6. A function with flag parameters
Question: In a shop’s code, this function is about 150 lines long. It is called from 8 different places:
def process(order, validate=True, send_email=True, discount_code=None):
if validate:
... # 40 lines of validation
if discount_code is not None:
... # 30 lines of discount logic
... # 30 lines: save to database
if send_email:
... # 40 lines: build and send email
# somewhere else in the code:
process(order, True, False, None)
process(order, False, True, "SUMMER")
You see this in code review. What do you think?
Short answer: This function does several separate jobs: validation, discount, saving and email. So it breaks the Single Responsibility principle. The boolean parameters (called flag arguments) show that the function is really several functions mixed together. Also, a call that only passes True, False and None in a row is not readable. The fix: split it into small functions with clear names.
Hint (if the candidate says there is no problem): A new developer reads the first call (with True, False and None). They do not know what True and False mean. A bug was also found: in one place the two booleans were swapped by mistake, and an order was saved without validation. Testing this function is also hard.
Why is this a problem? (step by step)
- With 2 booleans and 1 optional parameter, we have at least 8 different cases. Each case needs a test.
- When someone reads a call, they must open the function definition to understand True and False.
- If two booleans are swapped, Python gives no error. The bug comes in silently.
- Every change to the email part edits the same function that holds the save logic. So the risk of breaking another part is higher.
- Every new flag doubles the number of cases.
The fix:
- Turn each job into a small function with a clear name.
- The main function only shows the order of the steps.
def validate_order(order) -> None: ...
def apply_discount(order, code: str) -> None: ...
def save_order(order) -> None: ...
def send_confirmation_email(order) -> None: ...
def place_order(order, discount_code: str | None = None) -> None:
validate_order(order)
if discount_code:
apply_discount(order, discount_code)
save_order(order)
send_confirmation_email(order)
- Where a special flow is needed, call the small functions directly. For example, an import of old data calls only save_order.
- If an option is really needed, at least make it keyword-only, so the call is readable:
def place_order(order, *, send_email: bool = True) -> None: ...
place_order(order, send_email=False) # clear at the call site
- Make the change in small steps. First write tests for the current behavior, then split.
One more important point: Not every flag is bad. A simple option like reverse in Python’s sorted function is fine, because it changes only a small detail of the behavior. The problem is when a flag turns a completely separate job on or off.
Follow-up question: In one place validate is False, because the order comes from an old system and validation rejects it. What do you do with this case?
Follow-up answer:
- First ask why it is rejected. Maybe the validation rule or the old data is wrong.
- If this flow is really different, create a separate function with a clear name, for example import_legacy_order, with its own rules.
- This way the decision “no validation” is visible in the function name, not hidden behind a False.
Red flag: The fix is to add one more flag, or thinks a 150-line function with many responsibilities is normal.
7. Storing money as float
Question: An online shop keeps prices in dollars. The cart total code is:
class CartItem:
def __init__(self, price: float, quantity: int):
self.price = price
self.quantity = quantity
def cart_total(items: list[CartItem]) -> float:
total = 0.0
for item in items:
total += item.price * item.quantity
return total
In the database, the price column is a FLOAT too. You see this in code review. What do you think?
Short answer: float is not a good type for money. A float stores numbers in base 2, and it cannot exactly represent many decimal fractions (like 0.1). Very small errors add up, and sometimes the final total is off by one cent. The fix: use Decimal, or store money as an integer number of cents. In the database, use NUMERIC or DECIMAL.
Hint (if the candidate says there is no problem): The finance team says the monthly total of invoices and the total of payments differ by one or two cents. We try this in the Python console:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
Why does this happen? (step by step)
- The computer stores a float in bits, which means in base 2.
- In base 10, one third cannot be written exactly: 0.3333 and it goes on forever.
- In the same way, in base 2 the number 0.1 cannot be written exactly. So the closest possible number is stored.
- Every addition and multiplication adds a very small error.
- After thousands of operations, or at rounding time, this error can reach one cent.
- Comparing with the equals sign is also not reliable, because 0.1 + 0.2 is not exactly 0.3.
The fix:
- In Python, use Decimal. Create the value from a string, not from a float, or the float error comes with it.
from decimal import Decimal, ROUND_HALF_UP
price = Decimal("19.99")
total = price * 3
print(total) # 59.97
tax = (total * Decimal("0.09")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
- Another way: store money as an integer in the smallest unit, for example 1999 cents instead of 19.99 dollars. Adding integers is always exact.
- In the database, define the column as NUMERIC with a fixed precision, for example NUMERIC with 12 digits, 2 of them after the decimal point.
- Decide the rounding rule: where we round (each line, or only the final total) and how (for example half-up, or banker’s rounding). This is a business decision, so agree on it with the finance team.
- Always keep money together with its currency. You must not add 10 dollars and 10 euros.
One more important point: Not all currencies have two decimal places. For example, the Japanese yen has no smaller unit in common use. So do not assume a fixed number of decimal places for every currency.
Follow-up question: We must split 100 dollars between 3 people. How do you do it correctly?
Follow-up answer:
- If each person gets 33.33, the total is 99.99, and one cent is lost.
- The right way: work in cents. 10,000 cents divided by 3 is 3,333, with a remainder of 1.
- Add the remainder to one of the shares: 3,334, 3,333 and 3,333.
- Now the shares add up exactly to the original amount. The business decides who gets the extra cent.
Red flag: Says “we just round at the end”, and does not know why 0.1 is not exact in a float.
8. Reviewing a big pull request
Question: A teammate opened a pull request and asked you to review it today. The PR stats are:
60 files changed, 3,041 insertions(+), 1,877 deletions(-)
Commits:
- add loyalty points feature
- refactor OrderService into smaller classes
- run new formatter on the whole project
- fix tests
The PR description is one line: “Added loyalty points”. How do you review this PR? What do you say to the author?
Short answer: This PR mixes three separate jobs: a new feature, a refactor and formatting changes. At this size, a real review is almost impossible. The important changes get lost among thousands of formatting lines. It is better to kindly ask the author to split it into several small PRs: first formatting, then the refactor with no behavior change, and finally the feature.
Hint (if the candidate says there is no problem): Two weeks later a bug is found in the order price calculation. It turns out the buggy line came in with this PR, but it was hidden among hundreds of formatting lines in OrderService, and nobody saw it.
Why is a big PR a problem? (step by step)
- Human attention is limited. After a few hundred lines, the reviewer gets tired and only skims.
- Formatting creates thousands of diff lines with no meaning. But the reviewer must find the important lines among them.
- When a refactor and a feature are together, you cannot tell if a behavior change is on purpose or a mistake.
- If a problem is found later, reverting the PR reverts all the work together.
- A big PR stays open a long time. So it has more conflicts with other people’s work.
What do I do?
- First I talk to the author, not only leave comments. Maybe they are under time pressure.
- I suggest splitting it like this:
- One PR only for formatting. It can be approved quickly with a quick look and green tests.
- One PR for the refactor, with no behavior change. The current tests must stay green without changes.
- One PR for the feature, with a full description and new tests.
- If splitting is really not possible, I at least review commit by commit, and view the diff with “ignore whitespace”.
- In the review I focus on what matters: correct logic, edge cases, security, tests. Personal taste and formatting are left to the linter and the formatter.
- I ask the author for a better description: what, why, and how it was tested.
Kindness in comments:
- Instead of “this is wrong”, I say “I think this fails when the list is empty. What do you think?”
- I talk about the code, not the person.
- I make clear which comments are required and which are only suggestions (for example, a “nit” prefix for a small point).
- I also mention the good parts of the PR.
Follow-up question: What do you do in the team so PRs this big do not happen again?
Follow-up answer:
- Do the project-wide formatting once, in a separate change, and run the formatter in CI or a pre-commit hook, so it no longer shows up in PRs.
- For big features, use feature flags, so unfinished code can be merged in small PRs without users seeing it.
- Add a template for PR descriptions.
- Agree as a team that small PRs get reviewed faster. That itself is a motivation.
Red flag: Approves without reading because “the tests are green”, or writes harsh, personal comments.
9. A test that depends on the system clock
Unit tests with xUnit· in roadmap
Question: A discount coupon has an expiry date. You see this function and its tests in code review:
from datetime import datetime, timedelta
def is_expired(coupon) -> bool:
return datetime.now() > coupon.expires_at
def test_coupon_expiring_tomorrow_is_valid():
now = datetime.now()
tomorrow = now.replace(day=now.day + 1, hour=0, minute=0)
coupon = Coupon(expires_at=tomorrow)
assert is_expired(coupon) is False
def test_coupon_from_one_second_ago_is_expired():
coupon = Coupon(expires_at=datetime.now() - timedelta(seconds=1))
assert is_expired(coupon) is True
The tests pass on the author’s machine. What do you think?
Short answer: Both the function and the tests read the real system clock. So the test result depends on when it runs. The test is not deterministic. The fix: inject the time into the function, either as a parameter or as a clock that the test can replace. In the test, pass a fixed time.
Hint (if the candidate says there is no problem): The test passes most days. But on the last day of every month it fails in CI with “day is out of range for month”. The next day, with no code change, it passes again.
Why does this happen? (step by step)
- In the first test, “tomorrow” is built by adding 1 to the day of the month.
- If today is the 31st, day 32 does not exist. So the replace method raises an error. This is a bug in the test itself.
- A bigger problem: the test calls the now function of datetime once, and is_expired calls it again. Between the two calls, time moves on. Near a boundary, this small gap can change the result.
- The result depends on the hour, the day, the time zone and the speed of the CI machine. A test that sometimes passes and sometimes fails is flaky.
- A flaky test teaches the team to ignore red builds. That is worse than having no test.
- One more problem: you cannot test edge cases. For example, “what happens exactly at the expiry moment?”
The fix:
- The simplest way: take the current time as a parameter.
from datetime import datetime, timezone
def is_expired(coupon, now: datetime) -> bool:
return now >= coupon.expires_at
def test_coupon_is_expired_exactly_at_expiry_time():
expiry = datetime(2026, 1, 31, 23, 59, 59, tzinfo=timezone.utc)
coupon = Coupon(expires_at=expiry)
assert is_expired(coupon, now=expiry) is True
- Now the test gives the same result every day and every hour. We can also test the edges exactly.
- If many layers need the time, inject a clock. In production it is the real clock; in tests it is a fixed one:
class SystemClock:
def now(self) -> datetime:
return datetime.now(timezone.utc)
class FixedClock:
def __init__(self, at: datetime):
self._at = at
def now(self) -> datetime:
return self._at
- There are also libraries that freeze datetime in tests (for example freezegun). They work, but they keep the hidden dependency on time inside the code.
- In the same change, decide the boundary: is the coupon still valid at the exact expiry moment? Agree on this with the product team and write a test for it.
- Keep times with a time zone (for example UTC), so comparing times from different zones does not go wrong.
Follow-up question: Apart from the clock, what else makes a test non-deterministic?
Follow-up answer:
- Random numbers without a fixed seed. Fix: inject the random source, or fix the seed in the test.
- The network and external services. Fix: use a fake or a stub in unit tests.
- Test order and data shared between tests. Fix: each test creates its own data.
- Order in structures like a set, and concurrent work (threads). Fix: do not rely on order, and do not use sleep to coordinate.
Red flag: Says “if it fails, we just re-run it”, or sees only the day-32 bug and misses the dependency on the clock.
10. An if/elif chain that keeps growing
SOLID· in roadmapDesign patterns· in roadmap
Question: A payment service supports several payment methods. Every few months a new method is added. This function is in the code:
def charge(payment_type: str, amount, details):
if payment_type == "card":
... # 30 lines: call card gateway
elif payment_type == "paypal":
... # 25 lines: call PayPal API
elif payment_type == "crypto":
... # 40 lines: create invoice, wait for confirmation
else:
raise ValueError(f"unknown payment type: {payment_type}")
Four other functions have the same if/elif chain on payment_type: refund, fee, validate_details and display_name. Now a PR adds a new method, “bank_transfer”. The PR adds an elif branch to all five functions. What do you think?
Short answer: The code works, but it breaks the Open/Closed principle from SOLID: to add a new method, we must change old code in five places. The knowledge about each payment method is also spread across five places. The fix: each payment method becomes its own class that holds all the behavior for that method (Strategy, or polymorphism). A registry maps the type name to the class. But if we only have two or three simple cases that rarely change, a plain if is enough (YAGNI).
Hint (if the candidate says there is no problem): Last month, when crypto was added, the developer forgot to add its branch to the refund function. The first customer who asked for a crypto refund got an “unknown payment type” error. The tests did not catch it, because they only tested charge.
Why is this a problem? (step by step)
- The logic of one payment method is spread over five functions. To fully understand crypto, you must read five places.
- Every new method means changing five old functions. Each change risks breaking the existing methods.
- If one place is forgotten, the error only shows at runtime, and only on that rarely used path.
- Each function keeps growing. Several people edit the same function at the same time, so conflicts increase.
The fix:
- Define one shared contract for all methods.
- Each method is a separate class that keeps all its behavior together.
- A registry maps the method name to the class.
from typing import Protocol
class PaymentMethod(Protocol):
def charge(self, amount, details) -> None: ...
def refund(self, amount, details) -> None: ...
def fee(self, amount): ...
def validate_details(self, details) -> None: ...
def display_name(self) -> str: ...
class CardPayment:
def charge(self, amount, details) -> None: ...
def refund(self, amount, details) -> None: ...
def fee(self, amount): ...
def validate_details(self, details) -> None: ...
def display_name(self) -> str: ...
METHODS: dict[str, PaymentMethod] = {
"card": CardPayment(),
# "paypal": PayPalPayment(), ...
}
def get_method(payment_type: str) -> PaymentMethod:
try:
return METHODS[payment_type]
except KeyError:
raise ValueError(f"unknown payment type: {payment_type}")
- Now adding bank_transfer means one new class and one line in the registry. The code of the existing methods is not touched.
- If the new class is missing a method, a type checker (like mypy) warns when it is added to the registry. So forgetting refund becomes harder.
- You can also write one simple test that calls every method on every entry in the registry.
When is a plain if better?
- When we have only two or three cases, and they are used in only one place.
- When there is no sign that new cases will come.
- When each branch is only a line or two. Building several classes for that is needless complexity.
- A practical rule: when the same if/elif chain is repeated in several places, or we had to add a new case for the second or third time, it is time to change the structure.
Follow-up question: How do you make this change in a live payment system without breaking anything?
Follow-up answer:
- First, write tests for the current behavior of all five functions and all three methods.
- Then move the methods into classes one by one. At first, the old functions just delegate to the new classes.
- Run the tests after each step, and send the change in small PRs.
- Add the new bank_transfer method only after this change is done, using the new structure.
Red flag: Either sees no problem in five repeated chains, or suggests a design pattern and several classes for every small if.
11. Hidden dependencies and dependency injection
Dependency Injection· in roadmap
Question: In a sales service, this class saves an order and sends the customer an email. The team wants to write tests for it:
class OrderService:
def __init__(self):
self.db = PostgresConnection("postgres://prod-db:5432/shop")
self.mailer = SmtpClient("smtp.company.com", 587)
def place_order(self, customer_email, items):
order_id = self.db.insert_order(customer_email, items)
self.mailer.send(customer_email, f"Order {order_id} received")
return order_id
You see this in code review. What do you think? Is there a problem?
Short answer: The class creates its own dependencies. So they are hidden, and nobody can replace them. The fix is dependency injection: the class receives the database and the mail sender from outside, through the constructor. Now a test can pass in a fake.
Hint (if the candidate says there is no problem): The team wrote a test for this class. The test only runs when a real database is reachable. Every test run sent a real email to a test address. Once, someone ran the tests with the wrong settings, and a test order was saved in the production database. Why can’t we test this class on its own?
Why is this a problem? (step by step)
- The constructor creates the database connection and the mail client itself.
- The code that creates the class has no way to say “use a different database this time”.
- So every test needs a real database and a real mail server.
- Tests become slow, depend on the network, and sometimes fail for no clear reason.
- The constructor signature does not show that the class needs a database and email. The dependencies are hidden.
- The server addresses are hard-coded. To use a test or staging environment, you must change the code.
The fix:
- Take dependencies from outside:
from typing import Protocol
class OrderRepository(Protocol):
def insert_order(self, email: str, items: list) -> int: ...
class Mailer(Protocol):
def send(self, to: str, body: str) -> None: ...
class OrderService:
def __init__(self, repo: OrderRepository, mailer: Mailer):
self.repo = repo
self.mailer = mailer
- The class now depends on a contract (interface), not on a concrete class like Postgres or SMTP.
- In tests, we pass simple fakes:
class FakeMailer:
def __init__(self):
self.sent = []
def send(self, to, body):
self.sent.append((to, body))
def test_place_order_sends_email():
mailer = FakeMailer()
service = OrderService(InMemoryOrderRepository(), mailer)
service.place_order("a@example.com", ["book"])
assert len(mailer.sent) == 1
- Everything is wired together in one place, called the composition root. It is usually the program’s entry point. Only there are the real objects created, and settings (like server addresses) are read from environment variables.
- You do not need a DI library for this. Passing objects into the constructor is enough.
A warning: do not overdo it.
- Not everything needs an interface. Only things that talk to the outside world (database, network, email, clock, files) need to be replaceable.
- A simple function that only calculates does not need injection.
- If every class has an interface with one implementation that never changes, the code is just noisier.
Follow-up question: With fakes, do we still need tests against a real database?
Follow-up answer:
- Yes. A unit test with fakes checks the class logic, fast and in isolation.
- But a fake cannot prove that the real SQL query is correct.
- So we also need a few integration tests that test the real repository against a real database. For example, a temporary database in a container, never production.
- Tests must never send real emails.
Red flag: Thinks testing means running against the production database. Or the opposite: wants an interface for every small class and a heavy DI framework, with no reason given.
12. A shared counter across threads
Question: We have a Python web app that serves requests with several threads. We want to count visits to the home page. Here is the code:
visit_count = 0
def home_page(request):
global visit_count
visit_count += 1
return render("home.html")
You see this in code review. What do you think? Is there a problem?
Short answer: This is a race condition. “Add one” is really three steps: read, add, write. These steps do not happen as one atomic action. If two threads do it at the same time, one increment is lost. The fix is a lock. But if the app runs as several processes or on several servers, an in-memory lock does not help. The counting must move to the database or Redis.
Hint (if the candidate says there is no problem): With a load-testing tool, we sent exactly 100,000 requests to the home page. All of them succeeded. But at the end, the counter shows a number below 100,000. Under light load, the number is correct. Why?
Why does this happen? (step by step)
- Adding one to the counter really means: read the current value, add one, write the result.
- Say the value is 10.
- Thread A reads the value: 10.
- Before A writes, the operating system or the Python interpreter switches to thread B. Thread B also reads 10.
- Thread B writes 11. Then thread A also writes 11.
- We had two visits, but the counter only went up by one.
- Under light load, this overlap is rare. That is why the bug only shows under load.
About Python’s GIL: Some people think the GIL solves this. But Python does not guarantee that this statement is atomic. So you must not rely on it.
The fix inside one process:
import threading
visit_count = 0
count_lock = threading.Lock()
def home_page(request):
global visit_count
with count_lock:
visit_count += 1
return render("home.html")
Now only one thread at a time can do the read and the write. Keep the work inside the lock short, so threads do not wait long.
But in a real deployment:
- Apps usually run several processes (for example, several Gunicorn workers) or several servers.
- Each process has its own memory and its own counter. A lock in one process cannot see another process.
- When a process restarts, the in-memory number goes back to zero.
- So the counter must live in a shared place, and the increment must be atomic there.
UPDATE page_stats SET visits = visits + 1 WHERE page = 'home';
- Or use the INCR command in Redis, which is atomic.
- Important: “read the value, add one in code, then write it back” has the same race condition in a database too. The increment must happen inside a single command.
Follow-up question: If we have millions of visits per minute, is one database row that everyone updates a problem?
Follow-up answer:
- Yes, that row becomes a bottleneck. All requests queue for the lock on that one row.
- One option: each process counts in memory (with a lock) and adds its total to the database every few seconds. We give up a little real-time accuracy.
- Another option: keep several counter rows and let each request update a random one. When reading, sum them.
- First ask: do we really need an exact real-time number?
Red flag: Says “Python has a GIL, so it is fine”. Or thinks an in-memory lock is enough for an app that runs on several servers.
13. An index that is not used
Question: The users table has about 5 million rows. There is an index on the email column. For login, a colleague wrote this query, because some users type their email with capital letters (the database is PostgreSQL):
CREATE INDEX idx_users_email ON users (email);
SELECT id, password_hash
FROM users
WHERE LOWER(email) = LOWER('Sara@Example.com');
You see this in code review. What do you think? Is there a problem?
Short answer: The index stores the email value itself, not its lowercase form. When the LOWER function runs on the column, the database cannot use that index. So it reads the whole table. The fix: create an expression index on the lowercase email, or store the email in lowercase from the start. To be sure, read the query plan with EXPLAIN.
Hint (if the candidate says there is no problem): The login page takes about 2 seconds. The colleague says “but we have an index on email”. When we run the same query without LOWER, the answer comes back in a few milliseconds. What is the difference?
Why does this happen? (step by step)
- A B-tree index is like a sorted list of the values in the email column. “Sara@Example.com” is stored exactly as it is.
- The query does not look for the column value. It looks for “the result of LOWER on the column”.
- That result is not stored in the index.
- So the database must run LOWER on all 5 million rows and compare each one. This is a Seq Scan (reading the whole table).
- The same rule applies to any function or calculation on a column. For example, taking the year from a date column, or adding a number to a column.
How do we check?
EXPLAIN ANALYZE
SELECT id FROM users WHERE LOWER(email) = 'sara@example.com';
If the output shows a Seq Scan on users, the index was not used. After the fix, we should see an Index Scan or a Bitmap Index Scan.
The fix:
- Option one, an expression index. Now the LOWER result itself is stored in the index:
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
- The query must use exactly the same expression for the index to be used.
- Option two, store normalized data. Convert the email to lowercase in code at sign-up and login. The query becomes simple, and the normal index works.
- If emails must be unique, build the unique index on the normalized form too. Otherwise “Sara@Example.com” and “sara@example.com” become two separate accounts.
- PostgreSQL also has the citext type, which compares text without caring about letter case. Which option to pick depends on the team.
Follow-up question: So should we add an index on every column that appears in a WHERE clause?
Follow-up answer:
- No. Every index has a cost.
- Every INSERT, UPDATE and DELETE must also update all indexes. So writes get slower.
- Every index uses disk space and memory.
- An index on a column with very few distinct values (like a true/false column) usually does not help.
- So add indexes only for real, frequent queries, and measure the effect with EXPLAIN.
Red flag: Thinks “we have an index, so the query is fast”. Or has never looked at a query plan.
14. Returning every row from an API
Question: An online shop has about 1 million orders, and a few thousand new orders arrive every day. The admin panel and several other services use this endpoint:
@app.get("/orders")
def list_orders():
rows = db.execute("SELECT * FROM orders ORDER BY created_at DESC").fetchall()
return [order_to_dict(r) for r in rows]
You see this in code review. What do you think? How would you design this API?
Short answer: This endpoint reads all orders at once and returns them in one JSON. As data grows, memory, time and network all grow with it. It needs pagination, a maximum page size, and filters. For large tables, cursor (keyset) pagination is better than OFFSET.
Hint (if the candidate says there is no problem): When there were few orders, everything was fine. Now the response takes 40 seconds, it is several hundred megabytes, and sometimes the server crashes with an out-of-memory error. The admin panel only shows the latest 20 orders anyway.
Why is this a problem? (step by step)
- The database must read and send 1 million rows.
- The app keeps them all in memory, turns them into dictionaries, and then builds one big JSON string. Several copies of the same data are in memory at once.
- All of this goes over the network, and the client must parse it all.
- If a few requests arrive at the same time, the server runs out of memory.
- Data grows every day, so this endpoint gets slower every day. The cost grows with the total data, not with what the user needs.
Option one: OFFSET and LIMIT
SELECT * FROM orders ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100000;
- It is simple, and you can jump straight to page 2000.
- But deep pages are slow. The database must read and throw away the first 100,000 rows, then return 50.
- If new orders arrive while the user is paging, rows shift. The user sees one order twice or misses one.
Option two: cursor or keyset
SELECT * FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
- The client sends the values of the last row from the previous page (usually as an opaque string called a cursor).
- With an index on these two columns, the database jumps straight to that point. Page 1 and page 2000 are about equally fast.
- The id column makes the order unique, because several orders can have the same timestamp.
- Downside: you cannot jump straight to “page 2000”. You only have “next”.
Other things the API should have:
- A default page size (for example 50) and a maximum (for example 200). If the client asks for more, the server returns the maximum.
- Filters, like order status or a date range. Often the client does not need all orders at all.
- Only the needed columns, not every column.
- The link or cursor for the next page in the response, so the client does not build it.
Follow-up question: The finance team really needs every order from the year for a report. What do you do?
Follow-up answer:
- That is a different need. It should not be done with thousands of calls to the paginated API.
- One option: a background export job that builds a CSV file and gives a download link.
- Another option: send the data to a data warehouse or a reporting database, so heavy queries do not run on the main database.
- If it really must come from the API, stream the response instead of building it all in memory.
Red flag: Thinks “it is only 1 million rows, it is fine”. Or does not know why OFFSET gets slow for deep pages.
15. Duplicate orders from retried requests
Idempotency· in roadmapAPI design· in roadmap
Question: A shop’s mobile app sends a POST request to the orders endpoint to place an order. Many users are on weak mobile networks. The app code is:
def submit_order(cart):
for attempt in range(3):
try:
return http.post("/orders", json=cart, timeout=5)
except TimeoutError:
continue
raise OrderFailed()
And the server side:
@app.post("/orders")
def create_order(cart):
order = db.insert_order(cart)
payments.charge(cart.customer_id, cart.total)
return order
You see this in code review. What do you think? Is there a problem?
Short answer: Retrying after a timeout is good, but this POST is not idempotent. If it runs twice, it creates two results. A timeout means “I did not get an answer”, not “the server did not do the work”. The fix: the client creates an idempotency key for each order and sends the same key on every retry. The server stores the key with a unique constraint, and if it sees the key again, it returns the first result.
Hint (if the candidate says there is no problem): Support reports that some customers have two identical orders and paid twice. Almost all of them were on mobile networks. In the server logs, both requests finished successfully.
Why does this happen? (step by step)
- The app sends the request. The server receives it, saves the order and charges the money.
- On the way back, the response is slow or lost because of the weak network.
- After 5 seconds, the app thinks the request failed. So it sends it again.
- The server does not know this is the same order. So it creates a new order and charges again.
- The app cannot tell “the request did not arrive” from “the response did not arrive”. So retries are needed. But retries are safe only when the operation is idempotent.
The fix:
- When the user taps “Place order”, the app creates a random ID (for example a UUID). It sends this ID in a header on every retry:
POST /orders
Idempotency-Key: 7f3c9a2e-5b1d-4e8a-9c0f-2d6b1a4e8f30
- The server has a table that stores the key with a unique constraint:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
customer_id BIGINT NOT NULL,
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
- When a request arrives, the server first tries to insert the key.
- If the insert works, the request is new. The server creates the order and stores the response next to the key. Creating the order and storing the response should be in one transaction.
- If the key already exists and has a response, the server returns that stored response. It does no new work.
- If the key exists but has no response yet (the first request is still running), the server returns a clear error like 409, so the client tries again a bit later.
- Tie the key to the customer, so one user’s key cannot affect another user.
- Delete old keys after some time (for example, one day).
One more point: The payment call needs the same protection. If your payment provider accepts an idempotency key (some do), send the order ID as the key.
Follow-up question: Why not just disable the button after the first tap?
Follow-up answer:
- Disabling the button is good, and it reduces double taps.
- But the problem here is not the user’s tap. The app code itself retries.
- Other clients, proxies and load balancers can also repeat a request.
- So the server must protect itself. The client side is never enough.
Red flag: Suggests removing retries completely. Or wants to detect duplicates by “same customer and same amount in the last minute”, which also blocks a real second purchase.
16. Storing passwords with SHA-256
Question: A website has about 2 million users. User passwords are stored and checked like this:
import hashlib
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def register(email, password):
db.insert_user(email, hash_password(password))
def check_login(email, password):
user = db.find_user(email)
return user.password_hash == hash_password(password)
A colleague says: “We do not store passwords as plain text. We hash them, so it is safe.” You see this in code review. What do you think?
Short answer: Hashing is better than plain text, but this is weak for passwords. There are two problems: SHA-256 is very fast, and there is no salt. Use an algorithm made for passwords, which is slow on purpose and creates the salt itself: Argon2id (recommended by OWASP), or scrypt or bcrypt. For existing users, re-hash the password with the new algorithm at their next login.
Hint (if the candidate says there is no problem): Imagine a database backup leaks one day. The attacker now has every hash. In the database, we see that several thousand users have exactly the same hash. How long does the attacker need to find the common passwords?
Why is this a problem? (step by step)
- A hash is one-way. The attacker cannot “reverse” it. But they can guess: hash a likely password and compare it with the stored hash.
- Functions like SHA-256 are built for speed. A normal graphics card can compute a huge number (billions) of SHA-256 hashes per second.
- So trying a big list of common passwords and their variations is very fast.
- Without a salt, two users with the same password have the same hash. One correct guess cracks all of them at once.
- Without a salt, the attacker can also use precomputed tables (rainbow tables).
- A smaller point: a normal equality check does not run in constant time. A constant-time comparison is better. Password libraries do this for you.
What is a salt? A random, unique value per user. It is added to the password before hashing and stored next to the hash. It is not secret. It just makes equal passwords produce different hashes, so each user must be attacked separately.
The fix:
- Use a ready-made library. Do not build your own. For example, with the argon2-cffi library:
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # Argon2id with library defaults
def hash_password(password: str) -> str:
return ph.hash(password) # salt and parameters are stored inside the result
def verify_password(stored_hash: str, password: str) -> bool:
try:
return ph.verify(stored_hash, password)
except VerifyMismatchError:
return False
- These algorithms are slow on purpose and use a lot of memory. For one login, a few dozen milliseconds do not matter. For an attacker making billions of guesses, this slowness matters a lot.
- Tune the parameters (time and memory) so they are acceptable on your own servers. Raise them as hardware gets faster.
Migrating existing users:
- We do not have users’ original passwords. So we cannot re-hash everyone at once.
- When a user logs in, check the password the old way. If it is correct, hash it with Argon2id right then and replace the old hash.
- A column or a prefix shows which algorithm made each hash.
- For users who do not log in for a long time, one option is to wrap the old hash inside Argon2id (a hash of the hash). Another option is to invalidate their password and ask them to reset it.
Follow-up question: Why not encrypt the passwords, so we can get them back if we need to?
Follow-up answer:
- The system should never need the user’s original password. It only needs to check it.
- Encryption can be reversed. If the key also leaks, every password leaks at once.
- Users often reuse one password on many sites. So our leak also hurts their other accounts.
Red flag: Thinks “it is hashed, so it is safe”. Or suggests writing their own hash scheme, or running SHA-256 several times in a row, instead of using a standard library.
17. A stale cache after a profile change
Question: The user profile page gets a lot of traffic. To reduce database load, the team put a Redis cache in front of it. The app runs on several servers, and they all share the same Redis:
CACHE_TTL = 600 # seconds
def get_profile(user_id):
key = f"profile:{user_id}"
cached = redis.get(key)
if cached:
return json.loads(cached)
profile = db.load_profile(user_id)
redis.set(key, json.dumps(profile), ex=CACHE_TTL)
return profile
def update_name(user_id, new_name):
db.execute("UPDATE users SET name = %s WHERE id = %s", (new_name, user_id))
You see this in code review. What do you think? Is there a problem?
Short answer: The name update only changes the database. It never touches the cache. So for up to 10 minutes, the cache shows the old version. The fix: after writing to the database, delete the cache key (invalidate it). The order matters: database first, then delete the cache. Even then, a small race remains, so keep the TTL as a safety net.
Hint (if the candidate says there is no problem): Users complain: “I changed my name, I saw a success message, but my profile page still shows the old name.” After a few minutes, it fixes itself. In the database, the new name is saved correctly.
Why does this happen? (step by step)
- The user opens the profile page. Data is read from the database and stored in the cache for 600 seconds.
- The user changes their name. The database is updated.
- The cache knows nothing about this change.
- The user opens the page again. The cache still has data, so the database is not read at all.
- Until the TTL runs out, the old name is shown.
The fix:
def update_name(user_id, new_name):
db.execute("UPDATE users SET name = %s WHERE id = %s", (new_name, user_id))
redis.delete(f"profile:{user_id}")
- Why delete instead of writing the new value into the cache? Deleting is simpler. The next read loads fresh data from the database. If two updates both write to the cache at the same time, the older value may be written last.
- Why database first, then cache? If we delete the cache first, a read at the same moment may put the old value back into the cache before the database changes.
- If the database write is in a transaction, delete the cache after the commit, not before.
The small race that remains:
- Read request A finds the cache empty and reads the old name from the database.
- Before A writes to the cache, the update runs and deletes the cache key.
- Now A writes the old name into the cache.
- Result: the cache is stale again, until the TTL runs out.
This is rare, because the timing must be exactly like this. That is why we do not remove the TTL. The TTL is an upper limit on how long data can be stale. If needed, you can delete the key again after a short delay (delayed double delete), or keep a version number in the data.
One more point: What if Redis is down at the moment of the delete? The user’s update should not fail. Log the error and rely on the TTL.
Follow-up question: What is a good TTL for this page? And should we cache everything this way?
Follow-up answer:
- There is no fixed number. Ask: how stale can this data be before users or the business are hurt?
- For a profile name and photo, a few minutes of staleness is usually fine for other viewers. But the user should see their own change right away.
- For things like account balance or permissions, staleness is dangerous. Either do not cache them, or cache them very carefully.
- Measure before you cache. Maybe the right index fixes the slowness and no cache is needed.
Red flag: The only fix is “make the TTL shorter”. Or thinks deleting the cache has no race condition at all.
18. Logs that do not help
Structured logging· in roadmap
Question: The user login service runs on 6 servers and handles about 2 million requests a day. Logs are collected in a central system. The logging code looks like this:
def handle_login(request):
print("user data: " + str(request.json))
try:
user = auth.login(request.json["email"], request.json["password"])
print("login ok")
return user
except Exception as e:
print("Error happened")
return error_response(500)
And a sample of the output:
user data: {'email': 'sara@example.com', 'password': 'Summer2024!'}
login ok
Error happened
Error happened
You see this in code review. What do you think?
Short answer: There are two serious problems. First, passwords and personal data are written to the logs. That is a security problem. Second, the logs are useless for finding problems: no level, no context (which request, which user), and no actual error or stack trace. The fix: structured logging, with context fields like a request id, correct levels, the full exception logged, and never any passwords or tokens.
Hint (if the candidate says there is no problem): Last night, 3% of logins failed. The on-call engineer only saw thousands of “Error happened” lines in the log system. They could not tell what the error was, which user it hit, or which server it came from. A week later, the security team also noticed that anyone with access to the log system can read user passwords.
Why is this a problem? (step by step)
- Everyone with log access (developers, support, external log services) now has user passwords.
- Logs are usually copied, kept for a long time, and protected less than the database.
- “Error happened” does not say the error type, the error message, or where in the code it happened.
- Without a request id, you cannot pick out the lines of one request from millions of lines across 6 servers.
- A print call has no level. So you cannot show only errors, or turn off detailed logs in production.
- Free text is hard to search and count. For example: “how many errors did this customer get in the last hour?”
The fix:
import logging
logger = logging.getLogger("auth")
def handle_login(request):
email = request.json.get("email")
try:
user = auth.login(email, request.json.get("password"))
logger.info("login succeeded", extra={"user_id": user.id})
return user
except InvalidCredentials:
logger.warning("login failed: invalid credentials")
return error_response(401)
except Exception:
logger.exception("login crashed") # logs the stack trace at ERROR level
return error_response(500)
- A formatter writes JSON. Each line has separate fields: time, level, message, service name, server.
- A middleware creates a request id for each request (or reads it from an incoming header) and adds it to every log line of that request. Return the same id in error responses, so support can find it.
- Log the user id, not the user’s email or name.
- Pick the right level:
- DEBUG for development details, usually off in production.
- INFO for normal, important events.
- WARNING for something unusual that the system handled, like a wrong password.
- ERROR for a failure that broke a request, with the stack trace.
- Never log: passwords, tokens, API keys, full card numbers. Log personal data only when needed, and mask it.
- Add a safety layer too: a filter that removes fields with names like password or token before writing.
Follow-up question: Passwords have been logged until today. What do you do now?
Follow-up answer:
- First, fix the code so no more passwords are written.
- Delete the old logs that contain passwords, from the log system and from backups.
- Treat it as a security incident and tell the security team. Affected users may need to change their passwords.
- Then check who had access to those logs.
Red flag: Says “the logs are internal, so logging passwords is fine”. Or tries to fix it by adding more free-text print calls.
19. A test suite that takes 70 minutes
Unit tests with xUnit· in roadmapIntegration tests· in roadmap
Question: A team of 8 works on an online shop web app. Here is a summary of the project’s tests:
End-to-end browser tests (Selenium/Playwright): 900
Integration tests (API + database): 40
Unit tests: 20
CI pipeline duration: ~70 minutes
Most browser tests check business rules. For example, “if a discount code has expired, show an error message” or “shipping is free for carts above a certain amount”. The manager says: “Our test coverage is excellent.” What do you think? How do you see this situation?
Short answer: This is the test pyramid upside down. Most tests are in the slowest and most expensive layer. Browser tests are slow, and they sometimes fail for no real reason because of the network and timing. The fix: check business logic with unit tests, check the database and API wiring with integration tests, and keep only a few browser tests for critical paths (like sign-up, checkout, payment).
Hint (if the candidate says there is no problem): Every pull request waits more than an hour for CI. Every few runs, some random tests fail and then pass on a rerun. Developers now press “rerun” without looking. Last week, a real bug was ignored the same way.
Why is this a problem? (step by step)
- Each browser test must start the app, open a browser, load pages and wait. Each takes seconds. A unit test takes milliseconds.
- A browser test has many moving parts: network, page timing, shared data, external services. Any of them can cause a random (flaky) failure.
- When tests fail randomly, the team stops trusting them. Real failures get ignored too.
- When a browser test fails, it is not clear where the problem is: the UI, the API, the database, or the business rule itself.
- A 70-minute feedback loop means developers switch to other work. Fewer, bigger changes get merged.
- The “expired discount” rule is a simple function. You do not need a browser to check it.
The test pyramid:
- Wide base: many unit tests, fast and isolated. Business logic is tested here.
- Middle: fewer integration tests. They check that the code works with a real database, queue or API.
- Narrow top: a few end-to-end tests. They only check that the main paths work from start to finish.
The fix:
- Sort the browser tests into groups. For each one, ask: “What does this test really check?”
- Move business logic down to unit tests. One browser test about shipping cost can become ten unit tests that cover every edge case in a fraction of a second:
def test_free_shipping_above_threshold():
assert shipping_cost(cart_total=Decimal("120.00")) == Decimal("0")
def test_shipping_charged_below_threshold():
assert shipping_cost(cart_total=Decimal("99.99")) == Decimal("7.50")
- To do this, you may need to pull the logic out of UI and controller code so it can be tested.
- Keep only a few dozen browser tests for critical paths.
- Find the flaky tests. Fix them, or quarantine them for a short time. Never accept “rerun until green” as normal.
- Run tests in parallel, and run the fast tests first so feedback comes sooner.
- Do not do it all at once. Each time you touch an area, move that area’s tests down.
Follow-up question: The manager is worried: “If we delete 800 browser tests, won’t our coverage drop?”
Follow-up answer:
- Coverage does not disappear. The same behaviors are checked in a lower layer, often with more cases.
- Never delete a test before its replacement is written.
- Success is not the number of tests. Measure CI time, the number of flaky failures, and bugs that reach production.
- A test suite nobody trusts gives no real coverage.
Red flag: The only fix is “buy a faster CI server” or “automatically rerun failed tests”. Or thinks unit tests have no value because “they do not simulate a real user”.
20. Storing appointment times without a time zone
Question: We have a booking app for clinics. At first it only worked in one city. Now it has users in several countries. The company also plans to move its servers to a data center in another region. Appointment times are stored like this, and a scheduled job sends reminders every minute:
from datetime import datetime, timedelta
def book(user_id, date_str, time_str):
# e.g. "2026-03-29", "09:30"
starts_at = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
db.insert_appointment(user_id, starts_at) # column type: TIMESTAMP (no time zone)
def send_reminders():
now = datetime.now() # server local time
for appt in db.appointments_between(now + timedelta(hours=1),
now + timedelta(hours=1, minutes=1)):
notify(appt.user_id, "Your appointment is in 1 hour")
You see this in code review. What do you think? Is there a problem?
Short answer: Times are stored without a time zone. So “9:30” does not say 9:30 where. The server then compares it with its own local clock. With a server move, users in other countries, and daylight saving time (DST) changes, reminders go out at the wrong hour or twice. The fix: store UTC for instants. For future local events, also keep the user’s time zone as an IANA name (like Europe/Berlin). Convert only at the edges: when you take input and when you display.
Hint (if the candidate says there is no problem): Users in another country say their reminder arrives hours early or late. After a test move to the new server, everyone’s reminders shifted. And on the night the clocks changed, some users got no reminder and some got two.
Why does this happen? (step by step)
- “March 29, 9:30” without a time zone is not one exact moment. In Tehran, Berlin and Toronto, it is a different moment.
- The code assumes the user and the server are in the same time zone. For a user in another country, that is wrong.
- When the server moves to another region, the function that reads the server’s current time returns a different value. But the stored data does not change. So every comparison breaks.
- In countries with daylight saving time, once a year one hour of the day does not exist (clocks jump forward), and once a year one hour happens twice (clocks go back).
- On the night clocks go back, the server’s local clock passes through one hour twice. A job based on local time can find the same appointments twice. On the night clocks jump forward, one hour is skipped, and reminders for that hour are never sent.
There are two kinds of time:
- An instant: the exact moment something happened (order created, user logged in). Always store this in UTC.
- A future local event: a 9:30 a.m. appointment at a clinic in Berlin. The user wants “9:30 local time”. If that country’s DST rules change before that day, it must still be 9:30 local. So keep the local time and the time zone name. Compute UTC from them.
The fix:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def book(user_id, local_str, tz_name):
# local_str like "2026-03-29 09:30", tz_name like "Europe/Berlin"
local = datetime.strptime(local_str, "%Y-%m-%d %H:%M").replace(tzinfo=ZoneInfo(tz_name))
starts_at_utc = local.astimezone(timezone.utc)
db.insert_appointment(user_id, local_str, tz_name, starts_at_utc)
def send_reminders():
now = datetime.now(timezone.utc) # never depends on the server's zone
for appt in db.due_reminders(now):
notify(appt.user_id, "Your appointment is in 1 hour")
db.mark_reminder_sent(appt.id)
- The database column should be a type that understands time zones (for example TIMESTAMPTZ in PostgreSQL), or be clearly UTC.
- Store the user’s or clinic’s time zone as an IANA name, not as a fixed offset like “+2”. The offset changes with DST.
- Set the servers’ time zone to UTC too. But the code must not depend on it.
- Mark each reminder as “sent”. Then even if the job runs twice, the reminder is not sent twice. This is also safer than fragile one-minute windows.
- Check for missing or repeated local times at input. For example, if a user picks a time that does not exist because of DST, warn them.
Follow-up question: We have a weekly appointment, “every Monday at 18:00”. How do you store it?
Follow-up answer:
- Store the rule, not a list of UTC instants: “Mondays, 18:00, zone Europe/Berlin”.
- Compute each occurrence from the rule and the time zone.
- If you store one fixed UTC time and add 7 days each week, the appointment moves by one hour after the DST change.
Red flag: Says “store everything in server time, all our servers are in one region”. Or thinks storing everything in UTC alone solves every problem, even for future local events.
21. Estimating a task with one number
Estimation and planning· in roadmap
Question: You are a senior engineer in a team of 5. The product manager comes to your desk and says: “Customers want to export their orders to Excel. How long will the export-to-Excel feature take? Give me one number now.” You have not seen any written requirements yet. How do you answer?
Short answer: A good answer is not “I don’t know”, and it is not a guess like “3 days”. A good answer is:
- Ask a few quick questions to understand the scope.
- Break the work into small parts.
- Name the unknowns.
- Give a range with a confidence level, for example “most likely 4 to 6 days, I am fairly sure it is under 8”.
- If there is a big unknown, propose a short spike (a small time-boxed experiment) first.
- Promise to update the estimate as you learn more.
Hint (if the candidate says there is no problem): The manager will put your number in a plan and tell a customer. Later it turns out some customers have 2 million orders, and the export must also include the order items and work with filters. What happens to your number?
Questions to ask first:
- Which data? Only orders, or orders with items and customer data?
- How big? 500 rows or 2 million rows? Big files should usually not be built inside one HTTP request. They need a background job.
- Which format? A real xlsx file, or is a CSV that opens in Excel good enough? CSV is much simpler.
- Do filters, dates, time zones, and number formats matter?
- Who can export? Do we need permission checks or an audit log?
Break down the work (example for a medium scope):
- Backend endpoint and query with filters.
- File generation with a library.
- Background job and download link, if files are big.
- UI button and progress state.
- Tests, review, and deploy.
Each small part is easier to estimate than the whole feature. Small parts also show work people often forget, like tests and deploy.
Why a range and not one number? (step by step)
- An estimate is a guess about the future. It has uncertainty.
- One number hides that uncertainty. People hear it as a promise.
- A range shows the risk. A wide range says “we know little”. A narrow range says “we know a lot”.
- The manager can then decide: accept the risk, cut scope, or wait for the spike.
Spike for risk: If the biggest unknown is “can we export 2 million rows without running out of memory?”, spend half a day or one day to try it. After the spike, the range becomes much narrower.
What to say, as a sample: “If it is a CSV of orders only, with the current filters, about 2 to 3 days. If we need real Excel with items and big files, it is closer to 6 to 10 days. Let me take half a day to check the data size and the library, then I will give you a tighter number.”
Follow-up question: The manager says: “I need one number for the roadmap, no ranges.” What do you do?
Follow-up answer:
- Give a number, but say clearly what it assumes. For example: “8 days, assuming CSV-like scope and background export.”
- Pick a number near the high end of the range, not the most optimistic one.
- Offer a smaller first version (for example CSV only) that can ship early.
- Write the assumptions down. If one changes, the estimate changes too, and everyone knows why.
- Update the estimate early when you learn something new. Bad news early is much better than bad news on the last day.
Red flag: Gives a confident single number with no questions, or refuses to give any estimate at all.
22. A binary search in code review
Algorithms and data structures· in roadmap
Question: A teammate wrote a function to find a product id in a sorted list. The list has about 50,000 ids. The code is:
def binary_search(a: list[int], target: int) -> int:
low = 0
high = len(a) - 1
while low < high:
mid = (low + high) // 2
if a[mid] == target:
return mid
if a[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
The tests search for a few ids in the middle of a list of 10 items, and they pass. You see this in code review. What do you think? Which tests would you add?
Short answer: There is an off-by-one bug. Here high is inclusive: it points to a real item. So the search range is from low to high, both included. When low equals high, there is still one item to check. But the loop condition “low < high” stops the loop, so that last item is never compared. The fix is “while low <= high”.
Hint (if the candidate says there is no problem): Try a list with one item, 5, and search for 5. Then try a list with two items, 1 and 3, and search for 3. What does the function return?
Why does this happen? (step by step)
- The list is 1 and 3, and the target is 3. Start: low is 0 and high is 1.
- The loop runs. mid is 0. The item at index 0 is 1, which is smaller than 3. So low becomes 1.
- Now low is 1 and high is 1. The range still has one item: the item at index 1, which is 3.
- But “1 < 1” is false. The loop stops and returns -1.
- With the one-item list it is worse: low is 0, high is 0, and the loop never runs at all.
Think in invariants: An invariant is a rule that is true at every step of the loop. Here the rule is: “if target is in the list, it is somewhere between low and high, both included”. With this rule:
- The range is empty only when low > high. So the loop must run while low <= high.
- After checking mid, we must remove mid from the range. So we use mid + 1 and mid - 1.
A second common bug: Some versions use a half-open range: high starts at the length of the list (not length minus one), the loop runs while low < high, and the else branch sets high to mid. That is also correct. But if someone sets low to mid instead of mid + 1, the loop can run forever. When high is exactly low + 1, mid equals low, so low never moves.
The fix:
def binary_search(a: list[int], target: int) -> int:
low, high = 0, len(a) - 1
while low <= high:
mid = low + (high - low) // 2
if a[mid] == target:
return mid
if a[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
Tests to add (the edge cases):
- Empty list.
- One item: found and not found.
- Target is the first item, and the last item.
- Target is smaller than all items, bigger than all items, and between two items.
- Two items, searching for each one.
Why low + (high - low) // 2? In Python, int has no size limit, so low + high cannot overflow. But in languages with fixed-size ints, like Java or C#, low + high can be bigger than the max int and become negative. This is a well-known bug that existed for years in the binary search of Java’s standard library. The form low + (high - low) / 2 avoids it.
Follow-up question: Would you write your own binary search in a real project?
Follow-up answer:
- Usually not. Python has the bisect module, which is tested and fast. Other languages have similar functions.
- It is easy to get binary search wrong, as this code shows. A library removes that risk.
- If the list changes often, a sorted list may be the wrong data structure. A set or a dict gives O(1) lookups on average.
- Writing it yourself still makes sense in an interview, or when the search is on something special, like “the first day when sales went over a limit”.
Red flag: Only tests the “happy path” in the middle of the list, and never thinks about empty input, one item, or the ends of the list.
23. Money transfer with two locks
Question: A small banking service keeps accounts in memory. Many worker threads handle transfer requests at the same time. Each account has its own lock, so transfers between different accounts can run in parallel. The code is:
import threading
class Account:
def __init__(self, account_id: int, balance: int):
self.id = account_id
self.balance = balance
self.lock = threading.Lock()
def transfer(src: Account, dst: Account, amount: int) -> None:
with src.lock:
with dst.lock:
src.balance -= amount
dst.balance += amount
You see this in code review. What do you think? Is there a problem?
Short answer: This code can deadlock. If one thread moves money from a to b and another thread moves money from b to a at the same time, the first takes the lock of a and waits for b. The second takes the lock of b and waits for a. Both wait forever. The usual fix is a global lock order: always lock the account with the smaller id first.
Hint (if the candidate says there is no problem): In tests everything works. In production, a few times a week, the service stops answering. CPU is near 0%. A thread dump shows two worker threads, each waiting to get a lock. Restarting the service fixes it for a while.
Why does this happen? (step by step)
- Thread 1 starts a transfer from a to b. It takes the lock of a.
- At the same moment, thread 2 starts a transfer from b to a. It takes the lock of b.
- Thread 1 now wants the lock of b. Thread 2 holds it, so thread 1 waits.
- Thread 2 now wants the lock of a. Thread 1 holds it, so thread 2 waits.
- Nobody will ever let go. Both threads are stuck forever, and so is every other request that needs a or b.
It is rare because the timing must be exact. That is why tests do not catch it.
The four conditions for deadlock: A deadlock can happen only when all four are true at the same time:
- Mutual exclusion: only one thread can hold a lock.
- Hold and wait: a thread holds one lock while it waits for another.
- No preemption: nobody can take a lock away from a thread.
- Circular wait: thread 1 waits for thread 2, and thread 2 waits for thread 1.
If we break any one of them, deadlock cannot happen. The easiest one to break is usually circular wait.
The fix: a global lock order
def transfer(src: Account, dst: Account, amount: int) -> None:
if src.id == dst.id:
return
first, second = (src, dst) if src.id < dst.id else (dst, src)
with first.lock:
with second.lock:
src.balance -= amount
dst.balance += amount
- Now a transfer from a to b and a transfer from b to a both lock the smaller id first.
- So both threads compete for the same first lock. One wins, the other waits without holding anything.
- There is no circle, so there is no deadlock.
- We also handle a transfer to the same account. Without that check, the thread would try to take the same lock twice and block itself, because a normal Lock is not reentrant.
Another option: timeouts. A thread can try the second lock with a timeout. If it fails, it releases the first lock, waits a short random time, and tries again. This breaks “hold and wait”, but it is more complex and can cause retries under load. A clear lock order is usually simpler.
The same idea in databases: Two transactions can update the same two rows in opposite order. Databases like PostgreSQL, MySQL and SQL Server detect this, cancel one transaction with a deadlock error, and let the other continue. So the application should:
- Update rows in a fixed order, for example by account id.
- Retry the transaction when it gets a deadlock error.
Follow-up question: Why not use one big global lock for all transfers? It cannot deadlock.
Follow-up answer:
- True, one lock cannot deadlock, and the code is simpler.
- But only one transfer can run at a time. With many threads, they all wait in line, so throughput drops.
- If traffic is low, one lock may be good enough. Simple and correct is better than fast and broken.
- If we need parallel transfers, we keep per-account locks with a fixed order.
Red flag: Says “add a sleep” or “just restart when it hangs”, or cannot explain why the opposite lock order is the cause.
24. Withdraw money from a wallet
Transactions and isolation levels· in roadmap
Question: A wallet app lets users withdraw money. The service runs on 4 instances behind a load balancer, and all of them use one PostgreSQL database. The withdraw code is:
def withdraw(conn, user_id: int, amount: int) -> None:
with conn.transaction():
row = conn.execute(
"SELECT balance FROM wallets WHERE user_id = %s", (user_id,)
).fetchone()
if row.balance < amount:
raise InsufficientFunds()
conn.execute(
"UPDATE wallets SET balance = %s WHERE user_id = %s",
(row.balance - amount, user_id),
)
The code runs inside a transaction, and the tests pass. You see this in code review. What do you think? Is there a problem?
Short answer: This is a check-then-act race, also called a lost update. The check (read the balance) and the act (write the new balance) are two separate steps. Two withdrawals at the same time can both read the same old balance, both pass the check, and both write. The transaction does not stop this at the default isolation level (READ COMMITTED in PostgreSQL). The best fix is one atomic UPDATE that checks and changes in a single statement.
Hint (if the candidate says there is no problem): A user has 100 in the wallet. They tap “withdraw 80” twice very fast, or a mobile client retries. Sometimes both requests succeed. Now the balance is either -60 or 20, but the user got 160. Which one depends on timing.
Why does this happen? (step by step)
- Request A reads the balance: 100.
- Request B, on another instance, reads the balance: also 100. A has not written yet.
- A checks 100 >= 80. OK. B checks 100 >= 80. Also OK.
- A writes 100 - 80 = 20. B writes 100 - 80 = 20.
- Two withdrawals of 80 happened, but the balance shows 20. One update was lost. If the code wrote “balance = balance - 80” instead, the balance would be -60.
The transaction alone does not help. At READ COMMITTED, each statement sees the latest committed data, but nothing locks the row between the SELECT and the UPDATE.
The fix 1 (best): one atomic UPDATE
UPDATE wallets
SET balance = balance - %(amount)s
WHERE user_id = %(user_id)s AND balance >= %(amount)s;
- The database does the check and the change together, on a locked row.
- If the second request comes at the same time, it waits for the first one, then sees the new balance, 20.
- Now 20 >= 80 is false, so zero rows are updated.
- In the code, if the number of updated rows is 0, we raise InsufficientFunds.
The fix 2: lock the row with SELECT FOR UPDATE
SELECT balance FROM wallets WHERE user_id = %s FOR UPDATE;
The row stays locked until the transaction ends. The second request waits at this line. This is useful when the logic between read and write is too complex for one UPDATE. Keep the transaction short, because others wait.
The fix 3: optimistic concurrency with a version column
UPDATE wallets SET balance = %s, version = version + 1
WHERE user_id = %s AND version = %s;
We read the balance and the version. When we write, we require the same version. If someone else changed the row in between, 0 rows are updated, and we read again and retry. This is good when conflicts are rare.
A safety net: Add a CHECK constraint so the balance can never be below 0. Then even a future bug cannot make it negative.
Follow-up question: Can we fix it just by raising the isolation level to SERIALIZABLE?
Follow-up answer:
- Yes, SERIALIZABLE makes the database act as if transactions ran one by one. One of the two withdrawals will fail with a serialization error.
- But the application must catch that error and retry the transaction. Many teams forget this.
- Under heavy load, there can be many failed transactions and retries.
- For this simple case, the atomic UPDATE is clearer and cheaper. It is also safe at the default level.
Red flag: Thinks “it is inside a transaction, so it is safe”, or proposes a lock in the Python code, which does not help with 4 instances.
25. A cache for user profiles
Question: An API service shows user profiles. Building a profile needs 3 database queries, so a teammate added a cache “for speed”. The service has about 2 million users, and it runs for weeks between deploys. The code is:
_profile_cache: dict[int, dict] = {}
def get_profile(user_id: int) -> dict:
if user_id in _profile_cache:
return _profile_cache[user_id]
profile = build_profile(user_id) # 3 database queries
_profile_cache[user_id] = profile
return profile
Response times became much better. You see this in code review. What do you think? Is there a problem?
Short answer: The cache has no size limit and no expiry. Every new user id adds an entry, and nothing ever removes one. So memory grows for as long as the process lives. An unbounded cache is a memory leak. We need a bound: a max size with an eviction rule like LRU, a time to live (TTL), or an external cache like Redis.
Hint (if the candidate says there is no problem): After a deploy, memory is 300 MB. Every day it grows a bit more. After about 4 days, the container hits its memory limit and gets killed (OOM). It restarts, and the cycle starts again. Also, some users say they changed their name, but the old name still shows.
Why does this happen? (step by step)
- Each call with a new user id adds one profile to the dict.
- The dict is at module level, so it lives as long as the process.
- Nothing deletes entries. Over days, more and more different users visit.
- With 2 million users, the dict can end up holding millions of profiles.
- Memory grows until the container limit, and the process is killed.
Two more problems:
- Stale data: when a user changes their profile, the cache still returns the old copy. There is no expiry and no invalidation.
- Many processes: if the service runs 8 worker processes, each one has its own copy of the cache. So the memory use is 8 times bigger, and different workers can return different data.
The fix:
- For a simple in-process cache with a size limit, use the lru_cache decorator from functools with a maxsize. When it is full, it removes the least recently used entry.
from functools import lru_cache
@lru_cache(maxsize=10_000)
def get_profile(user_id: int) -> dict:
return build_profile(user_id)
- Note: lru_cache has no expiry. If data changes, add a TTL. The third-party cachetools library has a TTLCache class with both a max size and a time to live.
- One more trap: lru_cache returns the same dict object every time. If a caller changes it, the cached value changes for everyone. Return a copy or an immutable object.
- If several processes or servers need the same data, use an external cache like Redis with a TTL on each key. Then there is one copy, and memory is outside the app process.
How to measure and prove it:
- Watch the process memory over time on a dashboard. A line that only goes up is a warning sign.
- Count the cache entries. For lru_cache, the cache_info method shows hits, misses and current size.
- In Python, the tracemalloc module can show which lines of code allocate the most memory.
Follow-up question: How do you choose the max size and the TTL?
Follow-up answer:
- Max size: estimate the size of one entry and decide how much memory the cache may use. For example, if one profile is about 2 KB and we allow 50 MB, that is about 25,000 entries.
- Then check the hit rate in production. If it is already high, a bigger cache adds little.
- TTL: ask the business how old the data may be. Showing a 5-minute-old name may be fine. A 5-minute-old account balance is not.
- On every profile update, also delete the cache key, so users see their own change at once.
Red flag: Adds caches without a size limit or expiry, or solves the problem by “giving the container more memory”.
26. Retrying calls to a payment provider
Resilience with Polly· in roadmap
Question: An online shop calls an external payment provider over HTTP. At peak time the shop sends about 200 payment calls per second. Sometimes the provider fails, so a teammate added retries:
def charge(order_id: str, amount: int) -> dict:
for attempt in range(10):
try:
resp = http.post(PROVIDER_URL, json={"order": order_id, "amount": amount}, timeout=5)
resp.raise_for_status()
return resp.json()
except Exception:
continue
raise PaymentFailed(order_id)
You see this in code review. What do you think? Is there a problem?
Short answer: There are several problems:
- Retries happen immediately, with no wait. During an outage this multiplies traffic by up to 10 and keeps the provider from recovering. We need exponential backoff with jitter.
- It retries every error, even errors that will never succeed, like 400 Bad Request.
- A payment is not safe to repeat by default. If the first call charged the card but the answer was lost (timeout), a retry can charge the customer twice. We need an idempotency key.
- There is no retry budget or circuit breaker to stop retrying when the provider is clearly down.
Hint (if the candidate says there is no problem): One day the provider has a short problem for 2 minutes. Our traffic to them jumps from 200 to almost 2,000 calls per second. Their problem lasts 40 minutes instead of 2. Later, support gets emails from customers who were charged twice.
Why does this happen? (step by step)
- The provider slows down and starts failing.
- Every failed call is sent again at once, up to 10 times. So each real payment becomes up to 10 calls.
- The provider now gets about 10 times more load, exactly when it is weakest.
- More load means more failures, which means even more retries. This is called a retry storm.
- If other layers also retry (the mobile app, an API gateway), the numbers multiply: 3 layers that each try 3 times can turn one click into 27 calls.
The fix:
- Backoff with jitter: wait longer after each failure, for example about 0.2, 0.4, 0.8 seconds, with a random part. The random part stops all clients from retrying at the same moment.
- Retry only retryable errors: timeouts, connection errors, 503, and 429 Too Many Requests. Respect the Retry-After header if the provider sends it. Do not retry 400, 401, or a “card declined” answer.
- Fewer attempts: 3 is usually enough. 10 attempts mostly add load.
- Idempotency key: send the same unique key (for example, based on the order id) with every attempt, if the provider supports it. Then the provider charges only once, even if it gets the request twice.
import random, time
RETRYABLE = {429, 502, 503, 504}
def charge(order_id: str, amount: int) -> dict:
headers = {"Idempotency-Key": f"charge-{order_id}"}
for attempt in range(3):
try:
resp = http.post(PROVIDER_URL, json={"order": order_id, "amount": amount},
headers=headers, timeout=5)
if resp.status_code not in RETRYABLE:
resp.raise_for_status() # 4xx: fail fast, no retry
return resp.json()
except (TimeoutError, ConnectionError):
pass
time.sleep(random.uniform(0, 0.2 * 2 ** attempt)) # backoff with full jitter
raise PaymentFailed(order_id)
Retry budget and circuit breaker:
- Retry budget: retries may be at most a small share of all calls, for example 10%. If the budget is used up, we stop retrying and fail fast.
- Circuit breaker: if many calls fail in a short time, the breaker “opens”. For a while, we do not call the provider at all and fail fast. After a pause, we let a few test calls through (half-open). If they succeed, we close the breaker again.
- Both protect the provider and also protect our own threads from waiting on a dead service.
Follow-up question: The breaker is open. What does the customer see at checkout?
Follow-up answer:
- Not a raw error page. Show a clear message: “Payment is temporarily unavailable, please try again in a few minutes.”
- If the business allows it, save the order as “pending payment” and finish it later from a queue.
- If there is a second payment provider, we could switch to it, but that is a business and contract decision.
- Alert the on-call engineer, because an open breaker means real money is not coming in.
Red flag: Thinks “more retries means more reliable”, or retries a payment with no idempotency key and no thought about double charges.
27. A test that fails sometimes in CI
CI/CD· in roadmapIntegration tests· in roadmap
Question: Your team has about 600 tests. One integration test, for creating an order, fails about 1 in 10 runs in CI. Nobody has ever seen it fail on a laptop. When it fails, the team clicks “re-run”, and it usually passes. CI runs the tests in parallel workers, against one shared test database. You join the team as a senior engineer. What do you think? What would you do?
Short answer: A flaky test is a real problem, not bad luck. Either the test is broken, or the code has a real bug (for example a race condition) that only shows up sometimes. Re-running hides it and teaches the team to ignore red builds. The right way is to reproduce it, find the cause, and fix it. If it cannot be fixed today, quarantine it, but only with an owner and a deadline.
Hint (if the candidate says there is no problem): Last month a real bug reached production. The CI had shown a red build for that change, but people thought “it’s the flaky test again” and re-ran it. Also, each re-run costs the team about 15 minutes of waiting.
Common causes of flaky tests:
- Shared state between tests: one test leaves data in the database or in a global variable, and another test depends on it.
- Order dependence: a test passes only if another test ran before it (or did not run).
- Parallel runs on the same database: two workers create an order with the same email or id at the same time, or one worker deletes rows another worker needs.
- Timing and sleep: the test sleeps 1 second and hopes a background job is done. On a slow CI machine, it is not.
- Real time: the test uses the current date, and fails near midnight, at month end, or in another time zone. CI servers often run in UTC.
- Network: the test calls a real external service, which is sometimes slow or down.
- Random data or unordered results: for example, a query with no ORDER BY that usually, but not always, returns rows in the same order.
Why does it pass locally? (step by step)
- Locally, tests usually run one by one, in a fixed order, on a fast machine.
- In CI, they run in parallel, maybe in a different order, on a busy shared machine.
- So problems caused by sharing, ordering, or timing only appear in CI.
How to find the cause:
- Run the test many times in a loop, locally and in CI, until it fails. The goal is to make it fail on purpose.
for i in $(seq 1 200); do pytest tests/test_orders.py -x -q || break; done
- Randomize the test order (for example, with a plugin like pytest-randomly), and run with parallel workers like CI does.
- Make failures easy to read: log the test data, the time, the worker id, and the full error.
- Look at the failure history. Does it fail with a certain worker, or at a certain time of day?
How to fix it:
- Give each test its own data: unique ids and emails, or a transaction that is rolled back after each test, or one database (schema) per worker.
- Replace sleep with waiting for a clear condition, with a timeout.
- Inject a clock, so tests control the time.
- Replace real external calls with a fake or a stub server.
- If the cause is a real race condition in the code, fix the code. The flaky test found a real bug.
Follow-up question: The fix will take a week. The release is tomorrow. What do you do?
Follow-up answer:
- Quarantine the test: move it out of the blocking set, so it still runs but does not block merges.
- Create a ticket with a named owner and a deadline, for example two weeks.
- Make sure the behavior it tested is still covered somehow, even manually for this release.
- Track the quarantine list. If it only grows, the team is just ignoring tests with extra steps.
Red flag: Says “it’s just flaky, re-run it” or adds automatic retries to all tests without ever looking for the cause.
28. Checkout errors at 2 a.m.
Metrics and alerts· in roadmapStructured logging· in roadmap
Question: You are the on-call engineer for an online shop. It is 2 a.m. Your phone rings with an alert: the error rate on the checkout API is 30%. Normally it is under 1%. A deploy of the checkout service went out 20 minutes ago. Customers in other time zones are shopping right now. Walk me through what you do, step by step.
Short answer: First stop the damage, then find the cause. The deploy 20 minutes ago is the most likely cause, so the first move is usually to roll back (or turn off the new feature flag), even before we fully understand the bug. In parallel, communicate: tell others an incident is open. When the error rate is back to normal, investigate the root cause with logs, metrics and traces. Later, write a blameless post-mortem with clear action items.
Step 1: Confirm and take ownership (first few minutes)
- Acknowledge the alert, so others know someone is on it.
- Check the dashboard quickly: is it really 30%? Is it all checkout calls, or one region, one payment method, one app version?
- Open an incident channel (or follow the team’s incident process), and write one line: “Investigating checkout errors at 30%, started after the 01:40 deploy.”
Step 2: Mitigate first (why?)
- Every minute, about 3 in 10 customers fail to pay. That is lost money and lost trust.
- The timing points strongly at the deploy. A rollback is usually fast, well-known, and safe.
- Finding the exact bug at 2 a.m. can take an hour. Rolling back can take a few minutes.
- So we roll back first, and we debug later, in calm conditions.
Before rolling back, check one thing: did the deploy include a database migration? If the new code changed the schema, the old code may not work with it. Then roll back carefully, or use a feature flag instead.
Step 3: Verify
- Watch the error rate after the rollback. Did it go back under 1%?
- If not, the deploy was maybe not the cause. Look at other changes: a dependency (payment provider, database), traffic, config, certificates, or infrastructure.
Step 4: Communicate
- Post short, regular updates in the incident channel, for example every 15 to 30 minutes, even if the update is “still investigating”.
- If the impact is big, wake up a second person or the incident lead. Asking for help is normal, not weakness.
- If there is a status page or a support team, tell them, so they can answer customers.
Step 5: Find the root cause (after mitigation)
- Logs: filter by the checkout service and the error. What is the error message? Which line?
- Traces: follow one failed request. Where does it fail: our code, the database, or the payment provider?
- Metrics: compare before and after the deploy: latency, database connections, memory.
- Diff: read the code change in the deploy. Often the cause is visible there.
Step 6: Blameless post-mortem
- Write a timeline: when it started, when we detected it, when we mitigated it.
- Focus on the system, not the person: “Why did our tests and our canary not catch this?” and not “Who broke it?”
- People hide problems when they are blamed. Blameless reviews make people share the real story.
- Create action items with owners and dates, for example: add a test, add a canary step, add an alert on checkout errors per payment method.
Follow-up question: The rollback fixed it. Do we also need to do anything about the 20 minutes of failed checkouts?
Follow-up answer:
- Yes. Check if any payments were charged but the order was not saved, or the other way around. That data must be fixed.
- Find the affected customers. The business may decide to contact them or offer something.
- Check whether some requests were retried and created duplicates.
- Add these checks to the post-mortem, so the next incident has a clear cleanup list.
Red flag: Starts debugging the code for an hour while customers keep failing, works alone without telling anyone, or looks for someone to blame.
29. Design an LRU cache
Algorithms and data structures· in roadmapCaching patterns· in roadmap
Question: Design a cache class with a fixed capacity N. It has two operations:
- get, with a key: return the value if the key is in the cache, otherwise return nothing.
- put, with a key and a value: add or update the value. If the cache is full, remove the least recently used item first.
Both operations must run in O(1) time. A “use” means a get or a put of that key. Explain your data structures, then write the code in Python.
Short answer: Use two structures together:
- A hash map from key to node. It finds any item in O(1).
- A doubly linked list of nodes, ordered by last use. The most recently used is at the front, the least recently used at the back. Moving or removing a node is O(1), because each node knows its previous and next node.
On get, we find the node in the map and move it to the front. On put, we add or update the node at the front. If we are over capacity, we remove the node at the back, and also its key from the map.
Why not something simpler? (step by step)
- A hash map alone is O(1) for lookup, but it does not know which item is the oldest. Finding it means scanning everything: O(N).
- A list or array alone keeps the order, but finding a key in it is O(N), and removing from the middle is O(N).
- A singly linked list cannot remove a node in O(1), because we need the previous node.
- A doubly linked list plus a map gives us both: fast lookup and fast reordering.
Data model:
- Each node has: key, value, prev, next.
- The node stores the key too. When we remove the last node, we need its key to delete it from the map.
- Two dummy nodes, head and tail, sit at the ends. Then we never need special cases for an empty list.
Implementation:
class Node:
__slots__ = ("key", "value", "prev", "next")
def __init__(self, key=None, value=None):
self.key, self.value = key, value
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity: int):
if capacity <= 0:
raise ValueError("capacity must be positive")
self.capacity = capacity
self.map: dict = {}
self.head, self.tail = Node(), Node() # dummy nodes
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, node: Node) -> None:
node.prev.next, node.next.prev = node.next, node.prev
def _add_front(self, node: Node) -> None:
node.prev, node.next = self.head, self.head.next
self.head.next.prev = node
self.head.next = node
def get(self, key):
node = self.map.get(key)
if node is None:
return None
self._remove(node)
self._add_front(node) # mark as most recently used
return node.value
def put(self, key, value) -> None:
node = self.map.get(key)
if node is not None:
node.value = value
self._remove(node)
else:
if len(self.map) == self.capacity:
lru = self.tail.prev # least recently used
self._remove(lru)
del self.map[lru.key]
node = Node(key, value)
self.map[key] = node
self._add_front(node)
The short way in Python: OrderedDict from the collections module keeps keys in insertion order, and inside it keeps a hash map plus a doubly linked list, the same idea as above. Its move_to_end method marks a key as recently used, and popitem with last set to False removes the oldest key. Both are O(1). For a real project, the lru_cache decorator in functools already does this for function results. In an interview, explain the structure even if you use OrderedDict.
Edge cases to test: capacity 1, get of a missing key, put of an existing key (it must update and move to front, not grow the size), and a get that saves an item from eviction.
Follow-up question: Many threads will use this cache at the same time. Is it safe? How do you fix it?
Follow-up answer:
- It is not safe. A put changes several pointers and the map in a few steps. If two threads do this at once, the list can break, for example a node that is in the map but not in the list.
- Note: even get changes the list, because it moves the node to the front. So reads need protection too. A read-write lock does not help much here.
- The simple fix: one lock around the body of get and put. Each call is short, so this is often fast enough.
- If the lock becomes a bottleneck, split the cache into several shards by key hash, each with its own lock and its own capacity. The cost: eviction is then “LRU per shard”, not exact global LRU.
Red flag: Uses a list and a scan for eviction and calls it O(1), or forgets to move an item to the front on get.
30. Design a large file upload service
System design· in roadmapAPI design· in roadmap
Question: Design a service where users upload files: videos and documents, up to 5 GB each. There are about 100,000 uploads per day. Many users are on mobile phones with unstable networks, so connections drop often. After upload, files must be checked for viruses, and videos need a preview image. Users can later download their own files. Walk me through your design.
Short answer: Do not send the file bytes through our API servers. The client asks our API for permission, and gets pre-signed URLs. Then it uploads the file directly to object storage (like Amazon S3) in parts (multipart upload). If the network drops, only the failed part is sent again. A metadata database keeps the file record and its status. When the upload finishes, a message goes to a queue, and background workers do the virus scan and the processing.
Requirements and numbers:
- 100,000 uploads per day is about 1.2 uploads per second on average. Peaks may be several times higher.
- Assume (we must ask) an average file of 50 MB. That is about 5 TB of new data per day, and almost 2 PB per year. Storage cost and retention rules matter.
- 5 TB per day is about 58 MB per second on average, all day. If this went through our API servers, they would spend their time copying bytes.
- One upload of 5 GB on a weak mobile network can take a long time. It must survive many disconnects.
Main parts:
- Upload API: checks the user, checks limits (size, type, quota), creates a file record, and returns pre-signed URLs.
- Object storage: stores the bytes. Very cheap per GB, very durable, scales without our work.
- Metadata database: one row per file.
- Queue and workers: virus scan, preview image, maybe video transcoding.
- Download: the API checks permission and returns a short-lived pre-signed download URL, or a CDN URL.
The upload flow (step by step):
- The client sends: file name, size, type. The API creates a record with status “uploading” and starts a multipart upload.
- The API returns pre-signed URLs for the parts (for example, parts of 10 MB). Each URL works only for a short time and only for that one part.
- The client uploads parts, a few in parallel. It saves which parts are done. After a disconnect, it asks the API which parts are missing and sends only those.
- When all parts are done, the client calls “complete”. The API tells storage to join the parts, sets the status to “scanning”, and puts a message on the queue.
- A worker scans the file. If it is clean, status becomes “ready”. If not, the file is deleted or moved to quarantine, and status becomes “rejected”.
- Users can download only files with status “ready”.
Data model (one table):
CREATE TABLE files (
id UUID PRIMARY KEY,
owner_id BIGINT NOT NULL,
storage_key TEXT NOT NULL, -- random key, not the user's file name
file_name TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
content_type TEXT NOT NULL,
status TEXT NOT NULL, -- uploading, scanning, ready, rejected
created_at TIMESTAMPTZ NOT NULL
);
The hard parts and trade-offs:
- Why multipart is a must here: for example, in S3 a single upload request can be at most 5 GB, and one failure means starting again from zero. Multipart upload allows up to 10,000 parts, and each part is retried on its own. An open protocol like tus is another option for resumable uploads.
- Abandoned uploads: many uploads will never finish. Their parts still cost money. Use a storage lifecycle rule to abort unfinished multipart uploads after a few days, and a cleanup job for “uploading” records that are too old.
- Security: never trust the client’s file type. Check the real content (magic bytes). Use random storage keys, so nobody can guess a URL. Keep the bucket private. Keep pre-signed URLs short-lived. Never serve a file before the scan says it is clean.
- Limits: max file size, a per-user quota, and a rate limit on starting uploads. Check size both when the upload starts and when it completes, because the client can lie.
- Async processing: scanning a 5 GB file is slow. A queue lets workers scale on their own, and a failed scan can be retried without the user uploading again.
How it scales: The API only handles small JSON calls, so a few instances are enough. Object storage scales by itself. Workers scale with the queue length. The metadata table grows by about 36 million rows per year, which one well-indexed database handles fine (index on owner_id and created_at).
Follow-up question: Uploads from users far away, for example on another continent, are very slow. What can you do?
Follow-up answer:
- Use storage in more than one region, and give each user pre-signed URLs for the nearest region.
- Some providers offer an accelerated upload feature that routes traffic through edge locations. It costs extra, so measure first.
- Tune the part size: smaller parts are better on unstable networks, bigger parts mean fewer requests.
- For downloads, a CDN helps a lot, but only for files that are downloaded often.
Red flag: Sends 5 GB files through the API servers in one HTTP request, or makes files public and serves them before the virus scan.
Not saved in this browser.
Result link for the candidate
This link is private. Anyone who has it can see the result (without private notes).