Codice fiscale in Python
Aggiornato il 30 luglio 2026 · 8 minuti di lettura
Un modulo Python 3 completo, con type hint e senza dipendenze esterne: normalizza, verifica la forma, scioglie l’omocodia, calcola il carattere di controllo e ricava data, sesso e comune di nascita. I sei blocchi qui sotto, nell’ordine in cui compaiono, formano il file codice_fiscale.py; il settimo è la sua suite di test.
È lo stesso algoritmo che alimenta il decoder di questo sito, tradotto da TypeScript e poi rimesso a confronto con l’originale su cinquantunomila casi generati automaticamente: nessuna divergenza. Il modulo è stato eseguito su Python 3.9.6 e 3.14.3 con esito identico.
Tabelle e forma
Il carattere di controllo attribuisce a ogni carattere due valori diversi a seconda che si trovi in posizione dispari o pari. Sono settantadue voci: conviene copiarle, non riscriverle a mano.
"""Codice fiscale: calcolo e validazione, senza dipendenze esterne.
Riferimento normativo: DM 23 dicembre 1976, Ministero delle Finanze.
Verificato su Python 3.9 e 3.14, senza librerie di terze parti.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Optional
# Valore di ogni carattere nelle posizioni DISPARI (1a, 3a, 5a, ... contate da 1).
DISPARI = {
"0": 1, "1": 0, "2": 5, "3": 7, "4": 9, "5": 13, "6": 15, "7": 17, "8": 19, "9": 21,
"A": 1, "B": 0, "C": 5, "D": 7, "E": 9, "F": 13, "G": 15, "H": 17, "I": 19, "J": 21,
"K": 2, "L": 4, "M": 18, "N": 20, "O": 11, "P": 3, "Q": 6, "R": 8, "S": 12, "T": 14,
"U": 16, "V": 10, "W": 22, "X": 25, "Y": 24, "Z": 23,
}
# Valore di ogni carattere nelle posizioni PARI (2a, 4a, 6a, ...).
PARI = {
"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9,
"A": 0, "B": 1, "C": 2, "D": 3, "E": 4, "F": 5, "G": 6, "H": 7, "I": 8, "J": 9,
"K": 10, "L": 11, "M": 12, "N": 13, "O": 14, "P": 15, "Q": 16, "R": 17, "S": 18,
"T": 19, "U": 20, "V": 21, "W": 22, "X": 23, "Y": 24, "Z": 25,
}
RESTO = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# Lettera del mese: A = gennaio ... T = dicembre.
MESI = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "H": 6,
"L": 7, "M": 8, "P": 9, "R": 10, "S": 11, "T": 12}
LETTERE_MESE = "ABCDEHLMPRST"
# Omocodia: le cifre sostituite da lettere e le sette posizioni (indici 0-based).
OMOCODIA = {"L": "0", "M": "1", "N": "2", "P": "3", "Q": "4",
"R": "5", "S": "6", "T": "7", "U": "8", "V": "9"}
POSIZIONI_NUMERICHE = (6, 7, 9, 10, 12, 13, 14)
# Forma ammessa: le posizioni numeriche accettano anche le dieci lettere omocodiche.
FORMA = re.compile(
r"^[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{2}"
r"[A-Z][0-9LMNPQRSTUV]{3}[A-Z]$"
)
VOCALI = frozenset("AEIOU")Normalizzazione
Due funzioni per due scopi diversi. normalizza ripulisce un codice fiscale digitato, che arriva spesso con spazi o trattini. solo_lettere prepara un cognome o un nome: scompone la stringa in forma NFD e scarta i segni combinanti, così Nicolò diventa NICOLO e l’apostrofo di D’Angelo sparisce.
# ---------------------------------------------------------------- #
# Utilità
# ---------------------------------------------------------------- #
def normalizza(testo: str) -> str:
"""Maiuscolo, via tutto ciò che non è lettera o cifra."""
return re.sub(r"[^A-Z0-9]", "", (testo or "").upper())
def solo_lettere(testo: str) -> str:
""""D'Angelo" -> "DANGELO", "Nicolò" con accento -> "NICOLO"."""
scomposto = unicodedata.normalize("NFD", testo or "")
senza_accenti = "".join(c for c in scomposto if not unicodedata.combining(c))
return re.sub(r"[^A-Z]", "", senza_accenti.upper())unicodedata.combining evita di dover elencare a mano le lettere accentate: riconosce qualunque segno diacritico, compresi quelli dei cognomi stranieri iscritti all’anagrafe italiana.
Il carattere di controllo
# ---------------------------------------------------------------- #
# Carattere di controllo
# ---------------------------------------------------------------- #
def check_char(primi15: str) -> str:
"""Sedicesimo carattere, calcolato sui primi quindici."""
if len(primi15) < 15:
raise ValueError("Servono almeno 15 caratteri.")
somma = 0
for i, ch in enumerate(primi15[:15]):
# i è 0-based: l'indice 0 è la PRIMA posizione, quindi dispari.
tabella = DISPARI if i % 2 == 0 else PARI
if ch not in tabella:
raise ValueError(f"Carattere non ammesso: {ch!r}")
somma += tabella[ch]
return RESTO[somma % 26]Attenzione allo sfasamento fra la numerazione della norma e quella dell’indice: la prima posizione è dispari, ma il suo indice è 0. La condizione corretta è quindi i % 2 == 0, e invertirla è l’errore più comune in assoluto in questa funzione. Il calcolo passo per passo
Omocodia
Quando due persone genererebbero lo stesso codice, l’Agenzia delle Entrate ne distingue una sostituendo cifre con lettere a partire da destra. La funzione restituisce anche il livello, cioè quante sostituzioni ha trovato: serve a interpretare correttamente anno, giorno e codice catastale, e a poterlo dire all’utente invece di mostrargli un errore.
# ---------------------------------------------------------------- #
# Omocodia
# ---------------------------------------------------------------- #
def sciogli_omocodia(cf: str) -> tuple[str, int]:
"""Riporta le lettere omocodiche alle cifre originarie.
Restituisce (codice_base, numero_di_sostituzioni).
"""
caratteri = list(cf)
livello = 0
for p in POSIZIONI_NUMERICHE:
cifra = OMOCODIA.get(caratteri[p])
if cifra is not None:
caratteri[p] = cifra
livello += 1
return "".join(caratteri), livelloCalcolo del codice
La regola del nome è quella che sfugge più spesso: con quattro o più consonanti si prendono la prima, la terza e la quarta, saltando la seconda. Per questo GIOVANNI dà GNN e non GVN. Per il cognome si prendono invece sempre le prime tre consonanti, e se non bastano si aggiungono le vocali e poi delle X.
# ---------------------------------------------------------------- #
# Codifica di cognome e nome
# ---------------------------------------------------------------- #
def encode_surname(cognome: str) -> str:
"""Cognome: consonanti in ordine, poi vocali, prime tre, riempite con X."""
s = solo_lettere(cognome)
consonanti = [c for c in s if c not in VOCALI]
vocali = [c for c in s if c in VOCALI]
return ("".join(consonanti) + "".join(vocali) + "XXX")[:3]
def encode_name(nome: str) -> str:
"""Nome: con quattro o più consonanti si prendono la 1a, la 3a e la 4a."""
s = solo_lettere(nome)
consonanti = [c for c in s if c not in VOCALI]
if len(consonanti) >= 4:
return consonanti[0] + consonanti[2] + consonanti[3]
vocali = [c for c in s if c in VOCALI]
return ("".join(consonanti) + "".join(vocali) + "XXX")[:3]
# ---------------------------------------------------------------- #
# Calcolo diretto
# ---------------------------------------------------------------- #
def build_cf(cognome: str, nome: str, data_nascita: date,
sesso: str, codice_catastale: str) -> str:
"""Costruisce il codice fiscale completo di carattere di controllo."""
if not solo_lettere(cognome):
raise ValueError("Cognome mancante.")
if not solo_lettere(nome):
raise ValueError("Nome mancante.")
sesso = (sesso or "").upper()
if sesso not in ("M", "F"):
raise ValueError('Sesso: attesi "M" o "F".')
catastale = (codice_catastale or "").upper().strip()
if not re.fullmatch(r"[A-Z]\d{3}", catastale):
raise ValueError("Codice catastale non valido.")
giorno = data_nascita.day + (40 if sesso == "F" else 0)
primi15 = (
encode_surname(cognome)
+ encode_name(nome)
+ f"{data_nascita.year % 100:02d}"
+ LETTERE_MESE[data_nascita.month - 1]
+ f"{giorno:02d}"
+ catastale
)
return primi15 + check_char(primi15)Decodifica
decode_cf non solleva eccezioni sui codici malformati: accumula gli errori in una lista e restituisce comunque tutti i campi che è riuscita a leggere. Per un campo di un modulo è il comportamento giusto, perché permette di spiegare che cosa non torna.
# ---------------------------------------------------------------- #
# Decodifica
# ---------------------------------------------------------------- #
@dataclass
class Esito:
cf: str
valido: bool = False
errori: list[str] = field(default_factory=list)
codice_cognome: Optional[str] = None
codice_nome: Optional[str] = None
data_nascita: Optional[date] = None
sesso: Optional[str] = None
codice_catastale: Optional[str] = None
omocodia: int = 0
controllo_trovato: Optional[str] = None
controllo_atteso: Optional[str] = None
def _risolvi_anno(aa: int, mese: int, giorno: int, adesso: date) -> int:
"""Due cifre non bastano a esprimere il secolo.
Si parte dall'anno corrente e, se la data così ottenuta è nel futuro,
si torna indietro di cento anni: un codice fiscale non può codificare
una nascita non ancora avvenuta.
"""
anno = (1900 if aa > adesso.year % 100 else 2000) + aa
try:
if date(anno, mese, giorno) > adesso:
anno -= 100
except ValueError:
pass # data inesistente in quel secolo: la segnala il chiamante
return anno
def decode_cf(testo: str, adesso: Optional[date] = None) -> Esito:
"""Decodifica un codice fiscale e ne verifica il carattere di controllo."""
adesso = adesso or datetime.now().date()
cf = normalizza(testo)
esito = Esito(cf=cf)
if len(cf) != 16:
esito.errori.append(f"Lunghezza errata: {len(cf)} caratteri invece di 16.")
return esito
if not FORMA.match(cf):
esito.errori.append(
"Forma non valida: 6 lettere, 2 cifre, 1 lettera, 2 cifre, "
"1 lettera, 3 cifre, 1 lettera."
)
return esito
atteso = check_char(cf[:15])
esito.controllo_trovato, esito.controllo_atteso = cf[15], atteso
if cf[15] != atteso:
esito.errori.append(
f"Carattere di controllo errato: trovato {cf[15]}, atteso {atteso}."
)
base, esito.omocodia = sciogli_omocodia(cf)
esito.codice_cognome, esito.codice_nome = cf[:3], cf[3:6]
aa = int(base[6:8])
mese = MESI.get(base[8])
giorno = int(base[9:11])
if mese is None:
esito.errori.append(f"Lettera del mese non valida: {base[8]}.")
else:
esito.sesso = "F" if giorno > 40 else "M"
if giorno > 40:
giorno -= 40
if not 1 <= giorno <= 31:
esito.errori.append(f"Giorno di nascita non valido: {giorno}.")
else:
anno = _risolvi_anno(aa, mese, giorno, adesso)
try:
esito.data_nascita = date(anno, mese, giorno)
except ValueError:
esito.errori.append(
f"Data di nascita inesistente: {giorno}/{mese}/{anno}."
)
esito.codice_catastale = base[11:15]
esito.valido = not esito.errori
return esitoAll’uso si presenta così:
>>> from datetime import date
>>> from codice_fiscale import build_cf, decode_cf
>>> build_cf("Rossi", "Mario", date(1985, 8, 1), "M", "H501")
'RSSMRA85M01H501Q'
>>> e = decode_cf("MLLSNT82P65Z404U")
>>> e.valido, e.data_nascita, e.sesso, e.codice_catastale
(True, datetime.date(1982, 9, 25), 'F', 'Z404')
>>> decode_cf("RSSMRA85M01H501Z").errori
['Carattere di controllo errato: trovato Z, atteso Q.']Il campo codice_catastale va poi risolto in un nome di comune. I dati pronti sono in /comuni-lookup.json: un dizionario di 10.533 voci che copre i 7.896 comuni attivi, i 197 Stati esteri (codici Z) e 2.440 comuni soppressi. Il valore è Nome|SIGLA|slug per una voce attiva e Nome|SIGLA||TIPO|anno|SUCCESSORI per un comune soppresso: il numero di campi è il discriminante, e lo slug dei soppressi è vuoto perché non esiste una pagina da collegare.
I test
Il file gira sia da solo sia sotto pytest, senza importarlo. Copre i tre vettori pubblicati, i casi noti della codifica, il round trip completo, l’omocodia di secondo livello, un carattere di controllo errato e il pivot del secolo.
"""Verifica di codice_fiscale.py.
Esegui con: python3 test_codice_fiscale.py
oppure con: python3 -m pytest test_codice_fiscale.py
"""
from datetime import date
from codice_fiscale import (
FORMA, build_cf, check_char, decode_cf, encode_name, encode_surname,
)
# La data è fissata: la lettura dell'anno dipende dal secolo corrente.
ADESSO = date(2026, 7, 30)
VETTORI = ("MRTMTT25D09F205Z", "FOXDRA26C24H872Y", "MLLSNT82P65Z404U")
def test_vettori_pubblicati():
for cf in VETTORI:
assert check_char(cf[:15]) == cf[15], cf
assert decode_cf(cf, ADESSO).valido, cf
def test_decodifica():
e = decode_cf("MLLSNT82P65Z404U", ADESSO)
assert e.data_nascita == date(1982, 9, 25)
assert e.sesso == "F"
assert e.codice_catastale == "Z404"
assert e.codice_cognome == "MLL"
assert e.codice_nome == "SNT"
def test_codifica_cognome_e_nome():
assert all(encode_surname(s) == "RSS" for s in ("ROSSI", "RUSSO", "ROSSO"))
assert encode_surname("RE") == "REX"
assert encode_surname("D'ANGELO") == "DNG"
assert encode_surname("Nicolò") == "NCL"
assert all(encode_name(s) == "MRA" for s in ("MARIO", "MARIA", "MAURO"))
assert encode_name("GIOVANNI") == "GNN"
def test_calcolo_diretto():
assert build_cf("Moretti", "Matteo", date(1925, 4, 9), "M", "F205") == "MRTMTT25D09F205Z"
assert build_cf("Molli", "Santa", date(1982, 9, 25), "F", "Z404") == "MLLSNT82P65Z404U"
assert build_cf("Rossi", "Mario", date(1985, 8, 1), "M", "H501") == "RSSMRA85M01H501Q"
def test_omocodia():
e = decode_cf("RSSMRA85M01H5LMT", ADESSO)
assert e.valido and e.omocodia == 2
assert e.codice_catastale == "H501"
assert e.data_nascita == date(1985, 8, 1)
assert check_char("RSSMRA85M01H50M") == "I"
assert check_char("RSSMRA85M01H5LM") == "T"
def test_carattere_di_controllo_errato():
e = decode_cf("RSSMRA85M01H501Z", ADESSO)
assert not e.valido
assert e.controllo_atteso == "Q"
# La forma è corretta: nessuna regex avrebbe potuto accorgersene.
assert FORMA.match("RSSMRA85M01H501Z")
def test_nessuna_data_nel_futuro():
cf = build_cf("Rossi", "Mario", date(2026, 12, 31), "M", "H501")
assert decode_cf(cf, ADESSO).data_nascita == date(1926, 12, 31)
if __name__ == "__main__":
for nome, funzione in sorted(globals().items()):
if nome.startswith("test_"):
funzione()
print("OK ", nome)
print("Tutte le verifiche superate.")Tre note prima di metterlo in produzione
Un input più lungo di sedici caratteri viene rifiutato
È l’unica differenza voluta rispetto al modulo che gira sul sito, il quale tronca a sedici perché serve un campo che si aggiorna a ogni tasto. In una funzione di validazione troncare è pericoloso: farebbe passare un codice valido seguito da un carattere di troppo.
L’anno è ambiguo, e la scelta va documentata
Con due cifre, 25 può essere 1925 o 2025. Il modulo sceglie il secolo più recente compatibile con una data già passata. Se il tuo dominio ha un vincolo migliore, sostituisci _risolvi_anno: è isolata apposta.
Non rifiutare un codice catastale sconosciuto
I comuni soppressi restano nei codici fiscali già attribuiti. La tabella dei comuni serve ad arricchire il risultato, mai a invalidarlo.
Domande frequenti
Lo stesso algoritmo in JavaScript e in Excel. Fonte: DM 23 dicembre 1976 (Ministero delle Finanze). Fonti e metodo