In my previous post, I introduced an interesting addition to the Dataverse developer toolbox: the ability to send SQL queries through the Dataverse Web API.
Knowing that SQL is available is one thing.
Actually writing queries with it is where things start getting interesting.
At first glance, the syntax feels immediately familiar:
SELECT name, telephone1FROM accountWHERE statecode = 0ORDER BY name
If you have worked with SQL before, there isn’t much here that needs explaining.
We are selecting columns, querying a table, filtering the records and sorting the result.
But that familiarity can also be slightly dangerous.
Dataverse isn’t exposing a general-purpose SQL Server endpoint here. It supports a specific read-only subset of T-SQL, and there are some important rules around how SELECT, WHERE, functions and expressions can be used.
So let’s go beyond a basic SELECT and see what writing real Dataverse SQL queries actually looks like.
Start by Selecting Only What You Need
Let’s begin with Accounts.
Suppose an integration only needs the Account name, telephone number and website.
We can query those columns explicitly:
SELECT a.name, a.telephone1, a.websiteurlFROM account AS a
The alias isn’t mandatory in a simple query, but using one can make queries much easier to read once joins start appearing.
There is also an important difference from the SQL many of us are accustomed to writing.
This won’t work:
SELECT *FROM account
SELECT * isn’t supported.
Dataverse requires us to explicitly specify the columns we want to retrieve.
So instead of asking for everything:
SELECT *FROM account
we should ask only for what the application actually needs:
SELECT name, telephone1, websiteurlFROM account
That restriction also encourages a good API practice: don’t retrieve data you aren’t going to use.
Making the Output Easier to Understand with Aliases
Column aliases are supported as well.
For example:
SELECT a.name AS account_name, a.telephone1 AS phoneFROM account AS a
Instead of returning the original Dataverse logical column names as our primary output names, we can give them names that make more sense to the consuming application.
For example, the response can contain values such as:
{ "account_name": "Wide World Importers", "phone": "(555) 100-0006"}
This becomes particularly useful when we eventually start joining multiple tables where similarly named columns might otherwise become confusing.
There is another limitation worth knowing here.
Selecting arbitrary literal values, calculations and functions isn’t supported.
For example, don’t expect queries such as this to work:
SELECT 'Account' AS record_type, 1 + 2 AS calculated_value, nameFROM account
The SQL interface is intentionally constrained around retrieving Dataverse data rather than providing the full expression capabilities of SQL Server.
Filtering Dataverse Data with WHERE
Most real-world queries don’t retrieve every row from a table.
We normally have some business condition that determines which records we want.
This is where WHERE comes in.
Suppose we only want active Accounts:
SELECT name, statecodeFROM accountWHERE statecode = 0
The supported comparison operators include:
=!=<><><=>=
So excluding inactive records could also be expressed as:
SELECT name, statecodeFROM accountWHERE statecode <> 1
Multiple conditions can be combined using AND and OR.
For example:
SELECT name, telephone1FROM accountWHERE statecode = 0 AND telephone1 IS NOT NULL
Now we aren’t simply asking for active Accounts.
We specifically want active Accounts that actually have a telephone number.
That is starting to look much more like the queries we encounter in real Dataverse implementations.
AND, OR and Parentheses Matter
As our business rules become more complicated, we can combine conditions.
Suppose we want records where the Account name is either Contoso or Fabrikam:
SELECT nameFROM accountWHERE name = 'Contoso' OR name = 'Fabrikam'
Or perhaps our condition is:
- Account can be active or inactive
- But it must have a telephone number
We can express that using parentheses:
SELECT name, telephone1FROM accountWHERE (statecode = 0 OR statecode = 1) AND telephone1 IS NOT NULL
This is familiar SQL territory, but parentheses become important when mixing AND and OR because they make the intended evaluation order explicit.
Searching Text with LIKE
Exact matches aren’t always enough.
Imagine we want Accounts whose names start with “Fourth”.
We can use:
SELECT nameFROM accountWHERE name LIKE 'Fourth%'
The % wildcard matches any sequence of characters.
So:
'Fourth%'
could match:
Fourth CoffeeFourth Coffee AustraliaFourth Consulting
The _ wildcard can be used to represent a single character.
For example:
SELECT nameFROM accountWHERE name LIKE '_ontoso'
could match:
Contoso
We can also exclude patterns using NOT LIKE.
For example, test data frequently finds its way into environments:
SELECT nameFROM accountWHERE name NOT LIKE '%test%'
That gives us a simple way of excluding Accounts whose names contain “test”.
Be Careful with Leading Wildcards
There is an important performance consideration with LIKE.
This query may look innocent:
SELECT nameFROM accountWHERE name LIKE '%Coffee'
But the leading wildcard can be expensive because Dataverse may need to perform a full table scan.
Where possible, Microsoft recommends preferring a trailing wildcard:
SELECT nameFROM accountWHERE name LIKE 'Fourth%'
A trailing wildcard has the potential to use an index, whereas a leading wildcard can hurt query performance.
This is a small difference in syntax that can become significant once we’re querying tables containing large numbers of rows.
Filtering Against Multiple Values with IN
Sometimes we have a known list of values that should be included.
Instead of writing:
SELECT nameFROM accountWHERE name = 'Contoso' OR name = 'Fabrikam' OR name = 'Fourth Coffee'
we can make the intention clearer using IN:
SELECT nameFROM accountWHERE name IN ( 'Contoso', 'Fabrikam', 'Fourth Coffee')
The reverse is available through NOT IN:
SELECT nameFROM accountWHERE name NOT IN ( 'Contoso', 'Fabrikam')
For a developer reading the query later, IN can make a multi-value filter much easier to understand.
Filtering Ranges with BETWEEN
Dataverse SQL also supports BETWEEN.
For example:
SELECT nameFROM accountWHERE name BETWEEN 'A' AND 'B'
BETWEEN represents an inclusive range.
Although this is a simple example, range-based filtering becomes particularly useful when dealing with suitable numeric or date-oriented filtering scenarios.
NULL Isn’t the Same as = NULL
This is standard SQL behaviour, but it is worth calling out because it is an easy mistake to make.
Suppose we want Accounts without a telephone number.
This isn’t the correct approach:
SELECT name, telephone1FROM accountWHERE telephone1 = NULL
Instead, use:
SELECT name, telephone1FROM accountWHERE telephone1 IS NULL
And to find Accounts where a telephone number exists:
SELECT name, telephone1FROM accountWHERE telephone1 IS NOT NULL
The Dataverse SQL documentation explicitly calls out that = NULL doesn’t produce the expected result.
Use IS NULL and IS NOT NULL.
Removing Duplicates with DISTINCT
Suppose we want to know which cities appear across our Account records.
Returning every Account isn’t necessary.
Instead:
SELECT DISTINCT a.address1_cityFROM account AS a
DISTINCT removes duplicate values from the result.
This can be useful when we’re interested in the unique values represented within a column rather than individual Dataverse records.
Sorting the Result with ORDER BY
Once we’ve selected and filtered the records, we often need predictable ordering.
For example:
SELECT name, telephone1FROM accountORDER BY name ASC
ASC sorts the result in ascending order and is the default.
We can also sort descending:
SELECT name, createdonFROM accountORDER BY createdon DESC
Multiple columns can be used:
SELECT name, createdonFROM accountORDER BY name ASC, createdon DESC
That would sort primarily by Account name and then by creation date within matching names.
There is another restriction to remember here.
ORDER BY can reference columns, but complex expressions aren’t supported.
Something like:
ORDER BY LEN(name)
isn’t supported.
Again, this reinforces the idea that we are dealing with a deliberately constrained SQL implementation.
What About Dates?
There is an interesting exception when working with date filtering.
Dataverse SQL supports DATEADD and GETUTCDATE in specific circumstances.
For example:
SELECT a.nameFROM account AS aWHERE a.createdon >= DATEADD( day, -3, GETUTCDATE())
This allows us to retrieve Accounts created within a range relative to the current UTC time.
But there is an important restriction.
Functions can’t be applied directly to column values.
For example, this isn’t supported:
SELECT a.nameFROM account AS aWHERE DATEADD(day, 3, a.createdon) >= GETUTCDATE()
Instead, the function needs to operate against a literal value or another supported function, leaving the Dataverse column as the other side of the comparison.
So this works:
WHERE a.createdon >= DATEADD(day, -3, GETUTCDATE())
while this doesn’t:
WHERE DATEADD(day, 3, a.createdon) >= GETUTCDATE()
It is a subtle distinction, but exactly the kind of restriction that developers need to understand before assuming that normal SQL patterns will translate directly.
WHERE Has Some Important Boundaries
There are several other familiar SQL patterns that aren’t supported in the WHERE clause.
For example:
WHERE accountid IN ( SELECT accountid FROM account)
Subqueries aren’t supported.
Neither are:
WHERE EXISTS (...)
or:
WHERE NOT EXISTS (...)
Even something as simple as:
WHERE 1 = 1
isn’t supported.
That last one might surprise developers who dynamically construct SQL queries and commonly start their conditions with WHERE 1 = 1.
Column-to-column comparisons are another limitation.
This isn’t supported:
WHERE modifiedon > createdon
Nor can we use arbitrary expressions such as:
WHERE revenue > 500 + 125
These restrictions matter because they reinforce something I mentioned in my previous post:
Knowing SQL doesn’t automatically mean you know Dataverse SQL.
The syntax is familiar.
The boundaries are different.
Putting It Together
Let’s finish with something closer to a query we might actually use.
Imagine an integration needs active Accounts that:
- Have a telephone number
- Don’t contain “test” in their name
- Were created within the last three days
- Are sorted alphabetically
We can express that as:
SELECT a.name, a.telephone1, a.createdonFROM account AS aWHERE a.statecode = 0 AND a.telephone1 IS NOT NULL AND a.name NOT LIKE '%test%' AND a.createdon >= DATEADD(day, -3, GETUTCDATE())ORDER BY a.name ASC
Now the capability starts to feel less like a technical curiosity and more like something we can potentially use in real integrations.
The query is readable.
The business conditions are easy to identify.
And for developers coming from a SQL background, the intent is immediately recognisable.
But we also need to remember the constraints we’ve seen throughout this article.
Familiar Syntax, Different Rules
That’s probably the best way to describe SQL through the Dataverse Web API.
At first glance:
SELECTFROMWHERELIKEINBETWEENORDER BY
all feel exactly as expected.
And for many straightforward queries, they largely are.
But underneath that familiar syntax is a deliberately restricted Dataverse query interface.
Understanding those restrictions is what separates simply getting a SQL query to work from designing queries that are appropriate for Dataverse.
And so far, we have only queried a single table.
Things become much more interesting when Dataverse relationships enter the picture.
Because instead of thinking about $expand or link-entity, we can start writing something SQL developers know extremely well:
INNER JOIN
And that is where we’ll go next.
