Three Python pitfalls that fool local LLM coding agents

Three Python pitfalls that fool local LLM coding agents

The worst bugs in Python never crash. Your code says what you meant, the tests you can see pass, and the language does something a little different underneath. A human reviewer misses them because the code looks right. A coding agent misses them for the same reason, then reports success because the test suite agrees. I built three small projects around three such language facts and handed them to local coding agents on my 16GB RTX 5060 Ti: a dense 27B and two 9B models, three runs each, in an agent loop with file and shell tools, with a hidden oracle that checks their fix against a reference on random inputs. I wanted to calibrate a benchmark; on the way I learned something that applies to every coding assistant on Python, local or not. Each pitfall below comes with the platform fact, the obvious fix that looks right, the correct fix, a test that tells them apart, and what the agents actually did.

Note

The bench: RTX 5060 Ti 16GB (Blackwell, 180 W limit) on PCIe Gen3 x8, Ryzen 5 4500 with 6 cores, 32GB of DDR4 at 3,600 MT/s, Debian 13, NVIDIA driver 615.71.09 with CUDA 13.4, llama.cpp at commit 391fac1 with --fit off, a 65,536-token context, a q4_0 KV cache and one slot, reasoning on, sampling at temperature 0.4 with a fixed seed per task and repetition. All the numbers you see here come from the benchmark procedure described in detail in how my harness runs agent tasks and the hidden oracle.

Pitfall 1: float() accepts far more than a number

The fact: Python's float() takes strings that most specifications would reject as numbers. It parses "nan", "inf" and "-Infinity" in any capitalisation, overflows "1e999" to infinity without an error, accepts underscores as digit separators ("1_000"), a leading plus sign, a bare leading or trailing dot (".5", "5."), leading zeros, and digits from non-Latin scripts. And json.dumps writes a NaN float as the bare token NaN, which strict JSON does not allow but json.loads happily reads back.

>>> [float(s) for s in ["nan", "INF", "1e999", "1_000", "+5", ".5", "5.", "007"]]
[nan, inf, inf, 1000.0, 5.0, 0.5, 5.0, 7.0]
>>> import json
>>> json.dumps({"min": float("nan")})
'{"min": NaN}'

The trap: a sensor log parser whose README defines a valid reading with a precise grammar: optional minus sign, digits, an optional dot followed by digits. Invalid readings must be skipped, a sensor with no valid readings must report null, and the output must be strict JSON.

The obvious fix: wrap float() in a try, skip on ValueError. The tests in the repo go green, and every form listed above gets through: nan counts as a reading, the mean comes out NaN, and a sensor with only junk readings crashes on min([]) or emits a bare NaN that strict JSON parsers refuse. Against the hidden oracle this fix disagrees on 36 trials out of 40.

The correct fix: validate against the grammar the README states, then convert.

import math
import re

# The README grammar: optional sign, ASCII digits, optional decimal part.
READING = re.compile(r"-?[0-9]+(\.[0-9]+)?")

def parse_reading(raw: str) -> float | None:
    text = raw.strip()
    if not READING.fullmatch(text):
        return None
    value = float(text)
    return value if math.isfinite(value) else None

What the agents did: better than I predicted. I expected the 9B models to stop at the obvious fix every time and the 27B to find the grammar some of the time, one to four successes in nine runs per variant. Instead all three models wrote fixes that respected the grammar: 9 of 9 on the plain variant and 7 of 9 on the variant with an extra strict-JSON test, and no run shipped the lazy fix. The README stated the grammar word for word, and the models applied it. For the benchmark, that meant a pitfall only traps a model when the specification gives the rule but not the shape of the solution. For you, it means a precise README is one of the best defences against bugs like this, for agents as much as for people.

If you use coding agents in a team and want guardrails that catch the platform bugs they miss, my hub on AI development tooling describes how I design them.

Pitfall 2: same-zone datetime subtraction ignores daylight saving

The fact: when you subtract two aware datetime objects that share the same tzinfo object, Python ignores the time zone and subtracts the wall-clock times. The datetime documentation says so explicitly: if both operands have the same tzinfo attribute, the tzinfo attributes are ignored and the subtraction runs on the naive times. If a daylight saving change falls in between, you're off by the size of the change, usually an hour. And ZoneInfo accepts a wall-clock time that does not exist in that zone, the hour skipped in spring, without any complaint.

>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> rome = ZoneInfo("Europe/Rome")
>>> start = datetime(2026, 10, 24, 22, 0, tzinfo=rome)
>>> end = datetime(2026, 10, 25, 6, 0, tzinfo=rome)
>>> end - start                      # wall-clock difference, not elapsed time
datetime.timedelta(seconds=28800)
>>> end.astimezone(ZoneInfo("UTC")) - start.astimezone(ZoneInfo("UTC"))
datetime.timedelta(seconds=32400)

The night of the autumn change lasts nine hours of real time and eight on the wall clock. A payroll that pays the eight is wrong by an hour for everyone who worked that night.

The trap: a timesheet calculator whose README says to compute elapsed real time, to treat a repeated hour as its first occurrence, and to reject a punch at a time that does not exist.

The obvious fix: attach the zone and subtract. It passes every visible test, which use winter dates, and it is wrong whenever the clocks change: 26 disagreements out of 40 against the oracle. Variants of it are just as plausible: converting to UTC without checking for skipped times (5 of 40), taking the repeated hour as its second occurrence (9 of 40), or correcting the wall-clock difference by a fixed hour (10 of 40).

The correct fix: convert both punches to UTC before subtracting, and validate each wall-clock time by checking that it survives a round trip through UTC.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

def to_utc_strict(naive: datetime, zone: ZoneInfo) -> datetime:
    # fold=0: a repeated wall-clock time means its first occurrence, as the README says.
    aware = naive.replace(tzinfo=zone, fold=0)
    utc = aware.astimezone(timezone.utc)
    # A skipped wall-clock time does not survive the round trip: reject it.
    if utc.astimezone(zone).replace(tzinfo=None) != naive:
        raise ValueError(f"{naive} does not exist in {zone.key}")
    return utc

def worked_seconds(start: datetime, end: datetime, zone: ZoneInfo) -> int:
    return int((to_utc_strict(end, zone) - to_utc_strict(start, zone)).total_seconds())

What the agents did: this one beat the panel, though not just because of the language fact. In the first calibration round only 2 of 9 runs solved the winter variant and none the summer one, but 12 of the 16 failures hit a ceiling of turns or time; they never got to a wrong answer. With the ceilings raised to 40 turns and 1200 s, the winter variant came out at 4 of 9: the 27B solved it in all three runs, one 9B in one of three, the other 9B in none. The wrong fixes carried the predicted signatures, most often the obvious same-zone subtraction, 26 of 40.

Qwen3.8-27B on three Python pitfalls, runs solved out of 3
Hardware
RTX 5060 Ti 16GB, 180 W, PCIe Gen3 x8; Ryzen 5 4500; 32GB DDR4-3600
OS
Debian 13, NVIDIA driver 615.71.09, CUDA 13.4
Software
llama.cpp 391fac1, harness 0.4.0, --fit off, 65,536-token context, one slot, q4_0 KV cache, reasoning on, temperature 0.4, min-p 0.05, agent loop, seven file and shell tools, 6,000 tokens/turn, hidden differential oracle, 40 trials on a pinned seed

Method: Each variant run three times per model with reasoning on, seed fixed per task and repetition. Solved means the visible suite passes and the hidden oracle finds zero disagreements on 40 random trials. Timesheet shown with raised ceilings, 40 turns and 1200 s.

Results (runs solved, higher is better)

Qwen3.8-27B on three Python pitfalls, runs solved out of 3values in runs solved
Qwen3.8-27B on three Python pitfalls, runs solved out of 3
Pitfall and variantQwen3.8-27B (runs solved)
float(), plain3
float(), strict JSON test2
datetime DST, winter tests3
datetime DST, summer tests2
rounding, two-decimal prices3
rounding, mills prices3
Source: author's measurements, early October 2026

The chart shows the 27B. On the same six variants, in the same order, Ornith-1.5-9B solved 3, 2, 1, 0, 3 and 2 runs out of 3, and Qwable-9B solved 3, 3, 0, 0, 0 and 0.

Aware datetimes that share a zone subtract as wall-clock times. If your code computes durations across a daylight saving change, it is either converting to UTC first or it is wrong twice a year.

Pitfall 3: round() rounds half to even, on a binary approximation

The fact: Python's round() uses round-half-to-even (the so-called banker's rounding), and it works on binary floating-point values, many of which can't hold the decimal you typed. The example in the documentation is round(2.675, 2), which returns 2.67, because 2.675 is stored as a number slightly below it. Three times 0.335 is not 1.005 in binary either. The decimal module avoids the representation problem, but Decimal.quantize() without an explicit rounding argument uses the context default, which is also round-half-to-even. And formatting negative zero gives -0.00.

>>> round(2.675, 2), round(0.125, 2), round(0.135, 2)
(2.67, 0.12, 0.14)
>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal("0.125").quantize(Decimal("0.01"))
Decimal('0.12')
>>> Decimal("0.125").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Decimal('0.13')
>>> f"{-0.0:.2f}"
'-0.00'

The trap: a VAT invoice calculator whose README requires exact decimal arithmetic, rounding half away from zero on each line, totals computed from the rounded lines, and never printing -0.00.

The obvious fix: floats and round(), which disagrees with the reference on 22 of 40 trials. The near misses are instructive: Decimal with the default quantize() (25 of 40), Decimal built from floats instead of strings (11 of 40), floor(x * 100 + 0.5) on floats (14 of 40), everything right except negative zero (9 of 40).

The correct fix: build Decimal values from the original strings, quantize with an explicit rounding mode, and normalise zero.

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

def to_cents(amount: Decimal) -> Decimal:
    # Explicit rounding: never rely on the context default, which is half-even.
    return amount.quantize(CENT, rounding=ROUND_HALF_UP)

def fmt(amount: Decimal) -> str:
    text = f"{to_cents(amount):f}"
    return "0.00" if text == "-0.00" else text

def line_total(qty: str, unit_price: str, rate: str) -> tuple[Decimal, Decimal]:
    net = to_cents(Decimal(qty) * Decimal(unit_price))
    vat = to_cents(net * Decimal(rate) / 100)
    return net, vat

What the agents did: the two-decimal variant came out at 6 of 9 and the variant with three- and four-decimal prices at 5 of 9. The 27B solved both variants in every run. The 9B models split: one solved most runs, the other none. And the panel produced a lazy fix that I had not predicted: rounding with int(x * 100 + 0.5) on floats, with the sign handled, and summing the totals from the line amounts without rounding them again. It disagrees with the reference on only 4 trials out of 40, so of all the wrong fixes it's the hardest to spot: right whenever the float holds the amount exactly, wrong only when it can't. Three runs of one 9B model shipped it.

Warning

A rounding fix that is wrong on 4 inputs out of 40 will pass almost any example-based test you write by hand. Only a test that generates many prices, including ones that are not exactly representable in binary, will catch it.

How the wrong rounding fixes differed

This is where the signature of a wrong fix pays off. Of the nine runs on the mills variant, four came back green on the repo's tests and wrong on the oracle, and each got it wrong in its own way. One run of one 9B model made its Decimal values from floats instead of the original strings, dragging the binary error into decimal arithmetic: 11 disagreements out of 40. Another stayed with floats and round(): 22 out of 40. Two shipped the unpredicted int(x * 100 + 0.5) variant: 4 out of 40. Each count points to one line of code, and you read a count quicker than a diff.

Writing the lazy fixes before you test an agent turns the oracle's pass or fail into a diagnosis.

The three pitfalls side by side

PitfallPlatform factObvious fixIts disagreement with the referencePanel result
Parsing numbersfloat() accepts nan, inf, underscores, non-ASCII digitsfloat() in a try36 of 409 of 9 and 7 of 9: models applied the stated grammar
Durations across DSTsame-zone aware subtraction is wall-clockattach ZoneInfo, subtract26 of 404 of 9 with raised ceilings
Money roundinground() and default quantize() are half-evenfloats and round()22 of 406 of 9 and 5 of 9, one unpredicted lazy fix at 4 of 40

The disagreement column is the signature of each lazy fix: the number of random trials out of forty on which it differs from the reference, measured before any model ran. An agent fix that lands on that count is almost certainly the predicted mistake.

How to test for these yourself

The common defence is a test that generates its inputs instead of listing them, and compares your code with a slow, obviously correct reference. For the three pitfalls above:

Which tests catch each pitfall
Test approachfloat() parsingDST durationsMoney rounding
Hand-written examples from the READMEPartial, if they include nanNo, winter dates passNo, easy values pass
Property test with random inputsYesYes, if it spans DST datesYes, if prices are not exact in binary
Differential test against a referenceYesYesYes
Strict JSON parser on the outputYesN/AN/A
Round trip through UTCN/AYesN/A
Source: author's measurements and Python documentation

For the date pitfall in particular, a property test needs dates around the change, not random dates across the year; for the rounding pitfall, prices with three or four decimals and values ending in 5. A Unix timestamp converter is handy to check by hand what a local time near a change maps to in UTC; that's usually when you realise the night had nine hours.

Why do coding agents ship these bugs?

Because the tests they could see never touched the language behaviour. All three facts are documented, stable and well known to experienced Python developers, and the agents still shipped the wrong fix in many runs. It's what I described in how local LLMs fake a fix: an agent optimises for whatever it can check. The 27B solved the date and rounding pitfalls in 11 of its 12 runs; the 9B models mostly didn't, in line with how far apart they sit in the wider comparison of the 27B with 9B coding models.

From these runs I keep one rule: put the language fact in the specification and the property in the tests. A README that says "durations are elapsed real time across daylight saving changes", plus one test that crosses a change, will do more for you than any choice of model. The float pitfall is the proof: when the README stated the grammar exactly, every model, including the 9B ones, got it right.

These bugs aren't exotic; people have shipped all three in production code. With an agent, what changes is how sure the report sounds: a green suite and a summary saying the bug is fixed. Treat that report as a claim, and give it a test it couldn't see. If you're bringing coding agents into a Python codebase and want acceptance tests that cover the language facts your domain depends on, you can describe the setup in the free quote form: two minutes, and I tell you upfront whether I can help.

Ultima modifica: