runlocally

runlocally engineering notes

Format SQL

How Format SQL is built

By Geppetto · · Open Format SQL →

Format SQL reflows whitespace and indentation in a pasted or uploaded SQL query, entirely client-side. This post covers what the tool actually does with sql-formatter, the library doing the work, and where the UI narrows down what that library is capable of.

Tech used

A tokenizer, not a parser, run on the main thread

The formatting engine is sql-formatter (^15.8.2), called from a single function in src/utils/sqlEngine.ts. There’s no logic of the tool’s own between the textarea and the library beyond plumbing options through: sqlFormat(input, { language: dialect, tabWidth: Number(indent), useTabs: false }), wrapped in a try/catch that turns a thrown error into a typed { ok: false, error } result instead of letting it propagate.

The reason that wrapper matters is stated directly in the code’s own comment: sql-formatter is a lenient tokenizer, not a strict parser for any one database’s grammar. It reflows whitespace around keywords, clauses, and joins without validating that the query is syntactically or semantically correct for a real engine — it can “format” SQL that would never run, and it can also reject dialect-specific syntax it doesn’t recognize even though a real database would accept it. Formatting succeeding is not proof a query is valid; formatting failing is not proof it isn’t.

Like Format JSON’s native JSON.parse/stringify pair, this runs synchronously on the main thread with no Web Worker — per the README, formatting SQL text is fast enough, single-threaded, main-thread work with no WASM or codec step to offload.

Five dialects exposed, out of considerably more the library supports

sql-formatter itself supports a long list of dialects — per its own README: GCP BigQuery, Clickhouse, IBM DB2, DuckDB, Apache Hive, MariaDB, MySQL, TiDB, Couchbase N1QL, Oracle PL/SQL, PostgreSQL, Amazon Redshift, SingleStoreDB, Snowflake, Spark, SQL Server Transact-SQL, and Trino/Presto. The tool’s dialect dropdown exposes five of them: sql (generic/standard SQL, the default), mysql, postgresql, sqlite, and mariadb. A code comment in sqlEngine.ts calls this “a reasonable subset… without turning a single dropdown into a 20-item list” — the DIALECTS constant is a plain readonly array (['sql', 'mysql', 'postgresql', 'sqlite', 'mariadb']), and a unit test pins both its membership and order so the default stays first.

Dialect choice only matters when a query uses syntax specific to one database. The test suite demonstrates both sides of that: a portable join-with-where query formats byte-identically across mysql, postgresql, sqlite, and mariadb (asserted by collapsing all four results into a Set and checking its size is 1), while a query using a MySQL backtick-quoted identifier (select id from `orders` where status = 1;) formats successfully under mysql but fails under postgresql with a raw “parse error” thrown by the library — backticks aren’t valid identifier quoting in Postgres’s grammar. That failure is expected and correctly surfaced, not a bug to route around.

What’s configurable, and what isn’t

Two options reach the UI: dialect (the five above) and indent width, 2 or 4 spaces, matching the precedent set by Format JSON’s indent selector. Both map directly onto sql-formatter’s own option names (language, tabWidth), and useTabs is hardcoded to false — there’s no UI for tab-vs-space indentation. sql-formatter also accepts a keywordCase option (upper/lower/preserve) and a linesBetweenQueries option among others, per its own docs, but neither is exposed here: the tool doesn’t rewrite keyword casing or otherwise touch anything besides whitespace and line breaks. A unit test checks this directly — collapsing all whitespace in a formatted result back down reproduces the original query character-for-character, confirming nothing beyond layout changed.

Implementation & operational notes

Malformed input never throws past the wrapper. formatSql('select * from ((( ', 'sql', '2') — unbalanced parens, unparseable — returns { ok: false } rather than throwing, and the error message shown in the UI is the library’s own thrown message, verbatim, not reworded or replaced with a generic fallback. This mirrors the same “never fabricate an error” rule Format JSON applies to browser-specific parse errors, just simpler here since there’s only one message source (the library) instead of three disagreeing browser engines.

Empty input is a no-op, not a formatting attempt. The React/Preact component only calls formatSql when the trimmed input is non-empty (isEmpty guard around the useMemo); an empty textarea shows a hint string instead of running the formatter or displaying an error.

The output name derives from the input file’s name, stripping one extension. deriveOutputName takes the last . in a loaded file’s name and inserts .formatted before it (query.sqlquery.formatted.sql; report.v2.sqlreport.v2.formatted.sql, keeping the .v2), falling back to a plain formatted.sql when the input came from pasted text rather than a file.

Result recomputation, not caching. The formatted output and any error are derived with useMemo from the current (input, dialect, indent) triple on every render, never stored as separate state — switching the dialect or indent dropdown while an error is showing re-runs the formatter immediately rather than leaving a stale result on screen.

Input arrives three ways, all funneling into the same path. Typing/pasting into the textarea, clicking to choose a .sql file, or dropping a file anywhere on the page (handled by a page-wide GlobalDropZone that dispatches a filesDropped event) all end up calling the same loadFile function, which checks the file against an accept-list (.sql/.txt extensions, or any text/* MIME type, since some OS/editor combinations report .sql files under a generic text/plain type) before reading its contents with file.text().

Try it / source