skip to content

Why does total // size undercount batches in an importer, and what is the correct ceiling-division idiom?

level: seniorimportance: should knowfreq 34%

answer

  1. Integer division rounds down
  2. The tail batch quietly disappears
  3. Negate around the division, twice
  4. Avoid routing big ints through float

basics

~20 s

Floor division discards the partial final batch, so 10001 records at 500 per batch reports 20 instead of 21. Compute the ceiling with -(-total // size) or (total + size - 1) // size, both exact for Python ints.

solid answer

~40 s

`//` floors, so any leftover that does not fill a whole batch silently vanishes from the count - the classic symptom is that the tail of a dataset is never imported and nothing raises. The integer-exact fixes are `-(-total // size)`, which flips the floor into a ceiling by negating on both sides, and `(total + size - 1) // size`. Both stay exact for arbitrarily large Python ints. Reach for `math.ceil(total / size)` only for small values: `/` converts to `float`, so beyond 2**53 the result silently rounds and past roughly 1e308 it raises `OverflowError`. When you also need the leftover, `divmod` gives both in one call. Best of all, iterate with `range(0, total, size)` so the count never has to be computed, and guard `size` against zero.

code

python · 8 lines
python
total, size = 10_001, 500

print(total // size)                # 20 - the tail batch vanished
print(-(-total // size))            # 21
print((total + size - 1) // size)   # 21

full, rest = divmod(total, size)
print(full + (1 if rest else 0))    # 21

go deeper

for a junior

Know that // rounds down and therefore drops any partial final batch, and that adding one unconditionally is wrong because it overcounts when the division comes out exact.

for a middle

Explain and justify the ceiling idioms - -(-total // size) and (total + size - 1) // size - and use divmod when the leftover is needed as well as the count.

for a senior

Diagnose the silent truncation from its symptom, avoid routing large integers through float division, guard the divisor at its entry point, and name the ragged-total test that would have caught it.

for a principal

Push the fix upstream: prefer batching constructs that make the count unrepresentable, standardise one ceiling helper across services rather than per-call idioms, and require ragged fixtures in the team's testing conventions.

## The failure, in the shape it actually arrives A museum-catalogue importer pages through records in fixed batches. The batch size is not a round number - it was derived from a 92nd-percentile payload budget, so it lands on something like 500 records - and the batch count is computed as `total // size`. With 10,001 catalogue records that is 20, and 20 batches move 10,000 records. The 10,001st record is never fetched. Nothing raises. No log line fires. The import reports success, the row counts look plausible, and the defect surfaces weeks later as a handful of missing objects that correlate with nothing. This is the canonical silent truncation, and it is the reason interviewers ask about ceiling division: the bug is invisible in every test whose fixture size happens to divide evenly. ## Why it happens `//` is floored division: `a // b` is the largest integer not greater than the exact quotient. That is precisely the wrong rounding direction for "how many containers do I need to hold N things", where any leftover requires one more container. Floor answers "how many **full** batches are there"; the question asked was "how many batches are there **at all**". ## The three integer-exact idioms **Negate twice.** `-(-total // size)` is the compact idiom. Negating the dividend turns the quotient negative, flooring it rounds *away* from zero relative to the original, and negating the result flips it back - a ceiling. It works for any Python integers, needs no extra term, and is worth a one-line comment because it reads as a trick the first time you meet it. **Add size minus one.** `(total + size - 1) // size` says the same thing in a form many readers find more obvious: push the value up to the next multiple boundary before flooring. In C this idiom carries an overflow hazard on the addition; in Python ints are unbounded, so it is safe here. It does assume `total` is non-negative. **Use divmod when you also need the tail.** `full, rest = divmod(total, size)` then `batches = full + (1 if rest else 0)`. This is the version to prefer when the leftover matters anyway - sizing the last request, logging the tail, or asserting an invariant. ## The trap: math.ceil on a true division `math.ceil(total / size)` looks like the clearest expression of intent, and for modest values it is fine. But `/` is true division and always produces a `float`. Above 2**53 the integer no longer has an exact binary64 representation, so the division silently rounds and the ceiling can come out one too low - the same class of silent truncation you were trying to fix. Above roughly 1.8e308 the conversion raises `OverflowError` instead. In a catalogue importer the totals are small enough that it works; in anything counting bytes, offsets or hashes it is a latent defect. `-(-a // b)` has no such ceiling. ## Better still: do not count The most robust fix is often to stop computing a batch count at all and let the iteration express the batching: ```python for start in range(0, total, size): chunk = records[start:start + size] ``` `range` with a step already handles the partial tail correctly, and the slice at the end is simply shorter. No off-by-one is possible because no rounding decision is made. Use an explicit count only when something outside the loop genuinely needs it - a progress bar, a work-queue fan-out, a pre-allocated result table. ## Guarding the divisor Whatever form you pick, `size` reaching zero raises `ZeroDivisionError` from `//`, `%` and `divmod` alike. A batch size that comes from configuration, a query parameter or a computed budget must be validated where it enters - failing loudly at the boundary with a clear message beats a `ZeroDivisionError` from deep inside an import loop, and beats a silent fallback that quietly re-introduces the truncation. ## What a senior answer sounds like Name the rounding direction as the cause rather than describing the symptom; give the exact integer idiom and be able to justify why the double negation is a ceiling; flag the `math.ceil(a / b)` float hazard before being asked; mention `divmod` when the leftover is wanted too; and close with the test that would have caught it - a case whose total deliberately does **not** divide evenly by the batch size, asserting that every record is visited exactly once. Fixture sizes that divide evenly are how this bug survives a full test suite.

  • Why is -(-total // size) a ceiling rather than a floor?
    Flooring always rounds toward negative infinity. Negating the dividend moves the quotient to the other side of zero, so flooring there rounds away from zero in the original frame, and negating the result maps it back - the net effect is rounding up. Because it never leaves integer arithmetic it is exact for Python ints of any size, unlike anything routed through a float.
  • What test would have caught the missing final batch before it reached production?
    One whose total deliberately does not divide evenly by the batch size - 10,001 records at 500 per batch - asserting that every record is processed exactly once, not merely that the run succeeded. Fixtures sized as round multiples are why this defect survives whole test suites: floor and ceiling agree precisely when the remainder is zero, so the only cases that discriminate are the ragged ones.
  • When is math.ceil(total / size) actually acceptable?
    When both operands are floats already, or when the integers are comfortably below 2**53 and bounded by something you control - a page count over a table with a few million rows, say. It becomes a defect when the values are unbounded or derived from byte counts, offsets or hashes, because `/` converts to binary64 and either rounds silently or raises `OverflowError` past about 1.8e308.

Nineteen passengers and a four-seat taxi need five taxis, not four; floor division books four and leaves three people at the kerb without telling you.

saying these in an interview costs you the question

  • Uses total // size and adds one unconditionally, overcounting exact multiples
  • Reaches for math.ceil(total / size) on unbounded integers
  • Calls the missing tail batch an edge case not worth handling
  • Tests only with fixture sizes that divide evenly
  • Leaves a configured batch size unguarded against zero

context