How does bisect.bisect_right turn a numeric score into a letter grade?
answer
- The return value is an index
- Boundaries in one list, labels in another
- Count of thresholds already passed
- Labels list is one longer than boundaries
- Ties land right of an equal boundary
basics
~10 sKeep the bucket boundaries in one ascending list and the labels in a parallel sequence. bisect.bisect_right returns how many boundaries the score has passed, and that count is the index of its label.
solid answer
~40 sWith `BREAKPOINTS = [60, 70, 80, 90]` and `GRADES = "FDCBA"`, `GRADES[bisect.bisect_right(BREAKPOINTS, score)]` is the whole lookup. `bisect_right` returns the position where the score would be inserted to keep the list sorted, placing it **after** any equal boundary, so a score of exactly 60 returns 1 and grades as D rather than F. The label sequence must be exactly one longer than the boundary list, because n boundaries cut the number line into n+1 buckets. Each lookup is O(log n) comparisons instead of an if/elif chain, and the thresholds live in data, so they can come from config. The one precondition is that the boundary list is sorted ascending; bisect never checks that for you.
code
python · 10 linesimport bisect
BREAKPOINTS = [60, 70, 80, 90]
GRADES = "FDCBA"
def grade(score):
return GRADES[bisect.bisect_right(BREAKPOINTS, score)]
print([grade(s) for s in (33, 59, 60, 69, 70, 89, 90, 100)])
# ['F', 'F', 'D', 'D', 'C', 'B', 'A', 'A']go deeper
Be ready to recall that bisect returns an insertion index, never a found/not-found flag, and that the labels list holds one more entry than the boundaries list. Writing the four-line grade function from memory is the whole bar here.
Explain why bisect_right rather than bisect_left is the boundary-inclusive choice, and demonstrate it on a score that equals a threshold exactly. Mention that the thresholds become data you can configure and test as a table.
Show the production instincts: assert sortedness at load time, keep the comparison keys separate from the bucket payload, and cover the exact boundary values in tests rather than only the midpoints of each band.
Own the case for a table-driven classifier over branching logic across a codebase — thresholds as configuration, one tested lookup helper reused by every band table, and the review rule that any new banding rule ships as data rather than as another if/elif chain.
### What the function actually returns `bisect.bisect_right(a, x)` does not answer "where is x?". It answers "at which index would x have to be inserted so that `a` stays sorted?", and when `x` equals an existing element it chooses the position **after** the run of equal values. `bisect.bisect_left` makes the mirror choice and returns the position before that run. Neither function raises when `x` is absent; a value below everything returns `0` and a value above everything returns `len(a)`. That return value is an integer in the closed range `0..len(a)`, and this is the insight the bucket idiom rests on: it is exactly *the number of boundaries the value has passed*. A list of n thresholds cuts the number line into n+1 half-open intervals, and the insertion point is the index of the interval the value fell into. ### The idiom ```python import bisect BREAKPOINTS = [60, 70, 80, 90] GRADES = "FDCBA" def grade(score): return GRADES[bisect.bisect_right(BREAKPOINTS, score)] ``` A score of 33 passes no boundary, so the index is 0 and the grade is `F`. A score of 90 passes all four, so the index is 4 and the grade is `A`. The two sequences are coupled by one invariant worth stating out loud in an interview: **`len(labels) == len(boundaries) + 1`**. Get that wrong and the top bucket raises `IndexError` on the highest inputs, which is the usual first bug. ### Why right rather than left The choice of variant *is* the decision about which side of a boundary is inclusive. `bisect_right` puts a value equal to a threshold into the **upper** bucket: 60 is a D, matching the ordinary reading "60 and above is a D". `bisect_left` would put an exact 60 into the lower bucket and grade it F. Neither is more correct in general; you pick the one matching the rule you were given, and you should be able to say which one you picked and why. A quick check against the boundary values themselves — not just values between them — catches the wrong choice immediately. ### Why it beats an if/elif chain A chain of comparisons works and, for four buckets, is not slow. The bisect version wins on other axes. The thresholds become data, so they can be loaded from configuration, tested as a table, or swapped per tenant without touching code. Adding a bucket is one entry in each sequence rather than a new branch. The lookup is O(log n) comparisons rather than O(n), which starts to matter with hundreds of bands — tax brackets, latency histograms, tier pricing, shipping-weight tables. And the chain has a failure mode the table does not: overlapping or misordered branches silently shadow one another, while the boundary list makes the ordering visible at a glance. ### The precondition nobody checks for you bisect assumes the sequence it is given is already sorted in ascending order, and it never verifies that. Handed `[3, 1, 2]` it returns a number rather than an error, and the number is meaningless. In a bucket table this shows up as a subtly wrong classification rather than a crash, so the boundaries deserve either a literal you can read top to bottom or an assertion at load time. ### Variations The labels need not be characters. A parallel list of tuples, dataclass instances or handler callables works identically, because the index is what carries the information — the payload is never compared. If the buckets carry a lot of data, keep the numeric thresholds in their own list rather than bisecting the records themselves; comparisons then never touch the payload. Since Python 3.10 you can instead bisect a list of records directly by passing `key=`, which extracts the threshold from each record during the search. The same shape handles any ordered type, not just numbers: dates for billing periods, versions for feature gates, or strings for alphabetical sections. `bisect.bisect` is simply an alias for `bisect.bisect_right`, so the idiom is often written with the short name. ### The ends, and the inputs Check the two extremes when you write one of these tables. A value below every boundary returns 0 and takes the first label; a value at or above the last boundary returns `len(boundaries)` and takes the last one. Because the insertion point is clamped to that range by construction, a correctly sized label sequence can never go out of bounds — which is why the length invariant is worth an assertion next to the table. The comparison itself is the ordinary `<` between the score and a boundary, so a `None` score or a `str` score against numeric boundaries raises `TypeError` at lookup time rather than misclassifying; validate the input before the lookup if you would rather return a default band.
- What changes if you build the same grade table with bisect.bisect_left?Every boundary value drops one bucket. `bisect_left` returns the index *before* a run of equal values, so a score of exactly 60 returns 0 and grades as F instead of D. Use `bisect_right` when "at or above the threshold" belongs to the higher band, and `bisect_left` when the threshold is an exclusive upper edge. Always test the boundary values themselves, not just values between them.
- How would you extend the lookup so each bucket carries more than a single label?Keep the parallel sequence as a list of tuples, dataclass instances or callables and index it with the same integer. The boundary list stays purely numeric, so comparisons never reach the payload and an unorderable payload can never raise. If you would rather store one list of records sorted by threshold, Python 3.10's `key=` parameter lets bisect extract the threshold during the search.
- What happens if the boundary list is not sorted?Nothing visible. bisect assumes ascending order and never validates it, so it returns an index computed from a broken assumption and the classification is silently wrong. There is no exception to catch. Either write the boundaries as a literal you can read in order or assert `boundaries == sorted(boundaries)` once at load time.
It is a ruler with notches: you slide the value along until it stops, and the number of notches behind it names the band it landed in.
saying these in an interview costs you the question
- Thinks bisect raises an error when the value is absent
- Makes the label list the same length as the boundary list
- Believes bisect checks that the boundary list is sorted
- Says bisect_left and bisect_right always return the same index
- Claims the lookup scans the boundaries linearly