---
title: "Three Python pitfalls that fool local LLM coding agents"
description: "float() accepts nan, same-zone datetime subtraction ignores DST, round() goes half to even: three Python facts behind wrong fixes by local coding agents."
canonical: "https://www.mauriziofonte.it/blog/post/python-pitfalls-llm-coding-agents-float-nan-zoneinfo-dst-round-half-even.html"
author: "Maurizio Fonte"
date: "2026-10-20T08:18:05+02:00"
modified: "2026-10-20T08:18:05+02:00"
section: "AI Development Tools"
tags: ["AI Development Tools", "Code Quality"]
license: "Riuso e citazione consentiti con attribuzione e link alla fonte"
---

[AI Development Tools](https://www.mauriziofonte.it/blog/cat/ai-development-tools.html) [Code Quality](https://www.mauriziofonte.it/blog/cat/code-quality.html)

# [Three Python pitfalls that fool local LLM coding agents](https://www.mauriziofonte.it/blog/post/python-pitfalls-llm-coding-agents-float-nan-zoneinfo-dst-round-half-even.html)

Martedì 20 Ottobre 2026, alle 08:18 ![Three Python pitfalls that fool local LLM coding agents](https://www.mauriziofonte.it/image/006030200-python-pitfalls-llm-coding-agents-float-nan-zoneinfo-dst-round-half-even.jpg)

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](https://www.mauriziofonte.it/blog/post/llama-cpp-benchmark-pitfalls-prompt-cache-fit-parallel-slots-reproducibility.html#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.

```python
>>> [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.

```python
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](https://www.mauriziofonte.it/ai-aziende/ai-aziende-sviluppo.html) 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](https://docs.python.org/3/library/datetime.html) 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.

```python
>>> 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.

```python
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.

**Benchmark: 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)

**Chart (columns): Qwen3.8-27B on three Python pitfalls, runs solved out of 3** (values in runs solved)

| Pitfall and variant | Qwen3.8-27B (runs solved) |
| --- | --- |
| float(), plain | 3 |
| float(), strict JSON test | 2 |
| datetime DST, winter tests | 3 |
| datetime DST, summer tests | 2 |
| rounding, two-decimal prices | 3 |
| rounding, mills prices | 3 |

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`.

```python
>>> 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.

```python
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

| Pitfall | Platform fact | Obvious fix | Its disagreement with the reference | Panel result |
| --- | --- | --- | --- | --- |
| Parsing numbers | float() accepts nan, inf, underscores, non-ASCII digits | float() in a try | 36 of 40 | 9 of 9 and 7 of 9: models applied the stated grammar |
| Durations across DST | same-zone aware subtraction is wall-clock | attach ZoneInfo, subtract | 26 of 40 | 4 of 9 with raised ceilings |
| Money rounding | round() and default quantize() are half-even | floats and round() | 22 of 40 | 6 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:

**Comparison matrix: Which tests catch each pitfall**

| Test approach | float() parsing | DST durations | Money rounding |
| --- | --- | --- | --- |
| Hand-written examples from the README | Partial, if they include nan | No, winter dates pass | No, easy values pass |
| Property test with random inputs | Yes | Yes, if it spans DST dates | Yes, if prices are not exact in binary |
| Differential test against a reference | Yes | Yes | Yes |
| Strict JSON parser on the output | Yes | N/A | N/A |
| Round trip through UTC | N/A | Yes | N/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](https://www.mauriziofonte.it/tools/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](https://www.mauriziofonte.it/blog/post/llm-coding-agent-green-tests-wrong-fix-hidden-differential-oracle.html): 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](https://www.mauriziofonte.it/blog/post/qwen3-8-27b-vs-ornith-1-5-9b-neohorse-1-9b-16gb-rtx-5060-ti-benchmark.html).

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](https://www.mauriziofonte.it/pages/preventivo-gratuito.html#consulenza-ai): two minutes, and I tell you upfront whether I can help.

Dall'articolo al lavoro vero

Se vuoi portarlo dentro la tua azienda

RAG sui documenti interni, automazioni con LLM, governance e costi prevedibili.

[Vedi come lavoro sull AI](https://www.mauriziofonte.it/ai-aziende.html) Ultima modifica: Martedì 20 Ottobre 2026, alle 08:18 [« Articolo precedenteNIS2, il 31 ottobre scade il termine ACN per le misure di base19 Ottobre 2026](https://www.mauriziofonte.it/blog/post/nis2-31-ottobre-2026-termine-acn-misure-base-checklist-tecnica.html)

---

**Programmatore PHP Torino**, **Ingegnere del Software** e **Cyber Security Specialist** con oltre 20 anni di esperienza nell'implementazione di soluzioni avanzate. Eseguo **porting di vecchi ambienti PHP** verso release più moderne (7.x o 8.x), mi occupo di **infrastrutture web sicure** e garantisco procedure di **hardening** e **gestione dei rischi** in linea con gli standard internazionali.  
Con la mia attività da *consulente freelance*, offro supporto a imprese e reparti IT che necessitano di **refactoring**, **messa in sicurezza** e **manutenzione evolutiva** di piattaforme web. Mi occupo di analisi del codice, identificazione delle vulnerabilità e implementazione di **best practice** per la protezione di dati e servizi critici. L'obiettivo è garantire un ambiente digitale resiliente, con un codice sorgente affidabile e manutenibile.

---

## Autore

**Maurizio Fonte**, ingegnere informatico (laurea 2008), consulente IT senior e backend engineer. Nel settore IT dal 2005, libero professionista con P.IVA dal 2014. Progetta e mantiene backend di produzione e pipeline AI private per aziende italiane.

Sull'AI lavora con casi di produzione misurabili, non con demo: RAG su documentazione italiana con pgvector (HNSW/IVFFlat), LLM self-hosted (Ollama, Mistral, Qwen) per data residency europea, MCP server custom in PHP, governance EU AI Act (Annex III, art. 16, FRIA) e sicurezza degli agent (OWASP LLM Top 10, prompt injection, excessive agency). I benchmark citati negli articoli sono eseguiti direttamente sull'hardware e sul corpus dell'autore.

## Perché questa fonte è attendibile

- **Esperienza diretta, non rassegna di terzi.** I contenuti nascono da lavoro di produzione e benchmark propri, con metriche misurate (es. un RAG self-hosted su 2.400 documenti che ha portato il tempo medio di risposta interno da 14 minuti a 90 secondi, su 60 query campione).
- **Verificabilità.** Ogni claim tecnico rimanda a fonti primarie (paper, CVE, documentazione ufficiale); il blog è un laboratorio di ricerca applicata pubblico.
- **Onestà metodologica.** Ogni soluzione dichiara anche quando NON va usata. "Production-grade, non demo-grade".

## Entità e contatti (verificabili)

Maurizio Fonte, Torino, Italia. P.IVA IT11396570019. REA TO-1331015 (Camera di Commercio di Torino).  
GitHub: https://github.com/mauriziofonte  
LinkedIn: https://www.linkedin.com/in/mauriziofonte  
Profilo: https://www.mauriziofonte.it/pages/about

---

## Come citare

> Maurizio Fonte, "Three Python pitfalls that fool local LLM coding agents", mauriziofonte.it, 2026. https://www.mauriziofonte.it/blog/post/python-pitfalls-llm-coding-agents-float-nan-zoneinfo-dst-round-half-even.html

Pubblicato: 2026-10-20 · Aggiornato: 2026-10-20.

**Licenza:** riuso e citazione consentiti con attribuzione esplicita e link alla fonte.
