Interview Question Banks

Python Interview Questions · questions with model answers

Fresher 0–1 years

20 questions · every code snippet and expected output was run and checked on Python 3.12.

In progress: 20 of 241 questions written so far. More are being added.

Easy (E)

FR-E-O01 · Indexing and slicing a city name

Easy · Output · Fresher (0–1 years) · strings, indexing, slicing · 3 min

Question: What does this print?

city = "Hyderabad"
print(city[0], city[-1])
print(city[2:5])
print(city[:3] + city[-3:])
print(city[::-1])
print(city[4:100], len(city))

Expected output:

H d
der
Hydbad
dabaredyH
rabad 9

Explanation:

A string is a sequence of characters indexed from 0. Negative indices count from the end, so city[-1] is the last character, d.

A slice s[start:stop] includes start and excludes stop: city[2:5] takes indices 2, 3 and 4, giving "der". Leaving out start means "from the beginning" and leaving out stop means "to the end", so city[:3] is "Hyd", city[-3:] is the last three characters "bad", and + joins them into "Hydbad". An optional third number is the step: [::-1] walks the whole string backwards, the usual one-liner for reversing it.

Slices are forgiving about bounds: city[4:100] just stops at the end and gives "rabad". Indexing a single position that doesn't exist (city[100]) raises IndexError instead. Every slice builds a new string; city itself never changes.

Takeaway: stop is exclusive, negative indices count from the end, and slices clip silently where single indexes raise.

Follow-ups:

  1. "What does city[100] do, and why is that different from city[4:100]?" It raises IndexError: string index out of range. A slice is defined as "whatever exists in this range", so it clips to the valid part; that's why name[:10] is safe on a short name, while asking for one specific missing position is an error.
  2. "Can you change the first letter with city[0] = "h"?" No: TypeError: 'str' object does not support item assignment. Strings are immutable. Build a new string instead, for example "h" + city[1:], and rebind the name to it.
  3. "How would you check whether a word such as "Malayalam" is a palindrome?" Normalise the case, then compare with the reverse: w = word.lower() and w == w[::-1], which is True for "Malayalam". Forgetting .lower() makes it False because M and m differ.

Scoring: Strong: All five lines exact; explains the exclusive stop and negative indices unprompted, and knows a slice clips where an index raises. Acceptable: One off-by-one slip (for example "dera" for [2:5]) that they fix after restating the rule. Red flag: Counts indices from 1, or expects city[4:100] to raise an error.

FR-E-O02 · Skipping and stopping in an order loop

Easy · Output · Fresher (0–1 years) · control flow, for loops, continue, break · 3 min

Question: What does this print?

orders = [250, 0, 1200, -50, 800, 3000, 150]
total = 0
for amount in orders:
    if amount <= 0:
        continue
    if amount > 2000:
        print("Needs approval:", amount)
        break
    total += amount
print("Total:", total)

Expected output:

Needs approval: 3000
Total: 2250

Explanation:

The loop visits the order amounts (in ₹) one by one, and two statements change its normal flow:

  • continue skips the rest of this iteration and moves to the next item. The invalid amounts 0 and -50 never reach total += amount.
  • break leaves the whole loop immediately. At 3000 the message is printed and the loop ends, so 150 is never looked at.

Only 250, 1200 and 800 are added: 250 + 1200 + 800 = 2250. The last print is outside the loop, so it runs once, after the break.

Takeaway: continue skips one item; break stops the loop, and nothing after the break point is processed.

Follow-ups:

  1. "The manager wants a list of every order above ₹2,000, not just the first one. What do you change?" Remove the break (otherwise processing stops at the first big order) and collect them: big = [a for a in orders if a > 2000]. Whether to stop at the first match or scan everything is a requirement question, so ask which one is wanted.
  2. "Rewrite the loop without continue." Nest the work inside if amount > 0:. Both are correct. continue used as an early "guard" keeps the main logic less indented, which reads better when there are several checks.
  3. "What would an else: block attached to this for loop do?" A for ... else runs the else block only if the loop finished without hitting break, for example to print "All orders auto-approved". Here the loop breaks at 3000, so it would not run. It's a bonus point for a fresher, not a requirement.

Scoring: Strong: Both lines exact; clearly separates continue (skip one) from break (stop the loop) and notes that 150 is never seen. Acceptable: Gets the approval line but adds 150 or 3000 into the total, then corrects it when asked to trace again. Red flag: Thinks continue ends the loop, or that break only skips the current item.

FR-E-C01 · Validate a PAN on a KYC form

Easy · Coding · Fresher (0–1 years) · strings, slicing, input validation, string methods · 10 min

Problem:

A KYC form asks users for their PAN (Permanent Account Number). Write is_valid_pan(pan) that returns True if the value is well-formed:

  • exactly 10 characters,
  • the first 5 are letters A–Z,
  • the next 4 are digits 0–9,
  • the last one is a letter A–Z.

For example, ABCPE1234F is well-formed. Users often type in lowercase and paste stray spaces, so ignore leading and trailing whitespace and accept lowercase letters (the form saves the value upper-cased). Only plain ASCII letters and digits count. You only check the format, not whether the PAN actually exists.

Signature: def is_valid_pan(pan: str) -> bool:

Constraints: pan is any str, possibly empty. Use either plain string operations or the re module.

Examples:

  • is_valid_pan("ABCPE1234F") → True
  • is_valid_pan(" abcpe1234f ") → True
  • is_valid_pan("AB1PE1234F") → False
  • is_valid_pan("ABCPE1234") → False

Hints:

  1. Normalise first: which two string methods turn ' abcpe1234f ' into 'ABCPE1234F'?
  2. Once the length is right, slice the value into three parts ([:5], [5:9], [9]) and check each part on its own.
  3. Try 'AB1PE'.isupper() and '³'.isdigit() in the REPL before relying on them.

Reference solution:

import string


def is_valid_pan(pan: str) -> bool:
    """Return True if `pan` is a well-formed PAN: 5 letters, 4 digits, 1 letter.

    Surrounding whitespace is ignored and lowercase letters are accepted,
    because the form upper-cases the value before saving it. Only ASCII
    letters A-Z and digits 0-9 are valid.
    """
    pan = pan.strip().upper()
    if len(pan) != 10:
        return False
    letters, digits, last = pan[:5], pan[5:9], pan[9]
    return (all(ch in string.ascii_uppercase for ch in letters)
            and all(ch in string.digits for ch in digits)
            and last in string.ascii_uppercase)

Complexity: O(n) for strip()/upper() on the raw input, then O(1): after the length check, exactly 10 characters are examined. O(n) extra space for the normalised copy.

Tests:

assert is_valid_pan("ABCPE1234F") is True
assert is_valid_pan("  abcpe1234f ") is True          # trimmed and upper-cased
assert is_valid_pan("ABCPE1234") is False             # 9 characters
assert is_valid_pan("ABCPE12345F") is False           # 11 characters
assert is_valid_pan("") is False
assert is_valid_pan("AB1PE1234F") is False            # digit among the first five
assert is_valid_pan("ABCPE12X4F") is False            # letter among the digits
assert is_valid_pan("ABCPE12345") is False            # last character is a digit
assert is_valid_pan("ABC PE1234") is False            # space inside
assert is_valid_pan("ABCPE12³4F") is False            # superscript 3: isdigit() says True
assert is_valid_pan("ÀBCPE1234F") is False            # non-ASCII letter

A common wrong solution (the tests catch it):

def is_valid_pan(pan: str) -> bool:
    pan = pan.strip().upper()
    return (len(pan) == 10
            and pan[:5].isupper()
            and pan[5:9].isdigit()
            and pan[9].isupper())

Alternatives and tradeoffs:

  • Regular expression: re.fullmatch(r"[A-Z]{5}[0-9]{4}[A-Z]", pan.strip().upper()) is not None. This is the shortest version and states the format in one line, so it's the one to prefer once you're comfortable with regex. Use [0-9], not \d: \d also matches other scripts' digits, such as Devanagari १.
  • isalpha() / isdigit() read nicely but accept far more than ASCII: 'À'.isalpha() and '³'.isdigit() are both True. That's acceptable for "is this a word?", not for an ID format.
  • The broken version above uses pan[:5].isupper(), which is True for "AB1PE": isupper() only requires that the cased characters are uppercase, and digits have no case. It's a very common fresher mistake, and the test "AB1PE1234F" catches it.

Follow-ups:

  1. "The 4th character encodes the holder type: P individual, C company, H HUF, F firm, and a few more. Add that check." Keep the allowed set as a constant, for example HOLDER_TYPES = "PCHFATBLJG", and add and pan[3] in HOLDER_TYPES. Add tests: one valid PAN for a company (C) and one with an invalid 4th letter such as X.
  2. "The form should tell users what is wrong instead of just rejecting the value. How would you change the design?" Keep is_valid_pan for yes/no checks and add pan_errors(pan) -> list[str] that returns messages such as "must be 10 characters" or "characters 6-9 must be digits". An empty list means valid, and is_valid_pan can then be not pan_errors(pan), so the rules live in one place.
  3. "How would you test this function properly?" One test per rule, plus boundaries: valid; lowercase and spaces; length 9, 10 and 11; empty string; a digit in the letter part; a letter in the digit part; a digit at the end; a non-ASCII letter or digit. Theory question FR-TH-TST-05 goes deeper.

Scoring: Strong: Normalises with strip().upper(), checks the length first, then each part with explicit ASCII ranges or a [A-Z]/[0-9] regex; names test cases for wrong length and misplaced characters. Done in about 10 minutes. Acceptable: Correct for normal input but uses isalpha()/isdigit() (accepting non-ASCII); fixes it once shown '³'.isdigit(). Or forgets strip()/upper() until reminded of the requirement. Red flag: No length check, or pan[:5].isupper()-style checks that let digits through, with no idea why after being given "AB1PE1234F".

Easy & Tricky (ET)

FR-ET-O01 · Marks that are still strings

Easy & Tricky · Output · Fresher (0–1 years) · types, strings, string comparison, type conversion · 3 min

Question: What does this print?

marks = "78 9 100 85".split()
print(marks)
print(max(marks), min(marks))
print(sorted(marks))
print(marks[0] + marks[1])
print(sum(int(m) for m in marks))

Expected output:

['78', '9', '100', '85']
9 100
['100', '78', '85', '9']
789
272

The trap: 100 9, then ['9', '78', '85', '100'], then 87. Tempting because the values look like numbers, and the candidate reads them the way a person would. The quotes in the first line are the clue.

Explanation:

str.split() returns a list of strings, as the quotes in the first line show. Nothing has been converted to numbers yet.

Strings compare lexicographically: character by character, like words in a dictionary, using each character's code. Comparing "9" with "100" looks only at the first characters, and "9" comes after "1", so "9" is the largest string and "100" the smallest. sorted() uses the same rule: "100" < "78" < "85" < "9". + on two strings joins them: "78" + "9" is "789". Only the last line converts each item with int(), so sum gets real numbers: 78 + 9 + 100 + 85 = 272.

The same thing happens with every text source: input(), lines read from a file, the csv module, form fields. They all give str. Convert once, at the point where the data comes in: marks = [int(m) for m in text.split()].

Where this bites: a result-processing script for a college project reads marks from a CSV, "finds the topper" with max, and announces the student with 9 marks.

Takeaway: data read from text is text until you convert it; strings compare letter by letter, not by numeric value.

Follow-ups:

  1. "How could you find the highest mark without converting the whole list first?" max(marks, key=int) compares by int(m) and returns '100'. Note that the result is still the string '100'. Converting the list once at the start is usually cleaner, because every later step then works on numbers.
  2. "What do int("85.5") and int(" 85 ") return?" int("85.5") raises ValueError: invalid literal for int() with base 10: '85.5', because int() parses whole numbers only; use float("85.5"), or int(float("85.5")) if you really want 85. int(" 85 ") returns 85: surrounding whitespace is allowed.
  3. "And "5" * 3 versus "5" + 3?" "5" * 3 is '555' (repetition). "5" + 3 raises TypeError: can only concatenate str (not "int") to str. Python never silently converts between str and int.

Scoring: Strong: Spots the strings from the quotes in line 1; gets 9 100, the lexicographic sort and 789; explains character-by-character comparison and says to convert at input time. Acceptable: Realises they are strings partway through but slips on the sorted order, then fixes it when asked to compare "9" and "100" first character first. Red flag: Answers 100 9 and 87, and still can't explain the real output after being told that split() returns strings.

FR-ET-O02 · Writing an attendance file inside a loop

Easy & Tricky · Output · Fresher (0–1 years) · files, file modes, with statement · 3 min

Question: What does this print?

present = ["Asha", "Ravi", "Meera"]
for name in present:
    with open("attendance.txt", "w", encoding="utf-8") as f:
        f.write(name + "\n")

with open("attendance.txt", encoding="utf-8") as f:
    lines = f.readlines()
print(lines)
print(len(lines))

Expected output:

['Meera\n']
1

The trap: ['Asha\n', 'Ravi\n', 'Meera\n'] and 3. Tempting because each write adds a line, so the loop looks as if it builds up the file.

Explanation:

Opening a file with mode "w" creates it if it doesn't exist and truncates it to empty if it does. The with statement sits inside the loop, so the file is opened, emptied, written and closed three times. Each pass wipes the previous name, and only "Meera\n" survives.

The common modes:

  • "r" (default): read; FileNotFoundError if the file is missing.
  • "w": write from scratch; existing content is erased on open.
  • "a": append; creates the file if needed and always writes at the end.
  • "x": create a new file; FileExistsError if it already exists.

readlines() returns a list of lines and keeps each line's "\n", which is why the output shows 'Meera\n'. Use f.read().splitlines() or line.rstrip("\n") when you want clean lines.

Fix: open the file once, outside the loop, and write every name inside it.

Where this bites: a project's results or log file that only ever contains the last record, or a scraper that saves every page to the same file with "w".

Takeaway: "w" erases on every open; open once and write many times, or use "a" when you really mean append.

Follow-ups:

  1. "Fix the code so the file contains all three names, opening the file only once." with open("attendance.txt", "w", encoding="utf-8") as f: and loop inside it: for name in present: f.write(name + "\n"). Alternatively f.writelines(n + "\n" for n in present), or f.write("\n".join(present) + "\n").
  2. "If you switch the mode to "a" and run the script twice, what is in the file?" Six lines: Asha, Ravi, Meera, twice. "a" never clears the file, so re-running duplicates the data. Use "w" for an output that should be fresh on each run and "a" for a running log.
  3. "Why use with instead of f = open(...) and f.close()?" with closes the file even if an exception happens inside the block. A file that's never closed may not flush its buffered writes to disk, and on Windows it stays locked.

Scoring: Strong: ['Meera\n'] and 1 with the truncate-on-open reason; knows that readlines() keeps the newline and fixes it by opening once outside the loop. Acceptable: Says three names first, then spots the "w" mode when asked what "w" does on open; may miss the \n. Red flag: Can't name the difference between "w" and "a", or thinks the file holds Asha because "the first write wins".

Medium (M)

FR-M-O01 · Comprehensions over a marks dictionary

Medium · Output · Fresher (0–1 years) · list comprehensions, dict comprehensions, conditional expressions, dicts · 5 min

Question: What does this print?

marks = {"Asha": 82, "Ravi": 35, "Meera": 91, "Kabir": 40}
passed = [name for name, m in marks.items() if m >= 40]
grades = ["P" if m >= 40 else "F" for m in marks.values()]
grace = {name: m + 5 for name, m in marks.items() if m < 40}
initials = [name[0] for name in marks if len(name) > 4]
print(passed)
print(grades)
print(grace)
print(initials)

Expected output:

['Asha', 'Meera', 'Kabir']
['P', 'F', 'P', 'P']
{'Ravi': 40}
['M', 'K']

Explanation:

Concept 1: what iterating a dict gives you. for name in marks gives the keys, .values() gives the values, and .items() gives (key, value) pairs that can be unpacked as name, m. All three follow insertion order, which Python guarantees since 3.7, so the output order is predictable.

Concept 2: where the if goes.

  • An if after the for is a filter: it decides whether an item appears at all. In passed, Ravi (35) is dropped, and Kabir (exactly 40) stays because the test is >=.
  • An if ... else before the for is a conditional expression: every item appears, and the expression picks which value to produce. grades therefore has four entries, one per student.

grace is a dict comprehension with a filter: only Ravi is below 40, so the result is {'Ravi': 40}. initials filters on the name's length: "Asha" and "Ravi" have 4 letters (not > 4), so only Meera and Kabir contribute their first letters.

Takeaway: [x for x in xs if cond] keeps some items; [a if cond else b for x in xs] transforms every item.

Follow-ups:

  1. "What happens with [m for m in marks.values() if m >= 40 else 0]?" SyntaxError: invalid syntax. A filter if cannot have an else. To keep every item and replace failing marks with 0, move the conditional to the front: [m if m >= 40 else 0 for m in marks.values()].
  2. "Write passed as an ordinary for loop." passed = [], then for name, m in marks.items():, if m >= 40:, passed.append(name). Same result. A comprehension is preferred when it stays short and readable; switch back to a loop once it needs several conditions or side effects such as printing.
  3. "How would you get the topper's name from marks?" max(marks, key=marks.get) gives 'Meera': it iterates the keys and compares them by their values. On a tie, max returns the first one it saw, so if ties matter, say how they should be broken.

Scoring: Strong: All four lines exact, including Kabir passing at exactly 40 and only two initials; clearly explains filter vs conditional expression. Acceptable: One slip (for example including 'A' in initials) that they fix on re-tracing; understands both comprehension forms. Red flag: Thinks iterating a dict gives values, or can't explain why grades has four items but passed has three.

FR-M-O02 · Default arguments, keyword arguments, and print vs return

Medium · Output · Fresher (0–1 years) · functions, default arguments, keyword arguments, return values · 4 min

Question: What does this print?

def greet(name, greeting="Hello", end="!"):
    return f"{greeting}, {name}{end}"

def shout(text):
    print(text.upper())

print(greet("Ravi"))
print(greet("Meera", end="."))
print(greet(greeting="Namaste", name="Arjun"))
result = shout("hi")
print(result)

Expected output:

Hello, Ravi!
Hello, Meera.
Namaste, Arjun!
HI
None

Explanation:

Concept 1: defaults and keywords. Parameters with a default value are optional; anything the caller leaves out uses the default. greet("Ravi") uses both defaults. A keyword argument is matched by name, not position, so end="." changes only end and keeps the default greeting, and greet(greeting="Namaste", name="Arjun") works even though the order differs from the definition.

Concept 2: print vs return. greet returns a string, and the caller decides to print it. shout prints inside the function (the HI line appears at that moment) but has no return statement. A function that ends without return returns None, so result is None and the last line prints None.

Printing shows a value to a human; returning hands a value back to the code that called the function. A function that only prints can't be reused in a calculation or checked in a test.

Takeaway: keyword arguments match by name; a function without return gives back None, whatever it printed.

Follow-ups:

  1. "What does greet("Ravi", "Hi", "?", "!") do?" TypeError: greet() takes from 1 to 3 positional arguments but 4 were given. The function has three parameters, and Python checks the call against them before running any of the body.
  2. "Why is def greet(greeting="Hello", name): an error?" SyntaxError: parameter without a default follows parameter with a default. Positional arguments fill parameters left to right, so a required parameter after an optional one could never be filled by position without also giving the optional one. Required parameters come first.
  3. "Change shout so that print(shout("hi")) prints HI exactly once." Make it return text.upper() and drop the inner print. Functions that compute something should return it and leave printing to the caller, which also makes them easy to test with assert shout("hi") == "HI".

Scoring: Strong: All five lines exact; explains keyword matching and that a missing return means None, and can say why returning beats printing. Acceptable: First four lines right but expects HI or nothing on the last line; understands once asked "what does shout return?" Red flag: Thinks keyword arguments must be in definition order, or believes print inside a function is the same as returning a value.

FR-M-C01 · Students below 75% attendance

Medium · Coding · Fresher (0–1 years) · string parsing, dicts, tuples as keys, data validation, sorting · 20 min

Problem:

Your college's attendance app exports records as text, one record per line: date,roll_no,status, where status is P (present) or A (absent). Write low_attendance(records, threshold=75.0) that returns the roll numbers whose attendance percentage is strictly below threshold, sorted in ascending order.

The export is messy:

  • Skip blank lines and malformed lines (not exactly 3 fields, an empty date or roll number, or a status other than P/A). Don't crash on them.
  • Fields may have spaces around them, and the status may be lowercase.
  • The app sometimes submits twice. If a student has more than one record for the same date, the last one wins, and that date counts only once.

Attendance percentage = days present ÷ distinct dates recorded for that student × 100.

Signature: def low_attendance(records: str, threshold: float = 75.0) -> list[str]:

Constraints: Up to about 1 lakh lines (a department's semester), so everything fits in memory. Treat dates as plain strings; no date parsing is needed. Roll numbers are fixed-width strings like 21CS001.

Examples:

  • low_attendance(log) # the 4-day log in the tests → ['21CS003'] # 21CS001 and 21CS002 are exactly 75%, which is not below
  • low_attendance("2026-07-01,21ME005,A\n2026-07-01,21ME005,P\n2026-07-02,21ME005,P") → [] # the second mark for 07-01 (P) wins: 2 of 2 days
  • low_attendance("") → []

Hints:

  1. Parse one line first. What does line.split(",") give for a blank line, and for ' 2026-07-01 , 21EC010 , a '?
  2. How can a second mark for the same student and date replace the first one? Think about what the dict key should be.
  3. To avoid float surprises at the 75% boundary, compare present * 100 < threshold * total instead of dividing first.

Reference solution:

def low_attendance(records: str, threshold: float = 75.0) -> list[str]:
    """Return roll numbers whose attendance percentage is below `threshold`.

    Each non-blank line is 'date,roll_no,status' with status P or A (any case,
    surrounding spaces allowed). Malformed lines are skipped. If a student is
    marked more than once for the same date, the last mark wins.
    """
    marks: dict[tuple[str, str], str] = {}
    for line in records.splitlines():
        parts = [p.strip() for p in line.split(",")]
        if len(parts) != 3:
            continue
        date, roll, status = parts[0], parts[1], parts[2].upper()
        if not date or not roll or status not in ("P", "A"):
            continue
        marks[(roll, date)] = status          # same (roll, date) again overwrites

    present: dict[str, int] = {}
    total: dict[str, int] = {}
    for (roll, _date), status in marks.items():
        total[roll] = total.get(roll, 0) + 1
        if status == "P":
            present[roll] = present.get(roll, 0) + 1

    return sorted(roll for roll in total
                  if present.get(roll, 0) * 100 < threshold * total[roll])

Complexity: O(n) time to parse n lines, plus O(r log r) to sort the r flagged roll numbers. O(n) space for the (roll, date) dict in the worst case.

Tests:

log = """
2026-07-01,21CS001,P
2026-07-01,21CS002,A
2026-07-01,21CS003,P
2026-07-02,21CS001,P
2026-07-02,21CS002,P
2026-07-02,21CS003,A
2026-07-03,21CS001,A
2026-07-03,21CS002,P
2026-07-03,21CS003,A
2026-07-04,21CS001,P
2026-07-04,21CS002,P
2026-07-04,21CS003,p
"""
# 21CS001: 3/4 = 75% (not below), 21CS002: 3/4 = 75%, 21CS003: 2/4 = 50%
assert low_attendance(log) == ["21CS003"]
assert low_attendance(log, threshold=80) == ["21CS001", "21CS002", "21CS003"]
assert low_attendance("") == []
# messy formatting and malformed lines are skipped, not fatal
messy = " 2026-07-01 , 21EC010 , a \n2026-07-01,21EC011\ngarbage\n2026-07-02,21EC010,L\n2026-07-02,21EC010,P\n"
assert low_attendance(messy) == ["21EC010"]          # 1 of 2 days = 50%
# double submission for the same date: the last mark wins, the day counts once
assert low_attendance("2026-07-01,21ME005,A\n2026-07-01,21ME005,P\n2026-07-02,21ME005,P") == []
dup = "2026-07-01,21ME005,A\n2026-07-01,21ME005,P\n2026-07-02,21ME005,P\n2026-07-03,21ME005,P\n2026-07-04,21ME005,A\n"
assert low_attendance(dup) == []                     # 3 of 4 days = 75%
# result is sorted, whatever the input order
order = "2026-07-01,21CS009,A\n2026-07-01,21CS004,A\n"
assert low_attendance(order) == ["21CS004", "21CS009"]

A common wrong solution (the tests catch it):

def low_attendance(records: str, threshold: float = 75.0) -> list[str]:
    # Counts every line, so a double submission is counted twice.
    present: dict[str, int] = {}
    total: dict[str, int] = {}
    for line in records.splitlines():
        parts = [p.strip() for p in line.split(",")]
        if len(parts) != 3:
            continue
        roll, status = parts[1], parts[2].upper()
        if status not in ("P", "A"):
            continue
        total[roll] = total.get(roll, 0) + 1
        if status == "P":
            present[roll] = present.get(roll, 0) + 1
    return sorted(roll for roll in total
                  if present.get(roll, 0) / total[roll] * 100 < threshold)

Alternatives and tradeoffs:

  • collections.Counter / defaultdict(int) for present and total removes the .get(..., 0) noise. Equally correct, and a good sign if the candidate knows them.
  • A dict of dicts, {roll: {date: status}}, deduplicates just as well and makes per-student reports easy (len(days) is the total). It's a fine choice; the tuple key is simply the flattest one.
  • The csv module (csv.reader(records.splitlines())) handles quoted fields with commas inside them. It's overkill for this format, but the right tool for real CSV exports.
  • Plain string sorting is correct here because roll numbers are fixed-width. Mixed widths such as CS2 vs CS10 would need a sort key.
  • The broken version above forgets the duplicate rule: a double submission counts as two days, so 21ME005 drops to 3 of 5 = 60% and gets flagged wrongly.

Follow-ups:

  1. "The export now starts with a header line date,roll_no,status. Does your code still work?" It happens to, because status isn't P or A, so the line is skipped. Relying on that is fragile. Skip the header explicitly (check whether the first line equals the expected header), so a future column change fails loudly instead of being silently ignored.
  2. "The class teacher also wants each flagged student's percentage, to one decimal, for the notice board." Return list[tuple[str, float]], for example (roll, round(present * 100 / total, 1)), or a dict. Compare using the exact values and round only for display. If you compared the rounded value instead, a student at 74.96% would round to 75.0 and drop off the list.
  3. "Next semester the export is a 2 GB file. What changes?" Read it line by line (with open(path, encoding="utf-8") as f: for line in f:) instead of loading one huge string. The per-line logic stays the same, and memory is then bounded by the number of distinct (roll, date) pairs rather than the file size.

Scoring: Strong: Dict keyed by (roll, date) for last-wins deduplication; skips malformed lines without crashing; handles the exact-75% boundary and sorts the output; mentions or writes a test for the duplicate case. Done in 15–20 minutes. Acceptable: Correct parsing and counting but misses the duplicate rule until pointed back to the requirement, or uses <= and fixes it after the 75% example. Red flag: Crashes on a blank or malformed line (IndexError, unpacking error) and can't explain why; or rescans the whole text once per student.

Medium & Tricky (MT)

FR-MT-O01 · A shopping cart passed to functions

Medium & Tricky · Output · Fresher (0–1 years) · functions, argument passing, mutability, augmented assignment · 6 min

Question: What does this print?

def add_item(cart, item):
    cart += [item]

def add_item_v2(cart, item):
    cart = cart + [item]

def empty(cart):
    cart = []

def add_point(score):
    score += 1

cart = ["rice"]
score = 10
add_item(cart, "dal")
add_item_v2(cart, "ghee")
empty(cart)
add_point(score)
print(cart, score)

Expected output:

['rice', 'dal'] 10

The trap: [] 11, or ['rice', 'dal', 'ghee'] 11. Tempting because every function looks as if it updates the caller's variable, and cart += [item] and cart = cart + [item] look like the same statement.

Explanation:

Mechanism 1: calling a function binds a new name to the same object. Inside each function, cart (or score) is a local name that starts out pointing at the caller's object. Nothing is copied.

Mechanism 2: mutating the object vs rebinding the name.

  • add_item: for a list, cart += [item] extends the existing list in place. The caller holds the same list, so it sees "dal".
  • add_item_v2: cart + [item] builds a new list, and cart = ... only rebinds the local name to it. The caller's list is untouched, and the new list is thrown away when the function returns.
  • empty: cart = [] also just points the local name at a new empty list; it doesn't clear anything. Emptying the caller's list needs cart.clear().
  • add_point: an int can't be changed in place, so score += 1 creates the int 11 and rebinds the local name. The caller's score is still 10.

So += means "change the object in place if it supports that (lists do), otherwise make a new object and rebind the name (ints, strings and tuples)." A plain = inside a function never affects the caller.

Where this bites: a helper meant to return a modified copy quietly changes the caller's data, or a helper meant to update a counter "works" when you test it inside the function and does nothing outside. The clean fix is to return the new value and assign it at the call site: score = add_point(score).

Takeaway: changes made to the object through a parameter are visible to the caller; assigning to the parameter name never is.

Follow-ups:

  1. "Make add_point actually update the caller's score." Return the new value, return score + 1, and assign it where you call it: score = add_point(score). Using global score also works, but it hides the data flow and makes the function harder to test.
  2. "You want add_item to leave the caller's list alone and give back a new list. How?" return cart + [item] (a new list), or copy first with new_cart = cart.copy() (or list(cart)), append to the copy and return it. The caller then chooses: cart = add_item(cart, "dal").
  3. "So is Python pass-by-value or pass-by-reference?" Neither, exactly. It passes object references ("call by sharing"): the parameter is a new name for the same object. Mutations are shared; rebinding the name is local. Ints only look passed by value because they can't be mutated.

Scoring: Strong: ['rice', 'dal'] 10, explaining in-place += on a list versus rebinding with =, and why the int is unchanged; suggests returning values. Acceptable: Gets cart right but says score is 11 (or the reverse), then corrects it after being asked whether an int can change in place. Red flag: Answers [] 11 and holds to "lists are passed by reference, so every change shows up" after walking through empty.

FR-MT-O02 · Defaults with `or` in a checkout function

Medium & Tricky · Output · Fresher (0–1 years) · truthiness, boolean operators, short-circuit evaluation, default values · 6 min

Question: What does this print?

def checkout(price, discount_pct=None, coupon=""):
    discount_pct = discount_pct or 10
    coupon = coupon or "NONE"
    final = price - price * discount_pct // 100
    return f"₹{final} (discount {discount_pct}%, coupon {coupon!r})"

print(checkout(500))
print(checkout(500, 0, "FREESHIP"))
print(checkout(500, 20, " "))
print(0 or "" or None)
print(2 and 3, [] and 3)

Expected output:

₹450 (discount 10%, coupon 'NONE')
₹450 (discount 10%, coupon 'FREESHIP')
₹400 (discount 20%, coupon ' ')
None
3 []

The trap: Line 2 as ₹500 (discount 0%, coupon 'FREESHIP'), line 3 with coupon 'NONE', then False and True False. Tempting because or/and are read as returning True/False, and 0 looks like a perfectly valid discount.

Explanation:

Mechanism 1: truthiness. In if, or and and, every value counts as true or false. The falsy values are None, False, 0, 0.0, "", and empty containers ([], {}, set()). Everything else is truthy, including " " (a space), "0", "False" and [0].

Mechanism 2: or and and return one of their operands, not a bool. a or b gives a if a is truthy, otherwise b. a and b gives a if a is falsy, otherwise b. Both stop as soon as the answer is known (short-circuit).

  • Line 1: None or 10 is 10; "" or "NONE" is "NONE"; 500 − 50 = 450.
  • Line 2: the caller explicitly asked for 0%, but 0 is falsy, so 0 or 10 gives 10. A real value is replaced by the default. This is the bug.
  • Line 3: 20 is truthy and stays; " " is a non-empty string, so it's truthy and also stays (!r makes the space visible): ₹400.
  • Line 4: every operand is falsy, so or returns the last one, None.
  • Line 5: 2 and 3 returns 3 (both truthy, so the last); [] and 3 stops at the falsy [] and returns it.

Fix: when 0 or "" are legitimate values, test for "missing" explicitly with if discount_pct is None: discount_pct = 10. For text, strip before testing: coupon = coupon.strip() or "NONE".

Where this bites: a form that allows "0% discount", "quantity 0" or "₹0 delivery charge" silently gets the default applied instead.

Takeaway: x or default treats every falsy value as missing; if 0 or an empty string is a valid input, compare with is None.

Follow-ups:

  1. "Rewrite the discount line so that an explicit 0 is respected." if discount_pct is None: discount_pct = 10, or in one line discount_pct = 10 if discount_pct is None else discount_pct. Now only a missing value gets the default.
  2. "Why is None rather than == None?" There is exactly one None object, so an identity check is precise and can't be fooled by a class that defines __eq__ strangely. PEP 8 recommends is None / is not None.
  3. "Is name = input().strip() or "Guest" a good use of or?" Yes. An empty name is meaningless, so treating "" as missing is exactly what you want. or is fine whenever every falsy value genuinely means "not provided".

Scoring: Strong: All five lines exact; names both mechanisms (falsy 0 and the space being truthy; or/and returning operands) and fixes it with is None. Acceptable: Spots the 0% bug but prints False for line 4 or True False for line 5; corrects it once told that or returns an operand. Red flag: Believes 0 or 10 gives 0 "because 0 was passed", or thinks " " is falsy, and can't explain after a hint.

Hard (H)

FR-H-O01 · Tracing a recursive digit sum

Hard · Output · Fresher (0–1 years) · recursion, call stack, integer division, modulo · 7 min

Question: What does this print?

def digit_sum(n):
    if n < 10:
        print("base", n)
        return n
    print("call", n)
    result = n % 10 + digit_sum(n // 10)
    print("back", n, result)
    return result

print(digit_sum(4721))

Expected output:

call 4721
call 472
call 47
base 4
back 47 11
back 472 13
back 4721 14
14

Explanation:

n % 10 is the last digit and n // 10 drops it (4721 → 472). Each call gets its own n and result, stored in its own frame on the call stack.

On the way down: digit_sum(4721) prints call 4721, then has to evaluate 1 + digit_sum(472). It pauses there until the inner call returns. The same happens for 472 and 47. digit_sum(4) hits the base case (4 < 10), prints base 4 and returns 4. That is the first value that comes back.

On the way back up: each paused call resumes where it stopped. 47 computes 7 + 4 = 11 and prints back 47 11. 472 computes 2 + 11 = 13. 4721 computes 1 + 13 = 14. Finally the outer print shows 14.

The call lines appear in the order the calls start, and the back lines in the reverse order: last in, first out, like a stack of plates. Without a reachable base case the calls never stop piling up, and Python raises RecursionError once the stack reaches its limit (1000 frames by default).

Takeaway: code before the recursive call runs on the way down; code after it runs on the way back up, in reverse order.

Follow-ups:

  1. "What does digit_sum(-25) return?" It prints base -25 and returns -25, because -25 < 10 takes the base case immediately. Wrong, but no error. Handle the sign first, for example n = abs(n). Testing negative and zero inputs is how you find this.
  2. "Write it without recursion." total = 0, then while n > 0: total += n % 10; n //= 10, and return total (after n = abs(n)). Or sum(int(d) for d in str(abs(n))). For this problem the loop is simpler and has no depth limit; recursion pays off on naturally recursive data such as folders or trees.
  3. "What happens if you delete the base case?" The calls continue past 4: 4 // 10 is 0, and digit_sum(0) calls digit_sum(0) forever, until RecursionError: maximum recursion depth exceeded. Python doesn't optimise tail calls, so every call costs a stack frame.

Scoring: Strong: All eight lines in order, explaining the pause-and-resume of each call and why the back lines come out in reverse; spots the negative-input bug when asked. Acceptable: Gets the call lines and the final 14 but mixes up the order or values of the back lines; fixes it when asked to draw the stack. Red flag: Expects each back line right after its call line, as if a call finished before its recursive call returned; can't trace even with a drawn stack.

FR-H-O02 · Interest on an inherited savings account

Hard · Output · Fresher (0–1 years) · OOP, inheritance, super(), class attributes, __str__ · 7 min

Question: What does this print?

class Account:
    interest_pct = 3

    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def add_interest(self):
        self.balance += self.balance * self.interest_pct // 100

    def __str__(self):
        return f"{type(self).__name__}({self.owner}, ₹{self.balance})"

class Savings(Account):
    interest_pct = 5

    def add_interest(self):
        super().add_interest()
        self.balance += 100  # loyalty bonus

for acc in [Account("Ravi", 10000), Savings("Priya", 10000)]:
    acc.add_interest()
    print(acc)
print(Account.interest_pct, Savings.interest_pct)
print(isinstance(Savings("A"), Account), isinstance(Account("B"), Savings))

Expected output:

Account(Ravi, ₹10300)
Savings(Priya, ₹10600)
3 5
True False

Explanation:

Account: 10000 × 3 // 100 = 300, so the balance becomes 10300. print(acc) calls __str__, and type(self).__name__ is "Account".

Savings: it has no __init__ of its own, so Account.__init__ is inherited and sets owner and balance. It overrides add_interest, and its version first calls super().add_interest(), which runs Account's method on the same self, the Savings object. Inside that method, self.interest_pct is looked up on the object first (not there), then on its class Savings, which has 5. Lookup stops at the first match, so Account's 3 is never reached. 10000 × 5 // 100 = 500 → 10500, plus the ₹100 bonus = 10600. The inherited __str__ also uses type(self), so it prints Savings.

Savings.interest_pct = 5 creates a separate class attribute that hides the parent's for Savings objects. Account.interest_pct is still 3.

isinstance follows inheritance as an "is-a" relationship: every Savings is an Account, but a plain Account is not a Savings.

A common wrong answer is Savings(Priya, ₹10400): "super() runs the parent's method, so it uses the parent's 3%". super() chooses which method runs, but self is still the Savings object, and attribute lookup always starts from the object's actual class.

Takeaway: an inherited or super()-called method still works on self, and self.attr is looked up from the object's real class upwards.

Follow-ups:

  1. "Savings needs a min_balance set when the account is created. Write its __init__." def __init__(self, owner, balance=0, min_balance=1000): then super().__init__(owner, balance) and self.min_balance = min_balance. If you forget the super().__init__ call, owner and balance are never set and the first add_interest fails with AttributeError.
  2. "What does print(acc) show if you delete __str__?" The default representation, something like <__main__.Account object at 0x7f3a...>. __str__ is the user-friendly text that print and f-strings use; __repr__ is the developer view used in the REPL and inside lists. Defining __repr__ gives you both.
  3. "Why compute interest with * interest_pct // 100 instead of * 0.03?" Floats are binary approximations (0.1 + 0.2 is 0.30000000000000004), so money stored as floats picks up tiny errors. Whole rupees or paise as integers, or decimal.Decimal, keep amounts exact.

Scoring: Strong: All four lines exact; explains that super() runs the parent's method with the same self, so self.interest_pct finds Savings' 5; knows the parent's attribute is unchanged. Acceptable: Gets 10300 but says 10400 for Priya, then reaches 10600 after being asked "what is self inside Account.add_interest here?" Red flag: Can't say what super() or inheritance does, or expects Savings("Priya", 10000) to fail because Savings has no __init__.

Hard & Tricky (HT)

FR-HT-O01 · Chained comparisons, and why C intuition fails

Hard & Tricky · Output · Fresher (0–1 years) · comparison operators, chained comparisons, booleans · 6 min

Question: What does this print?

age = 20
print(18 <= age < 60)
print(1 < 3 > 2)
print(3 > 2 > 1)
print((3 > 2) > 1)
print(5 > 4 == True)
print((5 > 4) == True)

Expected output:

True
True
True
False
False
True

The trap: True, False, False, False, True, True: reading each line left to right the way C does, where 3 > 2 > 1 becomes (3 > 2) > 1, i.e. 1 > 1, which is false. (Java refuses to compile it.) Tempting for anyone who learned C first, which is common in Indian engineering colleges.

Explanation:

Mechanism 1: Python chains comparisons. a < b < c means a < b and b < c, with b evaluated only once. This applies to all comparison operators (<, >, ==, !=, <=, >=, in, is), and they all have the same precedence, so a line mixing them is one chain.

  • 18 <= age < 60 → 18 <= 20 and 20 < 60 → True. This is the readable range check the feature exists for.
  • 1 < 3 > 2 → 1 < 3 and 3 > 2 → True. Legal but confusing; don't write it.
  • 3 > 2 > 1 → 3 > 2 and 2 > 1 → True.

Mechanism 2: bool is a subclass of int, so True == 1 and False == 0. Parentheses break the chain:

  • (3 > 2) > 1 → True > 1 → 1 > 1 → False.
  • 5 > 4 == True is a chain: 5 > 4 and 4 == True → True and (4 == 1) → False.
  • (5 > 4) == True → True == True → True.

Where this bites: if marks > 40 == True: written by habit looks like "is marks > 40 true?", but it is a chain that also checks 40 == True, so it is always false. Don't compare with True at all: write if marks > 40:.

Takeaway: a op1 b op2 c means a op1 b and b op2 c; wrap part of it in parentheses and you're comparing a bool, which behaves as the int 0 or 1.

Follow-ups:

  1. "Is x == y == 0 the same as x == y and y == 0?" Yes, with y evaluated once. It's a neat way to write "both are zero". x == y == 0 is not (x == y) == 0, which would be true when x and y are different.
  2. "What are True + True + False and sum([True, False, True])?" Both are 2, because booleans are ints. That's useful for counting: sum(m >= 40 for m in marks) counts how many students passed.
  3. "What does print(1 in [1, 2] == True) print?" False. in and == are both comparison operators, so this is the chain 1 in [1, 2] and [1, 2] == True, and the second part is false.

Scoring: Strong: All six lines exact; states the chaining rule (a < b < c is a < b and b < c) and uses True == 1 to explain the parenthesised lines; spots the == True chain. Acceptable: Knows 18 <= age < 60 chains but treats 5 > 4 == True as (5 > 4) == True; gets it once told that == chains like <. Red flag: Answers line by line in C style and, after the chaining rule is explained, can't apply it to the remaining lines.

FR-HT-O02 · A loader whose error handler crashes

Hard & Tricky · Output · Fresher (0–1 years) · exceptions, try/except/finally, reading tracebacks, string concatenation · 7 min

Question: What does this print, and what is the last line of the traceback?

def load(rows):
    total = 0
    for row_no, value in enumerate(rows, start=1):
        try:
            total += int(value)
        except ValueError:
            print("Skipping row " + row_no)
        finally:
            print("checked row", row_no)
    return total

print(load(["10", "20"]))
print(load(["10", "x", "30"]))

Expected output:

checked row 1
checked row 2
30
checked row 1
checked row 2
TypeError: can only concatenate str (not "int") to str

The trap: checked row 1, checked row 2, 30, then checked row 1, Skipping row 2, checked row 2, checked row 3, 40. Tempting because except ValueError clearly handles the bad "x", so the program looks crash-proof.

Explanation:

First call: no errors. finally prints after each row, and the function returns 30.

Second call: row 1 is fine. On row 2, int("x") raises ValueError, which the handler catches. But the handler itself runs "Skipping row " + row_no, and row_no is an int from enumerate. Joining a str and an int raises TypeError.

Mechanism 1: try/except only protects the try body. An exception raised inside a handler is not caught by that handler or its sibling except clauses. It propagates out like any other error.

Mechanism 2: finally runs even when the block exits with an exception, including one raised by the handler. So checked row 2 is still printed, and then the TypeError leaves load. Row 3 is never processed and the second print never runs.

Reading the traceback: Python prints both exceptions. First comes the original ValueError pointing at total += int(value), then the line "During handling of the above exception, another exception occurred:", then the TypeError pointing at the print inside the handler. Read a traceback from the bottom: the last line is the exception that actually stopped the program, and the frame just above it shows the line where it was raised.

Fix: print(f"Skipping row {row_no}"). An f-string converts any value for you.

Where this bites: error-handling code runs only when something goes wrong, so it is the code least likely to have been tested. The first bad row in real data (or in the evaluator's hidden test case) crashes the handler meant to deal with it.

Takeaway: an except block is ordinary code. If it raises, finally still runs and the new exception propagates, with the original one shown as "during handling".

Follow-ups:

  1. "Why didn't the first call reveal the bug?" The handler only runs when a row is bad, and the first input had no bad rows, so that line never executed. A test with at least one invalid row would have caught it immediately. Always test the failure path, not just the happy path.
  2. "After fixing the handler, what does the second call print?" checked row 1, Skipping row 2, checked row 2, checked row 3, then 40.
  3. "Would changing the handler to except Exception: stop the crash?" No. Whatever type it names, an except clause only catches exceptions raised in its try body. An exception raised inside a handler can only be caught by an outer try around the whole statement, or by fixing the handler.

Scoring: Strong: Exact stdout and the TypeError line; explains that the handler isn't protected, that finally still prints checked row 2, and how to read the two-part traceback bottom-up. Acceptable: Spots the str + int error but forgets that finally prints checked row 2 before the crash, or isn't sure which exception the traceback ends with. Red flag: Gives the trap answer and, after being shown the TypeError, believes the except should have caught it.

Theory questions

FR-TH-DM-04 · `is` vs `==`: code that works by accident

Medium & Tricky · Theory · Fresher (0–1 years) · identity, equality, None, objects · 4 min

Question: A classmate writes if marks is 100: and if name is "Asha":, and it worked when they tested it. What is the difference between is and ==, and is this code correct?

Answer:

Trap: "is is just a stricter (or faster) ==, and it worked, so it's fine."

== compares values: are these two objects equal? is compares identity: are these two names pointing at the very same object in memory (the same id())? Equal values can live in different objects: two separately created lists [1, 2, 3] are == but not is, as the example shows.

The classmate's code works by accident. CPython reuses objects for small integers (−5 to 256) and for some strings, so is happens to be True for those values. That's an implementation detail, not a language rule: it can fail for a number computed at run time or a name read with input(). Python 3.12 even warns at compile time: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?.

Rule: use == to compare values. Use is only to check for a specific single object, which in practice means x is None and x is not None.

a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b, a is b)
print(a == c, a is c)
c.append(4)
print(a, b)

Output:

True False
True True
[1, 2, 3, 4] [1, 2, 3]

Follow-ups:

  1. "Why is x is None preferred over x == None?" There is exactly one None object, so the identity check is exact and can't be changed by a class that defines __eq__ oddly. It also reads clearly, and PEP 8 recommends it.
  2. "In the example, why does a change when we append to c?" c = a doesn't copy anything; it makes c a second name for the same list, which is exactly what a is c being True means. b is a separate list, so it's unaffected. To get an independent copy, use a.copy() or list(a).

Scoring: Strong: Clear value-vs-identity distinction; explains why the test passed (small-int and string reuse as an implementation detail); states the is None rule. Acceptable: Correct distinction and recommends ==, but can't explain why the classmate's test passed. Red flag: Thinks is is a faster ==, or that the code is fine because it worked in testing.

FR-TH-EXC-02 · Is `try/except: pass` a fix?

Easy & Tricky · Theory · Fresher (0–1 years) · exceptions, error handling, debugging · 3 min

Question: Your project sometimes crashes, so a friend suggests wrapping the main code in try: ... except: pass. Is that a good fix?

Answer:

Trap: "Yes, the program won't crash any more."

It hides the bug; it doesn't fix it. A bare except: catches every exception: real bugs such as TypeError, KeyError and NameError (a typo), and even KeyboardInterrupt (Ctrl+C) and SystemExit. pass then throws away the error message and the traceback. The program carries on with missing or wrong data, and when the output is wrong you have nothing to debug with. In the example below, two completely different problems both silently become None.

What to do instead:

  1. Read the traceback and fix the cause. The last line names the error; the lines above it show where it happened.
  2. Catch only the exceptions you expect and can handle, as close as possible to where they happen. For example, wrap just int(user_input) in except ValueError:.
  3. Do something meaningful in the handler: show a helpful message, use a sensible default, ask again, or log the error and re-raise it. If you must catch broadly at the top of a script, use except Exception as e: and print or log e; never pass.

def average(marks):
    try:
        return sum(marks) / len(marks)
    except:
        pass

print(average([70, 80]))
print(average([]))
print(average(["70", "80"]))

Output:

75.0
None
None

Follow-ups:

  1. "In average, what should happen for an empty list and for a list of strings?" They are different problems. An empty list is an expected case: decide on the behaviour and handle it explicitly (if not marks: return 0.0, or raise ValueError("no marks")). A list of strings is a bug in the caller, so let the TypeError surface and fix the caller by converting with int() at input time.
  2. "What is the difference between except: and except Exception:?" A bare except: also catches KeyboardInterrupt and SystemExit, so Ctrl+C or sys.exit() can be swallowed. except Exception: leaves those alone. It is still too broad for normal code, but it's the correct form for a top-level "log and exit" handler.

Scoring: Strong: Says no; explains that it hides bugs, loses the traceback and even swallows Ctrl+C; proposes specific exceptions with meaningful handling. Acceptable: Says it hides errors and suggests catching specific exceptions, without the KeyboardInterrupt detail. Red flag: Agrees it's a good fix, or recommends "catch everything and print 'Something went wrong'" as standard practice.

FR-TH-HSH-03 · Why `not in` on a list is slow

Medium · Theory · Fresher (0–1 years) · sets, lists, time complexity, membership testing · 4 min

Question: You have two lists of about 1 lakh roll numbers each, one of students who registered for an exam and one of students who paid the fee. To find who registered but didn't pay, you wrote [r for r in registered if r not in paid], and it takes minutes. Why, and how do you fix it?

Answer:

r not in paid on a list is a linear scan: Python compares r with each element in turn until it finds a match, and for a student who didn't pay it checks all of them. That's O(m) per check, done for n students, so O(n × m) overall. With 1 lakh each, that is up to 10¹⁰ comparisons.

Fix: convert paid to a set once, before the loop: paid_set = set(paid), which is O(m). A set is a hash table: the value's hash tells Python almost directly where to look, so a membership test takes the same short time on average, O(1), however big the set is. The total becomes O(n + m), well under a second.

  • The comprehension with paid_set keeps registration order and any duplicates in registered.
  • set(registered) - set(paid) is shorter but loses order and duplicates. Sort it if you need a stable output.
  • Only hashable (immutable) values can go in a set or be dict keys: strings, numbers and tuples are fine; lists are not.
  • Build the set outside the comprehension. if r not in set(paid) rebuilds the set for every student, which is even slower than the list.

registered = ["21CS001", "21CS002", "21CS003", "21CS004"]
paid = ["21CS003", "21CS001"]
paid_set = set(paid)
print([r for r in registered if r not in paid_set])
print(sorted(set(registered) - paid_set))

Output:

['21CS002', '21CS004']
['21CS002', '21CS004']

Follow-ups:

  1. "When would you still keep a list instead of a set?" When order matters, when duplicates matter, or when you need indexing (items[0]). And for a handful of items, the difference doesn't matter. The set is worth it when you test membership many times against a large collection.
  2. "Can you put a list of marks inside a set, for example {[70, 80]}?" No: TypeError: unhashable type: 'list'. A list can change, which would change its hash while it's stored. Convert it to a tuple, {(70, 80)}.

Scoring: Strong: Names O(n × m) vs O(n + m), builds the set once outside the loop, and explains in a sentence that set lookup is fast because of hashing. Acceptable: Knows to use a set but can't explain the complexity, or needs a nudge to build the set outside the comprehension. Red flag: Suggests a faster computer, threads, or sorting both lists while still using in on a list, with no idea why the original is slow.

FR-TH-MUT-01 · List vs tuple

Easy · Theory · Fresher (0–1 years) · lists, tuples, mutability · 2 min

Question: What is the difference between a list and a tuple, and when would you choose a tuple?

Answer:

A list ([...]) is mutable: you can append, remove and replace items after creating it. A tuple ((...)) is immutable: once created, its items can't be added, removed or reassigned.

  • Use a list for a collection that grows or changes: items in a cart, marks being entered, results being collected in a loop.
  • Use a tuple for a fixed group of values where each position has a meaning: a coordinate (lat, lon), a (roll_no, date) pair, or several values returned from a function (return lowest, highest).

Because tuples (of immutable items) can't change, they are hashable, so they can be dict keys and set members. Lists can't (TypeError: unhashable type: 'list'). It's the comma, not the parentheses, that makes a tuple: (5,) is a tuple, while (5) is just the int 5.

point = (12.97, 77.59)
attendance = {("21CS001", "2026-07-01"): "P"}
print(attendance[("21CS001", "2026-07-01")])
print(type((5,)).__name__, type((5)).__name__)
try:
    point[0] = 13.0
except TypeError as e:
    print(e)

Output:

P
tuple int
'tuple' object does not support item assignment

Follow-ups:

  1. "Can the contents of a tuple ever change?" The tuple itself can't, but if it holds a mutable object, that object can: with t = ([1], 2), t[0].append(3) works and t becomes ([1, 3], 2). Such a tuple is also no longer hashable, so it can't be a dict key.
  2. "How do you return two values from a function, and what do you actually get back?" return lowest, highest returns one tuple, which the caller usually unpacks: lo, hi = min_max(marks).

Scoring: Strong: Mutability plus a concrete reason to pick a tuple (fixed records, dict keys, returning several values); knows the (5,) syntax. Acceptable: States the mutability difference correctly; gets to "tuples can be dict keys" after a prompt. Red flag: Says "tuples are just faster lists" with no mention of immutability, or believes you can append to a tuple.

FR-TH-OOP-06 · Method overloading in Python

Hard & Tricky · Theory · Fresher (0–1 years) · OOP, methods, overloading, default arguments, *args · 5 min

Question: Does Python support method overloading like Java? What happens if a class defines two add methods with different numbers of parameters?

Answer:

Trap: "Yes. Python picks the method that matches the number of arguments."

It doesn't. A class body runs top to bottom like ordinary code, and def add(...) simply assigns a function to the name add in the class. The second def add rebinds that name, so the first method is gone; only the last definition exists. Nothing fails when the class is defined (a linter such as pylint flags it as function-redefined). The error appears only when someone calls the method with the old signature, as the example shows.

To get the same effect in Python:

  • Default values: def add(self, a, b, c=0).
  • Variable arguments: def add(self, *nums): return sum(nums).
  • Dispatch on argument type (bonus): functools.singledispatchmethod.

Don't confuse this with overriding (a subclass redefining a parent's method, which Python fully supports) or with operator overloading (defining __add__, __eq__ and similar so that + and == work on your objects, which Python also supports).

class Calculator:
    def add(self, a, b):
        return a + b

    def add(self, a, b, c):
        return a + b + c

calc = Calculator()
print(calc.add(1, 2, 3))
print(calc.add(1, 2))

Output:

6
TypeError: Calculator.add() missing 1 required positional argument: 'c'

Follow-ups:

  1. "Rewrite add so that both add(1, 2) and add(1, 2, 3) work." def add(self, *nums): return sum(nums) accepts any number of arguments. def add(self, a, b, c=0): return a + b + c also works if the maximum is fixed at three.
  2. "What, then, is operator overloading in Python?" Defining special methods so that operators work on your objects. For example, Money.__add__ makes Money(100) + Money(50) work: Python turns a + b into a call to a.__add__(b).

Scoring: Strong: Says Python has no overloading; explains that the second def rebinds the name and predicts the TypeError; offers default arguments or *args; distinguishes overloading from overriding. Acceptable: Knows Python doesn't overload but can't say what happens to the first method, or needs a prompt to reach *args/defaults. Red flag: Insists Python picks the method by argument count, or treats overloading and overriding as the same thing.

FR-TH-TST-05 · Choosing test cases for a validator

Hard · Theory · Fresher (0–1 years) · testing, assert, pytest, edge cases · 6 min

Question: You wrote is_valid_pan(pan) (see FR-E-C01). How would you test it? Which test cases would you choose, and how would you run them?

Answer:

How: a test calls the function with a chosen input and checks the result automatically, for example assert is_valid_pan("ABCPE1234F") is True. A plain script of asserts works. The standard tool is pytest: put functions named test_... in a file such as test_pan.py and run pytest. It finds them, runs them all, and for a failing assert shows the actual values involved.

Which cases: don't pick random inputs. Think in groups, one per rule in the spec, and add the boundaries:

  1. Happy path: a valid PAN.
  2. Normalisation: lowercase input, leading and trailing spaces.
  3. Each rule broken once: a digit among the first five (AB1PE1234F), a letter among the digits, a digit in the last position.
  4. Length boundaries: 9, 10 and 11 characters, and the empty string.
  5. Tricky characters: a space inside, a non-ASCII letter (À), digits that isdigit() accepts but the spec doesn't (³, Devanagari १).

Good habits:

  • Work out each expected value from the specification, not by running the code and copying what it returns.
  • Give each test one reason to fail, so a failure points at one rule.
  • Use @pytest.mark.parametrize to run a table of (input, expected) pairs without copy-pasting.
import pytest
from pan import is_valid_pan

@pytest.mark.parametrize("pan, expected", [
    ("ABCPE1234F", True),
    ("  abcpe1234f ", True),
    ("AB1PE1234F", False),   # digit in the letter part
    ("ABCPE1234", False),    # too short
    ("ABCPE12³4F", False),   # non-ASCII digit
])
def test_is_valid_pan(pan, expected):
    assert is_valid_pan(pan) is expected

Finally, check that the tests can fail: a suite containing only valid PANs would also pass for def is_valid_pan(pan): return True.

Follow-ups:

  1. "All your tests pass. Does that prove the function is correct?" No. It shows the function works for the cases you chose. That's why cases are chosen to try to break it: each rule, the boundaries, and odd input. A quick sanity check is to break the code on purpose (for example, remove the length check) and confirm that some test fails.
  2. "Should is_valid_pan(None) return False or raise an error? How would you test your choice?" It's a design decision. The signature says str, so passing None is a bug in the caller, and raising is reasonable: the current code already raises AttributeError from None.strip(), and an explicit isinstance check could raise a clearer TypeError. Returning False is friendlier for raw form data. Whichever you choose, write it down and test it: with pytest.raises(AttributeError): is_valid_pan(None), or assert is_valid_pan(None) is False.

Scoring: Strong: Groups cases by rule, covers the length boundaries and normalisation, mentions pytest (ideally parametrize) and that expected values come from the spec; knows passing tests don't prove correctness. Acceptable: A sensible handful of valid and invalid cases written as asserts; misses the boundaries or the non-ASCII cases until prompted. Red flag: Says "I'd run it with a few inputs and look at the output", with no automated checks, or tests only valid PANs.