Azure Data Engineering Notes · Part III — Synapse Serverless SQL Pools Chapter 8 / 9
Chapter Eight

Querying JSON Files with OPENJSON

Serverless SQL has no native JSON reader. Here's the trick that disguises a JSON file as delimited text, and the two functions — JSON_VALUE and OPENJSON — that take it from there.

The parser never knows it's reading JSON — it's just reading lines that happen never to contain their own delimiter.

8.1

Why there's no FORMAT='JSON'

OPENROWSET recognizes exactly three formats: CSV, PARQUET, and DELTA. JSON isn't one of them — there is no built-in JSON reader in serverless SQL.

The practical consequence is that reading JSON becomes a two-step problem instead of one. First, get the raw text of each JSON document into SQL as a plain string, untouched. Second, interpret that string as JSON using dedicated functions. OPENROWSET only ever does the first step here — it has no idea the text it's reading happens to be JSON.

Step one — text in

OPENROWSET reads each line as a single, unsplit column of raw text, using delimiters chosen specifically so nothing in the file can trigger them.

Step two — structure out

JSON_VALUE or OPENJSON parse that raw text afterward, pulling out fields or expanding it into rows.

Postgres bridge

Postgres has native json and jsonb column types with built-in operators — this two-step workaround doesn't exist there because a Postgres table can store and query JSON directly. Synapse serverless has no such native type in this context; it's reading raw files, not storing typed columns, so it has to work around the gap.

8.2

The disguise: reading JSON as untouched text

The trick is to set every CSV structural character — field delimiter, field quote, row terminator — to something that will never naturally appear inside a JSON document. Nothing then splits, and each line comes through exactly as written.

reading a JSON-lines file untouchedT-SQL
SELECT TOP 100 *
FROM OPENROWSET(
    BULK 'raw/payment_type.json',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '1.0',
    FIELDTERMINATOR = '0x0b',
    FIELDQUOTE = '0x0b',
    ROWTERMINATOR = '0x0a'
)
WITH (
    jsonDoc NVARCHAR(MAX)
) AS [pt];
FIELDTERMINATOR
= '0x0b'

The column separator, set to a hex control character. Since it never occurs in real JSON, no line ever splits into more than one field.

FIELDQUOTE
= '0x0b'

Set to the same obscure character, for the same reason — JSON is full of ordinary " characters, and the default field-quote character would otherwise try to treat every one of them as structural.

ROWTERMINATOR
= '0x0a'

The hex code for a line feed (\n). This says each line in the file is one row — the standard layout for JSON Lines, one JSON object per line.

PARSER_VERSION
= '1.0'

This pattern is written and commonly used against the older parser. It isn't verified to behave identically on 2.0, so stick with 1.0 for this specific technique unless tested otherwise.

jsonDoc
NVARCHAR(MAX)

One column, one JSON document per row. NVARCHAR(MAX) is needed both for unlimited length and full Unicode support — see Chapter 7 for the fuller VARCHAR vs. NVARCHAR picture.

The result is a single column of raw, intact JSON strings:

jsonDoc
--------------------------------------------------------
{"payment_type": 1, "description": "Credit card"}
{"payment_type": 2, "description": "Cash"}

Nothing has been parsed yet. This is still just text — the next two sections cover pulling actual structure out of it.

8.3

An aside: what is a vertical tab?

Worth a short detour, since 0x0B looks arbitrary until you know where it comes from.

0x09 → decimal 9 → Horizontal Tab — the familiar "\t"
0x0A → decimal 10 → Line Feed — the familiar "\n"
0x0B → decimal 11 → Vertical Tab — obscure, essentially unused today

Vertical Tab dates back to teletypes and line printers, where it told the machine to advance the paper down to the next preset row — the vertical counterpart to horizontal tab moving across to a column stop. Once printing moved away from that kind of hardware, the character lost its purpose. It's still valid in every text encoding, but almost nothing produces it intentionally anymore.

That's precisely why it works so well here. The goal was never to use vertical tab for its original meaning — it's simply obscure enough that no real JSON content will ever contain it by accident. Any similarly rare control character (0x1E, 0x1F) would serve the same purpose; 0x0B is just the conventional choice for this pattern.

8.4

JSON_VALUE: pulling out one scalar

Once a document is sitting in a column as text, JSON_VALUE extracts a single field from it by path.

flattening two fields out of jsonDocT-SQL
SELECT
    JSON_VALUE(jsonDoc, '$.payment_type')  AS payment_type,
    JSON_VALUE(jsonDoc, '$.description')   AS description
FROM OPENROWSET(
    BULK 'raw/payment_type.json',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '1.0',
    FIELDTERMINATOR = '0x0b',
    FIELDQUOTE = '0x0b',
    ROWTERMINATOR = '0x0a'
)
WITH (
    jsonDoc NVARCHAR(MAX)
) AS [pt];

$.payment_type means "the payment_type key at the document's root." The rules governing what comes back are simple and worth knowing exactly:

SituationResult
Path exists, value is a string or numberThe scalar value
Path doesn't exist in the documentNULL — no error
Path resolves to an object or arrayNULL — JSON_VALUE never serializes nested structures
Postgres bridge

Postgres's ->> operator is the direct equivalent: col->>'key' pulls out one scalar value, the same way JSON_VALUE(col, '$.key') does — an operator instead of a function, same underlying idea.

8.5

OPENJSON: unpacking into a rowset

JSON_VALUE only ever returns one value. When a document is really an array — or you want several fields extracted at once, shaped as real columns — OPENJSON is the tool that expands JSON into a proper table.

expanding jsonDoc into typed columnsT-SQL
SELECT *
FROM OPENROWSET(
    BULK 'raw/payment_type.json',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '1.0',
    FIELDTERMINATOR = '0x0b',
    FIELDQUOTE = '0x0b',
    ROWTERMINATOR = '0x0a'
)
WITH (
    jsonDoc NVARCHAR(MAX)
) AS pt
CROSS APPLY OPENJSON(jsonDoc)
WITH (
    payment_type      SMALLINT,
    payment_type_desc VARCHAR(50)
) AS j;

CROSS APPLY runs OPENJSON once per incoming row, feeding it that row's jsonDoc value and joining the expanded result back to it — the standard pattern for turning one JSON column into several real columns.

Common mistake

Just like OPENROWSET, the result of OPENJSON(...) WITH (...) is a derived table and needs its own alias — the AS j at the end here. Leaving it off produces a plain syntax error, since T-SQL requires every derived table to be named, exactly the rule covered for OPENROWSET in Chapter 7.

When the shape isn't known ahead of time

A WITH clause assumes a known, fixed set of fields. When the document's structure is unpredictable, dropping WITH entirely gives back every key/value pair as generic rows instead:

OPENJSON without WITH — generic key/value outputT-SQL
SELECT *
FROM OPENROWSET(
    BULK 'raw/payment_type.json',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '1.0',
    FIELDTERMINATOR = '0x0b',
    FIELDQUOTE = '0x0b',
    ROWTERMINATOR = '0x0a'
)
WITH (
    jsonDoc NVARCHAR(MAX)
) AS pt
CROSS APPLY OPENJSON(jsonDoc) AS kv;
key value type
-------------- -------------- ----
payment_type 1 2
description Credit card 1

The type column tells you what kind of value it is, which matters when the shape is genuinely unknown ahead of time:

Type codeMeaning
0null
1string
2number
3boolean (true / false)
4array
5object
Postgres bridge

jsonb_each / jsonb_each_text give the same generic key-value breakdown as bare OPENJSON. For expanding a JSON array into rows specifically, jsonb_array_elements or json_to_recordset are the closer match to OPENJSON used with a WITH clause.

8.6

Nested values: the AS JSON modifier

A WITH clause normally extracts a plain scalar per column. When a field is itself an object or array, that default extraction quietly fails.

Common mistake

Given a field like "aliases": ["Credit", "CC"], mapping it as a plain NVARCHAR(MAX) column doesn't error — it just returns NULL for every row, silently. OPENJSON won't serialize a nested structure into a scalar column unless it's told to.

without AS JSON — nested field comes back NULLT-SQL
CROSS APPLY OPENJSON(jsonDoc)
WITH (
    payment_type SMALLINT,
    aliases      NVARCHAR(MAX)   -- always NULL: this path is an array
) AS j;
with AS JSON — nested field returned as raw JSON textT-SQL
CROSS APPLY OPENJSON(jsonDoc)
WITH (
    payment_type SMALLINT,
    aliases      NVARCHAR(MAX) '$.aliases' AS JSON
) AS j;

AS JSON tells OPENJSON: don't try to extract this as a scalar, just hand back whatever's at this path as its own raw JSON text. That text can then be parsed further with a second, nested OPENJSON call if the individual array elements are needed as rows too.

Field in source JSONWITH clause syntax
Plain scalar (string / number)col_name TYPE '$.path'
Nested object or arraycol_name NVARCHAR(MAX) '$.path' AS JSON

One shortcut worth knowing: the '$.path' portion can be omitted whenever the JSON key name matches the column name exactly — OPENJSON assumes $.column_name by default. It's written out explicitly above only for clarity.

8.7

JSON_VALUE vs. OPENJSON, at a glance

 JSON_VALUEOPENJSON
InputOne JSON documentOne JSON document, often containing an array
OutputOne scalar valueA full rowset — multiple rows and columns
Use casePluck a single field outExplode an array into rows, or flatten several fields at once
Nested objects/arraysReturns NULLRetrievable via AS JSON, or expandable with a further OPENJSON call

As a rule of thumb: reach for JSON_VALUE when a document is already one flat record and only a couple of fields are needed inline. Reach for OPENJSON the moment more than a couple of fields are needed as real typed columns, or the document actually contains an array that should become multiple rows.

8.8

Troubleshooting and practical notes

SymptomCauseFix
Syntax error right after OPENJSON(...) WITH (...) Missing table alias on the derived table Add AS plus a name immediately after the closing parenthesis
A column always comes back NULL That path points to a nested object or array Add AS JSON to that column's definition
Rows are missing, merged, or misaligned The file's actual line endings don't match ROWTERMINATOR Try 0x0d0a (CRLF) instead of 0x0a (LF only)
A field's content looks corrupted Rare: the chosen delimiter character actually appears in the data Pick a different obscure control character, e.g. 0x1E or 0x1F
8.9

Putting it together

One pass, combining the disguise trick with a nested field, using the same external data source pointer set up in Chapter 7.

full read, including a nested array fieldT-SQL
-- Chapter 7's pointer, assumed already created:
-- CREATE EXTERNAL DATA SOURCE nyc_taxi_data WITH (LOCATION = '...')

SELECT *
FROM OPENROWSET(
    BULK 'raw/payment_type_array.json',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '1.0',
    FIELDTERMINATOR = '0x0b',
    FIELDQUOTE = '0x0b',
    ROWTERMINATOR = '0x0a'
)
WITH (
    jsonDoc NVARCHAR(MAX)
) AS pt
CROSS APPLY OPENJSON(jsonDoc)
WITH (
    payment_type      SMALLINT,
    payment_type_desc VARCHAR(50),
    aliases           NVARCHAR(MAX) '$.aliases' AS JSON
) AS j;

Three ideas from this chapter land in a single query here: the delimiter disguise gets the raw text in, the mandatory alias makes the expansion legal, and AS JSON keeps the nested aliases array from silently disappearing.

Every technique in this chapter exists to work around one gap: OPENROWSET has no idea what JSON is. It just reads text.

  • There's no FORMAT='JSON'. Reading JSON means disguising it as delimited text first, then parsing that text separately.
  • Obscure control characters like 0x0B and 0x0A make safe delimiters precisely because real JSON content never contains them.
  • JSON_VALUE pulls one scalar out of a document — NULL for a missing path, NULL for a path that resolves to an object or array.
  • OPENJSON expands a document into a full rowset, and — like OPENROWSET — always needs its own table alias.
  • A nested field needs the AS JSON modifier, or it comes back NULL with no error to explain why.
  • OPENJSON without a WITH clause still has a use: it returns every key, value, and type when the shape isn't known in advance.