S SCHEMA.BIZ

Last updated:

SQL Query Visualizer

Paste your database schema and a SELECT query to visualize which tables, columns, and joins are involved. Referenced elements are highlighted in blue; uninvolved tables are grayed out.

What this tool does

Reading a multi-table SELECT and holding the implied data flow in your head is a skill that breaks down somewhere around the fifth join. The Query Visualizer takes the schema you paste in and the query you are trying to understand, parses both, and projects the result onto an interactive entity-relationship diagram. Tables touched by the query light up; columns in the SELECT list and the join conditions get individual highlights; everything else fades into the background so the active surface area of the query becomes obvious at a glance.

The whole pipeline runs in your browser. No driver is loaded, no schema is uploaded, no query is executed. That matters because the most useful queries to visualize are usually the most sensitive ones: production reporting queries, half-finished migrations, customer-data extracts. You can paste a schema with personally identifiable column names or a query that joins your billing tables; neither leaves the browser, and we record only counts, such as how many tables and joins the query has, never the SQL. It handles the SELECT, FROM, JOIN, and WHERE queries of everyday reporting, with no setup or signup. CTEs, set operations, subqueries, and window functions aren't supported yet.

When to use the Query Visualizer

Reviewing a teammate's pull request that adds a new reporting query. The diff shows the SQL but not which tables are now coupled to which. Pasting the query into the visualizer shows every table it touches and how they're joined.

Diagnosing a query that returns the wrong number of rows. When a count comes back too high or too low, the cause is almost always a join condition that creates duplicates or a WHERE filter that silently converts an outer join into an inner one. Seeing the join graph and the conditions side-by-side makes the failure mode jump out.

Onboarding to a database you have never seen before. The fastest way to learn a new schema is to read its real queries. Visualizing a handful of representative reporting queries shows which tables are central, which are leaf nodes, and which fact tables fan out to which dimensions.

Pruning queries before they hit a slow path. A query that joins eight tables but only uses columns from three is a refactor candidate. The visualizer shows which tables are joined and which columns the SELECT list uses, so a join that contributes no selected columns stands out as a candidate for an EXISTS subquery.

Walkthrough with a real example

Paste the following schema into the left pane:

CREATE TABLE customers (
  id           BIGSERIAL PRIMARY KEY,
  email        TEXT NOT NULL UNIQUE,
  signup_date  DATE NOT NULL
);

CREATE TABLE orders (
  id            BIGSERIAL PRIMARY KEY,
  customer_id   BIGINT NOT NULL REFERENCES customers(id),
  status        TEXT NOT NULL,
  placed_at     TIMESTAMPTZ NOT NULL,
  total_cents   INTEGER NOT NULL
);

CREATE TABLE order_items (
  id          BIGSERIAL PRIMARY KEY,
  order_id    BIGINT NOT NULL REFERENCES orders(id),
  product_id  BIGINT NOT NULL,
  quantity    INTEGER NOT NULL
);

CREATE TABLE refunds (
  id          BIGSERIAL PRIMARY KEY,
  order_id    BIGINT NOT NULL REFERENCES orders(id),
  reason      TEXT,
  amount_cents INTEGER NOT NULL
);

Then paste this query, which calculates lifetime net revenue per high-value customer:

SELECT
  c.id,
  c.email,
  COUNT(DISTINCT o.id) AS lifetime_orders,
  SUM(o.total_cents) - COALESCE(SUM(r.amount_cents), 0) AS net_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'completed'
LEFT JOIN refunds r ON r.order_id = o.id
WHERE c.signup_date >= '2025-01-01'
GROUP BY c.id, c.email
HAVING SUM(o.total_cents) > 50000
ORDER BY net_cents DESC
LIMIT 100;

The diagram immediately lights up customers, orders, and refunds as the active set, leaving order_items grayed out — visually confirming that this query does not need the line items table even though it is part of the same business entity. Join edges run from orders to customers and from refunds to orders, each labeled with the join type and the joined columns (LEFT JOIN, customer_id = id). Inside each node, the columns from the SELECT list and the join conditions are highlighted: id and email on customers, customer_id and id on orders, order_id on refunds. Columns used only inside functions or in the WHERE, GROUP BY, and HAVING clauses, such as signup_date and total_cents, aren't highlighted yet. (Keep each ON condition on one line: the parser doesn't read conditions that wrap onto a second line yet.)

The analysis panel breaks the query into its parts: three tables (with their aliases), four selected expressions (two columns, two aggregates), the two joins with their full ON clauses, and the WHERE predicate on signup_date. It doesn't list the GROUP BY, HAVING, or LIMIT clauses yet. The notes flag "LEFT JOIN with WHERE conditions on the joined table may behave like INNER JOIN". Here that's a false alarm worth checking by hand: the WHERE filters customers, the preserved side, so customers without orders still come through the LEFT JOINs.

SQL concepts you should know

Join types and what they preserve. An INNER JOIN returns only rows where the ON predicate is satisfied on both sides. A LEFT JOIN preserves every row from the left input and pads the right side with NULLs when no match exists; RIGHT JOIN does the mirror. FULL OUTER JOIN preserves unmatched rows from both sides. CROSS JOIN produces every pair, with no condition — almost always a mistake outside of generating ranges or pivoting. The visualizer labels each edge with its type so the preservation semantics are explicit.

The ON clause vs the WHERE clause. Predicates in the ON clause are part of the join itself and run before the rows are combined. Predicates in the WHERE clause run after. For an INNER JOIN this distinction is invisible — the optimizer can move predicates between the two. For an OUTER JOIN it is decisive: a WHERE predicate that references the nullable side filters out the NULL-padded rows the OUTER JOIN was supposed to preserve, collapsing it into an INNER JOIN. This is the single most common cause of "my LEFT JOIN is acting like an INNER JOIN" bug reports.

Subqueries vs CTEs vs derived tables. A subquery in the SELECT or WHERE returns a value or a set of values per outer row. A derived table is a subquery in the FROM clause that acts as an inline relation. A CTE (the WITH clause) is the same idea, but named and reusable within the query. Modern optimizers usually inline non-recursive CTEs into the surrounding query, so the choice between a CTE and a derived table is mostly about readability — except in PostgreSQL versions before 12, where CTEs were optimization fences.

The logical execution order of a query. SQL reads top-to-bottom but executes in a different order: FROM and JOIN first, then WHERE, then GROUP BY, then HAVING, then SELECT, then DISTINCT, then ORDER BY, then LIMIT. That is why a column alias defined in the SELECT cannot be referenced in the WHERE — the WHERE runs first. It is also why HAVING can filter on aggregates but WHERE cannot. Internalizing this order is the single biggest unlock for writing correct queries on the first try.

Common mistakes

Filtering the nullable side of a LEFT JOIN in the WHERE clause. The classic outer-join collapse:

-- Looks like a LEFT JOIN, behaves like an INNER JOIN
SELECT u.id, p.title
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE p.published_at > '2025-01-01';

Customers with no posts disappear because p.published_at is NULL for them, and NULL fails the comparison. Move the predicate into the ON clause and the LEFT JOIN behaves as intended:

-- Predicate moved into the ON clause: LEFT JOIN preserved
SELECT u.id, p.title
FROM users u
LEFT JOIN posts p
  ON p.user_id = u.id
  AND p.published_at > '2025-01-01';

Forgetting the ON clause and getting a Cartesian product. Writing FROM users, orders without a join condition multiplies row counts together. On a small dev database it returns slowly; on production it can exhaust memory.

Aggregating before joining and doubling counts. Joining to a table that has multiple matching rows per parent row, then doing a SUM or COUNT, multiplies the parent's other columns. Aggregate first in a subquery or CTE, then join the aggregate to the parent.

Using SELECT * in production code. It hides which columns are actually used, breaks downstream consumers when the schema changes, and makes the visualizer's column-highlighting useless because every column is "referenced." Name the columns explicitly.

Assuming join order matches execution order. The optimizer reorders joins based on statistics, not on the order you wrote them. Putting a small filtering table first in the FROM clause is a hint, not a guarantee — and on modern planners, often not even a hint.

FAQ

Does the visualizer execute my query against a real database?

No. The tool reads your CREATE TABLE statements and the SELECT query as text, in your browser. No driver is loaded, no database connection is opened, and neither the schema nor the query is sent anywhere. We record only anonymous counts about each run, such as how many tables and joins the query has, never the SQL or its names.

Which SQL dialects are supported for the query input?

Everyday SELECT queries in PostgreSQL, MySQL, SQLite, or SQL Server syntax, including backticked and bracketed identifiers and INNER, LEFT, RIGHT, FULL, and CROSS joins. The query must start with SELECT, and each ON condition must be on one line. Window functions, JOIN … USING, and NATURAL or LATERAL joins aren't read correctly yet.

Can it visualize Common Table Expressions (CTEs) and subqueries?

Not yet. A query that starts with WITH shows an error, because the parser expects SELECT, and subqueries aren't analyzed. Rewrite the CTE or subquery as a join, or paste its inner SELECT on its own.

How does it handle UNION, INTERSECT, and set operations?

Not yet. The parser doesn't split a UNION, INTERSECT, or EXCEPT into its branches, so the result is wrong. Paste each SELECT on its own.

What about views and materialized views?

Not yet. Only CREATE TABLE statements are read, so a view named in the query is listed under Tables Used but has no node in the diagram. Paste the view's underlying SELECT instead.

Related tools and guides

  • SQL to Diagram — when you have CREATE TABLE statements but no query yet, this tool gives you the bare ER diagram. Use it for documentation and onboarding before you start writing queries.
  • Visual Schema Designer — design a new schema from scratch on a visual canvas, then export DDL for PostgreSQL, MySQL, SQLite, or SQL Server when you are ready to run migrations.
  • Schema Diff — compare two versions of a schema and generate the migration to go from one to the other, for you to review before running it.
  • Database Design Fundamentals — the underlying theory behind normalization, key selection, and indexing strategies that make queries fast in the first place.
  • API Versioning Guide — when your queries back an API, the schema changes you make are also API contract changes; this guide covers how to evolve them without breaking clients.