Netune by DataAsh ← Netune

How to import JSON into SQL Server (and the parts OPENJSON leaves to you)

Getting a JSON file into SQL Server takes ten lines of T-SQL. Getting all of it in, into tables a report can join, is the part every tutorial stops just before.

The short answer

For a small, flat file you will load once: OPENJSON in T-SQL, SQL Server 2016 or later. Read the file with OPENROWSET(BULK ...), describe the columns in a WITH clause, insert the result. The code is below.

For anything nested, anything with records that do not all carry the same fields, or a file you will receive again next week, something has to decide the shape: objects become columns, arrays of objects become child tables carrying their parent’s key, and the types are read from every record rather than guessed from the first one.

The native way: OPENJSON

Two steps: read the file into one string, then shred the string into rows. Here is a file of orders, each with a customer object and an array of lines:

{ "orders": [
  { "id": 1001, "placed": "2026-03-14T09:30:00Z",
    "customer": { "number": "C-4471", "city": "Frankfurt" },
    "lines": [ { "sku": "A-7", "qty": 2, "price": 19.90 },
               { "sku": "B-2", "qty": 1, "price": 4.50 } ] }
] }

And the T-SQL that turns the orders into a table:

DECLARE @json NVARCHAR(MAX);

SELECT @json = BulkColumn
FROM OPENROWSET(BULK 'C:\data\orders.json', SINGLE_CLOB) AS f;

INSERT INTO dbo.Orders (OrderId, PlacedAt, CustomerNumber, City)
SELECT OrderId, PlacedAt, CustomerNumber, City
FROM OPENJSON(@json, '$.orders')
WITH (
    OrderId         INT            '$.id',
    PlacedAt        DATETIME2(0)   '$.placed',
    CustomerNumber  NVARCHAR(20)   '$.customer.number',
    City            NVARCHAR(100)  '$.customer.city'
);

The lines belong in a table of their own, each row carrying the order it came from. CROSS APPLY a second OPENJSON over the array, declared AS JSON so it arrives as a fragment rather than as NULL:

INSERT INTO dbo.OrderLines (OrderId, Sku, Qty, Price)
SELECT o.OrderId, l.Sku, l.Qty, l.Price
FROM OPENJSON(@json, '$.orders')
     WITH (OrderId INT '$.id', Lines NVARCHAR(MAX) '$.lines' AS JSON) AS o
CROSS APPLY OPENJSON(o.Lines)
     WITH (Sku NVARCHAR(20) '$.sku', Qty INT '$.qty', Price DECIMAL(10,2) '$.price') AS l;

Four things that catch everybody the first time:

Where OPENJSON stops being enough

Everything above works. What it does not do is decide anything: every path and every type in that WITH clause is a guess you typed. On a real file, five of those guesses go wrong quietly.

1. A field you did not list is not imported, and nothing says so

JSON records are allowed to differ. If record 551 is the first to carry a discount field and you wrote the WITH clause from the first record, the discount is simply not there — no error, no warning, no NULL column to notice. The column list has to come from every record, which means reading the whole file before deciding the shape.

2. Exploding an array duplicates its parent

Shredding orders and lines into one result with CROSS APPLY gives one row per line, with the order’s columns repeated on each. Sum an order-level amount over that and every order with three lines counts three times. An array of objects is a second table, not a wider first one — which is why the example above inserts lines separately, carrying only the key.

3. Dates are strings, and some carry an offset

JSON has no date type. "2026-03-14T09:30:00+01:00" is text until you say otherwise, and DATETIME2 has nowhere to keep the +01:00. Either convert to UTC on the way in and record that you did, or use DATETIMEOFFSET. What you must not do is drop the offset silently, which is what a plain DATETIME2 conversion of a mixed file does to an hour of every day.

4. One field, two types

"id": 7 in most records and "id": "A-7" in a few. Typed INT, the few fail the whole insert — or, with TRY_CAST, become NULL without comment. Count what each field actually holds before choosing its type: “number in 493, text in 7” is a decision, and it should be yours.

5. Arrays of plain values

"tags": ["b2b", "priority"] is not a set of rows worth a table and does not fit in one cell either. There are five honest things to do with it — keep it as JSON text, take the first value, join the values with a separator, count them, or skip it — and four of the five throw something away. Keeping the JSON text is the only default that loses nothing.

Flattening rules worth agreeing on before you start

However the import is done — by hand, in a script, or with a tool — it is making these decisions. Better to make them on purpose:

In the JSONIn SQL ServerWhy
A nested objectColumns named by path: customer_address_cityOne value per cell, and the name says where it came from
An array of objectsA child table, each row carrying the parent’s keyAvoids duplicating the parent; joins back cleanly
An array of valuesJSON text by default; first / joined / count if chosenOnly the JSON text keeps everything
A field some records lackA column, NULL where absentNever a shifted column; never a dropped field
The parent key in the childorder_id, not idForeign keys are often matched by name; id matches everything
Where the records are$.orders, $.data.items, or the rootPick the path holding the most records; offer the others

That fifth row is a small thing with a large effect. If the lines table calls its key order_id, anything that later infers relationships by column name — a modelling tool, a BI tool’s auto-detect, a colleague — links the two tables by itself. Call it id and it collides with the child’s own key and links to nothing.

Choosing SQL Server types for JSON values

JSON valueUsual SQL Server typeWatch for
true / falseBITStrings like "yes" are not booleans; leave them as text
Whole numbersINT or BIGINTIDs that are numbers today and text tomorrow
Decimals (money, prices)DECIMAL(p,s)Not FLOAT — 0.1 + 0.2 must equal 0.3
ISO dateDATE / DATETIME2Strings that look like dates but are not
ISO date with offsetDATETIMEOFFSET, or UTC in DATETIME2Silently dropped offsets
StringsNVARCHAR(n) sized from the dataNVARCHAR(MAX) everywhere is slow to index
Leftover nested JSONNVARCHAR(MAX) with CHECK (ISJSON(col) = 1)Queryable later with JSON_VALUE

SQL Server 2025 and Azure SQL add a native json type, which stores a document more compactly and validates it on the way in. It is a good home for the leftovers in the last row; it does not change the advice for everything above it, because a report still wants columns.

JSON Lines (.jsonl, .ndjson)

One JSON object per line, no surrounding array — the format most APIs and log exports use. OPENJSON expects one document, so split the file on line breaks first:

SELECT j.*
FROM STRING_SPLIT(@json, CHAR(10)) AS line
CROSS APPLY OPENJSON(line.value)
     WITH (Id INT '$.id', Event NVARCHAR(50) '$.event', At DATETIME2 '$.at') AS j
WHERE ISJSON(line.value) = 1;

The ISJSON filter skips the empty last line and any CHAR(13) leftovers from Windows line endings. For a file of millions of lines this is slow — it is one string in memory — and the honest answer is to load it in batches from outside SQL Server.

Doing it with Netune

Netune’s Add Data → JSON does the deciding for you and then shows its work. It reads .json, .jsonl and .ndjson, finds where the records are, flattens nested objects into columns named by path, and offers each repeating group as a table of its own carrying its parent’s key. The columns come from every record, not a sample; ISO date strings are read as dates and offsets converted to UTC with a note saying so; each column shows how many values of each kind it really holds. Every name, type and array choice is editable before anything is written. Then the parent and its child tables are created and filled in one transaction, so a failure leaves nothing half-imported. It runs on your machine and is part of the free beta.

Questions people ask

How do I import a JSON file into SQL Server?

Read it into a string with OPENROWSET(BULK 'path', SINGLE_CLOB), then shred it with OPENJSON and a WITH clause naming each column, its type and its JSON path, and insert the result. The path is on the SQL Server machine, and the database needs compatibility level 130 or higher.

Which versions of SQL Server support JSON?

SQL Server 2016 and later, with the database at compatibility level 130 or higher, have OPENJSON, JSON_VALUE, JSON_QUERY and ISJSON. SQL Server 2025 and Azure SQL add a native json data type.

How do I import nested JSON arrays into SQL Server?

Put each array of objects in its own table. Declare the array AS JSON in the parent's WITH clause, CROSS APPLY OPENJSON over it, and insert the child rows with the parent's key. Exploding the array into the parent's table instead repeats the parent on every child row, and sums over it double-count.

Why does OPENJSON return NULL for a field that is clearly there?

Almost always the path's case: JSON paths are case-sensitive, so $.Customer does not match customer. In the default lax mode a missing path returns NULL instead of an error; prefix the path with strict while developing and the mistake fails loudly.

Can SQL Server read JSON Lines files?

Not directly. Read the file into a string, split it on CHAR(10) with STRING_SPLIT, keep the lines where ISJSON is 1, and CROSS APPLY OPENJSON to each. For very large files, load them in batches from outside SQL Server instead.

Why are characters like ü and ß mangled after the import?

SINGLE_CLOB reads the file as VARCHAR in the database's code page, which is not UTF-8 unless the database uses a UTF-8 collation (SQL Server 2019 and later). Use a UTF-8 collation, or convert the file to UTF-16 and read it with SINGLE_NCLOB.

Import a JSON file properly, in a few clicks

Netune reads the whole file, flattens it into a parent table and child tables with their keys, shows what every column really holds, and writes it all to SQL Server in one transaction. Free, and it runs on your machine.

Download the free beta