skip to content

A payroll CSV import raises UnboundLocalError on one row out of 6,800 — how do you diagnose and fix it?

level: seniorimportance: should knowfreq 34%

answer

  1. The rare row took the other path
  2. Every binding sits under a condition
  3. One branch never filled the slot
  4. Make the binding total, not caught
  5. Bad data deserves a validation error

basics

~20 s

A name assigned only inside an if or try is still local for the whole function, so the one row that skipped that branch reaches the read with an empty slot. Fix it by binding the name unconditionally, never by catching.

solid answer

~50 s

The rule is the same as the textbook case — an assignment anywhere makes the name local for the entire body — but here the assignment is *conditional*, so 6,799 rows take the branch and one does not. A clock-skew row where the clock-out timestamp precedes the clock-in is exactly the kind of input that fails an `if` guard nobody expected to fail. Diagnose by reading the failing name out of the message and finding every binding of it in the function; `co_varnames` confirms it is local. Fix it by making the binding **total**: give the name an explicit initial value above the branch, return early from inside the branch, or raise a domain error for the bad row so the failure names the data problem instead of the scoping one. Then add the skewed row to the test suite, since the happy path never exercised it.

code

python · 11 lines
python
def normalize_hours(row):
    if row["clock_out"] >= row["clock_in"]:
        hours = row["clock_out"] - row["clock_in"]
    return hours

print(normalize_hours({"clock_in": 9, "clock_out": 17}))
try:
    normalize_hours({"clock_in": 9, "clock_out": 8})
except UnboundLocalError as exc:
    print(exc)
    print(normalize_hours.__code__.co_varnames)

go deeper

for a junior

Recall that a name assigned only inside an if or try is still local for the whole function, so a skipped branch leaves it with no value. Read the variable name straight out of the error message.

for a middle

Explain why the failure is rare rather than intermittent: the same deterministic rule, triggered by an input that takes the other branch. List the variants — a try body, a zero-iteration loop, a del — and the unconditional-binding fix.

for a senior

Show the diagnosis method and then choose the right fix for the domain. Demonstrate that you would capture the failing input, reject corrupt data explicitly instead of defaulting it, and add the branch-level test the happy path never covered.

for a principal

Treat the single failure as a signal about the gate, not the row. Argue for possibly-unbound diagnostics as a blocking CI check and for validation at the ingest boundary, so malformed input is rejected with an identifiable error rather than reaching business logic.

## The shape of the bug The importer walks a batch of rows and normalises each one. Somewhere in the per-row function a name is bound inside a conditional: ```python def normalize_hours(row): if row["clock_out"] >= row["clock_in"]: hours = row["clock_out"] - row["clock_in"] return hours ``` `hours` is the target of an assignment in the body, so it is local for the whole function. On a well-formed row the branch runs, the slot is filled, and the return works. On a row where the clock-out timestamp is *earlier* than the clock-in — a clock-skew artefact from a terminal whose time drifted backwards across the sync — the branch is skipped, the slot is still empty, and `return hours` raises `UnboundLocalError: cannot access local variable 'hours' where it is not associated with a value`. One row in 6,800 is the tell. A scoping bug that fires on *every* call is caught by the first test; a scoping bug that fires on a rare input is caught in production, because the happy path never proved that the binding was total. ## Diagnosis, in order **1. Take the name out of the message.** The message names the variable and the traceback names the line. That is already most of the answer: you are not looking for a missing global, you are looking for every place in *this* function that binds this name. **2. Enumerate the bindings.** Search the body for the name as an assignment target — including `for` targets, `with ... as`, `except ... as`, and augmented assignment. If every binding sits under a conditional, a `try`, or a loop that can iterate zero times, the read is reachable with the slot empty. **3. Confirm the classification.** `func.__code__.co_varnames` containing the name proves the compiler made it local, which rules out "a global went missing" as a theory and stops the fruitless hunt through imports and module state. **4. Find the input that took the other path.** The message identifies the defect but not the data. Log or capture the failing row — its identifier, and the fields the branch condition reads. Here that is the pair of timestamps, and seeing clock-out before clock-in tells you the row is genuinely malformed, which changes the fix. ## The variants that produce the same failure - **`try`/`except` binding.** A name assigned in the `try` body and read after the `except` clause. If the except path does not bind it, the recovery path is unbound. - **A loop that may run zero times.** `for r in rows: last = r` followed by a read of `last` fails on an empty batch. - **`del` inside a branch.** Deleting a name marks it local and leaves it unbound; a later read raises the same error. - **A partially-declared `global`.** Adding `global` to one function that rebinds the module-level name but not to another is a common half-fix that just relocates the failure. ## Fixes, best first **Make the binding total.** Assign the name above the branch — an explicit initial value that is meaningful, not a placeholder chosen to silence the error. If there is no meaningful default, that is information: the function has no answer for this input. **Return or raise from inside the branch.** Restructuring so each path produces its own result removes the shared name that could be unbound. Often the clearest version has no fall-through read at all. **Raise a domain error for bad data.** For the clock-skew row, the honest outcome is not a zero-hours result silently written to a payroll record — it is a validation failure naming the row and the two timestamps, so a human decides. Substituting a default here would quietly under-pay someone, which is a worse failure than the crash. **Do not catch the error.** Wrapping the call in `except UnboundLocalError` or, worse, `except NameError` turns a precise, located defect into a wrong number in a payroll file. The exception is telling you the function is incomplete; catching it makes the incompleteness invisible. ## Preventing the next one Static analysis catches this class before it ships: type checkers and linters report a possibly-unbound name from the same compile-time information the interpreter uses, and turning that diagnostic into a CI gate is far cheaper than the incident. Test the skipped branch explicitly — an empty batch, a row that fails each guard, an exception path that reaches the code after the `try`. And prefer designs where each branch produces its own return value, so there is no shared name whose binding has to be proven total by inspection. ## What an interviewer is listening for Not the definition of the error — the *method*. Read the name from the message, find the bindings, prove the classification, then decide whether the right fix is a default, a restructure, or a validation failure. The last step is the senior one: the choice depends on whether a missing value is a legitimate case or corrupt input, and in a payroll import it is corrupt input.

  • Why did the test suite miss this when the function has full line coverage?
    Line coverage can be complete while branch coverage is not: a test that only supplies well-formed rows executes every line of the guarded block and the return, yet never executes the path where the guard is false. The missing case is a branch, not a line, so the fix is a test with a row that fails each guard, plus branch coverage in the gate.
  • Would initialising `hours = 0` above the branch be an acceptable fix here?
    It removes the crash but is wrong for payroll: a skewed row would silently record zero hours and under-pay someone. A default is only right when the absent value is a legitimate case with a meaningful value. For corrupt input, reject the row with an error that names the identifier and the offending timestamps so a human resolves it.
  • How would you catch this class of bug before deployment rather than in production?
    Static analysis. A type checker or linter derives possibly-unbound names from the same compile-time symbol information the interpreter uses, and reports the read as unsafe without running anything. Make that diagnostic a blocking CI check, and add branch-level tests for guards, empty batches and exception paths.

The function reserved a labelled pigeonhole for the value and then only filled it on the days the guard let it through. On the one day the guard said no, the clerk still walked to the pigeonhole and found it empty.

saying these in an interview costs you the question

  • Blames a missing global or a bad import
  • Wraps the call in try/except to keep the batch running
  • Initialises to a placeholder that corrupts the output
  • Assumes the code path is unreachable because tests passed
  • Ignores which input took the unexpected branch
  • Says the error is intermittent and therefore not deterministic

context