BV
All tools
developer

SQL Formatter

Format any SQL query into clean, indented, readable output in your browser. Nothing is uploaded. Includes a worked example showing how a misplaced LEFT JOIN filter silently drops rows.

Muhammad Bilal
Muhammad Bilal Virk
13 min read
Live tool
Formatted SQL
SELECT
  u.id,
  u.email,
  count(o.id) AS orders
FROM
  users u
  LEFT JOIN orders o ON o.user_id = u.id
WHERE
  u.active = 1
GROUP BY
  u.id
ORDER BY
  orders DESC
LIMIT
  10;

Paste a query, get it back with one clause per line and joins aligned under the tables they attach to. It runs in your browser, so nothing is uploaded. Formatting never changes what a query returns — it just makes the logic visible enough to judge, which is how most surviving query bugs get caught.

What this tool does and who it is for

Paste a SQL query — one line or two hundred — and this formatter returns it with each clause on its own line, joins aligned under the tables they attach to, and nesting indented so you can see where a subquery begins and ends. It runs entirely in your browser. Nothing is uploaded, which matters, because the queries people most want to read are the ones carrying real table names, real column names and occasionally a real customer identifier in a WHERE clause.

It is built for three situations in particular. You have inherited a query from a colleague, a BI tool, or an ORM's debug log, and it arrived as a single four-hundred-character line. You are about to paste SQL into an n8n Postgres node or a Make.com database module and you want to read it once more before it runs against something that matters. Or you are reviewing a query that an AI assistant generated, which is now one of the most common routes by which a plausible-looking but wrong query reaches a production database.

SQL Formatter — illustration

Formatting does not change what a query returns. That is the whole point, and it is also the reason it is worth the ten seconds: the formatter's only job is to make the logic visible so that you can judge it yourself. Almost every query bug that survives a review survives because nobody could see it.

How to read the output — a worked example

Here is the sample query this very page used to carry, before anyone read it properly:

sql
SELECT u.id, u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.total > 100 ORDER BY o.total DESC;

Formatted:

sql
SELECT u.id,
       u.name,
       o.total
FROM users u
LEFT JOIN orders o
       ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;

Nothing has changed except that the bug now has a line to itself.

The LEFT JOIN says: keep every row from users, whether or not a matching order exists. Where no order exists, the orders columns come back NULL. Then WHERE o.total > 100 is applied — and a NULL compared against 100 does not produce false, it produces NULL, which is not true, so the row is discarded. Every user the LEFT JOIN was written to preserve is thrown away one line later. The query is an INNER JOIN wearing a LEFT JOIN's clothes.

The PostgreSQL manual demonstrates this with two tiny tables, and the row counts are the clearest way to see it. Table t1 holds three rows: (1, a), (2, b), (3, c). Table t2 holds three rows: (1, xxx), (3, yyy), (5, zzz).

A plain left join, FROM t1 LEFT JOIN t2 ON t1.num = t2.num, returns three rows — one per row of t1, with NULLs where t2 had no match:

text
 num | name | num  | value
-----+------+------+-------
   1 | a    |    1 | xxx
   2 | b    | NULL | NULL
   3 | c    |    3 | yyy

Add the restriction to the ON clause — ON t1.num = t2.num AND t2.value = 'xxx' — and it still returns three rows. Row 3 loses its match and gains NULLs, but it survives, because the restriction was applied to t2 before the join, and the join's job is to preserve t1 regardless:

text
 num | name | num  | value
-----+------+------+-------
   1 | a    |    1 | xxx
   2 | b    | NULL | NULL
   3 | c    | NULL | NULL

Move the identical restriction into WHERE and the result collapses to one row:

text
 num | name | num | value
-----+------+-----+-------
   1 | a    |   1 | xxx

Three rows to one, from moving five words across a line break. The manual's own explanation is the sentence worth memorising: a restriction in the ON clause is processed before the join, while a restriction in the WHERE clause is processed after it. With an inner join that distinction does not matter. With an outer join it decides the answer.

Scale that up and the cost becomes obvious. Suppose the users table holds 1,000 rows and 180 of those users have placed an order above 100. The formatted query returns 180 rows. The report built on it — "customers and their high-value orders" — is missing 820 people, and it is missing them silently. There is no error, no warning and no empty result to tip you off. It just quietly answers a different question from the one you asked, and it will keep doing so every morning until somebody notices the total looks small.

That is the shape of the expensive automation bug. Not a crash, which you find in an hour, but a query that runs successfully and returns the wrong set, which you find in a quarter. If you have workflows making decisions on query output — sending follow-ups, flagging churn risk, triggering invoices — it is worth having someone read the SQL underneath them once, deliberately. That review is a small piece of work and it is the kind I am often asked to do alongside building the automation itself.

The method, stated plainly

The formatter is a parser, not a text-substitution script. It tokenises the query, identifies clause boundaries, then re-emits the tokens under a fixed set of rules:

  • Reserved words are uppercased; identifiers, string literals and comments are reproduced byte for byte. A literal such as 'select all' is never touched, and neither is a column called order.
  • Each top-level clause — SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT — starts a new line at indent zero.
  • Items in the select list go one per line, aligned to the width of SELECT, so a list of thirty columns reads as a column rather than a paragraph.
  • Each join gets its own line, with its ON condition indented beneath it. This is the rule that catches the bug above, because it puts the join predicate and the WHERE predicate at visibly different depths.
  • Subqueries and parenthesised expressions indent one level per nesting depth, and closing brackets return to the depth of their opener.
  • Boolean operators in a multi-condition WHERE lead their lines, so AND and OR stack vertically and precedence becomes legible.

For the ON-versus-WHERE decision the formatter reveals but cannot make for you, the rule is short enough to keep in your head. If the condition filters the preserved table — the one on the left of a LEFT JOIN — it belongs in WHERE. If it filters the optional table, it belongs in ON. And if you find yourself wanting to filter the optional table in WHERE, you did not want an outer join at all; write INNER JOIN and say so, because a reader who sees LEFT JOIN will assume you meant it.

NULL is the other thing formatting exposes

Once a query is readable, the second class of bug becomes visible: comparisons against NULL. MySQL's manual is blunt about it. 1 = NULL, 1 <> NULL, 1 < NULL and 1 > NULL all evaluate to NULL rather than to true or false. No value — including NULL itself — is ever equal to NULL under =. The only reliable tests are IS NULL and IS NOT NULL.

A few consequences that catch people:

  • In a WHERE clause, NULL is not true, so the row is dropped. Zero and NULL are both false; anything else is true. That is why the LEFT JOIN example collapses.
  • NOT IN with a NULL anywhere in the list returns no rows at all, because the comparison that would have to be false is NULL instead. This is the single most common cause of a filter that "just stopped matching anything".
  • GROUP BY treats two NULLs as the same group, even though = says they are not equal. Aggregation and comparison disagree on purpose.
  • In ORDER BY, NULLs sort first ascending and last descending. Paginate on a nullable column and the rows on page one are not the rows you think.
  • 0 and the empty string (a string of zero length) are real values, not NULLs. Both can be inserted into a NOT NULL column, which is why "the column is NOT NULL so the data must be there" is not a safe assumption.

Which join you actually wrote

Written as Rows kept Filter on the optional table belongs in Silent failure mode
INNER JOIN Only matched pairs ON or WHERE — identical result None from placement; the risk is expecting unmatched rows to appear
LEFT JOIN All left rows, matched or not ON Filter in WHERE demotes it to an INNER JOIN
RIGHT JOIN All right rows, matched or not ON, on the left table Same demotion, mirrored — and harder to spot because the preserved side is not the one you read first
FULL JOIN All rows from both sides ON Any WHERE on either side's columns cuts unmatched rows from that side
CROSS JOIN Every combination Nowhere — there is no ON A missing join predicate written as a comma produces this by accident
NATURAL JOIN Matched on every same-named column ON is unavailable by definition A column added to either table later silently joins on it too

That last row deserves its own warning, and the PostgreSQL manual gives it one: NATURAL is described as considerably more risky than an explicit USING or ON, precisely because the set of join columns is decided by whatever names the two tables happen to share today. Add a created_at to both tables in a migration and every NATURAL JOIN between them starts joining on timestamps. Nothing errors. The row count just changes.

Common mistakes

Filtering the optional table in WHERE. The worked example above. If a LEFT JOIN is followed by a WHERE that mentions the joined table's columns, treat it as a bug until proven otherwise.

Using NATURAL JOIN for brevity. It saves a line today and creates a join predicate that changes when the schema does. Write USING (id) or a full ON clause.

Referring to a table by its original name after aliasing it. Once you write FROM my_table AS m, the name my_table is gone for the rest of the query — WHERE my_table.a > 5 is not a subtle bug, it is a syntax error, and it is the most common error people hit when a formatter helpfully introduces aliases they were not expecting.

Mixing comma joins with explicit JOIN. JOIN binds more tightly than a comma, so FROM a, b JOIN c ON ... groups b with c first, not a with b. If a query mixes both styles, format it and then convert every comma to an explicit join before you reason about it.

NOT IN (SELECT ...) where the subquery can return NULL. Returns nothing, reports nothing. Use NOT EXISTS, or add WHERE col IS NOT NULL to the subquery.

Assuming uppercase keywords are cosmetic. They are, to the database. They are not to the reader, and neither is consistency: a diff of a reformatted query is unreadable if half the team formats and half does not. Agree one style and apply it to everything, or the tool creates review noise instead of removing it.

Expecting the formatter to tell you the query is correct. It cannot, and this is its honest limit. A formatter parses syntax. It has no idea what your tables contain, what your foreign keys mean, or which of your columns is nullable. It will format a query that returns the wrong rows just as neatly as one that returns the right rows. Every example on this page is a query that a formatter accepts without complaint.

Frequently Asked Questions

Does formatting change what my query returns?

No. Whitespace and keyword casing are not significant to a SQL parser outside of string literals and quoted identifiers, both of which the formatter reproduces exactly. If a formatted query behaves differently from the original, that is a bug in the formatter, not a property of SQL.

Is my SQL sent to a server?

No. The parsing and re-emission happen in your browser, and nothing about the query leaves the page. That is deliberate — a formatter is most useful on queries containing real schema and real identifiers, which are exactly the queries you should not be pasting into someone else's backend.

Should the filter go in ON or in WHERE?

If the condition applies to the table you are preserving, WHERE. If it applies to the optional table in an outer join, ON. With an inner join the two are equivalent and it does not matter. With an outer join, a condition on the optional table placed in WHERE turns the outer join into an inner join.

Why does my NOT IN query suddenly return nothing?

Almost certainly a NULL in the list or subquery. A comparison against NULL yields NULL rather than false, so NOT IN can never be satisfied. Rewrite it as NOT EXISTS, or exclude NULLs from the subquery explicitly.

Which dialects does it handle?

The formatter targets the common core — SELECT, joins, CTEs, subqueries, set operations, INSERT, UPDATE and DELETE — which covers PostgreSQL, MySQL, MariaDB, SQLite and SQL Server for the overwhelming majority of everyday queries. Vendor-specific syntax that is not part of that core may be passed through unindented rather than reformatted. It is a formatter, not a dialect translator, and it never rewrites your SQL into a different dialect.

Does it validate my query?

No, and you should not want it to. Validation requires knowing your schema. The formatter will tell you if the query cannot be parsed at all, which catches unbalanced brackets and misplaced keywords, but a query that parses can still be semantically wrong in every way that matters.

Can I use it on a query an AI assistant wrote?

That is one of the better uses for it. Generated SQL is usually syntactically valid and often arrives as one long line, which is the exact combination that hides join and NULL mistakes. Format it, then read the join lines and the WHERE lines against each other. The bug in the worked example above is precisely the kind that generated SQL reproduces, because it appears in a great deal of the text these models learned from.

What about comments in my SQL?

Both -- line comments and /* */ block comments are preserved verbatim and kept on the lines they belong to. A formatter that strips comments is destroying the only documentation most queries have.

When formatting is not the problem

A formatter gets you to the point where you can see the query. It cannot tell you whether the query answers the question you asked, and on a real schema that judgement is most of the work.

If you are building something where that matters — a reporting pipeline, a lead-scoring query feeding an n8n workflow, a dashboard whose numbers people are about to make decisions on — the useful thing is usually a second pair of eyes on the SQL and the workflow together, rather than on either alone. That is a large part of what I do: writing and reviewing the queries, the Python or FastAPI service around them, and the automation that consumes the output, so that the whole path from table to decision is one thing somebody has actually read. Book a call and bring the query. If you would rather start with a small scoped piece of work, that is available through Upwork or Fiverr.

Related reading and tools: Python FastAPI Webhook Automation covers the service layer that usually sits between a query like this and the workflow consuming it, and n8n Credentials Security covers where the database password should and should not live. For the adjacent formatting jobs, there is the JSON Formatter for the payloads your queries produce and the .env Manager for the connection strings that get them there.

Sources: PostgreSQL documentation, Table Expressions for join semantics and the ON/WHERE demonstration; MySQL 8.4 Reference Manual, Working with NULL Values for NULL comparison behaviour.

Muhammad Bilal
Muhammad Bilal Virk
AI automation engineer — building agents, workflows, and RPA that remove repetitive work.
Share
Newsletter

One email, when I ship something worth reading.

No cadence, no filler. Unsubscribe any time.

Free consultation

Want this built against your real numbers?

A 30-minute call to scope the workflow, agent, or automation you actually need.

Book a free consultation

More developer tools

All tools
Next step

Have a workflow that's burning hours every week?

Bring me one real bottleneck. I'll tell you whether it's worth automating, and what it would take.

Book 30 Minutes Call