JOIN no PostgreSQL: combine tabelas sem perder linhas
Entenda INNER JOIN e LEFT JOIN com autores, livros, clientes e pedidos, veja o erro de coluna ambígua e por que um WHERE pode apagar linhas.
JOIN combina linhas de duas ou mais fontes conforme uma condição. Nesta lição,
você vai montar resultados com livro e autor, cliente e pedido, preservar quem
ainda não comprou e reconhecer duas consultas parecidas que respondem perguntas
diferentes.
O nome técnico do processo é junção. Um banco relacional separa entidades
para evitar repetição e usa chaves para ligá-las novamente quando surge uma
pergunta. JOIN não cola tabelas de forma permanente; ele produz uma tabela de
resultado durante aquela consulta.
Imagine duas listas na expedição: uma contém pedidos e outra contém clientes.
O número do cliente é o campo usado para encontrar o par. A tabela à esquerda é
a lista que você começa lendo; ON é a regra de conferência; as colunas do
SELECT são o que vai para o relatório. O limite da analogia é a cardinalidade:
se uma chave encontra várias linhas do outro lado, o PostgreSQL cria vários
pares. Ele não escolhe uma delas por contexto.
A informação separada precisa de uma chave para voltar a se encontrar
O laboratório da Livraria Horizonte usa cinco tabelas. Autores têm livros,
clientes fazem pedidos e cada pedido se liga a livros por itens_pedido:
Rode o bloco num database vazio, porque esta lição monta seu próprio recorte.
CREATE TABLE autores (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nome text NOT NULL
);
CREATE TABLE livros (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
titulo text NOT NULL,
autor_id bigint NOT NULL REFERENCES autores(id),
preco numeric(10,2) NOT NULL CHECK (preco > 0)
);
CREATE TABLE clientes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nome text NOT NULL
);
CREATE TABLE pedidos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cliente_id bigint NOT NULL REFERENCES clientes(id),
status text NOT NULL CHECK (status IN ('pendente', 'pago', 'cancelado')),
criado_em date NOT NULL
);
CREATE TABLE itens_pedido (
pedido_id bigint REFERENCES pedidos(id) ON DELETE CASCADE,
livro_id bigint REFERENCES livros(id),
quantidade integer NOT NULL CHECK (quantidade > 0),
preco_unitario numeric(10,2) NOT NULL CHECK (preco_unitario > 0),
PRIMARY KEY (pedido_id, livro_id)
);autor_id guarda o id de autores, não o nome repetido. cliente_id faz o
mesmo em pedidos. A tabela de itens resolve uma relação muitos-para-muitos: um
pedido contém vários livros e um livro aparece em vários pedidos. A
lição de modelagem
explica por que essa tabela intermediária também guarda quantidade e preço.
Cadastre um autor ainda sem livro e uma cliente ainda sem pedido. Essas ausências vão tornar a diferença entre os joins visível:
INSERT INTO autores (nome)
VALUES ('Machado de Assis'), ('Clarice Lispector'),
('Conceição Evaristo'), ('Itamar Vieira Junior');
INSERT INTO livros (titulo, autor_id, preco)
VALUES ('Dom Casmurro', 1, 39.90),
('A Hora da Estrela', 2, 34.50),
('Olhos d''Água', 3, 45.00);
INSERT INTO clientes (nome)
VALUES ('Ana'), ('Bruno'), ('Carla');
INSERT INTO pedidos (cliente_id, status, criado_em)
VALUES (1, 'pago', '2026-08-20'),
(2, 'pendente', '2026-08-21');
INSERT INTO itens_pedido (pedido_id, livro_id, quantidade, preco_unitario)
VALUES (1, 1, 1, 39.90), (1, 2, 2, 34.50), (2, 3, 1, 45.00);INNER JOIN devolve somente os pares encontrados
A pergunta “qual é o autor de cada livro?” só precisa de livros que tenham par.
INNER JOIN percorre as combinações que satisfazem a.id = l.autor_id:
SELECT l.titulo, a.nome AS autora_ou_autor
FROM livros AS l
INNER JOIN autores AS a ON a.id = l.autor_id
ORDER BY l.id;l e a são aliases. Eles diminuem a consulta e, principalmente, deixam claro
de onde vem cada coluna. INNER pode ser omitido — JOIN sozinho significa
inner join —, mas escrever o tipo enquanto você aprende ajuda a enxergar a
pergunta.
Pense no ON como a regra de pareamento, não como um filtro final. Primeiro ele
define quais linhas formam pares; outras condições podem filtrar o conjunto
depois. Essa diferença aparece com força no LEFT JOIN.
O erro de coluna ambígua é um pedido por endereço completo
As duas primeiras tabelas possuem uma coluna chamada id. Se você pedir apenas
id, o PostgreSQL não adivinha qual delas deveria entrar no resultado:
SELECT id, nome, titulo
FROM livros
JOIN autores ON autores.id = livros.autor_id;“Ambiguous” quer dizer que mais de uma resposta seria sintaticamente válida.
A correção é qualificar: livros.id AS livro_id ou autores.id AS autor_id.
Em consulta com join, prefixe todas as colunas. Além de resolver o erro, o código
continua legível se outra tabela ganhar uma coluna nome no futuro.
LEFT JOIN preserva a lista que define a pergunta
Agora a pergunta muda: “mostre todos os autores, inclusive quem ainda não tem livro cadastrado”. Autores precisam ficar à esquerda porque são o conjunto que não pode desaparecer:
SELECT a.nome, l.titulo
FROM autores AS a
LEFT JOIN livros AS l ON l.autor_id = a.id
ORDER BY a.id;Itamar não encontrou uma linha em livros, então as colunas do lado direito
recebem NULL. Não é string vazia nem zero; é ausência de valor. O mesmo
mecanismo encontra clientes sem compra:
SELECT c.nome, p.id AS pedido_id, p.status
FROM clientes AS c
LEFT JOIN pedidos AS p ON p.cliente_id = c.id
ORDER BY c.id;A ordem dos lados comunica a intenção. Quase todo RIGHT JOIN pode ser escrito
como LEFT JOIN trocando as tabelas, e essa forma costuma deixar a pergunta
mais direta: comece pelo conjunto que deve permanecer.
Um WHERE pode desfazer o efeito do LEFT JOIN
Queremos todos os clientes e, quando existir, apenas o pedido pago. Esta versão
parece razoável, mas coloca a condição do pedido no WHERE:
SELECT c.nome, p.id AS pedido_id
FROM clientes AS c
LEFT JOIN pedidos AS p ON p.cliente_id = c.id
WHERE p.status = 'pago'
ORDER BY c.id;Bruno e Carla sumiram. Depois da junção, as linhas sem pedido tinham
p.status = NULL; a expressão NULL = 'pago' não é verdadeira e o WHERE as
removeu. Para a condição participar da busca pelo par sem excluir clientes,
coloque-a no ON:
SELECT c.nome, p.id AS pedido_id
FROM clientes AS c
LEFT JOIN pedidos AS p
ON p.cliente_id = c.id
AND p.status = 'pago'
ORDER BY c.id;Agora o ON diz: procure um pedido que pertença ao cliente e esteja pago.
Se não houver, preserve o cliente e complete a parte do pedido com NULL. A
posição da condição mudou a pergunta, não apenas o estilo.
O recibo completo atravessa quatro tabelas
Uma tela de detalhe do pedido precisa do cliente, do livro, da quantidade e do preço congelado no item. Cada join percorre uma relação declarada:
SELECT p.id AS pedido,
c.nome AS cliente,
l.titulo,
i.quantidade,
i.preco_unitario,
i.quantidade * i.preco_unitario AS subtotal
FROM pedidos AS p
JOIN clientes AS c ON c.id = p.cliente_id
JOIN itens_pedido AS i ON i.pedido_id = p.id
JOIN livros AS l ON l.id = i.livro_id
ORDER BY p.id, l.id;O pedido 1 aparece duas vezes porque possui dois itens. Isso não é duplicação acidental: a granularidade do resultado é “uma linha por item do pedido”. Antes de chamar um join de errado, complete a frase “uma linha do meu resultado representa…”. Ela revela se a multiplicação era esperada.
Depois da junção, agrupe na granularidade do relatório
Para produzir uma linha por cliente, agregue os itens. O LEFT JOIN mantém
Carla, count(DISTINCT p.id) evita contar o mesmo pedido uma vez por item e
coalesce troca o total ausente por zero:
SELECT c.nome,
count(DISTINCT p.id) AS pedidos,
coalesce(sum(i.quantidade * i.preco_unitario), 0) AS total
FROM clientes AS c
LEFT JOIN pedidos AS p ON p.cliente_id = c.id
LEFT JOIN itens_pedido AS i ON i.pedido_id = p.id
GROUP BY c.id, c.nome
ORDER BY c.id;Faça a missão com uma mudança controlada: cadastre um segundo pedido para Ana, sem itens, e acrescente um livro de Itamar. Rode novamente os três relatórios. O teste passa se Itamar deixar de ter título nulo, Ana tiver dois pedidos sem o total mudar e Carla continuar aparecendo com zero.
Você já sabe operar uma tabela com
SELECT, INSERT, UPDATE e DELETE
e agora sabe reconstruir relações. O próximo passo é descobrir quanto custa
encontrar esses pares quando as tabelas crescem: em
índices no PostgreSQL, EXPLAIN
mostra o caminho usado em 200 mil pedidos.
Perguntas frequentes
Qual a diferença entre INNER JOIN e LEFT JOIN?
Posso juntar mais de duas tabelas na mesma consulta?
JOIN exige foreign key?
Por que minha coluna ficou ambígua?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em PostgreSQL 18.6 em postgres:18-alpine e Docker 29.5.3, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- PostgreSQL 18 — Joins entre tabelas — postgresql.org
- PostgreSQL 18 — Expressões de tabela e JOIN — postgresql.org
- PostgreSQL 18 — SELECT — postgresql.org


