bateCarimbo.
Código

Validação de CPF e CNPJ em Python, Java e C#: implementações comentadas e testes

A mesma regra de módulo 11 em três linguagens: função pura, integração com Pydantic, Bean Validation e FluentValidation, tabela de casos de teste compartilhada e onde validar em cada camada da aplicação.

Por · Publicado em · 13 min de leitura

Um validador de CPF e CNPJ cabe em trinta linhas em qualquer linguagem. O que costuma dar errado não é a aritmética — é a camada em volta dela: onde a normalização acontece, o que o framework de validação faz com um campo nulo, como cada linguagem converte um caractere em número e o que o teste realmente cobre. Este guia mostra a mesma regra em Python, Java e C#, com atenção aos pontos em que as três divergem.

A regra é a mesma; muda o idioma

O cálculo é módulo 11 nos dois documentos: multiplique cada caractere da base por um peso, some, divida por 11 e derive o dígito do resto. O passo a passo com as contas resolvidas está no guia de módulo 11 e dígito verificador e não se repete aqui. O que interessa agora é o contrato da função e o que cada linguagem faz de diferente para cumpri-lo.

O contrato das três implementações é sempre o mesmo: recebem uma string que pode vir nula, com máscara ou sem, e devolvem um booleano. Nunca lançam exceção por entrada malformada — entrada ruim é resposta false, não throw. E a normalização acontece dentro da função, não é responsabilidade de quem chama, porque a chamada vem de lugares demais: formulário, importação de planilha, mensagem de fila, script de migração.

Três decisões de implementação valem para as três linguagens. A primeira: a regra que rejeita sequências repetidas é uma regra extra, não faz parte do módulo 11 — os números que ela pega passam na conta e precisam ser barrados à parte. No CPF são dez, de 000.000.000-00 a 999.999.999-99: todos fecham os dois dígitos. No CNPJ numérico é só 00.000.000/0000-00; as outras nove repetições de catorze dígitos já são reprovadas pelo próprio módulo 11 — a de 1 teria de terminar em 80, e aí deixa de ser repetição. A segunda: o CPF usa pesos decrescentes fixos (10 a 2 e depois 11 a 2), enquanto o CNPJ usa uma lista que cicla de 2 a 9 da direita para a esquerda, o que na prática vira uma janela sobre um array de treze pesos. A terceira: o resto menor que 2 vira dígito 0; do contrário, o dígito é 11 − resto.

Vale separar desde já o que essa função responde do que ela não responde. Ela diz que o número é bem formado e consistente. Não diz que existe, nem que está regular na Receita — essa distinção está detalhada em CPF válido não é CPF existente.

Python

A função pura

Em Python o risco está nas funções que parecem fazer a coisa certa. A classe de caracteres \d e o método str.isdigit() não se limitam a 0-9: outros algarismos Unicode entram na conta, e alguns deles int() converte sem reclamar. Por isso a limpeza usa [^0-9] explicitamente, e não \D.

Python
import re

_NON_DIGIT = re.compile(r"[^0-9]")
_NON_ALNUM = re.compile(r"[^0-9A-Za-z]")
_CNPJ_SHAPE = re.compile(r"^[0-9A-Z]{12}[0-9]{2}$")

# Pesos do CNPJ para a base de 13 caracteres; a base de 12 usa os 12 últimos.
_CNPJ_WEIGHTS = (6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2)


def _module11(products: int) -> int:
    remainder = products % 11
    return 0 if remainder < 2 else 11 - remainder


def _cpf_check_digit(base: str, start_weight: int) -> int:
    total = sum(int(c) * (start_weight - i) for i, c in enumerate(base))
    return _module11(total)


def is_valid_cpf(value: str | None) -> bool:
    cpf = _NON_DIGIT.sub("", value or "")
    if len(cpf) != 11 or cpf == cpf[0] * 11:
        return False
    first = _cpf_check_digit(cpf[:9], 10)
    second = _cpf_check_digit(cpf[:9] + str(first), 11)
    return cpf[9:] == f"{first}{second}"


def _cnpj_check_digit(base: str) -> int:
    weights = _CNPJ_WEIGHTS[-len(base):]
    total = sum((ord(c) - 48) * w for c, w in zip(base, weights))
    return _module11(total)


def is_valid_cnpj(value: str | None) -> bool:
    cnpj = _NON_ALNUM.sub("", value or "").upper()
    if not _CNPJ_SHAPE.match(cnpj) or cnpj == cnpj[0] * 14:
        return False
    first = _cnpj_check_digit(cnpj[:12])
    second = _cnpj_check_digit(cnpj[:12] + str(first))
    return cnpj[12:] == f"{first}{second}"
Um módulo sem dependências; funciona em Python 3.10 ou superior por causa do tipo str | None.

O detalhe que faz o CNPJ alfanumérico funcionar está em ord(c) - 48. Para um algarismo isso devolve o próprio valor (ord("7") - 48 == 7); para uma letra maiúscula, devolve 17 no caso de A, 18 no de B, e assim por diante. É a mesma função para os dois formatos, e é por isso que o .upper() antes do cálculo é obrigatório: a minúsculo daria 49, não 17.

Validator no Pydantic

Em um projeto FastAPI ou em qualquer coisa que use Pydantic v2, a função pura não deve ser chamada no handler. Ela vira um tipo anotado com AfterValidator, e aí todo modelo que declara aquele tipo ganha validação e normalização de graça. O validator devolve a string limpa, então o resto do código recebe sempre onze caracteres sem pontuação.

Python
from typing import Annotated

from pydantic import AfterValidator, BaseModel, Field


def _normalize_cpf(value: str) -> str:
    cpf = _NON_DIGIT.sub("", value or "")
    if not is_valid_cpf(cpf):
        raise ValueError("CPF inválido")
    return cpf


def _normalize_cnpj(value: str) -> str:
    cnpj = _NON_ALNUM.sub("", value or "").upper()
    if not is_valid_cnpj(cnpj):
        raise ValueError("CNPJ inválido")
    return cnpj


Cpf = Annotated[str, AfterValidator(_normalize_cpf)]
Cnpj = Annotated[str, AfterValidator(_normalize_cnpj)]


class Cliente(BaseModel):
    nome: str = Field(min_length=3)
    cpf: Cpf


class Fornecedor(BaseModel):
    razao_social: str
    cnpj: Cnpj


# Cliente(nome="Ana", cpf="123.456.789-09").cpf  ->  "12345678909"
AfterValidator roda depois da coerção de tipo do Pydantic; levantar ValueError vira erro 422 no FastAPI.

Massa de teste com Faker pt_BR

O Faker com locale pt_BR resolve o caso comum: fake.cpf() e fake.cnpj() devolvem documentos com dígito verificador correto. Duas ressalvas. O gerador não garante que o número não seja uma sequência repetida, então ele não substitui o teste do caso 111.111.111-11. E se a versão do provedor que você usa ainda emite apenas CNPJ numérico, a massa não exercita o caminho alfanumérico — nesse caso vale gerar você mesmo, reaproveitando a função de dígito já escrita.

Python
import random

from faker import Faker

fake = Faker("pt_BR")
Faker.seed(42)  # a mesma seed produz sempre a mesma massa

fake.cpf()   # CPF com máscara e dígitos verificadores válidos
fake.cnpj()  # CNPJ numérico com máscara

_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"


def fake_cnpj_alfanumerico(rng: random.Random) -> str:
    """Gera um CNPJ alfanumérico fictício com DV correto."""
    base = "".join(rng.choice(_CHARSET) for _ in range(12))
    first = _cnpj_check_digit(base)
    second = _cnpj_check_digit(base + str(first))
    return f"{base}{first}{second}"


# fake_cnpj_alfanumerico(random.Random(42))  ->  base aleatória + 2 dígitos
Faker.seed fixa a sequência: a mesma suíte gera a mesma massa em qualquer máquina.

Estratégias de fixture, factory e seed reproduzível estão em como montar massa de dados de teste brasileira. Para um lote rápido sem escrever código, o gerador de CPF e o gerador de CNPJ exportam em CSV e JSON.

Python
import pytest

CPF_CASES = [
    ("123.456.789-09", True),
    ("12345678909", True),
    ("111.111.111-11", False),
    ("123.456.789-00", False),
    ("123456789", False),
    (None, False),
]

CNPJ_CASES = [
    ("11.222.333/0001-81", True),
    ("12.ABC.345/01DE-35", True),
    ("12.abc.345/01de-35", True),
    ("00.000.000/0000-00", False),
    ("12.ABC.345/01DE-3X", False),
    (None, False),
]


@pytest.mark.parametrize("entrada,esperado", CPF_CASES)
def test_is_valid_cpf(entrada, esperado):
    assert is_valid_cpf(entrada) is esperado


@pytest.mark.parametrize("entrada,esperado", CNPJ_CASES)
def test_is_valid_cnpj(entrada, esperado):
    assert is_valid_cnpj(entrada) is esperado
pytest parametrizado: a tabela de casos fica separada da asserção.

Java

O método estático

Java tem uma armadilha própria e ela é silenciosa: Character.getNumericValue('A') devolve 10, não 17. Quem usa esse método achando que ele é o equivalente do ord() escreve um validador que aceita alguns CNPJ alfanuméricos errados e rejeita outros corretos, sem nunca lançar exceção. A conversão certa é a subtração direta: c - 48, com o char promovido a int.

Java
package br.com.exemplo.documentos;

import java.util.Locale;

public final class Documentos {

    private static final int[] CNPJ_WEIGHTS = {6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2};

    private Documentos() {
    }

    public static String onlyDigits(String value) {
        return value == null ? "" : value.replaceAll("[^0-9]", "");
    }

    public static String normalizeCnpj(String value) {
        return value == null
                ? ""
                : value.replaceAll("[^0-9A-Za-z]", "").toUpperCase(Locale.ROOT);
    }

    private static int module11(int total) {
        int remainder = total % 11;
        return remainder < 2 ? 0 : 11 - remainder;
    }

    public static boolean isValidCpf(String value) {
        String cpf = onlyDigits(value);
        if (cpf.length() != 11 || cpf.chars().distinct().count() == 1) {
            return false;
        }
        int first = cpfCheckDigit(cpf.substring(0, 9), 10);
        int second = cpfCheckDigit(cpf.substring(0, 9) + first, 11);
        return cpf.charAt(9) - '0' == first && cpf.charAt(10) - '0' == second;
    }

    private static int cpfCheckDigit(String base, int startWeight) {
        int total = 0;
        for (int i = 0; i < base.length(); i++) {
            total += (base.charAt(i) - '0') * (startWeight - i);
        }
        return module11(total);
    }

    public static boolean isValidCnpj(String value) {
        String cnpj = normalizeCnpj(value);
        if (!cnpj.matches("[0-9A-Z]{12}[0-9]{2}")
                || cnpj.chars().distinct().count() == 1) {
            return false;
        }
        String base = cnpj.substring(0, 12);
        int first = cnpjCheckDigit(base);
        int second = cnpjCheckDigit(base + first);
        return cnpj.charAt(12) - '0' == first && cnpj.charAt(13) - '0' == second;
    }

    private static int cnpjCheckDigit(String base) {
        int offset = CNPJ_WEIGHTS.length - base.length();
        int total = 0;
        for (int i = 0; i < base.length(); i++) {
            total += (base.charAt(i) - 48) * CNPJ_WEIGHTS[offset + i];
        }
        return module11(total);
    }
}
Classe final com construtor privado: o compilador impede instanciar um utilitário.

Duas escolhas de API merecem nota. toUpperCase(Locale.ROOT) é obrigatório: a versão sem argumento usa o locale da JVM, e em locale turco a letra i não vira I. E String.matches exige correspondência total da string, ao contrário de Matcher.find — é o comportamento que queremos aqui, mas é bom saber por que não há ^ nem $ no padrão.

Se o validador roda em laço — validando uma planilha de dez mil linhas, por exemplo —, troque replaceAll e matches por um Pattern estático compilado uma vez. Os métodos de String compilam a expressão a cada chamada, o que em Python e em C# já está resolvido pelos objetos de regex criados no nível do módulo e da classe. É a diferença mais visível de custo entre as três versões, e ela não aparece em nenhum teste unitário.

Anotação de Bean Validation

O Hibernate Validator já traz as anotações @CPF e @CNPJ no pacote de restrições brasileiras, e para o formato numérico elas resolvem. Antes de confiar nelas para o CNPJ alfanumérico, confira o comportamento da versão que está no seu pom.xml. Escrever a sua própria restrição custa dois arquivos e deixa a regra sob o seu controle:

Java
package br.com.exemplo.documentos;

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CnpjValidator.class)
public @interface Cnpj {
    String message() default "CNPJ inválido";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

class CnpjValidator implements ConstraintValidator<Cnpj, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // null passa: quem exige preenchimento é o @NotNull/@NotBlank.
        return value == null || Documentos.isValidCnpj(value);
    }
}

// Uso no DTO:
// public record FornecedorRequest(@NotBlank String razaoSocial, @NotBlank @Cnpj String cnpj) {}
A convenção de Bean Validation é aceitar null e deixar a obrigatoriedade para @NotNull.

Teste JUnit

@CsvSource deixa a tabela de casos legível dentro do próprio teste. As aspas simples em volta de cada entrada preservam espaços e evitam que o parser do JUnit se confunda com o alinhamento — sem elas, você acabaria testando strings diferentes das que escreveu.

Java
package br.com.exemplo.documentos;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class DocumentosTest {

    @ParameterizedTest
    @CsvSource({
        "'123.456.789-09', true",
        "'12345678909',    true",
        "'111.111.111-11', false",
        "'123.456.789-00', false",
        "'123456789',      false"
    })
    void validaCpf(String entrada, boolean esperado) {
        assertEquals(esperado, Documentos.isValidCpf(entrada));
    }

    @ParameterizedTest
    @CsvSource({
        "'11.222.333/0001-81', true",
        "'12.ABC.345/01DE-35', true",
        "'12.abc.345/01de-35', true",
        "'00.000.000/0000-00', false",
        "'12.ABC.345/01DE-3X', false"
    })
    void validaCnpj(String entrada, boolean esperado) {
        assertEquals(esperado, Documentos.isValidCnpj(entrada));
    }

    @org.junit.jupiter.api.Test
    void naoExplodeComNulo() {
        assertEquals(false, Documentos.isValidCpf(null));
        assertEquals(false, Documentos.isValidCnpj(null));
    }
}
O caso do nulo fica em um teste próprio porque @CsvSource não expressa null sem configuração extra.

C#

Método de extensão

Em C# a forma natural é um método de extensão sobre string?, o que permite escrever documento.IsValidCpf() na regra de negócio. Um detalhe que surpreende quem vem de outra linguagem: chamar um método de extensão em uma referência nula não lança NullReferenceException, porque a chamada é estática e a instância vira apenas o primeiro argumento. Isso torna o caso nulo testável sem cerimônia.

O outro ponto é o Regex. Em .NET, \d casa qualquer dígito decimal Unicode por padrão, não só 0-9. Assim como em Python, a correção é escrever a faixa explícita.

C#
using System.Linq;
using System.Text.RegularExpressions;

namespace Exemplo.Documentos;

public static class DocumentoExtensions
{
    private static readonly int[] CnpjWeights = { 6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2 };

    private static readonly Regex NonDigit = new("[^0-9]", RegexOptions.Compiled);
    private static readonly Regex NonAlphanumeric = new("[^0-9A-Za-z]", RegexOptions.Compiled);
    private static readonly Regex CnpjShape = new("^[0-9A-Z]{12}[0-9]{2}$", RegexOptions.Compiled);

    public static string OnlyDigits(this string? value) =>
        value is null ? string.Empty : NonDigit.Replace(value, string.Empty);

    public static string NormalizeCnpj(this string? value) =>
        value is null
            ? string.Empty
            : NonAlphanumeric.Replace(value, string.Empty).ToUpperInvariant();

    private static int Module11(int total)
    {
        var remainder = total % 11;
        return remainder < 2 ? 0 : 11 - remainder;
    }

    public static bool IsValidCpf(this string? value)
    {
        var cpf = value.OnlyDigits();
        if (cpf.Length != 11 || cpf.All(c => c == cpf[0]))
        {
            return false;
        }

        var baseDigits = cpf[..9];
        var first = CpfCheckDigit(baseDigits, 10);
        var second = CpfCheckDigit(baseDigits + first, 11);
        return cpf[9] - '0' == first && cpf[10] - '0' == second;
    }

    private static int CpfCheckDigit(string baseDigits, int startWeight)
    {
        var total = 0;
        for (var i = 0; i < baseDigits.Length; i++)
        {
            total += (baseDigits[i] - '0') * (startWeight - i);
        }

        return Module11(total);
    }

    public static bool IsValidCnpj(this string? value)
    {
        var cnpj = value.NormalizeCnpj();
        if (!CnpjShape.IsMatch(cnpj) || cnpj.All(c => c == cnpj[0]))
        {
            return false;
        }

        var baseChars = cnpj[..12];
        var first = CnpjCheckDigit(baseChars);
        var second = CnpjCheckDigit(baseChars + first);
        return cnpj[12] - '0' == first && cnpj[13] - '0' == second;
    }

    private static int CnpjCheckDigit(string baseChars)
    {
        var offset = CnpjWeights.Length - baseChars.Length;
        var total = 0;
        for (var i = 0; i < baseChars.Length; i++)
        {
            total += (baseChars[i] - 48) * CnpjWeights[offset + i];
        }

        return Module11(total);
    }
}
Regex estáticos e compilados: instanciar Regex dentro do método a cada chamada é desperdício em rota quente.

ToUpperInvariant() cumpre aqui o mesmo papel do Locale.ROOT em Java. Se o processo roda com cultura tr-TR, ToUpper() produz caracteres que a sua regex não reconhece, e um CNPJ perfeitamente válido é recusado em produção enquanto passa na sua máquina.

A partir do .NET 7 dá para substituir os três campos Regex por métodos parciais marcados com [GeneratedRegex]: o gerador de código produz o autômato em tempo de compilação, elimina a construção em tempo de execução e ainda avisa no build se o padrão estiver malformado. A regra de validação não muda; muda só quem paga o custo da compilação da expressão.

DataAnnotations ou FluentValidation

As duas abordagens convivem. DataAnnotations é o caminho mais curto quando o ASP.NET Core já faz o model binding e você só quer o ModelState reprovando o request. FluentValidation tira a regra do DTO, o que ajuda quando o mesmo objeto tem regras diferentes por operação — cadastro exige o documento, atualização parcial não.

C#
using System;
using System.ComponentModel.DataAnnotations;
using FluentValidation;

namespace Exemplo.Documentos;

// 1) DataAnnotations: serve para ASP.NET Core validar o model binding sozinho.
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class CnpjAttribute : ValidationAttribute
{
    public CnpjAttribute() => ErrorMessage = "CNPJ inválido.";

    public override bool IsValid(object? value) =>
        value is null || (value is string text && text.IsValidCnpj());
}

public sealed class FornecedorRequest
{
    [Required]
    public string RazaoSocial { get; init; } = string.Empty;

    [Required]
    [Cnpj]
    public string Documento { get; init; } = string.Empty;
}

// 2) FluentValidation: mesma regra, mas fora do DTO e com mensagens por caso.
public sealed class FornecedorRequestValidator : AbstractValidator<FornecedorRequest>
{
    public FornecedorRequestValidator()
    {
        RuleFor(x => x.RazaoSocial).NotEmpty().MinimumLength(3);

        RuleFor(x => x.Documento)
            .NotEmpty().WithMessage("Informe o CNPJ.")
            .Must(documento => documento.IsValidCnpj())
            .WithMessage("CNPJ inválido.");
    }
}
O atributo delega para a extensão: a regra existe em um lugar só, independentemente de quem a chama.

Teste xUnit

C#
using Xunit;

namespace Exemplo.Documentos.Tests;

public class DocumentoExtensionsTests
{
    [Theory]
    [InlineData("123.456.789-09", true)]
    [InlineData("12345678909", true)]
    [InlineData("111.111.111-11", false)]
    [InlineData("123.456.789-00", false)]
    [InlineData("123456789", false)]
    [InlineData(null, false)]
    public void ValidaCpf(string? entrada, bool esperado) =>
        Assert.Equal(esperado, entrada.IsValidCpf());

    [Theory]
    [InlineData("11.222.333/0001-81", true)]
    [InlineData("12.ABC.345/01DE-35", true)]
    [InlineData("12.abc.345/01de-35", true)]
    [InlineData("00.000.000/0000-00", false)]
    [InlineData("12.ABC.345/01DE-3X", false)]
    [InlineData(null, false)]
    public void ValidaCnpj(string? entrada, bool esperado) =>
        Assert.Equal(esperado, entrada.IsValidCnpj());
}
InlineData aceita null diretamente, o que torna o caso da entrada ausente parte da mesma tabela.

A mesma tabela de casos de teste nas três linguagens

Repare que os três testes acima usam exatamente as mesmas entradas. Isso não é coincidência: quando um serviço em Java, um worker em Python e um gateway em C# validam o mesmo documento, divergência entre eles vira incidente. Manter uma tabela canônica de casos — de preferência num arquivo compartilhado, versionado fora de cada repositório — é o que garante que as três implementações concordem.

Conjunto mínimo de casos. Todos os números são fictícios.
EntradaDocumentoEsperadoO que o caso cobre
123.456.789-09CPFválidoentrada com máscara
12345678909CPFválidomesma entrada sem máscara
111.111.111-11CPFinválidosequência repetida que passa no módulo 11
123.456.789-00CPFinválidosegundo dígito verificador trocado
123456789CPFinválidocomprimento menor que 11
11.222.333/0001-81CNPJválidoformato numérico com máscara
12.ABC.345/01DE-35CNPJválidoformato alfanumérico, exemplo oficial
12.abc.345/01de-35CNPJválidoletras minúsculas exigem normalização
00.000.000/0000-00CNPJinválidoúnico repetido que fecha o módulo 11
12.ABC.345/01DE-3XCNPJinválidoos dois DVs continuam numéricos
nullambosinválidonão pode lançar exceção

O caso 00.000.000/0000-00 é o mais instrutivo da lista: é o único CNPJ numérico de dígitos repetidos cujos dois verificadores fecham o módulo 11 — base zerada, soma zero, resto zero, dígitos 00. Um validador que só faz a conta aprova esse CNPJ; só a regra extra de sequência repetida o rejeita — e é justamente essa regra que costuma ser esquecida quando alguém reescreve o validador “mais limpo”. Outros deslizes do mesmo tipo estão catalogados em dez erros comuns em validadores.

Acima dessa tabela cabe um teste de propriedade, e as três linguagens têm biblioteca para isso — Hypothesis em Python, jqwik em Java, FsCheck em C#. A propriedade útil aqui é redonda: para qualquer base gerada aleatoriamente, o documento montado com os dígitos calculados pela sua função é aceito por ela; e trocar qualquer um dos dois dígitos verificadores por outro valor faz a validação falhar. São duas propriedades sempre verdadeiras, e verificá-las sobre milhares de bases sorteadas encontra erros de peso ou de índice que uma tabela fixa de poucas entradas deixa passar.

Onde validar: borda, serviço ou banco

Validar na borda — no DTO do controller, com Pydantic, Bean Validation ou DataAnnotations — dá a melhor mensagem de erro e falha barato. Mas a borda HTTP é só uma das portas. Consumidor de fila, importador de CSV, job noturno e script de migração entram pelos fundos e não passam por anotação nenhuma. Quem confia só na borda descobre isso quando encontra documento inválido gravado em produção.

Um cuidado prático para quem valida em mais de um ponto: a normalização precisa ser idempotente. Se a borda grava 12345678909 e o consumidor da fila roda a mesma limpeza de novo, o resultado tem de ser idêntico. As três implementações acima satisfazem isso porque removem caracteres em vez de reposicionar máscara — aplicar a função duas vezes devolve a mesma string.

A camada que realmente protege é o domínio, e as três linguagens oferecem a mesma ferramenta: um tipo que não pode existir em estado inválido. Em Java isso é um record com construtor compacto; em C#, um readonly record struct com a checagem no construtor; em Python, o tipo anotado mostrado acima, ou uma @dataclass(frozen=True) com __post_init__. O ganho é o mesmo nos três casos: depois da construção, nenhuma função precisa revalidar nada.

Java
package br.com.exemplo.documentos;

/** Value object: se a instância existe, o CPF é válido e está normalizado. */
public record Cpf(String valor) {

    public Cpf {
        valor = Documentos.onlyDigits(valor);
        if (!Documentos.isValidCpf(valor)) {
            throw new IllegalArgumentException("CPF inválido");
        }
    }

    /** Formatação só na saída: 000.000.000-00 */
    public String formatado() {
        return valor.replaceFirst("(\\d{3})(\\d{3})(\\d{3})(\\d{2})", "$1.$2.$3-$4");
    }
}
O construtor compacto de um record pode reatribuir o parâmetro antes de ele virar campo.

No banco fica a última linha de defesa, contra o que entrou por caminhos que você não controla. Uma restrição CHECK chamando uma função de validação resolve, e as implementações em PostgreSQL, MySQL e SQL Server estão em validar CPF e CNPJ no banco de dados. Uma regra acompanha todas as camadas: grave o documento normalizado, só os caracteres, em coluna de texto de tamanho fixo. Máscara é apresentação e se aplica na saída, como no método formatado() acima.

O CNPJ alfanumérico nas três linguagens

Desde julho de 2026 as novas inscrições no CNPJ podem sair no formato alfanumérico, com letras nas doze posições da base e os dois dígitos verificadores ainda numéricos. O detalhamento do formato está no guia do CNPJ alfanumérico; o que interessa aqui é que cada linguagem tropeça em um ponto diferente ao absorver a mudança.

Em Python, o tropeço é a exceção mascarada. Um validador antigo escrito com int(c) levanta ValueError na primeira letra; se essa chamada estiver dentro de um try/except Exception: return False — padrão comum em validadores defensivos —, todo CNPJ alfanumérico passa a ser reportado como inválido, sem log e sem stack trace. O sintoma chega como reclamação de usuário, não como erro no monitoramento.

Em Java, o tropeço é o resultado errado sem erro nenhum. Como Character.getNumericValue atribui 10 a A, 11 a B e assim por diante, a soma fecha em um número plausível e o método devolve true ou false com a mesma confiança de sempre. É o pior tipo de defeito: determinístico, silencioso e só detectável comparando com o exemplo oficial.

Em C#, o tropeço costuma estar antes da validação, na tipagem. Código legado que recebe o documento como long ou decimal — ou que chama long.Parsepara “limpar” a entrada — quebra com uma exceção de formato antes de qualquer regra rodar. Converter o campo para string em todo o caminho, do DTO ao repositório, é pré-requisito para o resto funcionar.

Há ainda um erro comum às três: aceitar a base com letras e esquecer que os DVs continuam numéricos. Uma regex de [0-9A-Z]{14} aprova 12.ABC.345/01DE-3X, que não é um CNPJ. O padrão certo separa as duas partes, como nas implementações acima: [0-9A-Z]{12}[0-9]{2}. Para conferir o seu validador contra entradas nos dois formatos, o gerador de CNPJ produz lotes numéricos e alfanuméricos com os dígitos calculados pela mesma regra usada neste guia.

Fontes e referências