Build SELECT Queries Without Syntax Errors
July 26, 2026 · DevTools
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:
- Alias aggregations with
AS.SUM(orders.total)becomes a column namedsum(orders.total)without a rename; most clients prefertotal_spent. - Reference columns by table name when you join.
users.idis unambiguous; bareidbreaks the moment two tables share a column name. - DISTINCT vs GROUP BY.
SELECT DISTINCTis shorter for unique combinations;GROUP BYcomposes 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:
| Join | Behaviour |
|---|---|
INNER JOIN (or JOIN) | Only rows where the ON condition matches in both tables |
LEFT JOIN | All rows from the left table, with matching rows from the right or NULLs |
RIGHT JOIN | Mirror of LEFT |
FULL OUTER JOIN | All rows from both, with NULLs where no match |
CROSS JOIN | Cartesian 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:
| Operator | Use for |
|---|---|
= | Exact match |
!= / <> | Not equal |
>, <, >=, <= | Numeric and date comparisons |
IN (...) | Match any of a list |
NOT IN (...) | Match none of a list |
LIKE | Pattern matching (% is wildcard, _ is single char) |
ILIKE | Case-insensitive LIKE (PostgreSQL) |
BETWEEN x AND y | Inclusive range |
IS NULL / IS NOT NULL | Null 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 ann, 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 columnname, not the string "name". PostgreSQL is strict about this; other databases are forgiving in inconsistent ways
Common mistakes
WHEREinstead ofHAVINGfor aggregates —WHERE SUM(...) > 100fails because the aggregate hasn't been computed yetGROUP BYmissing a SELECT column — MySQL silently returns the wrong value; PostgreSQL rejects the query outright- Cartesian product — forgetting
ONfor a JOIN returns every combination of every row; a small table joined to a large one becomes enormous fast =instead ofIS NULL—WHERE x = NULLmatches nothing, becauseNULL = NULLisNULL, notTRUE- Date arithmetic in the wrong dialect —
WHERE created_at >= NOW() - INTERVAL 30 DAYis fine in MySQL; PostgreSQL needsNOW() - 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:
- Run
EXPLAIN— most databases offerEXPLAIN SELECT ...which shows the execution plan. A missing index shows up as aSeq Scan(PostgreSQL) orFull Table Scan(MySQL) - Spot-check edge rows — the first and last row of the result, plus any row with a known oddity (
O'Brien, an order withtotal = 0) - Count rows before
LIMIT— wrap the query inSELECT 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:
- SELECT —
users.name,SUM(orders.total) AS total_spent - FROM —
users - JOIN —
INNER JOIN orders ON users.id = orders.user_id - WHERE —
orders.status = 'paid',orders.created_at >= '2026-01-01' - GROUP BY —
users.id,users.name - HAVING —
SUM(orders.total) > 500 - ORDER BY —
total_spent DESC - LIMIT —
20
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
- Clean up a hand-written query: SQL Formatter
- Compose more complex queries (CTEs, unions, subqueries) visually: SQL Builder
- Diff two versions of the same report: SQL Compare
- A deeper guide to SELECT construction: How to Build a SELECT Query Visually
- Tidying hand-written SQL: How to Format SQL: A Complete Guide