O que é SQL?
SQL, pronunciado como “sequel” ou “S-Q-L”, é a Structured Query Language (Linguagem de Consulta Estruturada) usada para ler e escrever dados em bancos de dados relacionais. Foi padronizada em 1986, o que a torna mais antiga que a web, e continua sendo a forma como quase toda aplicação se comunica com seus dados.
O SQL divide-se em duas partes. DDL (data definition language) cria e altera a estrutura: CREATE TABLE, ALTER TABLE, DROP INDEX. DML (data manipulation language) trabalha com os dados em si: SELECT, INSERT, UPDATE, DELETE. A maior parte do seu tempo será gasta em DML, sendo SELECT, de longe, o comando mais comum.
O ponto importante a entender logo no início é que SQL não é uma linguagem de programação no sentido usual. Não existem loops na linguagem core nem variáveis em uma query simples. Você descreve o resultado desejado, e o banco de dados descobre como produzi-lo.
Declarativo e baseado em conjuntos
Em uma linguagem como JavaScript ou Python, você computaria um relatório através de iterações:
const result = [];
for (const author of authors) {
let count = 0;
for (const post of posts) {
if (post.author_id === author.id) count++;
}
if (count > 0) result.push({ author, count });
}
Em SQL, você expressa a mesma intenção em uma única expressão:
SELECT a.name, COUNT(p.id) AS posts
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.id, a.name
HAVING COUNT(p.id) > 0;
Não existe loop. Você declarou que deseja cada autor com a sua respectiva contagem de posts. O banco de dados pode resolver isso com um index scan, um hash join ou algo completamente diferente — e se você adicionar um índice amanhã, a mesma query pode se tornar mais rápida sem alterar um único caractere.
Este é o mindset baseado em conjuntos (set-based): as instruções operam sobre conjuntos inteiros de linhas de uma só vez. Quando você compreende esse conceito, o SQL torna-se conciso de uma forma que loops raramente conseguem ser.
SELECT: escolhendo colunas
Toda leitura começa com SELECT, que lista as colunas que você deseja.
SELECT id, title, published_at
FROM posts;
SELECT * retorna todas as colunas e é útil durante a exploração, mas especifique o nome das colunas em queries de aplicações. Listas explícitas são estáveis quando o schema muda, evitam a transferência de colunas grandes e não utilizadas, e tornam a intenção óbvia. Você pode computar novas colunas e renomeá-las:
SELECT title,
LENGTH(body) AS body_length,
COALESCE(published_at, created_at) AS visible_at
FROM posts;
AS atribui um alias a uma coluna. Use-o para tornar os resultados legíveis e para dar nome a colunas computadas, o que é importante quando uma biblioteca cliente mapeia linhas para objetos.
Filtrando com WHERE
WHERE mantém apenas as linhas que satisfazem uma condição. Ele suporta as comparações usuais além de AND, OR, NOT, IN, BETWEEN e LIKE.
SELECT id, title
FROM posts
WHERE author_id = 7
AND published_at IS NOT NULL
AND title ILIKE '%sql%';
Dois detalhes valem a pena ser memorizados. Primeiro, LIKE diferencia maiúsculas de minúsculas (case-sensitive) em muitos bancos de dados, enquanto ILIKE (Postgres) não diferencia (case-insensitive); o LIKE do MySQL não diferencia com as collations usuais. Segundo, um caractere curinga no início, como '%sql', impede que um índice comum ajude, pois não há um prefixo para a busca. Para buscas reais de texto, utilize um índice de full-text em vez disso.
Ordenação e paginação
ORDER BY ordena o resultado, e LIMIT com OFFSET o fatia.
SELECT id, title
FROM posts
WHERE published_at IS NOT NULL
ORDER BY published_at DESC
LIMIT 20 OFFSET 40;
Sem ORDER BY, o banco de dados não garante a ordem das linhas. Uma query que “por acaso” retorna ordenada hoje pode mudar quando o planner escolhe um plano diferente, portanto, sempre ordene explicitamente quando a ordem for importante.
OFFSET pula linhas, o que se torna caro em tabelas grandes (deep paging) porque o banco de dados ainda precisa percorrer esses registros. A paginação por keyset evita esse custo ao lembrar a última linha visualizada:
SELECT id, title
FROM posts
WHERE published_at < :last_published_at
ORDER BY published_at DESC
LIMIT 20;
NULL e a lógica trivalente
NULL não significa zero ou uma string vazia. Significa desconhecido, e o SQL utiliza a lógica trivalente: cada condição é avaliada como verdadeira, falsa ou desconhecida. As linhas só passam por uma cláusula WHERE quando a condição é verdadeira, portanto, o “desconhecido” é tratado como falso para fins de filtragem.
SELECT id FROM posts WHERE published_at = NULL; -- never matches
SELECT id FROM posts WHERE published_at IS NULL; -- correct
SELECT id FROM posts WHERE published_at IS NOT NULL;
Consequências para ficar atento:
NULL = NULLé desconhecido, não verdadeiro. UseIS NULLouIS NOT DISTINCT FROM.NULL + 1éNULL; useCOALESCE(x, 0)para substituir por um valor padrão.NOT IN (1, 2, NULL)nunca é verdadeiro, pois comparar com um elemento desconhecido resulta em desconhecido. PrefiraNOT EXISTSquando a lista puder conterNULL.- Agregados ignoram
NULL:COUNT(column)conta valores não nulos, enquantoCOUNT(*)conta as linhas.
A nulidade é uma decisão de design. Declare as colunas como NOT NULL, a menos que valores ausentes sejam genuinamente significativos.
JOINs, com um diagrama
Um join combina linhas de duas tabelas com base em uma coluna relacionada. Suponha que authors tenha Ada, Grace e Linus, e posts tenha duas linhas da Ada e uma da Grace. Os tipos de join produzem resultados diferentes.
authors posts
------- ----------------------------
id name id author_id title
1 Ada 10 1 Joins
2 Grace 11 1 Indexes
3 Linus 12 2 NULLs
INNER JOIN -> posts 10, 11, 12 (only matches)
LEFT JOIN -> posts 10, 11, 12, Linus (all authors, NULL post)
RIGHT JOIN -> posts 10, 11, 12 (all posts)
FULL JOIN -> posts 10, 11, 12, Linus (both sides)
-- Every author, including those with no posts.
SELECT a.name, p.title
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
ORDER BY a.name;
A condição do join fica no ON. Para outer joins, adicionar um filtro da tabela da direita ao WHERE transforma silenciosamente o outer join em um inner join, porque as linhas NULL falham no filtro. Coloque tais condições na cláusula ON quando quiser que as linhas não correspondentes sobrevivam.
-- Keeps authors with no published posts.
SELECT a.name, p.title
FROM authors a
LEFT JOIN posts p
ON p.author_id = a.id
AND p.published_at IS NOT NULL;
Um self join utiliza a mesma tabela duas vezes com aliases diferentes, sendo útil para hierarquias. Um cross join emparelha cada linha com todas as outras e raramente é o que você deseja por acidente.
GROUP BY e HAVING
Agregadores colapsam várias linhas em uma só: COUNT, SUM, AVG, MIN, MAX. GROUP BY define os grupos.
SELECT a.name AS author,
COUNT(p.id) AS post_count,
MAX(p.created_at) AS latest
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.id, a.name
HAVING COUNT(p.id) > 0
ORDER BY post_count DESC;
A regra é que toda coluna selecionada deve estar no GROUP BY ou envolta em um agregador. Bancos de dados que impõem isso (Postgres e MySQL 8 por padrão) estão protegendo você de resultados arbitrários.
WHERE filtra linhas antes do agrupamento, e HAVING filtra grupos depois. Portanto, uma condição sobre um agregador pertence ao HAVING, e uma condição sobre uma coluna simples geralmente pertence ao WHERE por questões de eficiência.
SELECT author_id, COUNT(*) AS posts
FROM posts
WHERE published_at IS NOT NULL -- filter rows
GROUP BY author_id
HAVING COUNT(*) > 5; -- filter groups
Subqueries e CTEs
Uma subquery é uma consulta aninhada dentro de outra. Ela pode aparecer em SELECT, FROM ou WHERE.
SELECT title
FROM posts
WHERE author_id IN (
SELECT id FROM authors WHERE name = 'Ada'
);
Uma common table expression (CTE) nomeia uma subquery com WITH, o que geralmente torna a leitura melhor do que o aninhamento e permite que você reutilize o resultado.
WITH published AS (
SELECT id, author_id, title
FROM posts
WHERE published_at IS NOT NULL
)
SELECT a.name, COUNT(p.id) AS posts
FROM authors a
LEFT JOIN published p ON p.author_id = a.id
GROUP BY a.id, a.name;
CTEs podem ser encadeadas, e uma recursive CTE referencia a si mesma para percorrer uma árvore, como categorias ou threads de comentários. Opte por uma CTE sempre que uma consulta começar a ter mais de um nível de aninhamento; a legibilidade também é um recurso de performance.
Escrevendo linhas
INSERT adiciona linhas, UPDATE as altera e DELETE as remove.
INSERT INTO posts (author_id, title, body)
VALUES (7, 'Learning SQL', 'SQL is declarative.');
UPDATE posts
SET published_at = now()
WHERE id = 42;
DELETE FROM posts
WHERE id = 42;
Sempre forneça uma cláusula WHERE para UPDATE e DELETE. Sem ela, todas as linhas serão afetadas. Um bom hábito é executar o SELECT correspondente primeiro para confirmar a contagem. Um UPDATE também pode referenciar valores existentes:
UPDATE posts
SET title = title || ' (updated)'
WHERE author_id = 7;
INSERT pode adicionar várias linhas de uma vez e pode ser alimentado por uma query:
INSERT INTO posts (author_id, title)
SELECT id, 'Welcome' FROM authors;
Transações
Uma transação agrupa instruções para que todas tenham sucesso ou todas falhem. É isso que mantém a consistência em alterações de múltiplas etapas.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Se uma instrução falhar, ou se você decidir abortar, ROLLBACK desfaz cada alteração feita desde BEGIN. Sem uma transação, uma falha entre as duas atualizações deixaria o dinheiro desaparecido. Agrupe escritas relacionadas, mantenha a transação curta e nunca aguarde a resposta de um usuário ou uma chamada de rede enquanto mantiver uma transação aberta.
Chaves e constraints
Constraints são regras que o banco de dados aplica por você:
PRIMARY KEY— identifica unicamente cada linha; também cria um índice e implicaNOT NULL.FOREIGN KEY— exige que o valor exista em outra tabela, preservando a integridade referencial.UNIQUE— proíbe duplicatas em uma coluna ou combinação de colunas.NOT NULL— exige um valor.CHECK— exige que uma condição seja atendida, comoprice >= 0.DEFAULT— fornece um valor quando nenhum é informado.
CREATE TABLE posts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_id bigint NOT NULL REFERENCES authors (id) ON DELETE CASCADE,
title text NOT NULL,
body text NOT NULL,
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
Uma primary key substituta (surrogate key), como uma coluna de identidade, é estável e compacta. Uma natural key, como um endereço de e-mail, é significativa, mas pode mudar; portanto, prefira uma surrogate key com uma constraint UNIQUE na chave natural.
Índices em nível conceitual
Um índice é uma estrutura separada e ordenada que mapeia valores de colunas para a localização das linhas. Sem um índice, encontrar linhas significa escanear a tabela; com um, o banco de dados pode saltar diretamente para as correspondências.
CREATE INDEX posts_author_published_idx
ON posts (author_id, published_at DESC);
Conceitualmente, pense em uma agenda telefônica ordenada por sobrenome. Buscar um nome é rápido porque a agenda está ordenada; filtrar por uma coluna que não seja a chave de ordenação significa ler todas as páginas. Os índices trocam velocidade de escrita e armazenamento por velocidade de leitura, pois cada insert e update deve mantê-los atualizados.
Crie índices nas colunas que você utiliza para filtrar e fazer joins, prefira índices compostos que correspondam aos formatos reais das suas queries e verifique com EXPLAIN se o planner realmente os está utilizando. Ter mais índices não é melhor; ter os índices certos é que é.
Window functions de uma vez só
Uma window function realiza cálculos em um conjunto de linhas relacionadas à linha atual, sem colapsá-las da maneira que o GROUP BY faz. Isso torna simples a criação de totais acumulados, rankings e comparações por grupo.
SELECT author_id,
title,
published_at,
ROW_NUMBER() OVER (
PARTITION BY author_id
ORDER BY published_at DESC
) AS rank_in_author
FROM posts
WHERE published_at IS NOT NULL;
A cláusula OVER define a janela: PARTITION BY divide as linhas em grupos e ORDER BY as ordena dentro de cada grupo. Funções comuns incluem ROW_NUMBER, RANK, LAG, LEAD e SUM(...) OVER (...). A principal diferença em relação à agregação é que cada linha original é preservada.
Normalização: 1NF, 2NF, 3NF
A normalização é o processo de remover redundâncias para que cada fato seja armazenado apenas uma vez. As três primeiras formas normais são as que você mais utilizará:
Primeira forma normal (1NF) — sem grupos repetidos ou colunas com múltiplos valores. Uma coluna tags que armazena "sql,indexes" viola esta regra; uma tabela post_tags separada resolve o problema.
Segunda forma normal (2NF) — 1NF mais a ausência de dependência parcial de parte de uma chave composta. Se um item de pedido é identificado por (order_id, product_id) e armazena product_name, esse nome depende apenas de product_id, portanto, ele pertence a products.
Terceira forma normal (3NF) — 2NF mais a ausência de dependência transitiva entre colunas que não são chaves. Se posts armazenasse tanto author_id quanto author_email, o e-mail dependeria do autor, e não do post, portanto, ele pertence a authors.
Considere uma tabela que repete o e-mail do autor em cada post. Se você alterar o e-mail, precisará atualizar diversas linhas; se esquecer de uma, os dados se tornarão contraditórios. Divida-a em authors e posts e o fato passará a existir em um único lugar. Desnormalize posteriormente, de forma deliberada, quando um problema de performance mensurável justificar isso.
Boas práticas
- Nomeie as colunas explicitamente em vez de usar
SELECT *nas queries da aplicação. - Sempre
ORDER BYquando a ordem dos resultados for importante. - Declare
NOT NULLpor padrão e trate valores ausentes comCOALESCE. - Use
IS NULLem vez de= NULL, e prefiraNOT EXISTSaNOT INcom listas anuláveis. - Filtre linhas no
WHEREe grupos noHAVING. - Use CTEs para manter queries complexas legíveis.
- Adicione uma cláusula
WHEREemUPDATEeDELETEe confirme as linhas de destino primeiro. - Envolva escritas relacionadas em uma transação e mantenha-a curta.
- Adicione índices para padrões de query reais e verifique-os com
EXPLAIN. - Armazene cada fato apenas uma vez e busque a terceira forma normal antes de desnormalizar.
Erros comuns
- Escrever
WHERE column = NULLe receber zero linhas. - Usar
NOT INem uma lista que contémNULLe não retornar nada silenciosamente. - Esquecer o
WHEREem umUPDATEouDELETEe modificar todas as linhas. - Selecionar colunas não agrupadas e receber valores arbitrários.
- Assumir que as linhas retornam em uma ordem útil sem o
ORDER BY. - Usar
LIMIT ... OFFSETpara paginação profunda e arcar com um custo crescente. - Filtrar a tabela correta em
WHEREe acidentalmente transformar umLEFT JOINem um inner join. - Indexar todas as colunas e deixar as escritas mais lentas sem nenhum benefício de leitura.
- Concatenar entradas do usuário em strings SQL em vez de usar parâmetros, o que abre brechas para injection.
- Armazenar fatos repetidos em uma única tabela larga em vez de normalizá-los.
Próximos passos
SQL é a base de todo banco de dados relacional, portanto, o próximo passo é escolher um engine e aprender seu dialeto e particularidades. Comece com PostgreSQL por sua conformidade com os padrões e tipos ricos, ou MySQL por sua onipresença e facilidade de replicação. A partir daí, veja como os resultados das queries se tornam respostas HTTP no guia de REST, e como um serviço Node.js envia essas queries utilizando uma pooled connection em Node.js basics.