Sistemas legados e integrações nem sempre entregam datas em uma coluna do tipo date. É comum encontrar um campo de texto com oito caracteres no formato AAAAMMDD, além de valores vazios e conteúdos que parecem datas, mas não existem no calendário.

Converter tudo diretamente com ::date torna a consulta frágil: basta uma linha inválida para interromper o relatório inteiro. Ignorar o problema também não resolve. Se os valores ruins desaparecerem silenciosamente, o total exibido pode parecer correto sem representar toda a carga recebida.

Neste episódio, vamos normalizar um conjunto sintético de datas textuais, separar ausências de erros e construir um calendário de faturamento que preserve inclusive os dias sem movimento.

O laboratório reproduzível do episódio 13 contém os scripts de criação, carga, consultas e validação automatizada apresentados neste artigo.

O cenário

O exemplo representa dados importados para uma base própria de integração. Não há leitura nem escrita em tabelas de ERP.

CREATE SCHEMA sql_semana_13;

CREATE TABLE sql_semana_13.faturamento_importado (
    movimento_id integer PRIMARY KEY,
    data_emissao  text,
    valor         numeric(12, 2) NOT NULL
);

INSERT INTO sql_semana_13.faturamento_importado
    (movimento_id, data_emissao, valor)
VALUES
    (1, '20261001', 120.00),
    (2, '20261003',  80.00),
    (3, '20261003',  45.50),
    (4, '20261007', 200.00),
    (5, '',           30.00),
    (6, '        ',   25.00),
    (7, '20260230',   60.00),
    (8, '2026-10-05', 70.00),
    (9, NULL,         15.00);

As quatro primeiras linhas possuem datas válidas no formato esperado. As demais representam situações diferentes:

  • texto vazio ou preenchido apenas com espaços;
  • NULL;
  • oito dígitos que não formam uma data real;
  • uma data válida em outro formato.

Essa distinção é útil. Ausência de informação e conteúdo inválido podem exigir tratamentos diferentes na origem e no monitoramento da integração.

Por que não converter diretamente

Uma expressão como esta pressupõe que todas as linhas são válidas:

SELECT data_emissao::date
FROM sql_semana_13.faturamento_importado;

Ao encontrar um conteúdo incompatível, o PostgreSQL gera erro e encerra o comando. Adicionar apenas um filtro por comprimento também não basta: 20260230 possui oito caracteres, mas 30 de fevereiro não existe.

to_date() tampouco deve ser usado sozinho como validador. A função foi projetada para interpretar uma máscara e pode normalizar determinadas entradas em vez de rejeitá-las segundo a regra estrita esperada pela integração.

O primeiro passo é declarar o contrato do dado: neste exemplo, uma data aceitável precisa conter exatamente oito dígitos no formato AAAAMMDD e representar um dia existente.

Normalizando sem interromper a consulta

Vamos preparar o texto em etapas. Primeiro, espaços e strings vazias são convertidos em NULL. Depois, somente oito dígitos são reorganizados para o formato ISO AAAA-MM-DD.

WITH preparados AS (
    SELECT
        movimento_id,
        NULLIF(btrim(data_emissao), '') AS data_original,
        valor
    FROM sql_semana_13.faturamento_importado
),
candidatos AS (
    SELECT
        movimento_id,
        data_original,
        valor,
        CASE
            WHEN data_original ~ '^[0-9]{8}$' THEN
                substr(data_original, 1, 4) || '-' ||
                substr(data_original, 5, 2) || '-' ||
                substr(data_original, 7, 2)
        END AS data_iso
    FROM preparados
),
validados AS (
    SELECT
        movimento_id,
        data_original,
        valor,
        data_iso,
        data_iso IS NOT NULL
            AND pg_input_is_valid(data_iso, 'date') AS data_valida
    FROM candidatos
)
SELECT
    movimento_id,
    data_original,
    CASE
        WHEN data_valida THEN data_iso::date
    END AS data_normalizada,
    valor
FROM validados
ORDER BY movimento_id;

pg_input_is_valid() verifica se o texto pode ser convertido para o tipo informado sem lançar o erro que uma conversão direta produziria. A expressão regular confirma o formato; a validação do tipo confirma o calendário.

O CASE só executa o cast nas linhas aprovadas. O resultado esperado para os movimentos válidos é:

MovimentoTexto originalData normalizadaValor
1202610012026-10-01120.00
2202610032026-10-0380.00
3202610032026-10-0345.50
4202610072026-10-07200.00

As demais linhas recebem NULL em data_normalizada, mas continuam disponíveis para diagnóstico.

Separando ausência de data e conteúdo inválido

Antes de produzir o relatório, vale medir a qualidade da entrada:

WITH preparados AS (
    SELECT
        movimento_id,
        NULLIF(btrim(data_emissao), '') AS data_original
    FROM sql_semana_13.faturamento_importado
),
candidatos AS (
    SELECT
        movimento_id,
        data_original,
        CASE
            WHEN data_original ~ '^[0-9]{8}$' THEN
                substr(data_original, 1, 4) || '-' ||
                substr(data_original, 5, 2) || '-' ||
                substr(data_original, 7, 2)
        END AS data_iso
    FROM preparados
),
classificados AS (
    SELECT
        movimento_id,
        CASE
            WHEN data_original IS NULL THEN 'ausente'
            WHEN data_iso IS NULL THEN 'formato_invalido'
            WHEN NOT pg_input_is_valid(data_iso, 'date') THEN 'data_invalida'
            ELSE 'valida'
        END AS situacao
    FROM candidatos
)
SELECT situacao, count(*) AS quantidade
FROM classificados
GROUP BY situacao
ORDER BY situacao;

Resultado esperado:

SituaçãoQuantidade
ausente3
data_invalida1
formato_invalido1
valida4

O valor 2026-10-05 é uma data reconhecível para uma pessoa, mas viola o contrato AAAAMMDD desta carga. Aceitá-lo automaticamente esconderia uma mudança no formato da origem. Se mais de um formato for realmente permitido, cada um deve ter uma regra explícita e testada.

Agregando somente datas aprovadas

Agora podemos converter as datas válidas e agregar o faturamento diário:

WITH preparados AS (
    SELECT
        movimento_id,
        NULLIF(btrim(data_emissao), '') AS data_original,
        valor
    FROM sql_semana_13.faturamento_importado
),
candidatos AS (
    SELECT
        movimento_id,
        valor,
        CASE
            WHEN data_original ~ '^[0-9]{8}$' THEN
                substr(data_original, 1, 4) || '-' ||
                substr(data_original, 5, 2) || '-' ||
                substr(data_original, 7, 2)
        END AS data_iso
    FROM preparados
),
movimentos_validos AS (
    SELECT
        movimento_id,
        data_iso::date AS dia,
        valor
    FROM candidatos
    WHERE pg_input_is_valid(data_iso, 'date')
)
SELECT
    dia,
    count(*) AS movimentos,
    sum(valor) AS total
FROM movimentos_validos
GROUP BY dia
ORDER BY dia;

Essa agregação retorna somente os três dias que possuem dados válidos. Os dias 2, 4, 5 e 6 de outubro não aparecem, embora pertençam ao intervalo do relatório.

Incluindo períodos sem movimento

Para representar todos os dias, criamos primeiro o calendário e o usamos como lado esquerdo de um LEFT JOIN:

WITH preparados AS (
    SELECT
        movimento_id,
        NULLIF(btrim(data_emissao), '') AS data_original,
        valor
    FROM sql_semana_13.faturamento_importado
),
candidatos AS (
    SELECT
        movimento_id,
        valor,
        CASE
            WHEN data_original ~ '^[0-9]{8}$' THEN
                substr(data_original, 1, 4) || '-' ||
                substr(data_original, 5, 2) || '-' ||
                substr(data_original, 7, 2)
        END AS data_iso
    FROM preparados
),
movimentos_validos AS (
    SELECT
        movimento_id,
        data_iso::date AS dia,
        valor
    FROM candidatos
    WHERE pg_input_is_valid(data_iso, 'date')
),
faturamento_diario AS (
    SELECT
        dia,
        count(*) AS movimentos,
        sum(valor) AS total
    FROM movimentos_validos
    GROUP BY dia
),
calendario AS (
    SELECT dia::date
    FROM generate_series(
        DATE '2026-10-01',
        DATE '2026-10-07',
        INTERVAL '1 day'
    ) AS serie(dia)
)
SELECT
    c.dia,
    coalesce(f.movimentos, 0) AS movimentos,
    coalesce(f.total, 0.00::numeric) AS total
FROM calendario AS c
LEFT JOIN faturamento_diario AS f USING (dia)
ORDER BY c.dia;

Resultado esperado:

DiaMovimentosTotal
2026-10-011120.00
2026-10-0200.00
2026-10-032125.50
2026-10-0400.00
2026-10-0500.00
2026-10-0600.00
2026-10-071200.00

O calendário define o domínio do relatório. O LEFT JOIN preserva cada dia desse domínio, e coalesce() substitui por zero as métricas ausentes.

Observe que o movimento com texto 2026-10-05 não foi contado no dia 5. Ele não representa um período sem movimento confirmado; representa uma linha rejeitada pelo contrato da carga. Por isso, o relatório financeiro e o indicador de qualidade devem ser analisados juntos.

Zero, ausência e erro não são a mesma coisa

O resultado 0.00 em um dia informa que nenhum movimento válido foi associado àquela data. Ele não prova que a origem não enviou registros problemáticos.

Uma rotina de importação pode manter pelo menos dois resultados:

  • a visão normalizada usada pelos relatórios;
  • uma área de rejeições com o valor original e o motivo da rejeição.

Também é importante conciliar os valores:

SELECT
    count(*) AS linhas_recebidas,
    sum(valor) AS valor_recebido
FROM sql_semana_13.faturamento_importado;

No conjunto de exemplo, foram recebidas nove linhas, somando 645.50. O calendário contém quatro movimentos válidos, que somam 445.50. A diferença de 200.00 pertence às cinco linhas sem data utilizável; ela não deve desaparecer sem explicação.

Melhorar o modelo continua sendo a solução principal

Se a aplicação controla o schema, datas devem ser armazenadas como date ou, quando representam instantes, como um tipo de timestamp escolhido segundo a regra de fuso. O tipo correto impede estados inválidos e elimina conversões repetidas nas consultas.

Em integrações nas quais o texto bruto precisa ser preservado, uma camada de staging pode guardar a entrada original. O processo de transformação valida esse conteúdo e grava uma coluna tipada em uma tabela curada, registrando separadamente as rejeições.

Para consultas frequentes, não é uma boa estratégia repetir a normalização em cada relatório. Além do custo, pequenas diferenças entre expressões podem produzir classificações incompatíveis. Centralize a regra em uma etapa de ingestão, view ou função bem testada, conforme o desenho do sistema.

Cuidados antes de levar a consulta para produção

  • confirme o formato documentado pela origem;
  • trate espaços, NULL e conteúdo inválido como estados explícitos;
  • não use uma expressão regular como única validação de calendário;
  • não faça cast antes de validar;
  • preserve o valor bruto para auditoria e diagnóstico;
  • monitore quantidade e valor das linhas rejeitadas;
  • gere somente o intervalo necessário no calendário;
  • avalie feriados, dias úteis e fuso quando fizerem parte da regra de negócio.

Uma série diária de datas não é um calendário empresarial. Se o relatório depende de dias úteis, fechamentos ou feriados locais, use uma dimensão de calendário com esses atributos em vez de inferi-los apenas com generate_series().

Pratique no laboratório

O laboratório do episódio 13 usa os mesmos nove movimentos sintéticos do artigo. Além das consultas, o arquivo 04-validacao.sql confere a classificação individual, as contagens por situação, a conciliação dos valores e cada linha do calendário.

Depois de preparar o PostgreSQL com Docker Compose conforme o README do repositório , abra o psql na raiz do projeto:

docker compose exec postgres \
  psql -X -v ON_ERROR_STOP=1 -U postgres -d sql_da_semana

Execute os quatro arquivos na ordem:

\i /sql-da-semana/13-datas-texto-calendario/01-tabelas.sql
\i /sql-da-semana/13-datas-texto-calendario/02-dados.sql
\i /sql-da-semana/13-datas-texto-calendario/03-consultas.sql
\i /sql-da-semana/13-datas-texto-calendario/04-validacao.sql

O primeiro script remove com CASCADE e recria somente o schema sql_semana_13. Para repetir o laboratório com segurança, execute novamente os quatro arquivos desde o primeiro; repetir apenas a carga causa conflito de chave primária.

O roteiro foi executado duas vezes, desde a recriação do schema, em PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1), 64-bit, usando a imagem postgres:16, psql -X e ON_ERROR_STOP=1. Nas duas execuções, a validação terminou com sucesso e confirmou:

  • nove linhas recebidas, no valor de 645.50;
  • quatro movimentos válidos, no valor de 445.50;
  • cinco linhas sem data utilizável, no valor de 200.00;
  • as quatro contagens por situação;
  • os sete dias e os totais do calendário.

Conclusão

Datas em campos de texto exigem duas decisões separadas: validar a qualidade da entrada e transformar somente o que cumpre o contrato.

No exemplo, NULLIF() e btrim() identificam ausências, a expressão regular verifica a estrutura AAAAMMDD, pg_input_is_valid() confirma que o dia existe e o cast acontece apenas depois dessas etapas. As linhas rejeitadas permanecem mensuráveis.

Com os movimentos válidos tipados, generate_series() cria o intervalo do relatório e o LEFT JOIN mantém os dias sem faturamento. O zero passa a ter um significado controlado: ausência de movimento válido naquele dia, não ausência de problemas na carga.

O melhor resultado não é apenas uma consulta que termina sem erro. É um relatório cujo total pode ser conciliado e cuja perda de dados, quando existir, fica visível.

Referências