Blogchevron_rightEngineering
Engineering

How Our SQL Prettifier Works Under the Hood

A technical walkthrough of our AST-based SQL formatter and the engineering decisions that make it handle edge cases gracefully.

September 18, 2024·9 min read·Try the SQL Formatter →

Why SQL Formatting Is Hard

SQL looks simple on the surface but is notoriously difficult to format well. Unlike JSON or YAML — which have rigid, unambiguous grammars — SQL has decades of dialects, vendor extensions, and underspecified behaviors. A naive formatter that works on standard SELECT statements will break on window functions, CTEs, lateral joins, or PostgreSQL-specific syntax.

Regex-based approaches are a dead end. They can handle simple cases but collapse under real-world queries. Building a proper SQL formatter requires a real parse step.

The Three-Stage Pipeline

Our SQL formatter operates in three distinct stages:

01

Lexing (Tokenization)

The raw SQL string is converted into a flat sequence of typed tokens: keywords, identifiers, literals, operators, and punctuation. The lexer handles quoted identifiers, string literals with escaped quotes, comments (both `--` and `/* */`), and dialect-specific tokens.

02

Parsing (AST Construction)

Tokens are fed into a recursive-descent parser that builds an Abstract Syntax Tree representing the grammatical structure of the query. Each node in the tree corresponds to a SQL construct: SELECT clause, FROM clause, JOIN, WHERE predicate, subquery, window frame, etc.

03

Pretty-printing (Code Generation)

The AST is traversed depth-first and serialized back to a SQL string with consistent indentation, keyword casing, and line-break rules applied based on the node type and nesting depth.

Handling Dialects

SQL dialects differ in ways that matter for formatting. PostgreSQL uses :: for type casts. MySQL uses backtick-quoted identifiers. BigQuery supports QUALIFY and STRUCT. T-SQL uses TOP instead of LIMIT.

We handle this by parameterizing the lexer and parser with a dialect configuration. The dialect config specifies which tokens are valid keywords in that dialect, which operators are supported, and how identifier quoting works. This allows a single parse pipeline to handle MySQL, PostgreSQL, SQLite, BigQuery, and T-SQL without branching spaghetti in the core logic.

Edge Cases We Had to Get Right

Window functions

SELECT name, salary,
  RANK() OVER (
    PARTITION BY department
    ORDER BY salary DESC
  ) AS dept_rank
FROM employees

Window functions require the OVER clause and optional PARTITION BY / ORDER BY to be formatted as a coherent nested block.

Common Table Expressions

WITH
  sales AS (
    SELECT ...
  ),
  ranked AS (
    SELECT ...
  )
SELECT * FROM ranked

CTE chains need to be formatted with consistent indentation and trailing comma handling before the main SELECT.

Nested subqueries

SELECT *
FROM (
  SELECT id, MAX(score)
  FROM results
  GROUP BY id
) AS top_scores

Subqueries in FROM, WHERE, and SELECT positions each require their own indentation context.

Error Recovery

Real-world SQL is often syntactically incorrect — queries under development, queries extracted from logs, fragments. A formatter that refuses to format invalid SQL is frustrating.

Our parser implements error recovery: when it encounters an unexpected token, it logs the error, skips tokens until it finds a safe resynchronization point (typically a statement boundary or a known clause keyword), and continues parsing. The resulting AST may be incomplete, but the formatter can still produce useful output for the portions it understood.

Try the SQL Formatter

Paste any SQL query — standard or dialect-specific — and get clean, readable output.

Open SQL Formatter →

Related Articles