Blog Programmazione

Il centesimo che non torna: fare i conti in euro con Python

Il tipo float non sa contare i soldi, e il motivo non è un difetto di Python. Il modulo decimal lo sa fare, ma solo se gli si dà un punto di partenza pulito e si decide esplicitamente dove arrotondare.

La segnalazione arriva sempre nella stessa forma: «il programma stampa un totale un centesimo più basso di quello che mi dice il commercialista». Chi l'ha scritto controlla le formule, non trova niente di sbagliato, e finisce per aggiungere una correzione a mano che fa tornare quel caso e ne rompe un altro sei mesi dopo. Il problema non è nelle formule: è nel tipo di numero con cui sono scritte.

Tutto quello che segue è stato eseguito con Python 3.13.16 e la sola libreria standard — decimal è dentro Python, non c'è niente da installare — e gli output riportati sono quelli reali.

Un numero binario non è un numero decimale

Il tipo float di Python è un numero a virgola mobile binaria: una frazione con denominatore una potenza di due. Alcuni numeri decimali, in quella forma, non esistono. La documentazione ufficiale lo dice a proposito di un decimo: «no matter how many base 2 digits you're willing to use, the decimal value 0.1 cannot be represented exactly as a base 2 fraction», e precisa che il valore memorizzato è 0.1000000000000000055511151231257827021181583404541015625.

Non si vede, perché la stampa di un float mostra la più corta fra le scritture decimali che si riconvertono in quello stesso float: «Python (on most systems) is now able to choose the shortest of these and simply display 0.1». Numero approssimato, stampa pulita: è la combinazione che fa perdere tempo. Appena si fanno due conti l'approssimazione esce:

>>> 0.1 + 0.2
0.30000000000000004
>>> sum([19.99, 19.99, 19.99, 0.015])
59.98499999999999

Il secondo caso è il più insidioso, perché quel totale somma importi che su uno scontrino hanno due decimali. E non è una questione di «precisione insufficiente»: aggiungere cifre non serve, perché il numero cercato non sta fra quelli rappresentabili.

round() non è l'arrotondamento commerciale

Il rimedio istintivo è arrotondare con round(). Funziona meno di quanto sembri, per due motivi distinti.

Il primo è una scelta di progetto, documentata: per i tipi incorporati «if two multiples are equally close, rounding is done toward the even choice», cioè nei pareggi si va verso la cifra pari. Da qui round(0.5) == 0 e round(2.5) == 2. È l'arrotondamento statistico, o banker's rounding: evita un errore sistematico verso l'alto su grandi quantità di dati, ma non è la regola che si aspetta chi legge una fattura.

Il secondo motivo non ha a che fare con i pareggi: su un float il pareggio spesso non c'è. La documentazione avverte che «the behavior of round() for floats can be surprising: for example, round(2.675, 2) gives 2.67 instead of the expected 2.68», e che non è un difetto. Il caso che si incontra in una fattura è però questo, misurato durante la stesura:

Lo stesso importo, due float diversi

Un chilo e mezzo a 3,79 euro al chilo fa 5,685 euro. Scritto come costante, 5.685 è il float che vale 5,68499999999999960920…; calcolato come 1.5 * 3.79 è un float diverso, che vale 5,68500000000000049737…. I due non sono uguali (5.685 == 1.5 * 3.79 è False) e round(…, 2) dà 5.68 sul primo e 5.69 sul secondo. Lo stesso conto, scritto in due modi equivalenti sulla carta, dà due centesimi diversi: nessuna regola di arrotondamento lo rimedia, perché i numeri di partenza sono già diversi.

Un numero che vale quello che c'è scritto

Il modulo decimal esiste per questo. La sua pagina si apre dichiarando che «in decimal floating point, 0.1 + 0.1 + 0.1 - 0.3 is exactly equal to zero. In binary floating point, the result is 5.5511151231257827e-017», e che il principio di progetto è che il calcolatore deve «provide an arithmetic that works in the same way as the arithmetic that people learn at school».

C'è una sola regola da non violare: un Decimal si costruisce da una stringa, mai da un float. Dal float conserva l'approssimazione invece di cancellarla — «if value is a float, the binary floating-point value is losslessly converted to its exact decimal equivalent» — e infatti Decimal(float('1.1')) restituisce Decimal('1.100000000000000088817841970012523233890533447265625'). Gli importi che arrivano da un file o da una risposta JSON vanno quindi passati come testo: la conversione intermedia in float è il punto esatto in cui si perde il centesimo.

L'arrotondamento si fa con quantize(), che «return a value equal to the first operand after rounding and having the exponent of the second operand»: come secondo operando si passa la precisione, cioè Decimal('0.01'). Il contesto predefinito non va bene per i soldi — «the default values are Context.prec=28, Context.rounding=ROUND_HALF_EVEN» — quindi la modalità si indica a ogni chiamata: ROUND_HALF_UP è documentato come «round to nearest with ties going away from zero», l'arrotondamento che si insegna a scuola.

Non è solo una preferenza estetica. L'articolo 5 del regolamento (CE) n. 1103/97 del Consiglio, del 17 giugno 1997, relativo a talune disposizioni per l'introduzione dell'euro, prevede che gli importi monetari da pagare o da registrare, arrotondati dopo una conversione, «shall be rounded up or down to the nearest cent», e che «if the application of the conversion rate gives a result which is exactly half-way, the sum shall be rounded up». Quella norma riguarda le conversioni di valuta, non la fatturazione, e non va letta come un obbligo sulle fatture: è però il riferimento ufficiale più vicino, e dice che nella pratica monetaria europea il mezzo centesimo va verso l'alto. Su quale metodo valga nel proprio caso concreto, la persona da sentire è il commercialista.

La fattura, per intero

"""Conti in euro con Decimal. Provato con Python 3.13.16, solo libreria standard."""
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN

CENT = Decimal('0.01')
ALIQUOTA = Decimal('0.22')


def euro(x: Decimal) -> Decimal:
    """Arrotonda al centesimo, con i pareggi verso l'alto."""
    return x.quantize(CENT, rounding=ROUND_HALF_UP)


def fattura(righe):
    """righe: elenco di (descrizione, quantita, prezzo_unitario) come stringhe."""
    corpo, imponibile, imposta = [], Decimal('0.00'), Decimal('0.00')
    for descrizione, quantita, prezzo in righe:
        netto_riga = euro(Decimal(quantita) * Decimal(prezzo))
        iva_riga = euro(netto_riga * ALIQUOTA)
        corpo.append((descrizione, netto_riga, iva_riga))
        imponibile += netto_riga
        imposta += iva_riga
    return corpo, imponibile, imposta, imponibile + imposta


def ripartisci(totale: Decimal, pesi) -> list:
    """Divide 'totale' in proporzione ai pesi: la somma delle parti torna sempre."""
    somma_pesi = sum(pesi)
    esatte = [totale * p / somma_pesi for p in pesi]
    parti = [e.quantize(CENT, rounding=ROUND_DOWN) for e in esatte]
    centesimi_residui = int(((totale - sum(parti)) / CENT).to_integral_value())
    ordine = sorted(range(len(pesi)),
                    key=lambda i: esatte[i] - parti[i], reverse=True)
    for i in ordine[:centesimi_residui]:
        parti[i] += CENT
    return parti


if __name__ == '__main__':
    righe = [('Assistenza remota', '1.5', '48.00'),
             ('Cavo di rete', '3', '9.99'),
             ('Pesato al banco', '1.5', '3.79')]
    corpo, imponibile, imposta, totale = fattura(righe)
    for descrizione, netto, iva in corpo:
        print(f'{descrizione:20s} {netto:>8} {iva:>7}')
    print(f'{"Imponibile":20s} {imponibile:>8}')
    print(f'{"Imposta":20s} {imposta:>8}')
    print(f'{"Totale":20s} {totale:>8}')

    iva_sul_totale = euro(imponibile * ALIQUOTA)
    print('\nImposta riga per riga:', imposta)
    print('Imposta sul totale  :', iva_sul_totale)
    print('Differenza          :', imposta - iva_sul_totale)

    parti = ripartisci(totale, [Decimal(1)] * 4)
    print('\nQuattro rate:', [str(p) for p in parti], '- somma', sum(parti))

Eseguito, stampa questo:

Assistenza remota       72.00   15.84
Cavo di rete            29.97    6.59
Pesato al banco          5.69    1.25
Imponibile             107.66
Imposta                 23.68
Totale                 131.34

Imposta riga per riga: 23.68
Imposta sul totale  : 23.69
Differenza          : -0.01

Quattro rate: ['32.84', '32.84', '32.83', '32.83'] - somma 131.34

Due metodi corretti, un centesimo di differenza

Le ultime righe dell'output sono la parte interessante, e non dipendono da Decimal: restano identiche con qualunque tipo di numero. L'imposta calcolata riga per riga dà 23,68; calcolata una volta sola sull'imponibile complessivo dà 23,69. Nessuno dei due è un errore: sono due ordini diversi fra moltiplicazione e arrotondamento, e arrotondare n volte non dà quello che dà arrotondare una volta.

Questa è la vera causa della segnalazione da cui siamo partiti, molto più spesso dei float: il programma arrotonda per riga, il foglio di calcolo arrotonda sul totale. Non si risolve scegliendo il metodo «giusto» in astratto, ma decidendo quale dei due usa l'azienda, scrivendolo in un punto solo del codice e commentandolo, perché fra due anni nessuno lo cambi per intuizione. Se gli importi finiscono in un tracciato di fattura elettronica il criterio lo impone il tracciato; negli altri casi va concordato con chi tiene la contabilità.

Dividere centotrentuno euro e trentaquattro in quattro

La funzione ripartisci risolve il secondo classico: le rate, il riparto fra soci, una spesa condominiale. Il metodo ingenuo — dividere e arrotondare — perde centesimi: 100,00 diviso tre dà tre rate da 33,33 che sommano 99,99. Qui si arrotonda per difetto con ROUND_DOWN, si conta quanti centesimi mancano al totale e si distribuisce un centesimo a chi ha il resto più grande. È il metodo dei resti maggiori, quello dei seggi in un'elezione proporzionale.

La proprietà che conta è verificabile con un'asserzione: la somma delle parti è sempre uguale al totale di partenza, per costruzione. Provata su 100,00 diviso in tre, in sette e in proporzione 3:1:1, ha dato 33.34 33.33 33.33, sette parti fra 14,28 e 14,29 e 60.00 20.00 20.00, con somma esatta ogni volta. Chi riparte spese ricorrenti farà bene a ruotare chi riceve il centesimo in più, altrimenti lo paga sempre lo stesso.

Dove tenere gli importi fra un calcolo e l'altro

Il calcolo pulito non basta se l'importo passa da un float nel tragitto, e il punto critico è la persistenza: in SQLite non esiste un tipo decimale e un REAL è un float, in MySQL e PostgreSQL si usa DECIMAL/NUMERIC con scala dichiarata, in JSON un numero con la virgola viene quasi sempre riletto come float. La strada più solida è tenere gli importi come interi di centesimi: int(importo.scaleb(2).to_integral_value()) dà 3657 per 36,57 euro e Decimal(3657) * Decimal('0.01') lo riporta indietro esatto. Un intero attraversa qualunque database senza perdere niente.

Due dettagli da conoscere prima di incontrarli in produzione. La precisione predefinita del contesto è 28 cifre significative e riguarda moltiplicazioni e divisioni, non i decimali: su un quantize di un numero enorme si supera, e lì il modulo solleva un'eccezione invece di dare un risultato sbagliato — «if the length of the coefficient after the quantize operation would be greater than precision, then an InvalidOperation is signaled». E decimal è solo decimale, non magia: un terzo non si rappresenta nemmeno lì, cambia che l'approssimazione cade dove ce l'aspettiamo.

Cosa fare, in concreto

Chi ha un programma che tocca importi può fare un controllo in mezz'ora. Si cerca dove i numeri con la virgola entrano da fuori — float( su un campo di file, modulo web o risposta JSON — e si sostituisce con Decimal( sulla stringa originale. Si guarda ogni round(x, 2) su un importo e si decide se il pareggio verso la cifra pari è quello che si vuole. Si scrive una funzione come euro() in un punto solo e si usa sempre quella. E si aggiunge un collaudo che prenda un documento reale, ricalcoli il totale e lo confronti con quello registrato.

Se la differenza sopravvive a tutto questo non è un problema di tipi: è il metodo di arrotondamento dell'imposta, e va chiarito con chi tiene la contabilità prima di toccare un'altra riga di codice.