Every developer has pasted a wall-of-text query into some online "SQL beautifier" and gotten back something readable. I always assumed those tools were doing something clever with the query. Turns out they mostly aren't — and the one genuinely hard part isn't the part you'd expect. So for Day 31 of my SolveFromZero series I built one by hand, in vanilla JS, no library, and it changed how I think about the whole category of "formatter" tools.
Here's the punchline up front: a formatter doesn't understand your SQL. It re-spaces a list of tokens. The database doesn't care whether you write SELECT id FROM t on one line or across ten — they run identically. Formatting is purely for humans: readability, code review, and — the underrated one — clean git diffs. When every column sits on its own line, changing one column is a one-line diff instead of a rewritten blob.
The trap I walked into first
My first instinct was the obvious one: run some regexes. Uppercase the keywords, add a newline before FROM and WHERE, done.
That falls apart in about thirty seconds. Consider this:
SELECT id, 'please select an option' AS hint FROM menu WHERE note LIKE '%group by%'
A naive replace(/select/gi, 'SELECT') corrupts the string literal. A newline-before-group by rule wrecks the LIKE pattern. And what about a column literally named `order`? Reserved words make perfectly legal quoted identifiers.
So the real lesson: before you move a single character, you have to split the text into tokens, and some of those tokens must be treated as opaque and untouchable — string literals, comments (-- line and /* block */), and quoted identifiers ("name", `name`).
The tokenizer is the whole game
The tokenizer scans character by character. When it hits an opening quote or a comment marker, it consumes greedily until the close and emits one token for the entire span. That's it — that single decision is what makes everything downstream safe. Once a whole string is one token, no layout rule can ever reach inside it. The word select in a literal is invisible to the formatter, by construction.
if (c === "'"){ // consume the whole string literal
let a = i; i++;
while (i < n){
if (s[i] === "'"){ if (s[i+1] === "'"){ i += 2; continue; } i++; break; }
i++;
}
toks.push({ type: "string", val: s.slice(a, i) }); // opaque
}
Everything after that is comparatively boring: runs of letters are words (keyword or identifier — decided by an uppercase lookup in a keyword set), digits are numbers, and , ( ) ; . get their own types because layout depends on them.
Printing is just three rules
With a clean token stream, the pretty-printer is almost anticlimactic. It keeps a current line and an indent level, and applies:
-
Major clauses break the line.
SELECT,FROM,WHERE,GROUP BY,HAVING,ORDER BY,LIMIT— each flushes the current line and starts a new one at the base indent. That's the stepped shape. -
Parens carry two meanings.
count(*)stays inline;(SELECT ...)opens a subquery. You tell them apart by peeking at the next token — if it'sSELECT, push a deeper frame onto a stack and indent. The stack makes nesting fall out for free, and each)pops back to its opener's column. -
Commas and AND/OR align. Inside
SELECT, each column goes on its own line (trailing- or leading-comma, your pick). InsideWHERE, eachAND/ORdrops one level so conditions stack up neatly.
Minifying is the same stream with the break rules switched off: drop comments, join tokens with single spaces (none around . ( ) or before commas), and you're guaranteed one line. It doubles as a correctness test — minify(format(sql)) should equal minify(sql), proving you only moved whitespace.
Where hand-rolling stops
My version handles ordinary queries well, but SQL is a family of dialects. MySQL uses # comments, SQL Server uses [bracketed] identifiers, Postgres has $$ function bodies, and some words are keywords in one position and column names in another. Getting all that right means actually parsing a specific grammar — which is exactly what a library like sql-formatter does. Roll your own to understand the mechanics; reach for the dialect-aware library when correctness across engines matters.
Play with the live version (tokenizer viewer, casing, indent, comma style, minify) here:
https://dev48v.infy.uk/solve/day31-sql-formatter.html
The tokenizer viewer is my favorite part — you can literally see the string literal light up as one green block, which is the entire reason the thing is safe.
Top comments (0)