Get SQL Server query results as JSON

The simplest way to get query results as JSON is to use FOR JSON PATH in the query (note: this was added in SQL Server 2016):

SELECT [Name], [Year]
FROM Movies
FOR JSON PATH
Code language: SQL (Structured Query Language) (sql)

It returns the results as a single JSON string with one JSON object per row:

[
  {
    "Name": "Office Space",
    "Year": 1999
  },
  {
    "Name": "John Wick",
    "Year": 2014
  },
  {
    "Name": "Mad Max: Fury Road",
    "Year": 2015
  }
]
Code language: JSON / JSON with Comments (json)

Note: SQL Server returns the JSON without indenting. All examples in this article show it as indented for readability purposes only.

In this article, I’ll go into details about how to use FOR JSON PATH, and at the end I’ll compare the performance of server-side vs client-side JSON serialization.

Getting the result on the client-side

On the client-side, when you are only getting a JSON string (and not just including it as one of the columns with a subquery), it will return the results as multiple rows of strings (of up to 2033 characters), which you have to join together.

Here’s an example of using Dapper to get a JSON string:

using Dapper;

using (var con = new SqlConnection(connectionString))
{
	var jsonStringRows = con.Query<string>("SELECT Name, Year FROM Movies FOR JSON PATH");
	return string.Join("", jsonStringRows);
}
Code language: C# (cs)

Return a single JSON object

By default, FOR JSON PATH will return a JSON array, even if there’s only one row in the results.

If you want it to return a single JSON object, you can use the WITHOUT_ARRAY_WRAPPER option:

SELECT TOP 1 [Name], [Year]
FROM Movies
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
Code language: SQL (Structured Query Language) (sql)

This returns a single JSON object:

{
  "Name": "Office Space",
  "Year": 1999
}
Code language: JSON / JSON with Comments (json)

Note: You can use WITHOUT_ARRAY_PARAMETER with multiple rows, but it returns comma-separated JSON objects (which is invalid JSON), so I don’t recommend it.

Changing the JSON property names

By default, the JSON property names will match the column names as specified in the query. This means you can change the JSON property names by changing the column name casing or by using an alias.

If you want to change the property names to use camel-casing, specify the column names with column casing in the select:

SELECT [name], [numberOfEpisodes], [numberOfSeasons]
FROM Shows
FOR JSON PATH
Code language: SQL (Structured Query Language) (sql)

This outputs the JSON with camel-cased property names:

[
  {
    "name": "Star Trek: Picard",
    "numberOfEpisodes": 10,
    "numberOfSeasons": 1
  }
]
Code language: JSON / JSON with Comments (json)

Note: This wouldn’t work if you’re using a case-sensitive collation. In that scenario, use aliasing instead.

If you want the property name to be different from the column name, you can use aliasing:

SELECT [Name], [NumberOfEpisodes] as [EpisodeCount], [NumberOfSeasons] as [SeasonCount]
FROM Shows
FOR JSON PATH
Code language: SQL (Structured Query Language) (sql)

This outputs the JSON using the column aliases as the property names:

[
  {
    "Name": "Star Trek: Picard",
    "EpisodeCount": 10,
    "SeasonCount": 1
  }
]
Code language: JSON / JSON with Comments (json)

If you’re using SELECT *, it’ll use the column names as they are defined in the table.

Getting a related table as a JSON array

A Show has one or more Episodes. To get the Episodes table as a JSON array, you can do a subquery:

SELECT [Name],
	(SELECT e.Season, e.Number, e.Title FROM Episodes e
	WHERE e.Show = s.Name
	FOR JSON PATH) as [Episodes]
FROM Shows s
FOR JSON PATH
Code language: SQL (Structured Query Language) (sql)

This outputs the following (notice the Episodes JSON array property):

[
  {
    "Name": "Star Trek: Picard",
    "Episodes": [
      {
        "Season": 1,
        "Number": 1,
        "Title": "Remembrance"
      },
      {
        "Season": 1,
        "Number": 2,
        "Title": "Maps and Legends"
      }
    ]
  }
]
Code language: JSON / JSON with Comments (json)

Note: You can do a JOIN with FOR JSON AUTO instead of a subquery, but I wouldn’t recommend it. The subquery approach is easier to understand, and there are some quirks with JOINs.

Changing the JSON property nesting

To nest properties, you can alias the column using the “dot syntax”:

SELECT [Name]
    ,[RuntimeMinutes]
    ,[Year] as 'BoxOffice.Year'
    ,[Revenue] as 'BoxOffice.Revenue'
FROM Movies
FOR JSON PATH
Code language: SQL (Structured Query Language) (sql)

This outputs the following with the nested BoxOffice property:

[
  {
    "Name": "Office Space",
    "RuntimeMinutes": 89,
    "BoxOffice": {
      "Year": 1999,
      "Revenue": 12200000
    }
  }
]
Code language: JSON / JSON with Comments (json)

This is useful because it’s common for table definitions (flat) to not match object definitions (hierarchy).

Include nulls

Null values aren’t included in the JSON results by default. If you want to include nulls, you can use the INCLUDE_NULL_VALUES option:

SELECT [Name]
      ,[FirstYear]
      ,[LastYear]
  FROM Shows
  FOR JSON PATH, INCLUDE_NULL_VALUES
Code language: SQL (Structured Query Language) (sql)

This outputs the following (notice the null property):

[
  {
    "Name": "Star Trek: Picard",
    "FirstYear": 2020,
    "LastYear": null
  },
  {
    "Name": "Breaking Bad",
    "FirstYear": 2008,
    "LastYear": 2013
  }
]
Code language: JSON / JSON with Comments (json)

DateTime format

DateTime (and related) columns are returned in the JSON string in the ISO-8601 standard format:

[
  {
    "Name": "John Wick",
    "DateOfRelease": "2014-10-24T19:00:00Z"
  }
]
Code language: JSON / JSON with Comments (json)

You can change the format if you want by using the FORMAT(date) SQL function:

SELECT [Name], 
    FORMAT([DateOfRelease], 'MM-dd-yyyy') as [DateOfRelease]
FROM [Movies]
FOR JSON PATH
Code language: SQL (Structured Query Language) (sql)

This uses the US date format (MM-dd-yyyy) for the DateOfRelease property:

[
  {
    "Name": "John Wick",
    "DateOfRelease": "10-24-2014"
  }
]
Code language: JSON / JSON with Comments (json)

Unnamed column error

If you call a SQL function on a column, the resulting column will be unnamed, and you’ll get the following error:

Column expressions and data sources without names or aliases cannot be formatted as JSON text using FOR JSON clause. Add alias to the unnamed column or table.

To fix this, alias the result of the function call.

For example, the following query would result in the unnamed column error:

SELECT COUNT(*), MAX(Year)
FROM Movies
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
Code language: SQL (Structured Query Language) (sql)

To fix this, alias COUNT(*) and MAX(Year):

SELECT COUNT(*) as [Count], MAX(Year) as [MaxYear]
FROM Movies
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
Code language: SQL (Structured Query Language) (sql)

Now this is able to run and outputs:

{
  "Count": 3,
  "MaxYear": 2015
}
Code language: JSON / JSON with Comments (json)

Performance comparison: server-side vs client-side JSON serialization

I compared the performance of server-side and client-side JSON serialization by sending 1, 10, and 100 concurrent requests. I repeated the test with 1000 rows and 1 row of data.

In conclusion, the server-side approach is slightly faster when dealing with small amounts of data. However, the client-side approach is significantly faster and uses less memory when dealing with more data.

Code compared

Server-side JSON serialization code:

using (var con = new SqlConnection(connectionString))
{
	var jsonStringRows = con.Query<string>("SELECT Id, Name, DateOfRelease, RuntimeMinutes FROM Movies FOR JSON PATH, INCLUDE_NULL_VALUES");
	return string.Join("", jsonStringRows);
}
Code language: C# (cs)

Client-side JSON serialization code:

using (var connection = new SqlConnection(connectionString))
{
	var movies = connection.Query<Movie>("SELECT Id, Name, DateOfRelease, RuntimeMinutes FROM Movies");
	var moviesJson = JsonSerializer.Serialize(movies, options);
	return moviesJson;
}
Code language: C# (cs)

Performance results

Getting 1000 rows

Here are the performance results when getting 1000 rows for 1, 10, and 100 concurrent client requests:

|     Method | ConcurrentClients |       Mean |  Allocated |
|----------- |------------------ |-----------:|-----------:|
| ServerSide |                 1 |   3.799 ms |   1,247 KB |
| ClientSide |                 1 |   2.559 ms |     491 KB |

| ServerSide |                10 |  15.377 ms |  12,476 KB |
| ClientSide |                10 |   9.127 ms |   4,910 KB |

| ServerSide |               100 | 143.857 ms | 124,709 KB |
| ClientSide |               100 |  71.449 ms |  49,149 KB |Code language: plaintext (plaintext)

Note: Times shown in milliseconds.

The server-side approach is 2x slower and uses 3x more memory than the client-side approach when getting more rows.

Getting 1 row

Here are the performance results when getting 1 row for 1, 10, and 100 concurrent client requests:

|     Method | ConcurrentClients |       Mean | Allocated |
|----------- |------------------ |-----------:|----------:|
| ServerSide |                 1 |   258.0 us |      4 KB |
| ClientSide |                 1 |   254.0 us |      5 KB |

| ServerSide |                10 |   644.3 us |     38 KB |
| ClientSide |                10 |   920.2 us |     46 KB |

| ServerSide |               100 | 4,102.4 us||    371 KB |
| ClientSide |               100 | 4,813.6 us |    453 KB |Code language: plaintext (plaintext)

Note: Times shown in microseconds.

With 10 concurrent requests, the server-side approach is faster (35%). With 100 concurrent requests, it’s slightly faster (15%).

Leave a Comment