SQL Syntax Reference
This page documents the SQL syntax supported by the ToolBox SQL engine (version 3.0.0.326 and later). Each statement type includes its formal syntax, a description of its behavior, and practical examples you can run directly in the SQL Workbench.
General Syntax Rules
Before diving into individual statement types, here are the general rules that apply across all SQL statements in the ToolBox engine.
Identifiers
Tables and fields can be referenced in three ways, all case-insensitive:
- By name -- the BC name as it appears in the AL object definition:
Customer,Name - By caption -- the translated caption shown in the UI
- By numeric ID -- the object or field number:
FROM 18queries the Customer table
When an identifier contains spaces, dots, slashes, or other special characters, wrap it in square brackets:
-- Bracketed identifiers (required when names contain spaces or special characters)
SELECT [No.], [Country/Region Code], [Balance (LCY)] FROM Customer
-- Unbracketed identifiers (allowed when names are simple)
SELECT Name FROM Customer
-- Reference a table by its numeric ID
SELECT [No.], Name FROM 18
AL-style double-quoted identifiers ("No.", "Sales Header") work the same way as brackets. Names containing letters outside ASCII (Købskladde) need no quoting.
Literal Values
- Text values are enclosed in single quotes:
'US','Hello World'. Escape an embedded quote by doubling it:'O''Brien'. - Numeric values are written as plain numbers:
100,3.14,-50. The decimal separator is always.regardless of your locale. - Date values should be written in ISO format, quoted:
'2026-04-01'. The engine parses literals in invariant format first, so ISO dates work regardless of the user's locale. - Boolean values must be written as a quoted string --
'true','false','yes','no'-- or as the numbers1/0. A baretrueis parsed as a column reference and produces an "Unknown column: true" error. - Option fields accept the option name or caption (
'Released') or the ordinal number.
Case Sensitivity
SQL keywords (SELECT, FROM, WHERE, and so on) are case-insensitive. Table and field name matching is also case-insensitive.
SELECT Statement
The SELECT statement retrieves records and fields from Business Central tables.
Syntax
SELECT [TOP n] [DISTINCT] columns
FROM Table [alias]
[ [INNER|LEFT|CROSS] JOIN Table2 [alias2] ON a.Field = b.Field [AND ...] ] ...
[WHERE condition]
[GROUP BY col [, col ...]]
[HAVING condition]
[ORDER BY col [ASC|DESC] [, col ...]]
[OFFSET n ROWS] [FETCH NEXT m ROWS ONLY]
Columns
retrieves all fields;alias.retrieves all fields of one joined table.- Individual columns: plain fields (
Name), bracketed fields ([No.]), or table-qualified fields (c.Name). - Column aliases via
ASor implicitly:SELECT Name AS CustomerNameorSELECT Name CustomerName. Duplicate result names get2,3suffixes (SELECT a.Name, b.NameyieldsNameandName_2), and ORDER BY / HAVING references resolve to the suffixed column. Names starting withsort,havingor_groupare reserved. - Sort order for sorts the engine performs in memory (several ORDER BY columns, an alias or computed column, a field without a key): blank values first, then values that parse as numbers in numeric order, then everything else as text. A single-column
ORDER BYon a keyed field uses the table key.LIKEalways matches the displayed text, soStatus LIKE 'Rel%'matches an Option by caption. - Computed columns: arithmetic with
+ - * /, unary minus, and parentheses. Division by zero yields 0. - CASE expressions:
CASE WHEN cond THEN x [WHEN ...] [ELSE y] END. - Every column inside an expression must exist, and aggregates or subqueries cannot appear inside expressions (
COUNT(*) * 2is an error). An expression is numeric -- and reported asDecimal-- when it is arithmetic, a number, a numeric field, or aCASEwhose results are all numeric; otherwise it isText, and a text result such as'007'is never turned into a number.
SELECT [No.], Quantity * [Unit Price] AS LineTotal FROM [Sales Line]
SELECT [No.], Name,
CASE WHEN [Balance (LCY)] > 1000 THEN 'High' ELSE 'Low' END AS Tier
FROM Customer
Row limits: TOP and the default cap
The default result cap is 1000 rows (the Max Rows setting); TOP n can lower it but not raise it. TOP is applied after ORDER BY, so TOP 10 ... ORDER BY Balance DESC is a true top-10. TOP takes a whole number.
SELECT TOP 10 [No.], Name, [Balance (LCY)] FROM Customer ORDER BY [Balance (LCY)] DESC
DISTINCT
SELECT DISTINCT removes duplicate result rows:
SELECT DISTINCT [Salesperson Code] FROM Customer
Basic examples
SELECT [No.], Name FROM Customer
SELECT * FROM Item
SELECT [No.], Name, City FROM Customer WHERE [Country/Region Code] = 'US'
SELECT [No.], Name FROM Customer ORDER BY Name
SELECT [No.], Name, City
FROM Customer
WHERE [Country/Region Code] = 'US' AND [Balance (LCY)] > 500
JOIN Clauses
The engine supports joins -- but the semantics differ from SQL Server, so read this section carefully.
Lookup join semantics
Joins are lookup joins: each row of the first (main) table is paired with the first matching record of the joined table. A join never multiplies rows the way a full SQL join does -- one main row yields at most one result row. (CROSS JOIN is the exception.)
| Join type | Behavior |
|---|---|
INNER JOIN (or bare JOIN) |
Main rows without a match are dropped |
LEFT JOIN / LEFT OUTER JOIN |
Unmatched main rows are kept with null in the joined columns |
CROSS JOIN |
Full Cartesian product (this one does multiply rows). FROM A, B is the same thing. Exactly two tables; no aggregates or GROUP BY |
RIGHT JOIN, FULL JOIN |
Not supported -- returns an error |
Rules
- The
ONcondition consists of terms combined withAND. Each term is either an equality between table-qualified columns (a.Field = b.Field) -- one side of which must name the joined table -- or a literal filter on the joined table's own column:e.[Document Type] = 'Invoice',e.Amount > 0,e.Code IN ('A', 'B'),e.Name LIKE 'A%',e.[Posting Date] BETWEEN '2026-01-01' AND '2026-01-31',e.[Salesperson Code] IS NULL. Several equalities may constrain the same joined field; the row joins only if all of them agree. - A literal filter in
ONnarrows the lookup: the join pairs the main row with the first joined row that also passes the filter. The same condition inWHEREonly tests the one row the lookup already picked, so when several joined rows can exist per main row, put the filter inON. - Literal filters take the joined field's type exactly like
WHEREliterals (option captions, ISO dates,'true'/'false'). They must be expressible as a native Business Central filter:=,<>,<,>,<=,>=,LIKEwith%/_,INwith a literal list,BETWEEN,IS [NOT] NULL; a value containing*or?is rejected. - Unqualified columns, literal conditions on any table other than the joined one,
OR, comparisons other than=between two columns, an unknown alias or column, or a missingONare reported as errors -- they cannot be executed as lookups, so they are never silently turned into an empty result. Conditions on the main table belong inWHERE. - Self-joins work via aliases.
- Chained joins can reference earlier joined tables in their
ONconditions. - WHERE conditions and SELECT columns may reference joined tables' fields.
Examples
-- Look up each customer's salesperson name
SELECT c.[No.], c.Name, s.Name AS Salesperson
FROM Customer c
LEFT JOIN [Salesperson/Purchaser] s ON c.[Salesperson Code] = s.Code
-- Self-join via aliases
SELECT a.[No.], b.Name
FROM Customer a
JOIN Customer b ON a.[Bill-to Customer No.] = b.[No.]
-- Literal filter in ON: the first entry of type Invoice, not the first entry
SELECT h.[No.], e.[Entry No.], e.Amount
FROM [Sales Invoice Header] h
LEFT JOIN [Cust. Ledger Entry] e ON e.[Document No.] = h.[No.] AND e.[Document Type] = 'Invoice'
-- Cross join with a field-to-field WHERE condition
SELECT h.[No.], l.Description
FROM [Sales Header] h
CROSS JOIN [Sales Line] l
WHERE h.[No.] = l.[Document No.]
WHERE Clause
Operators
| Operator | Meaning | Example |
|---|---|---|
= |
Equal to | WHERE [No.] = '10000' |
<> or != |
Not equal to | WHERE Status <> 'Open' |
< > <= >= |
Comparisons | WHERE [Balance (LCY)] > 1000 |
LIKE |
Pattern matching | WHERE Name LIKE 'A%' |
IN (list) |
Match any value in a list | WHERE City IN ('Atlanta', 'Chicago') |
IN (subquery) |
Match any value returned by a subquery | WHERE [No.] IN (SELECT [Sell-to Customer No.] FROM [Sales Header]) |
BETWEEN x AND y |
Inclusive range | WHERE [Posting Date] BETWEEN '2026-01-01' AND '2026-03-31' |
IS NULL / IS NOT NULL |
Blank test | WHERE [Salesperson Code] IS NULL |
EXISTS / NOT EXISTS |
Subquery existence test | WHERE EXISTS (SELECT * FROM [Sales Header] WHERE Status = 'Open') |
Combine conditions with AND, OR, NOT, and parentheses. OR across different fields works correctly. Standard SQL precedence applies (AND binds more tightly than OR).
Field-to-field comparisons
Conditions can compare two fields, including fields from joined tables:
SELECT [No.] FROM [Sales Line] WHERE Amount > Quantity
Arithmetic expressions on the left side of a comparison are not supported: WHERE Amount * 2 > 100 is rejected with an Unexpected ''* parse error -- rewrite it as WHERE Amount > 50.
Literals are compared using the field's type in every form of the condition. WHERE Blocked = 1 OR Quantity = 10, WHERE NOT (Status = 1) and WHERE 200 < Amount give the same results as the equivalent simple comparisons.
LIKE patterns
%-- matches any sequence of zero or more characters._-- matches any single character.- Every other character, including
*and?, matches literally; a%or_in the data is matched by the corresponding wildcard like any other character.
Matching is case-insensitive on Text and Code fields alike, following Business Central's standard database collation. Equality comparisons behave the same way.
SELECT [No.], Name FROM Customer WHERE Name LIKE 'A%'
SELECT [No.], Name FROM Customer WHERE Name LIKE '%wood%'
IS NULL semantics
Business Central has no real NULL values. IS NULL matches the field's blank state: empty text/code, 0 for numeric fields, and empty dates.
Unknown columns
Unknown columns in WHERE or ORDER BY produce an error rather than being silently ignored -- a typo cannot quietly return wrong results.
Incomplete statements
The whole statement must be understood. Anything the parser cannot place -- a stray word after ORDER BY, an expression in a position that only accepts a column, an extra ) -- produces an Unexpected '…' at position n error instead of being dropped. A single trailing semicolon is allowed.
Subqueries
Subqueries are supported in three forms. All are uncorrelated: the subquery runs once and may not reference the outer query's tables.
-- Scalar: compare against a single value
SELECT [No.] FROM [Sales Line]
WHERE Quantity = (SELECT MAX(Quantity) FROM [Sales Line])
-- List: IN over a subquery result (an empty result matches nothing)
SELECT [No.], Name FROM Customer
WHERE [No.] IN (SELECT [Sell-to Customer No.] FROM [Sales Header])
-- Existence: EXISTS / NOT EXISTS
SELECT [No.], Name FROM Customer
WHERE NOT EXISTS (SELECT * FROM [Sales Header] WHERE Status = 'Open')
Subqueries have their own row limits, independent of the outer query's Max Rows setting: scalar and EXISTS subqueries read only their first row, and an IN subquery may return up to 100,000 values (more is reported as an error rather than silently truncated). Long IN lists (over 500 values) are matched per row through a hash set instead of a native filter.
Aggregate Functions and GROUP BY
Aggregates
COUNT(*), COUNT(field), SUM, AVG, MIN, MAX. Each takes exactly one argument, which can be a joined-table column. COUNT(field) does not count blank values (empty text, 0, false -- the same notion of blank as IS NULL). MIN/MAX compare numerically on numeric fields and as text otherwise, so MIN over Codes '10' and '2' is '10'.
These five are the only functions in the engine -- there is no UPPER, LOWER, DATEADD, etc., and SUM(*) is an error.
Without GROUP BY, an aggregate query returns one result row. Over an empty set, COUNT and SUM return 0 and MIN/MAX return null. Mixing plain columns with aggregates without GROUP BY is an error.
SELECT COUNT(*) AS OpenOrders FROM [Sales Header] WHERE Status = 'Open'
GROUP BY and HAVING
GROUP BY supports multiple columns, table-qualified columns, joined columns, and SELECT list positions (GROUP BY 1). The result has exactly the SELECT list's columns, in order and with their aliases; a GROUP BY column that is not selected is not output, but can still be used in HAVING and ORDER BY. Every plain column in the SELECT list must be a GROUP BY column, and computed columns are not allowed together with aggregates.
HAVING filters the grouped rows. It accepts aggregates (matched by function and argument, whether or not they are selected), select aliases, and GROUP BY columns, with the full set of predicates (=, <>, <, >, IN, BETWEEN, LIKE, IS NULL) combined with AND/OR/NOT. Comparisons follow the column's field type exactly as in WHERE: Options match by ordinal or caption (HAVING Status = 1 and HAVING Status = 'Released' are the same), Codes compare as text (HAVING [No.] = 7 does not match '007'), numbers numerically and Booleans as true/false/1/0; IS NULL on a GROUP BY column matches its blank value (0, false, ''). MIN/MAX results keep the argument's type, so MAX(Blocked) is a Boolean. HAVING without GROUP BY or an aggregate is an error.
SELECT [Salesperson Code], COUNT(*) AS Customers, SUM([Balance (LCY)]) AS Total
FROM Customer
GROUP BY [Salesperson Code]
HAVING COUNT(*) > 5
ORDER BY Total DESC
ORDER BY and Paging
- Multiple columns, with
ASC/DESCper column. Sorting is numeric-aware. - You can order by columns that are not in the SELECT list, by SELECT aliases, and by SELECT list position (
ORDER BY 2 DESC). In grouped queries the column must be selected, an alias, or aGROUP BYcolumn. - Sorting uses a table key when the first ORDER BY column is the first field of an active key; otherwise the matching rows are sorted in memory, which is refused beyond 100,000 rows.
OFFSET n ROWS FETCH NEXT m ROWS ONLYprovides paging -- use it together with ORDER BY for stable pages. Paging works past the row cap: the engine fetches enough rows to reach the requested page, then the cap (TOPor the Max Rows setting) limits the size of the page itself.FETCH NEXT 0 ROWSreturns no rows.
SELECT [No.], Name FROM Customer
ORDER BY Name
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
LIMIT is not supported -- use TOP or OFFSET/FETCH.
Set Operations
Two or more SELECT statements can be combined with:
UNION-- combined result, duplicates removedUNION ALL-- combined result, duplicates keptINTERSECT-- rows present in both results, duplicates removedINTERSECT ALL-- rows present in both results, keeping as many copies as occur on both sidesEXCEPT-- rows in the first result that are not in the secondEXCEPT ALL-- rows in the first result, with one copy removed per matching row in the second
SELECT City FROM Customer
UNION
SELECT City FROM Vendor
Several operators can be chained; they apply left to right (A UNION B UNION C is (A UNION B) UNION C). Only SELECT statements can be combined. Both sides must select the same number of columns; the result uses the first SELECT's column names, and rows from the other side are matched to them by position. Each side is read in full (up to 100,000 rows) before combining, so INTERSECT and EXCEPT see every row; ORDER BY (by name or position) and OFFSET/FETCH written after the last SELECT apply to the combined result, and Max Rows caps that combined result.
INSERT Statement
Syntax
INSERT INTO TableName (field1, field2 [, ...])
VALUES (value1, value2 [, ...]) [, (value1, value2 [, ...]) ...]
Description
- Multi-row inserts are supported: list several
(...)groups afterVALUES. Every group must contain exactly one value per listed column; a mismatch is an error and nothing is written. - Values must be literals: a quoted string, a number (negative numbers included) or
NULL. A column reference, an unquoted word (VALUES (ORD100, Alpha)) or a subquery is rejected before anything is written. - Values are converted by field type: dates as
'yyyy-mm-dd', booleans as'true'/'false'/1/0, Option fields accept the option name or caption ('Released') or the ordinal number, and BigInteger values work. - Fields not specified receive their default values.
- The insert goes through the standard BC record insert path: every assigned field is validated (its
OnValidatetrigger runs), thenOnInsertfires. - The result reports
rowsAffected.
Examples
INSERT INTO [Item Category] (Code, Description)
VALUES ('CUSTOM', 'Custom Category')
-- Multi-row insert
INSERT INTO [My Custom Table] (Code, Description, Amount)
VALUES ('REC001', 'First Record', 250.00),
('REC002', 'Second Record', 125.50)
Important Considerations
- Primary key fields are typically required. If you omit them, the insert will fail.
- Unknown columns in the INSERT column list produce an error.
- Always verify the records were created by running a SELECT query afterward.
UPDATE Statement
Syntax
UPDATE TableName
SET field1 = value1 [, field2 = value2, ...]
[WHERE condition]
Description
- The full WHERE feature set applies -- subqueries,
OR,NOT,IN,BETWEEN, and so on. SET field = NULLblanks a field.- Updating the very field the WHERE clause filters on is safe.
- The value must be a literal: a quoted string, a number or
NULL.SET Name = Category(a column reference),SET Category = FRUIT(an unquoted word) andSET x = (SELECT …)are rejected before anything is written; copying or computing values is not supported. - The update goes through the standard BC record modify path: every assigned field is validated (its
OnValidatetrigger runs), thenOnModifyfires.SET field = NULLvalidates the blank value; NULL is not supported for Blob and Media fields. - The result reports
rowsAffected.
Examples
UPDATE Customer SET [Phone No.] = '555-0100' WHERE [No.] = '10000'
UPDATE Item SET Blocked = 'false' WHERE [No.] = '1000'
-- Blank a field
UPDATE Customer SET [Salesperson Code] = NULL WHERE [No.] = '10000'
-- WHERE with a subquery
UPDATE Customer SET Blocked = 'false'
WHERE [No.] IN (SELECT [Sell-to Customer No.] FROM [Sales Header] WHERE Status = 'Open')
Important Considerations
- Always use a WHERE clause. Running an UPDATE without WHERE will modify every record in the table.
- Before running an UPDATE, run a SELECT with the same WHERE clause first to verify which records will be affected.
DELETE Statement
Syntax
DELETE FROM TableName
[WHERE condition]
Description
- The same full WHERE feature set as UPDATE applies.
- The delete goes through the standard BC record delete path, so
OnDeletetriggers and cascade logic fire. - The result reports
rowsAffected.
Examples
DELETE FROM [My Temp Table] WHERE [Entry No.] = 5001
DELETE FROM [My Custom Table] WHERE Code = 'OBSOLETE'
DELETE FROM [My Temp Table]
WHERE [Entry No.] NOT IN (SELECT [Entry No.] FROM [My Archive Table])
Important Considerations
- Always use a WHERE clause to avoid accidentally deleting all records in a table.
- Before running a DELETE, run a SELECT with the same WHERE clause to see exactly which records will be removed.
- Deleted records cannot be recovered through the SQL engine.
Data Type Handling
Text and Code Fields
Single quotes, with '' escaping an embedded quote:
WHERE [No.] = '10000'
WHERE Name = 'O''Brien Ltd.'
Numeric Fields
Plain numbers with . as the decimal separator:
WHERE Quantity = 10
WHERE [Unit Price] > 3.14
Date Fields
ISO format, quoted -- works regardless of locale:
WHERE [Posting Date] = '2026-04-01'
WHERE [Posting Date] BETWEEN '2026-01-01' AND '2026-03-31'
Boolean Fields
Quoted strings or the numbers 1/0 -- a bare true is treated as a column name and errors:
WHERE Blocked = 'true'
WHERE Blocked = 0
Option Fields
Option name, caption, or ordinal number:
WHERE Status = 'Released'
WHERE Status = 1
FlowFields
FlowFields are calculated automatically when selected, filtered, or aggregated -- SELECT [Balance (LCY)] FROM Customer just works. FlowFilter fields cannot be queried.
Error Behavior
The engine reports errors instead of silently returning wrong data. The following all produce an error message:
- Unknown table or alias
- Unknown column -- including in WHERE, ORDER BY, HAVING, JOIN ON, computed columns and INSERT column lists
- Unsupported functions (only the five aggregates exist),
SUM(*),COUNT(DISTINCT ...), and aggregates used inside expressions or WHERE RIGHT JOIN/FULL JOIN, JOIN without a usable ON clause (literal conditions on the main table,OR, unqualified columns), more than two tables in a cross join- Derived tables (
FROM (SELECT ...)) - Plain columns mixed with aggregates without GROUP BY, or not covered by GROUP BY
- Unterminated string literals or identifiers, leftover tokens, non-numeric
TOP/OFFSET/FETCH - Non-literal values in
INSERT/UPDATE, aVALUESgroup with the wrong number of values, a column standing alone as a condition INwithout a parenthesized list, andINSERT/UPDATE/DELETEused as a set-operation operand- A
VALUESlist without parentheses or its closing),SETwithout=, an aggregate with the wrong number of arguments, a reserved alias, NULL assigned to an unsupported field type
SQL Workbench
The SQL Workbench page is the interactive front end for the engine:
- Run statements with Execute SQL (or the editor's save shortcut); results appear in a grid.
- Export to Excel executes the statement and downloads the results as an Excel file.
- Context-aware IntelliSense: table suggestions after
FROM/JOIN/INSERT INTO/UPDATE(searching both names and captions); field suggestions afteralias., in the SELECT list, in WHERE/ON/HAVING/GROUP BY/ORDER BY clauses, in INSERT column lists, and in UPDATE SET (covering fields from all joined tables); plus keyword and aggregate suggestions appropriate to the clause. Only constructs the engine supports are suggested.
Limitations
- Joins are lookup joins (first match), not row-multiplying SQL joins;
CROSS JOINis the exception. - Correlated subqueries are not supported -- subqueries run once and cannot reference the outer query's tables.
- Derived tables --
FROM (SELECT ...)-- are not supported. RIGHT JOIN/FULL JOINare not supported.- No scalar functions (string/date functions like
UPPER,DATEADD). - No arithmetic expressions on the left side of WHERE comparisons (
WHERE Amount * 2 > 100does not parse; useWHERE Amount > 50). LIMITis not supported -- useTOPorOFFSET/FETCH.- FlowFilter fields cannot be queried.
- Results are capped at Max Rows (default 1000);
TOPcannot raise the cap. ORDER BY without a matching active table key reads all matching rows before sorting in memory and is refused beyond 100,000 rows. - Subqueries return at most 100,000 values; correlated subqueries are not supported.
Quick Reference
| Statement | Basic Syntax | ||
|---|---|---|---|
| SELECT | SELECT [TOP n] [DISTINCT] cols FROM T [JOIN ...] [WHERE ...] [GROUP BY ...] [HAVING ...] [ORDER BY ...] [OFFSET ... FETCH ...] |
||
| INSERT | INSERT INTO T (fields) VALUES (values) [, (values) ...] |
||
| UPDATE | UPDATE T SET field = value [, ...] [WHERE ...] |
||
| DELETE | DELETE FROM T [WHERE ...] |
||
| Set operations | `SELECT ... UNION [ALL] | INTERSECT [ALL] | EXCEPT [ALL] SELECT ...` |
| Element | Rule |
|---|---|
| Identifiers | Brackets or double quotes for special characters: [Sales Header], "No." -- or use the table/field caption or numeric ID |
| Text literals | Single quotes, '' escapes a quote: 'O''Brien' |
| Numeric literals | No quotes, . decimal separator: 100, 3.14 |
| Date literals | ISO format, quoted: '2026-04-01' |
| Boolean literals | 'true' / 'false' / 'yes' / 'no' or 1 / 0 (never bare true) |
| Joins | INNER / LEFT / CROSS (lookup semantics; no RIGHT/FULL); ON takes a.Field = b.Field equalities and literal filters on the joined table, AND-combined |
| Operators | = <> != < > <= >= LIKE IN BETWEEN IS [NOT] NULL [NOT] EXISTS + AND OR NOT |
| Subqueries | Scalar, IN (subquery), EXISTS -- uncorrelated only |
| Aggregates | COUNT(*), COUNT(field), SUM, AVG, MIN, MAX |
| Wildcards (LIKE) | % for any sequence, _ for a single character |
| Row limits | Default cap 1000 (Max Rows); TOP n lowers it; OFFSET n ROWS FETCH NEXT m ROWS ONLY for paging |