All posts

Build SELECT Queries Without Syntax Errors

July 26, 2026 · DevTools

sql
query-builder
database
developer-tools

You need the names and order totals for customers who bought something in the last thirty days, sorted by total, only the top 20. You know every column name. The only thing tripping you up is the order of the clauses — and an unescaped apostrophe in someone's name keeps breaking the parser.

A visual builder skips both problems. You fill in the SELECT list, pick the FROM table, add WHERE conditions, and the tool stitches them into valid SQL in the canonical order. You can try every example in this guide with the Visual SQL Query Builder — it runs entirely in your browser, so even queries against production schemas stay on your machine.

The canonical clause order

Every SELECT query has at most six clauses, in this order:

SELECT → FROM → JOIN → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT

(Some dialects accept a WITH clause before SELECT for CTEs; for plain SELECTs this is the full grammar.)

Each clause consumes the output of the previous one:

  • SELECT — the columns you want back
  • FROM — the source table
  • JOIN — additional tables, stitched on a condition
  • WHERE — row-level filters, applied before aggregation
  • GROUP BY — collapses rows that share the same values into one
  • HAVING — group-level filters, applied after aggregation
  • ORDER BY — sorts the final result
  • LIMIT — truncates to a maximum number of rows

If you write WHERE after GROUP BY, you're filtering on aggregated values — and you probably wanted HAVING. Putting them in the right order isn't stylistic; it's how the language is defined.

A complete example, in canonical order:

SELECT
  users.name,
  SUM(orders.total) AS total_spent
FROM
  users
  JOIN orders ON users.id = orders.user_id
WHERE
  orders.status = 'paid'
  AND orders.created_at >= '2026-01-01'
GROUP BY
  users.id,
  users.name
HAVING
  SUM(orders.total) > 500
ORDER BY
  total_spent DESC
LIMIT
  20;

The Visual SQL Query Builder emits clauses in this order regardless of the order you added them — add ORDER BY first by accident and it still ends up last.

Step 1: SELECT — what you want back

The first block is the column list. Add a column per row, or use * for everything:

SELECT id, name, email
SELECT *
SELECT users.id, users.name, COUNT(*) AS order_count

Three quick notes:

  1. Alias aggregations with AS. SUM(orders.total) becomes a column named sum(orders.total) without a rename; most clients prefer total_spent.
  2. Reference columns by table name when you join. users.id is unambiguous; bare id breaks the moment two tables share a column name.
  3. DISTINCT vs GROUP BY. SELECT DISTINCT is shorter for unique combinations; GROUP BY composes with aggregates.

Step 2: FROM and JOIN — the source

FROM names the base table. JOIN adds more:

FROM users
JOIN orders ON users.id = orders.user_id
LEFT JOIN addresses ON users.id = addresses.user_id

The join type matters:

JoinBehaviour
INNER JOIN (or JOIN)Only rows where the ON condition matches in both tables
LEFT JOINAll rows from the left table, with matching rows from the right or NULLs
RIGHT JOINMirror of LEFT
FULL OUTER JOINAll rows from both, with NULLs where no match
CROSS JOINCartesian product — every combination; rarely what you want

Most real-world queries use INNER or LEFT. Forgetting the ON clause produces a Cartesian product — a 10,000-row table joined to a 5,000-row table gives you 50 million rows, and the database will dutifully compute them. The builder forces you to set the ON condition for every join.

Step 3: WHERE — filter rows before aggregation

WHERE filters rows before GROUP BY runs. If you want to filter on the value of an aggregate (SUM(...) > 500), use HAVING instead.

WHERE
  orders.status = 'paid'
  AND orders.created_at >= '2026-01-01'
  AND users.country IN ('US', 'CA', 'MX')

Each predicate combines three things: a column, an operator, and a value. Common operators:

OperatorUse for
=Exact match
!= / <>Not equal
>, <, >=, <=Numeric and date comparisons
IN (...)Match any of a list
NOT IN (...)Match none of a list
LIKEPattern matching (% is wildcard, _ is single char)
ILIKECase-insensitive LIKE (PostgreSQL)
BETWEEN x AND yInclusive range
IS NULL / IS NOT NULLNull checks (= NULL does not work)

Step 4: GROUP BY — collapse rows that share values

Once you have an aggregate in the SELECT list (SUM, COUNT, AVG, MIN, MAX), every other column must appear in GROUP BY — otherwise the database doesn't know which value to return for the grouped row.

Wrong:

SELECT users.name, SUM(orders.total)
FROM users
JOIN orders ON users.id = orders.user_id
GROUP BY users.id
-- "users.name" is not in GROUP BY

Right:

SELECT users.name, SUM(orders.total)
FROM users
JOIN orders ON users.id = orders.user_id
GROUP BY users.id, users.name

MySQL is famously permissive about this — it lets the broken query run and picks an arbitrary users.name. PostgreSQL, SQL Server, and SQLite reject it. Test your queries on the strict dialect first.

Step 5: HAVING — filter groups after aggregation

HAVING is WHERE for groups:

GROUP BY users.id, users.name
HAVING SUM(orders.total) > 500

This says "give me the customers whose total spending exceeds $500." The same expression in WHERE would fail — WHERE runs before aggregation, so the value of SUM(...) doesn't exist yet.

Step 6: ORDER BY — sort the final result

ORDER BY total_spent DESC, users.name ASC

ASC is the default; specify DESC for descending. Sort by column name or alias; position-based ORDER BY 2 is fragile because it follows the SELECT list order.

Step 7: LIMIT — take the first N rows

LIMIT 20

Some dialects use different syntax (TOP 20 in SQL Server, FETCH FIRST 20 ROWS ONLY in standard SQL). The builder emits the form your chosen dialect expects.

The unescaped apostrophe problem

This is the silent killer of otherwise-correct SQL. A name like O'Brien — one apostrophe — has to become two apostrophes inside the string literal:

SELECT * FROM users WHERE name = 'O''Brien'

Most languages let you escape this with backslashes ('O\'Brien') or doubled delimiters ("O'Brien"). SQL uses doubled single quotes inside single-quoted strings — and there's no warning when you get it wrong, because the parser just thinks your string literal ends early and the rest of the query is syntax error.

The Visual SQL Query Builder handles the escaping for you: any single quote inside a string value is doubled automatically, so O'Brien becomes 'O''Brien' in the output.

Other string-literal gotchas:

  • Backslashes aren't escapes in standard SQL — '\n' is a backslash followed by an n, not a newline. Use the database's native string function (CONCAT, CHR(10)) or a bound parameter instead
  • Double quotes are for identifiers, not strings — "name" is the column name, not the string "name". PostgreSQL is strict about this; other databases are forgiving in inconsistent ways

Common mistakes

  • WHERE instead of HAVING for aggregatesWHERE SUM(...) > 100 fails because the aggregate hasn't been computed yet
  • GROUP BY missing a SELECT column — MySQL silently returns the wrong value; PostgreSQL rejects the query outright
  • Cartesian product — forgetting ON for a JOIN returns every combination of every row; a small table joined to a large one becomes enormous fast
  • = instead of IS NULLWHERE x = NULL matches nothing, because NULL = NULL is NULL, not TRUE
  • Date arithmetic in the wrong dialectWHERE created_at >= NOW() - INTERVAL 30 DAY is fine in MySQL; PostgreSQL needs NOW() - INTERVAL '30 days'. The builder emits the right form for your chosen dialect

Validating before you run

A query that parses is not a query that returns the right answer. Three cheap checks:

  1. Run EXPLAIN — most databases offer EXPLAIN SELECT ... which shows the execution plan. A missing index shows up as a Seq Scan (PostgreSQL) or Full Table Scan (MySQL)
  2. Spot-check edge rows — the first and last row of the result, plus any row with a known oddity (O'Brien, an order with total = 0)
  3. Count rows before LIMIT — wrap the query in SELECT COUNT(*) FROM (...) to see how many rows would be returned without the truncation; paste the result into SQL Compare to diff it against a previous version of the same report

A complete before-and-after

You want: the top 20 customers by 2026 spend, paid orders only, $500 minimum.

Form input:

  • SELECTusers.name, SUM(orders.total) AS total_spent
  • FROMusers
  • JOININNER JOIN orders ON users.id = orders.user_id
  • WHEREorders.status = 'paid', orders.created_at >= '2026-01-01'
  • GROUP BYusers.id, users.name
  • HAVINGSUM(orders.total) > 500
  • ORDER BYtotal_spent DESC
  • LIMIT20

Generated SQL (PostgreSQL dialect):

SELECT
  users.name,
  SUM(orders.total) AS total_spent
FROM
  users
  JOIN orders ON users.id = orders.user_id
WHERE
  orders.status = 'paid'
  AND orders.created_at >= '2026-01-01'
GROUP BY
  users.id,
  users.name
HAVING
  SUM(orders.total) > 500
ORDER BY
  total_spent DESC
LIMIT
  20;

Copy it into your SQL client and it runs as-is. The same form emits LIMIT 20 for MySQL/PostgreSQL, TOP 20 or OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY for SQL Server, depending on the version.

Everything stays in your browser

The Visual SQL Query Builder only produces text. It doesn't connect to a database, doesn't run anything, doesn't log your schema. That's the difference between a tool you can paste production table names into and one you can't.

Next steps