Um painel consulta pedidos várias vezes ao dia para mostrar quantidade e valor por data e situação. Quando a consulta precisa percorrer e agrupar muitas linhas a cada acesso, recalcular sempre o mesmo resultado pode deixar de ser uma boa troca.

Uma materialized view permite armazenar o resultado dessa consulta no PostgreSQL. A leitura passa a usar dados já consolidados, mas surge uma decisão nova: quando atualizar essa cópia?

Neste episódio, vamos criar indicadores de pedidos, alterar os dados de origem e observar que a materialized view permanece desatualizada até receber um REFRESH.

O laboratório do episódio 10 está disponível no GitHub. Ele permite comparar a consulta direta com o resultado armazenado e executar os dois modos de refresh apresentados neste artigo.

O cenário

O exemplo representa uma base própria de integração, com dados sintéticos. Não há acesso nem escrita em tabelas do ERP.

Queremos consultar, por dia e situação:

  • a quantidade de pedidos;
  • o valor total;
  • o ticket médio.

Para manter o exercício pequeno, cada pedido já possui seu valor total. Em um sistema real, esse valor pode vir de itens, descontos, impostos e outras regras que precisam fazer parte da definição do indicador.

Tabela e dados iniciais

CREATE SCHEMA sql_semana_10;

CREATE TABLE sql_semana_10.pedidos (
    pedido_id integer PRIMARY KEY,
    criado_em timestamptz NOT NULL,
    situacao  text NOT NULL CHECK (
        situacao IN ('pendente', 'aprovado', 'cancelado')
    ),
    valor_total numeric(12, 2) NOT NULL CHECK (valor_total >= 0)
);

INSERT INTO sql_semana_10.pedidos
    (pedido_id, criado_em, situacao, valor_total)
VALUES
    (1, '2026-09-28 09:15:00-03', 'aprovado',  120.00),
    (2, '2026-09-28 10:40:00-03', 'aprovado',   80.00),
    (3, '2026-09-28 14:10:00-03', 'cancelado',  50.00),
    (4, '2026-09-29 08:30:00-03', 'aprovado',  200.00),
    (5, '2026-09-29 11:20:00-03', 'pendente',   90.00),
    (6, '2026-09-30 16:00:00-03', 'aprovado',  160.00);

O uso de timestamptz preserva o instante. Para definir o dia do indicador, precisamos também declarar qual fuso representa o calendário do negócio.

A consulta que será consolidada

Antes de criar outro objeto, podemos validar a agregação diretamente nas tabelas de origem:

SELECT
    (criado_em AT TIME ZONE 'America/Sao_Paulo')::date AS dia,
    situacao,
    count(*) AS quantidade,
    sum(valor_total) AS valor_total,
    round(avg(valor_total), 2) AS ticket_medio
FROM sql_semana_10.pedidos
GROUP BY
    (criado_em AT TIME ZONE 'America/Sao_Paulo')::date,
    situacao
ORDER BY dia, situacao;

Resultado esperado:

DiaSituaçãoQuantidadeValor totalTicket médio
2026-09-28aprovado2200.00100.00
2026-09-28cancelado150.0050.00
2026-09-29aprovado1200.00200.00
2026-09-29pendente190.0090.00
2026-09-30aprovado1160.00160.00

Essa consulta já responde à pergunta. A materialized view não muda o significado da agregação: ela muda onde e quando o resultado é calculado e armazenado.

Criando a materialized view

CREATE MATERIALIZED VIEW sql_semana_10.indicadores_pedidos AS
SELECT
    (criado_em AT TIME ZONE 'America/Sao_Paulo')::date AS dia,
    situacao,
    count(*) AS quantidade,
    sum(valor_total) AS valor_total,
    round(avg(valor_total), 2) AS ticket_medio
FROM sql_semana_10.pedidos
GROUP BY
    (criado_em AT TIME ZONE 'America/Sao_Paulo')::date,
    situacao;

Por padrão, o PostgreSQL executa a consulta durante a criação e armazena suas linhas. Depois disso, podemos consultar o objeto como uma relação:

SELECT dia, situacao, quantidade, valor_total, ticket_medio
FROM sql_semana_10.indicadores_pedidos
ORDER BY dia, situacao;

O resultado inicial é igual ao da consulta de origem. A diferença aparece quando os pedidos mudam.

Observando a defasagem

Vamos aprovar o pedido que estava pendente e incluir outro pedido no mesmo dia:

UPDATE sql_semana_10.pedidos
SET situacao = 'aprovado'
WHERE pedido_id = 5;

INSERT INTO sql_semana_10.pedidos
    (pedido_id, criado_em, situacao, valor_total)
VALUES
    (7, '2026-09-29 18:45:00-03', 'aprovado', 110.00);

A consulta direta sobre pedidos passa a mostrar três pedidos aprovados em 29/09, total de 400.00 e ticket médio de 133.33. A linha pendente desaparece.

Já a materialized view continua com o retrato anterior:

SELECT dia, situacao, quantidade, valor_total, ticket_medio
FROM sql_semana_10.indicadores_pedidos
WHERE dia = DATE '2026-09-29'
ORDER BY situacao;

Ela ainda informa um aprovado de 200.00 e um pendente de 90.00. Uma materialized view não acompanha automaticamente cada INSERT, UPDATE ou DELETE realizado nas tabelas de origem.

Atualizando o resultado armazenado

REFRESH MATERIALIZED VIEW sql_semana_10.indicadores_pedidos;

Depois do comando, a mesma consulta sobre 29/09 retorna:

DiaSituaçãoQuantidadeValor totalTicket médio
2026-09-29aprovado3400.00133.33

O REFRESH recalcula a consulta que define a materialized view e substitui seu conteúdo. Ele não é um agendador. A aplicação ou a operação do banco precisa decidir quando executá-lo, considerando custo, frequência das mudanças e defasagem aceitável para quem consome o indicador.

E se houver leitores durante o refresh?

O modo padrão bloqueia consultas concorrentes à materialized view enquanto atualiza seu conteúdo. Quando ela precisa continuar disponível para leitura, o PostgreSQL oferece:

REFRESH MATERIALIZED VIEW CONCURRENTLY
    sql_semana_10.indicadores_pedidos;

Esse comando exige que a materialized view já esteja populada e possua ao menos um índice UNIQUE baseado apenas em colunas, sem cláusula WHERE, e que identifique todas as linhas. Neste resultado, dia e situacao identificam cada grupo:

CREATE UNIQUE INDEX indicadores_pedidos_dia_situacao_uq
    ON sql_semana_10.indicadores_pedidos (dia, situacao);

REFRESH MATERIALIZED VIEW CONCURRENTLY
    sql_semana_10.indicadores_pedidos;

O modo concorrente evita bloquear as leituras da materialized view, mas não torna a atualização gratuita nem permite várias atualizações simultâneas do mesmo objeto. A documentação também observa que o refresh comum tende a usar menos recursos e terminar mais rapidamente. A escolha deve ser validada com o volume e a concorrência reais.

Mesmo que a definição contenha ORDER BY, um refresh não garante preservar a ordem física. Toda consulta que depende de ordenação deve usar seu próprio ORDER BY.

Quando usar — e quando não usar

Uma materialized view pode ser útil quando:

  • a consulta de origem é relativamente cara;
  • o resultado é lido muitas vezes;
  • alguma defasagem é aceitável;
  • existe uma regra clara para atualizar e monitorar o objeto.

Ela pode ser uma escolha ruim quando o consumidor precisa refletir cada alteração imediatamente. Também não corrige uma consulta mal definida, índices ausentes nas tabelas de origem ou um modelo inadequado.

Antes de adotá-la, vale responder:

  1. Qual atraso é aceitável para o indicador?
  2. Quanto tempo e quantos recursos o refresh consome?
  3. O que acontece se o refresh falhar?
  4. Como o consumidor conhece a última atualização bem-sucedida?
  5. O refresh comum atende ou as leituras precisam continuar disponíveis?

Praticando no laboratório

O laboratório do episódio 10 contém:

episodios/10-materialized-views/
|-- 01-tabelas.sql
|-- 02-dados.sql
|-- 03-consultas.sql
`-- README.md

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

Ajuste usuário e banco se personalizou o .env. Dentro do psql, execute na ordem:

\i /sql-da-semana/10-materialized-views/01-tabelas.sql
\i /sql-da-semana/10-materialized-views/02-dados.sql
\i /sql-da-semana/10-materialized-views/03-consultas.sql

O primeiro arquivo remove e recria somente o schema sql_semana_10; o segundo carrega os seis pedidos iniciais; o terceiro percorre a consulta direta, a criação da materialized view, a defasagem, os dois modos de refresh e a consulta do índice no catálogo.

Para repetir o laboratório, execute os três arquivos desde o início. Repetir somente a carga causa conflito de chave primária; repetir somente as consultas causa conflito nos nomes da materialized view e do índice.

Validação registrada

O README do episódio registra a execução em 26/09/2026 no PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1), com psql -X -v ON_ERROR_STOP=1.

A sequência completa foi executada duas vezes. A validação confirmou os cinco grupos iniciais, a defasagem depois da alteração dos pedidos, o grupo atualizado com três aprovados e total de 400.00, a existência do índice UNIQUE e o sucesso do REFRESH MATERIALIZED VIEW CONCURRENTLY. A comparação dos dumps também não encontrou alterações nos schemas dos episódios anteriores, desconsiderando os tokens aleatórios de proteção do pg_dump.

Conclusão

Materialized views trocam atualização imediata por leituras de um resultado previamente calculado. No cenário dos pedidos, o painel deixa de refazer a agregação em cada acesso, mas passa a depender de uma política explícita de refresh.

O ponto principal não é somente criar o objeto. É definir a defasagem aceitável, validar o custo da atualização e tornar visível quando o indicador foi renovado pela última vez.

Referências