Considere duas queries que devolvem o mesmo resultado: o estado do cliente e o valor de cada venda acima de mil.

-- Query A
SELECT estado, valor
FROM (
  SELECT v.*, c.*
  FROM vendas v
  JOIN clientes c ON v.id_cliente = c.id
)
WHERE valor > 1000;
-- Query B
SELECT c.estado, v.valor
FROM (
  SELECT valor, id_cliente
  FROM vendas
  WHERE valor > 1000
) v
JOIN clientes c ON v.id_cliente = c.id;

Qual delas parece mais eficiente?

A Query B sugere que o filtro acontece antes do join e que apenas as colunas necessárias são carregadas. A Query A sugere o oposto: primeiro o join entre todas as vendas e clientes, depois o filtro. Se cada query fosse uma receita imperativa, B seria a escolha mais eficiente.

Mas SQL não é uma receita imperativa. É uma linguagem declarativa: ela descreve o resultado desejado, enquanto o banco decide como obtê-lo. Para responder à pergunta inicial, precisamos seguir a query desde o texto até a execução.

Os diagramas deste artigo são modelos didáticos. Eles explicam as etapas de planejamento, mas não reproduzem literalmente a saída de um banco específico.

A jornada

Respondê-la significa seguir a query por todas as etapas que um banco percorre antes de mover uma única linha: o texto, uma estrutura que o parser consegue interpretar, um plano construído a partir da álgebra relacional, esse mesmo plano reescrito por um otimizador e, por fim, as instruções físicas que de fato executam. Todo engine que fala SQL faz alguma versão disso, mesmo quando as fronteiras entre as etapas são mais fluidas do que aqui.

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    SQL[1. SQL] --> AST[2. AST] --> LOGICO[3. Plano lógico] --> OTIMIZADO[4. Plano otimizado] --> FISICO[5. Plano físico]
    class SQL,AST,LOGICO,OTIMIZADO,FISICO muted;

Vamos percorrer esse caminho com as Queries A e B. Para acompanhar a mesma operação mudando de forma entre os diagramas, filtros, joins e projeções de colunas recebem tratamentos visuais próprios; scans e nós estruturais que ainda não são operadores aparecem de forma neutra.

1. SQL

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    SQL[1. SQL] --> AST[2. AST] --> LOGICO[3. Plano lógico] --> OTIMIZADO[4. Plano otimizado] --> FISICO[5. Plano físico]
    class AST,LOGICO,OTIMIZADO,FISICO muted;
    class SQL active;

A primeira coisa que chega ao banco é uma string: caracteres formando palavras como SELECT, FROM, WHERE e JOIN. Ela ainda não é uma sequência de instruções para a máquina.

As duas queries expressam a mesma intenção; a diferença é sintática — a posição do filtro e da lista de colunas no texto. Essa posição não obriga o banco a executar as operações nessa ordem.

Lidas de cima para baixo, as duas queries já parecem diferentes:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef opProject fill:#E3F3EF,stroke:#2F9E8F,color:#1F2937,stroke-width:1.5px;
    classDef opFilter fill:#FBE4E2,stroke:#C0392B,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    subgraph SA["Texto SQL — Query A (ordem de leitura)"]
        direction TB
        SA1["SELECT estado, valor"] --> SA2["FROM (subquery)"]
        SA2 --> SA3["SELECT v.*, c.*"]
        SA3 --> SA4["JOIN vendas v, clientes c"]
        SA4 --> SA5["WHERE valor > 1000"]
    end
    subgraph SB["Texto SQL — Query B (ordem de leitura)"]
        direction TB
        SB1["SELECT c.estado, v.valor"] --> SB2["FROM (subquery)"]
        SB2 --> SB3["SELECT valor, id_cliente"]
        SB3 --> SB4["WHERE valor > 1000"]
        SB4 --> SB5["JOIN clientes c"]
    end
    SA ~~~ SB
    class SA1,SA3,SB1,SB3 opProject;
    class SA4,SB5 opJoin;
    class SA5,SB4 opFilter;
    class SA2,SB2 muted;

Na Query A, WHERE é a última coisa que você lê; na Query B ele está dentro da subquery, antes do join. Essa posição — e não algo que o banco já decidiu — é toda a origem da nossa intuição.

O banco primeiro interpreta a estrutura do texto. Esse é o papel da próxima etapa.

2. AST

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    SQL[1. SQL] --> AST[2. AST] --> LOGICO[3. Plano lógico] --> OTIMIZADO[4. Plano otimizado] --> FISICO[5. Plano físico]
    class SQL,LOGICO,OTIMIZADO,FISICO muted;
    class AST active;

AST significa Abstract Syntax Tree: uma estrutura interna que o parser constrói lendo o texto e reconhecendo sua gramática. Não é só uma ilustração — é uma etapa real do processamento, que transforma o texto literal em algo que o interpretador consegue analisar. Espaços, quebras de linha e indentação deixam de importar; o que permanece é a relação entre SELECT, FROM, subqueries, filtros e joins.

Uma representação simplificada das duas ASTs, lado a lado, deixa a diferença fácil de ver:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef opProject fill:#E3F3EF,stroke:#2F9E8F,color:#1F2937,stroke-width:1.5px;
    classDef opFilter fill:#FBE4E2,stroke:#C0392B,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    subgraph QA["AST — Query A"]
        direction TB
        A1["SelectStatement<br/>(estado, valor)"] --> A2["FromClause"]
        A2 --> A3["Subquery"]
        A3 --> A4["Join<br/>(vendas v, clientes c)"]
        A1 --> A5["Predicate<br/>(valor > 1000)"]
    end
    subgraph QB["AST — Query B"]
        direction TB
        B1["SelectStatement<br/>(c.estado, v.valor)"] --> B2["Join"]
        B2 --> B3["Subquery v"]
        B3 --> B4["Predicate<br/>(valor > 1000)"]
        B2 --> B5["TableRef<br/>(clientes c)"]
    end
    QA ~~~ QB
    class A1,B1 opProject;
    class A4,B2 opJoin;
    class A5,B4 opFilter;
    class A2,A3,B3,B5 muted;

Aqui está a origem da nossa intuição: na árvore da Query B, o nó do predicado realmente está mais abaixo, aninhado dentro da subquery, sob o join. Na Query A, o mesmo predicado está ligado direto à instrução externa, acima do join. Ainda assim, a AST só mostra a estrutura gramatical da query, não a sequência final de trabalho sobre os dados. Para chegar nela, o banco converte a árvore em operadores.

3. Plano lógico

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    SQL[1. SQL] --> AST[2. AST] --> LOGICO[3. Plano lógico] --> OTIMIZADO[4. Plano otimizado] --> FISICO[5. Plano físico]
    class SQL,AST,OTIMIZADO,FISICO muted;
    class LOGICO active;

O plano lógico traduz a AST em operadores da álgebra relacional — as operações matemáticas que descrevem como relações (tabelas e seus conjuntos de linhas) se combinam e se transformam, uma das bases conceituais do SQL.

Cada parte familiar da query encontra um paralelo nessa álgebra:

No SQLNo plano lógicoO que faz
FROM vendasscanLê a relação vendas.
WHERE valor > 1000filter ou seleçãoMantém somente as linhas que passam na condição.
JOIN … ON …joinCombina relações pela condição de junção.
SELECT estado, valorproject ou projeçãoMantém ou calcula as colunas de saída.

Agora a query deixa de ser uma árvore de palavras-chave e passa a ser uma árvore de operações sobre dados:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef opProject fill:#E3F3EF,stroke:#2F9E8F,color:#1F2937,stroke-width:1.5px;
    classDef opFilter fill:#FBE4E2,stroke:#C0392B,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    subgraph LA["Plano lógico — Query A"]
        direction TB
        LA1["Project<br/>(estado, valor)"] --> LA2["Filter<br/>(valor > 1000)"]
        LA2 --> LA3["Join<br/>(v.id_cliente = c.id)"]
        LA3 --> LA4["Scan (vendas)"]
        LA3 --> LA5["Scan (clientes)"]
    end
    subgraph LB["Plano lógico — Query B"]
        direction TB
        LB1["Project<br/>(estado, valor)"] --> LB2["Join<br/>(v.id_cliente = c.id)"]
        LB2 --> LB3["Project<br/>(valor, id_cliente)"]
        LB3 --> LB4["Filter<br/>(valor > 1000)"]
        LB4 --> LB5["Scan (vendas)"]
        LB2 --> LB6["Scan (clientes)"]
    end
    LA ~~~ LB
    class LA1,LB1,LB3 opProject;
    class LA3,LB2 opJoin;
    class LA2,LB4 opFilter;
    class LA4,LA5,LB5,LB6 muted;

Neste momento, a pergunta inicial ainda está aberta. Os dois planos lógicos refletem a posição das cláusulas no SQL. Antes de escolher como executar qualquer um deles, o otimizador ainda pode reescrever essas operações.

4. Otimização

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    SQL[1. SQL] --> AST[2. AST] --> LOGICO[3. Plano lógico] --> OTIMIZADO[4. Plano otimizado] --> FISICO[5. Plano físico]
    class SQL,AST,LOGICO,FISICO muted;
    class OTIMIZADO active;

Otimizar, neste contexto, significa transformar o plano lógico em outro plano que produz o mesmo resultado com menos trabalho, ou com trabalho mais bem posicionado. A mudança não é feita no texto SQL original; ela é feita na árvore de operadores.

Há muitas regras de otimização. Para este exemplo, duas das mais importantes são predicate pushdown e projection pushdown.

Predicate pushdown

Um predicado é uma condição que resulta em verdadeiro ou falso. Em SQL, a condição de um WHERE é um predicado. Predicate pushdown tenta mover esse filtro para baixo no plano, aproximando-o da fonte dos dados.

Na Query A, o predicado v.valor > 1000 depende apenas de vendas. Como o exemplo usa um join interno, uma venda com valor menor ou igual a mil não poderia aparecer no resultado de qualquer forma. Então o filtro pode ser aplicado antes do join:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef opFilter fill:#FBE4E2,stroke:#C0392B,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    subgraph BEFOREP["Query A — antes do predicate pushdown"]
        direction TB
        P1["Filter<br/>(valor > 1000)"] --> P2["Join"]
        P2 --> P3["Scan (vendas)"]
        P2 --> P4["Scan (clientes)"]
    end
    subgraph AFTERP["Query A — depois do predicate pushdown"]
        direction TB
        Q1["Join"] --> Q2["Filter<br/>(valor > 1000)"]
        Q1 --> Q3["Scan (clientes)"]
        Q2 --> Q4["Scan (vendas)"]
    end
    BEFOREP ~~~ AFTERP
    class P2,Q1 opJoin;
    class P1,Q2 opFilter;
    class P3,P4,Q3,Q4 muted;

A Query B já tinha o filtro posicionado abaixo do join em seu plano lógico (veja a comparação acima), então essa reescrita não tem efeito para ela — não há mais nada para empurrar.

Em alguns engines, inclusive no Trino, esse deslocamento continua até a fonte de dados, através do que o Trino chama de connector — o componente que liga o engine a um sistema de dados específico, como um banco relacional, um Data Lake ou uma fila. Quando o connector suporta predicate pushdown, ele envia a restrição para baixo e a fonte elimina linhas durante a leitura; o suporte varia com o connector e a fonte. A documentação do Trino descreve essa condição.

Projection pushdown

Projection pushdown aplica a mesma lógica às colunas. Na tabela vendas, o exemplo precisa apenas de valor e id_cliente: valor participa do filtro e do resultado; id_cliente participa do join. Em clientes, só são necessários id e estado.

O otimizador insere ou move projeções para que só essas colunas sejam carregadas no caminho. Conectores que suportam isso podem ler e devolver somente as colunas necessárias, reduzindo I/O e transferência de dados. No Trino, isso aparece nas colunas do TableScan no plano. A documentação de pushdown explica como verificar.

Aplicado ao plano da Query A, depois do predicate pushdown:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef opProject fill:#E3F3EF,stroke:#2F9E8F,color:#1F2937,stroke-width:1.5px;
    classDef opFilter fill:#FBE4E2,stroke:#C0392B,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    subgraph BEFOREJ["Query A — antes do projection pushdown"]
        direction TB
        R1["Project<br/>(estado, valor)"] --> R2["Join"]
        R2 --> R3["Filter<br/>(valor > 1000)"]
        R3 --> R4["Scan (vendas)"]
        R2 --> R5["Scan (clientes)"]
    end
    subgraph AFTERJ["Query A — depois do projection pushdown"]
        direction TB
        S1["Project<br/>(estado, valor)"] --> S2["Join"]
        S2 --> S3["Filter<br/>(valor > 1000)"]
        S3 --> S4["Project<br/>(valor, id_cliente)"]
        S4 --> S5["Scan (vendas)"]
        S2 --> S6["Project<br/>(estado, id)"]
        S6 --> S7["Scan (clientes)"]
    end
    BEFOREJ ~~~ AFTERJ
    class R1,S1,S4,S6 opProject;
    class R2,S2 opJoin;
    class R3,S3 opFilter;
    class R4,R5,S5,S7 muted;

Depois das duas regras, as Queries A e B convergem para o mesmo plano lógico otimizado:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef opProject fill:#E3F3EF,stroke:#2F9E8F,color:#1F2937,stroke-width:1.5px;
    classDef opFilter fill:#FBE4E2,stroke:#C0392B,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    subgraph CA["Query A — otimizada"]
        direction TB
        CA1["Project<br/>(estado, valor)"] --> CA2["Join"]
        CA2 --> CA3["Filter<br/>(valor > 1000)"]
        CA3 --> CA4["Project<br/>(valor, id_cliente)"]
        CA4 --> CA5["Scan (vendas)"]
        CA2 --> CA6["Project<br/>(estado, id)"]
        CA6 --> CA7["Scan (clientes)"]
    end
    subgraph CB["Query B — otimizada"]
        direction TB
        CB1["Project<br/>(estado, valor)"] --> CB2["Join"]
        CB2 --> CB3["Filter<br/>(valor > 1000)"]
        CB3 --> CB4["Project<br/>(valor, id_cliente)"]
        CB4 --> CB5["Scan (vendas)"]
        CB2 --> CB6["Project<br/>(estado, id)"]
        CB6 --> CB7["Scan (clientes)"]
    end
    CA ~~~ CB
    class CA1,CA4,CA6,CB1,CB4,CB6 opProject;
    class CA2,CB2 opJoin;
    class CA3,CB3 opFilter;
    class CA5,CA7,CB5,CB7 muted;

A Query B não é automaticamente mais eficiente só porque seu texto sugere que ela filtra primeiro: com a mesma semântica e transformações válidas, o otimizador pode chegar à mesma sequência de operadores para as duas.

Isso não é uma garantia universal. Outer joins, limites, ordenação, agregações, funções não determinísticas, valores nulos, estatísticas e as capacidades da fonte podem mudar o que pode ser reescrito ou o custo de cada plano.

5. Plano físico

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart LR
    classDef active fill:#FFF4DB,stroke:#B45309,color:#1F2937,stroke-width:2px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    SQL[1. SQL] --> AST[2. AST] --> LOGICO[3. Plano lógico] --> OTIMIZADO[4. Plano otimizado] --> FISICO[5. Plano físico]
    class SQL,AST,LOGICO,OTIMIZADO muted;
    class FISICO active;

Até agora falamos do que precisa ser calculado. O plano físico define como essas operações serão executadas com recursos reais:

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#F7F7F5",
    "primaryTextColor": "#1F2937",
    "primaryBorderColor": "#D6D3D1",
    "secondaryColor": "#EEEDE8",
    "tertiaryColor": "#FFF4DB",
    "lineColor": "#6B7280",
    "clusterBkg": "#EEEDE8",
    "clusterBorder": "#D6D3D1",
    "fontFamily": "Inter, Segoe UI, Helvetica Neue, Arial, sans-serif",
    "fontSize": "15px"
  },
  "flowchart": {
    "curve": "basis",
    "nodeSpacing": 28,
    "rankSpacing": 36,
    "padding": 8
  }
}}%%
flowchart TD
    classDef opProject fill:#E3F3EF,stroke:#2F9E8F,color:#1F2937,stroke-width:1.5px;
    classDef opJoin fill:#E4ECF7,stroke:#3B6FA0,color:#1F2937,stroke-width:1.5px;
    classDef muted fill:#F7F7F5,stroke:#D6D3D1,color:#4B5563,stroke-width:1px;
    PR["Project<br/>(estado, valor)"] --> HJ["BroadcastHashJoin<br/>(build: clientes)<br/>id_cliente = id"]
    HJ --> FS1["FileScan vendas — formato: parquet<br/>predicate pushdown: valor > 1000<br/>projection pushdown: valor, id_cliente"]
    HJ --> FS2["FileScan clientes — formato: parquet<br/>projection pushdown: id, estado"]
    class PR opProject;
    class HJ opJoin;
    class FS1,FS2 muted;

É aqui que o banco decide, por exemplo, qual algoritmo de join usar, quantas tarefas executar em paralelo, onde manter dados em memória e quando trocar dados pela rede. Em engines distribuídos, essa representação também aparece como plano distribuído. No Trino, o EXPLAIN padrão mostra esse plano em fragmentos, indicando onde o trabalho ocorre e como os dados são distribuídos entre nós. A documentação de EXPLAIN descreve fragmentos como SOURCE, HASH, BROADCAST e SINGLE.

As decisões físicas dependem de características que não estão visíveis apenas no SQL: volume de dados, particionamento, localização, formato dos arquivos, memória disponível e estatísticas. Por exemplo, o Trino pode usar estatísticas fornecidas pelo connector para estimar custos, escolher uma ordem de joins e decidir entre distribuição broadcast e particionada. A documentação sobre otimização baseada em custo detalha essas decisões.

Por isso o plano lógico otimizado não encerra a investigação de performance — ele diz o que será feito, não como o ambiente vai executar.

Conferindo o plano

Agora voltamos à pergunta inicial com uma ferramenta prática. Em vez de reescrever uma query apenas porque ela parece executar em outra ordem, use EXPLAIN para ver o plano que o engine produziu:

EXPLAIN (TYPE DISTRIBUTED)
SELECT c.estado, v.valor
FROM vendas v
JOIN clientes c ON v.id_cliente = c.id
WHERE v.valor > 1000;

Ao ler o resultado, procure responder:

  1. Quais colunas estão sendo lidas de cada fonte?
  2. Onde o filtro está sendo aplicado?
  3. Em que ponto há troca de dados entre nós?
  4. Qual estratégia foi usada para o join?
  5. As estimativas fazem sentido para os dados que você conhece?

EXPLAIN mostra o plano planejado. Quando for necessário observar uma execução real, alguns engines oferecem ferramentas equivalentes a EXPLAIN ANALYZE. No Trino, ele executa a query e mostra métricas por operação; portanto, não é uma inspeção sem custo. A documentação de EXPLAIN ANALYZE explica suas métricas e limitações.

O que isso significa na prática

  • Legibilidade acima de micro-otimização. Use CTEs bem nomeadas e estruturas claras; o otimizador cuida da performance. Código legível é mais fácil de manter e depurar.
  • Confie no otimizador. Não tente “enganar” o motor com reescritas complexas só para forçar uma ordem de filtro. Otimizadores modernos — o Catalyst do Spark, o otimizador baseado em custo do Trino — são bons em reconhecer a intenção.
  • Use EXPLAIN com frequência. Na dúvida sobre performance, olhe o plano real em vez de supor. Verifique se o predicate pushdown e o projection pushdown realmente aconteceram.
  • Conheça seus dados. A performance final depende da distribuição dos dados, do particionamento, das estatísticas das tabelas e do formato dos arquivos (Parquet, ORC). Otimizar o plano é só metade da batalha.

A resposta

SQL começa como texto. O parser o transforma em AST; a AST vira um plano lógico baseado em operadores da álgebra relacional; o otimizador reescreve esse plano quando encontra transformações válidas; por fim, o banco produz um plano físico para executar o trabalho.

As Queries A e B nos ajudam a lembrar que a aparência do SQL não basta para prever performance. Escreva a forma que comunica melhor sua intenção e preserva a semântica. Quando houver uma dúvida real de custo ou latência, siga o mesmo caminho que o banco seguiu: olhe o plano.