Erik's ToolBox Documentation
Index

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 18 queries 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 numbers 1 / 0. A bare true is 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 AS or implicitly: SELECT Name AS CustomerName or SELECT Name CustomerName. Duplicate result names get 2, 3 suffixes (SELECT a.Name, b.Name yields Name and Name_2), and ORDER BY / HAVING references resolve to the suffixed column. Names starting with sort, having or _group are 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 BY on a keyed field uses the table key. LIKE always matches the displayed text, so Status 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(*) * 2 is an error). An expression is numeric -- and reported as Decimal -- when it is arithmetic, a number, a numeric field, or a CASE whose results are all numeric; otherwise it is Text, 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 ON condition consists of terms combined with AND. 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 ON narrows the lookup: the join pairs the main row with the first joined row that also passes the filter. The same condition in WHERE only tests the one row the lookup already picked, so when several joined rows can exist per main row, put the filter in ON.
  • Literal filters take the joined field's type exactly like WHERE literals (option captions, ISO dates, 'true'/'false'). They must be expressible as a native Business Central filter: =, <>, <, >, <=, >=, LIKE with %/_, IN with 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 missing ON are 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 in WHERE.
  • Self-joins work via aliases.
  • Chained joins can reference earlier joined tables in their ON conditions.
  • 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/DESC per 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 a GROUP BY column.
  • 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 ONLY provides 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 (TOP or the Max Rows setting) limits the size of the page itself. FETCH NEXT 0 ROWS returns 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 removed
  • UNION ALL -- combined result, duplicates kept
  • INTERSECT -- rows present in both results, duplicates removed
  • INTERSECT ALL -- rows present in both results, keeping as many copies as occur on both sides
  • EXCEPT -- rows in the first result that are not in the second
  • EXCEPT 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 after VALUES. 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 OnValidate trigger runs), then OnInsert fires.
  • 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 = NULL blanks 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) and SET 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 OnValidate trigger runs), then OnModify fires. SET field = NULL validates 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 OnDelete triggers 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, a VALUES group with the wrong number of values, a column standing alone as a condition
  • IN without a parenthesized list, and INSERT/UPDATE/DELETE used as a set-operation operand
  • A VALUES list without parentheses or its closing ), SET without =, 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 after alias., 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 JOIN is 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 JOIN are not supported.
  • No scalar functions (string/date functions like UPPER, DATEADD).
  • No arithmetic expressions on the left side of WHERE comparisons (WHERE Amount * 2 > 100 does not parse; use WHERE Amount > 50).
  • LIMIT is not supported -- use TOP or OFFSET/FETCH.
  • FlowFilter fields cannot be queried.
  • Results are capped at Max Rows (default 1000); TOP cannot 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