Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

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.

Rodolfo Mori5 min de leitura

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.

sql
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)
);
CREATE TABLE CREATE TABLE CREATE TABLE CREATE TABLE CREATE TABLE

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:

sql
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);
INSERT 0 4 INSERT 0 3 INSERT 0 3 INSERT 0 2 INSERT 0 3

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:

sql
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;
titulo | autora_ou_autor -------------------+-------------------- Dom Casmurro | Machado de Assis A Hora da Estrela | Clarice Lispector Olhos d'Água | Conceição Evaristo (3 rows)

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:

sql
SELECT id, nome, titulo
FROM livros
JOIN autores ON autores.id = livros.autor_id;
ERROR: column reference "id" is ambiguous LINE 1: SELECT id, nome, titulo ^

“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:

sql
SELECT a.nome, l.titulo
FROM autores AS a
LEFT JOIN livros AS l ON l.autor_id = a.id
ORDER BY a.id;
nome | titulo ----------------------+------------------- Machado de Assis | Dom Casmurro Clarice Lispector | A Hora da Estrela Conceição Evaristo | Olhos d'Água Itamar Vieira Junior | (4 rows)

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:

sql
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;
nome | pedido_id | status -------+-----------+---------- Ana | 1 | pago Bruno | 2 | pendente Carla | | (3 rows)

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:

sql
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;
nome | pedido_id ------+----------- Ana | 1 (1 row)

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:

sql
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;
nome | pedido_id -------+----------- Ana | 1 Bruno | Carla | (3 rows)

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:

sql
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;
pedido | cliente | titulo | quantidade | preco_unitario | subtotal --------+---------+-------------------+------------+----------------+---------- 1 | Ana | Dom Casmurro | 1 | 39.90 | 39.90 1 | Ana | A Hora da Estrela | 2 | 34.50 | 69.00 2 | Bruno | Olhos d'Água | 1 | 45.00 | 45.00 (3 rows)

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:

sql
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;
nome | pedidos | total -------+---------+-------- Ana | 1 | 108.90 Bruno | 1 | 45.00 Carla | 0 | 0 (3 rows)

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.

  • postgresql
  • sql
  • join
  • inner join
  • left join
  • banco relacional

Perguntas frequentes

Qual a diferença entre INNER JOIN e LEFT JOIN?
INNER JOIN devolve apenas pares que atendem à condição ON. LEFT JOIN também preserva cada linha da tabela à esquerda quando não existe par, preenchendo as colunas da direita com null.
Posso juntar mais de duas tabelas na mesma consulta?
Pode. Cada JOIN acrescenta uma fonte e sua condição. Use aliases, qualifique colunas e confira a cardinalidade para não multiplicar linhas sem perceber.
JOIN exige foreign key?
Não na sintaxe: ON aceita qualquer condição booleana. A foreign key, porém, garante que a referência seja válida e torna o relacionamento explícito no modelo.
Por que minha coluna ficou ambígua?
Porque mais de uma tabela do FROM possui aquele nome. Prefixe a coluna com o alias, como l.id ou a.id, para dizer exatamente de qual tabela ela vem.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. PostgreSQL 18 — Joins entre tabelas — postgresql.org
  2. PostgreSQL 18 — Expressões de tabela e JOIN — postgresql.org
  3. PostgreSQL 18 — SELECT — postgresql.org

Continue por aqui