Um campo de CPF precisa aceitar 123.456.789-09, aceitar 12345678909 e recusar 1234567890. Uma expressão regular resolve os três casos em uma linha, e é por isso que ela costuma ser a primeira checagem de qualquer formulário brasileiro. O problema aparece quando ela vira a única checagem e passa a responder uma pergunta que não é dela.
O que regex resolve e o que ela nunca vai resolver
Regex é boa em uma coisa só: forma. Quantos caracteres, quais caracteres são permitidos em cada posição, onde ficam os separadores. Isso já basta para três usos legítimos — barrar lixo no cliente antes do submit, virar uma restrição CHECK no banco e filtrar linhas de log antes de tentar parsear qualquer coisa.
O que ela não faz, e nenhuma variação esperta muda isso:
- Dígito verificador. Módulo 11 é aritmética sobre os algarismos — soma ponderada, resto de divisão, tratamento de resto 0 e 1. Não existe jeito prático de expressar isso em uma expressão regular. A conta está explicada no guia de módulo 11 e dígito verificador.
- Dizer qual documento é.
^\d{11}$vale igualmente para CPF, PIS, RENAVAM e CNH. Se a sua tabela tem uma coluna genéricadocumento, a regex não vai desempatar; no máximo o cálculo do verificador elimina algumas hipóteses, e mesmo assim há colisões. - Barrar sequências triviais.
00000000000e11111111111passam em qualquer regex de onze dígitos, e vários deles também passam em implementações desatentas do módulo 11. Esse e outros tropeços estão reunidos em erros comuns em validadores. - Dizer se o documento existe. Formato certo e verificador certo não significam que o número foi emitido, nem que está regular — a diferença está em CPF válido não é CPF existente.
A divisão de trabalho que funciona: a regex é o porteiro de formato, e a validação do verificador é uma função pura, separada e testada. Quem junta as duas coisas acaba com uma expressão ilegível que continua deixando passar o que importa.
Tabela completa de expressões
As expressões abaixo são as mesmas que os geradores deste site usam para validar o que você cola nos campos. Todas assumem a entrada já normalizada: separadores removidos e letras em maiúscula. Normalizar primeiro e testar depois reduz o número de expressões pela metade e elimina a maior fonte de bugs.
| Documento | Expressão | Observação |
|---|---|---|
| CPF | ^\d{11}$ | Onze algarismos. A mesma expressão casa PIS, RENAVAM e CNH — é o campo, não a regex, que diz qual documento é. |
| CNPJ numérico | ^\d{14}$ | Oito de raiz, quatro de ordem do estabelecimento, dois de dígito verificador. |
| CNPJ alfanumérico | ^[0-9A-Z]{12}\d{2}$ | A base aceita letras; os dois verificadores continuam numéricos. É um superconjunto: casa também o CNPJ só de números. |
| CEP | ^\d{8}$ | Cinco dígitos do CEP mais três do sufixo de distribuição. |
| RG (padrão SSP-SP) | ^\d{8}[0-9X]$ | O último caractere pode ser a letra X, que representa o verificador 10. Vale só para São Paulo. |
| PIS / PASEP / NIS | ^\d{11}$ | Mesmo formato do CPF, mas um único verificador e outra tabela de pesos. |
| Título de eleitor | ^\d{12}$ | Oito sequenciais, dois de código da UF e dois verificadores. |
| RENAVAM | ^\d{11}$ | Dez de base e um verificador. Registros mais curtos aparecem completados com zeros à esquerda — não corte esses zeros. |
| CNH | ^\d{11}$ | Número de registro, não o número do espelho impresso no documento. |
| Placa antiga | ^[A-Z]{3}\d{4}$ | Três letras e quatro algarismos, sem o hífen. |
| Placa Mercosul | ^[A-Z]{3}\d[A-Z]\d{2}$ | A quinta posição é letra. É o único ponto que separa os dois padrões. |
| Placa (os dois padrões) | ^[A-Z]{3}\d[A-Z0-9]\d{2}$ | Quinta posição letra ou algarismo. Use quando só precisa aceitar, não classificar. |
| Celular | ^\d{2}9\d{8}$ | DDD, o nono dígito e mais oito algarismos. |
| Telefone fixo | ^\d{2}[2-5]\d{7}$ | DDD e oito algarismos. Troque [2-5] por \d se não quiser depender da faixa do primeiro dígito. |
Duas linhas merecem atenção. A do CNPJ alfanumérico é um superconjunto da numérica — todo CNPJ de catorze algarismos também casa com ^[0-9A-Z]{12}\d{2}$. Se o seu código precisa saber qual dos dois formatos chegou, teste a expressão numérica primeiro e só então a alfanumérica; se precisa apenas aceitar, uma expressão basta.
A outra é a placa. Quem só precisa aceitar o que o usuário digitou pode usar a versão unificada, com [A-Z0-9] na quinta posição. Quem precisa saber se aquilo é uma placa antiga ou Mercosul — para decidir se exibe o hífen, por exemplo — tem de testar as duas expressões separadamente, porque a unificada apaga exatamente a informação que distingue os padrões.
Quando a validação acontece sobre o texto já mascarado (um CHECK em coluna que guarda máscara, ou a conferência de um arquivo de importação), as expressões mudam:
| Documento | Expressão | Exemplo |
|---|---|---|
| CPF | ^\d{3}\.\d{3}\.\d{3}-\d{2}$ | 123.456.789-09 |
| CNPJ numérico | ^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$ | 11.222.333/0001-81 |
| CNPJ alfanumérico | ^[0-9A-Z]{2}\.[0-9A-Z]{3}\.[0-9A-Z]{3}\/[0-9A-Z]{4}-\d{2}$ | 12.ABC.345/01DE-35 |
| CEP | ^\d{5}-\d{3}$ | 01310-100 |
| RG (SSP-SP) | ^\d{2}\.\d{3}\.\d{3}-[0-9X]$ | 24.598.973-0 |
| PIS | ^\d{3}\.\d{5}\.\d{2}-\d$ | 120.16619.18-1 |
| Título de eleitor | ^\d{4}\s\d{4}\s\d{4}$ | 1234 5678 2097 |
| Placa antiga | ^[A-Z]{3}-\d{4}$ | ABC-1234 |
| Celular | ^\(\d{2}\)\s9\d{4}-\d{4}$ | (11) 91234-5678 |
| Telefone fixo | ^\(\d{2}\)\s[2-5]\d{3}-\d{4}$ | (11) 3123-4567 |
Repare no escape do ponto e da barra. . sem barra invertida casa qualquer caractere, e / sem escape fecha o literal de regex em JavaScript. São os dois erros de digitação mais comuns dessa tabela inteira.
Grupos nomeados para extrair raiz, ordem e dígitos
Validar é metade do trabalho; a outra metade é quebrar o número em pedaços úteis. Em vez de espalhar slice(0, 8) pelo código, dá para nomear os pedaços dentro da própria expressão. No CNPJ isso separa a raiz (que identifica a empresa), a ordem (que identifica o estabelecimento) e os verificadores:
const CNPJ = /^(?<raiz>[0-9A-Z]{8})(?<ordem>[0-9A-Z]{4})(?<dv>\d{2})$/;
export function partesCnpj(entrada: string) {
const normalizado = entrada.replace(/[.\-/]/g, "").toUpperCase();
const match = CNPJ.exec(normalizado);
// Em TypeScript, groups é Record<string, string> | undefined.
// Sem essa checagem o compilador reclama, e com razão.
if (!match?.groups) return null;
const { raiz, ordem, dv } = match.groups;
return { raiz, ordem, dv, matriz: ordem === "0001" };
}
partesCnpj("12.ABC.345/01DE-35");
// { raiz: "12ABC345", ordem: "01DE", dv: "35", matriz: false }Duas armadilhas nessa versão. A primeira é que match.groups é tipado como possivelmente indefinido — não é firula do TypeScript, é o comportamento real quando a expressão não tem nenhum grupo nomeado. A segunda é o toUpperCase(): sem ele, um CNPJ alfanumérico digitado em minúsculas falha na expressão, e se você “consertar” com a flag i o número passa e depois quebra no cálculo do verificador, que trabalha sobre o valor ASCII do caractere.
Em Python a sintaxe do grupo nomeado é outra, e há mais um detalhe:
import re
CNPJ = re.compile(
r"^(?P<raiz>[0-9A-Z]{8})(?P<ordem>[0-9A-Z]{4})(?P<dv>[0-9]{2})\Z"
)
def partes_cnpj(entrada: str) -> dict[str, str] | None:
normalizado = re.sub(r"[.\-/]", "", entrada).upper()
achou = CNPJ.match(normalizado)
return achou.groupdict() if achou else None
partes_cnpj("12.ABC.345/01DE-35")
# {'raiz': '12ABC345', 'ordem': '01DE', 'dv': '35'}O \Z no lugar do $ não é preciosismo. Em Python, $ também casa imediatamente antes de uma quebra de linha final, então "12345678909\n" passaria numa expressão ancorada com $ — e uma quebra de linha perdida é exatamente o que sobra ao ler CSV linha a linha. \Z casa só o fim absoluto da string.
O mesmo recurso serve para ler o dígito de região fiscal do CPF, que é o nono algarismo:
const CPF = /^(?<base>\d{8})(?<regiao>\d)(?<dv>\d{2})$/;
const { groups } = CPF.exec("12345678909") ?? {};
groups?.regiao; // "9" — 9ª região fiscal (PR e SC)Máscara na digitação, texto puro no banco
Essas são duas responsabilidades diferentes, e tentar cobrir as duas com uma expressão só é o caminho curto para um campo que trava enquanto o usuário digita. Uma regex ancorada só aceita o valor completo; durante a digitação, 123.4 nunca vai casar com nada. Máscara é trabalho de uma função de formatação que insere separadores conforme o comprimento, não de validação.
No armazenamento, a regra é guardar texto puro e formatar só na exibição. Isso evita o clássico de ter 123.456.789-09 e 12345678909 como registros distintos da mesma pessoa, e deixa índice e chave única funcionando:
// Entrada: qualquer coisa que o usuário colou.
// Saída: o que vai para o banco, para a comparação e para a regex.
export function normalizar(valor: string): string {
return valor.replace(/[^0-9A-Za-z]/g, "").toUpperCase();
}
normalizar("12.ABC.345/01DE-35"); // "12ABC34501DE35"
normalizar(" 123.456.789-09 "); // "12345678909"Duas decisões de esquema acompanham isso. A coluna precisa ser textual de largura fixa (char(11) para CPF, char(14) para CNPJ), nunca numérica — tipo numérico come o zero à esquerda de 01234567890 e, no caso do CNPJ, simplesmente não aceita letras. E a restrição de formato no próprio banco, com a mesma expressão da tabela acima, está detalhada no guia de validação em SQL.
Anti-padrões
Expressão sem âncora. \d{11} sem ^ e $ casa onze dígitos em qualquer lugar de uma string de vinte — o cartão de crédito do cliente passa como CPF. Se a busca é dentro de um texto corrido, o certo não é remover a âncora, é trocá-la por \b nas duas pontas.
Reaproveitar uma regex com a flag g. Em JavaScript, uma expressão com g guarda lastIndex entre chamadas, então RE.test(x) alterna entre verdadeiro e falso para a mesma entrada. Regex de validação nunca leva g.
Ponto não escapado na máscara. ^\d{3}.\d{3}.\d{3}-\d{2}$ aceita 123X456Y789-09 sem reclamar, porque o ponto casa qualquer caractere. É um bug silencioso: a expressão parece certa e passa nos testes felizes.
Supor que \d é [0-9]. Em JavaScript é, mas em Python 3 e em .NET \d casa qualquer algarismo decimal do Unicode. Um CPF escrito com algarismos árabes orientais passa na regex, passa no int() do Python e chega ao banco como uma string que não bate com nenhuma outra. Escrever [0-9] custa dois caracteres a mais e vale em todas as linguagens; em Python, re.ASCII resolve de uma vez para o padrão inteiro.
Quantificador aninhado. Uma expressão como ^(\d+\.?)+$, escrita para aceitar pontos opcionais em qualquer posição, tem um número exponencial de formas de dividir a mesma entrada. Contra um texto longo que quase casa, o interpretador fica minutos tentando todas — e, rodando no servidor sobre entrada de usuário, isso é uma negação de serviço. A defesa é a mesma receita de sempre: normalize antes, e aí a expressão vira um padrão de comprimento fixo, ancorado, sem ambiguidade nenhuma.
Flag i em placa e CNPJ alfanumérico. Aceitar abc1c34 parece gentileza, mas o valor minúsculo segue vivo pelo sistema até bater em alguma comparação que diferencia caixa. Aplique toUpperCase() na normalização e mantenha a expressão restrita a [A-Z].
Testando suas regex com números gerados aqui
Uma expressão regular só está pronta quando você a submeteu a casos que deveriam falhar. Os casos positivos são fáceis de conseguir: os geradores de CPF, CNPJ, CEP, placa e PIS entregam lotes de até cem valores fictícios, com e sem máscara, que você cola direto na tabela de testes.
Os negativos você constrói a partir deles, e são esses que encontram os bugs. Vale ter no conjunto, no mínimo: um valor com um dígito a menos e outro com um a mais; um com separador no lugar errado; um com espaço no começo e no fim; um todo de dígitos iguais; uma placa com letra na quarta posição em vez da quinta; um CNPJ alfanumérico com letra em um dos dois verificadores, que a expressão precisa recusar; e uma string longa com um CPF válido no meio, que é o caso que denuncia a falta de âncora.
Para montar esse conjunto de uma vez, o gerador de pessoas devolve fichas completas e coerentes — CPF, RG, CEP e telefone da mesma UF — que servem tanto para a regex quanto para os testes de integração que vêm depois dela. E, se o próximo passo for o cálculo do verificador, a implementação comentada está em validar CPF e CNPJ em JavaScript e TypeScript.