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 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.
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.
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];
= '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.
= '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.
= '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.
= '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.
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:
Nothing has been parsed yet. This is still just text — the next two sections cover pulling actual structure out of it.
An aside: what is a vertical tab?
Worth a short detour, since 0x0B looks arbitrary until you know where it comes from.
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.
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.
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:
| Situation | Result |
|---|---|
| Path exists, value is a string or number | The scalar value |
| Path doesn't exist in the document | NULL — no error |
| Path resolves to an object or array | NULL — JSON_VALUE never serializes nested structures |
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.
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.
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.
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:
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;
The type column tells you what kind of value it is, which matters when the shape is genuinely unknown ahead of time:
| Type code | Meaning |
|---|---|
| 0 | null |
| 1 | string |
| 2 | number |
| 3 | boolean (true / false) |
| 4 | array |
| 5 | object |
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.
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.
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.
CROSS APPLY OPENJSON(jsonDoc)
WITH (
payment_type SMALLINT,
aliases NVARCHAR(MAX) -- always NULL: this path is an array
) AS j;
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 JSON | WITH clause syntax |
|---|---|
| Plain scalar (string / number) | col_name TYPE '$.path' |
| Nested object or array | col_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.
JSON_VALUE vs. OPENJSON, at a glance
| JSON_VALUE | OPENJSON | |
|---|---|---|
| Input | One JSON document | One JSON document, often containing an array |
| Output | One scalar value | A full rowset — multiple rows and columns |
| Use case | Pluck a single field out | Explode an array into rows, or flatten several fields at once |
| Nested objects/arrays | Returns NULL | Retrievable 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.
Troubleshooting and practical notes
| Symptom | Cause | Fix |
|---|---|---|
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 |
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.
-- 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
0x0Band0x0Amake safe delimiters precisely because real JSON content never contains them. JSON_VALUEpulls one scalar out of a document —NULLfor a missing path,NULLfor a path that resolves to an object or array.OPENJSONexpands a document into a full rowset, and — likeOPENROWSET— always needs its own table alias.- A nested field needs the
AS JSONmodifier, or it comes backNULLwith no error to explain why. OPENJSONwithout aWITHclause still has a use: it returns every key, value, and type when the shape isn't known in advance.