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.
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:
- The path is on the SQL Server machine, not on yours.
C:\data\orders.jsonmeans the server’s C: drive, and the service account needs read access to it — plus your login needsADMINISTER BULK OPERATIONS. On Azure SQL Database there is no server drive at all; the file has to come from blob storage. - The database must be at compatibility level 130 or higher, or
OPENJSONis not recognised — even on a 2019 or 2022 server, if the database was restored from an older one. - JSON paths are case-sensitive.
'$.Customer'does not find"customer", and in the default lax mode it returns NULL rather than an error. Write'strict $.customer'while developing and a wrong path fails loudly instead. - Encoding.
SINGLE_CLOBreads the file asVARCHARin the database’s code page, so a UTF-8 file containing ü or ß can arrive mangled;SINGLE_NCLOBexpects UTF-16. Check one row with an umlaut in it before trusting the rest.
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 JSON | In SQL Server | Why |
|---|---|---|
| A nested object | Columns named by path: customer_address_city | One value per cell, and the name says where it came from |
| An array of objects | A child table, each row carrying the parent’s key | Avoids duplicating the parent; joins back cleanly |
| An array of values | JSON text by default; first / joined / count if chosen | Only the JSON text keeps everything |
| A field some records lack | A column, NULL where absent | Never a shifted column; never a dropped field |
| The parent key in the child | order_id, not id | Foreign keys are often matched by name; id matches everything |
| Where the records are | $.orders, $.data.items, or the root | Pick 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 value | Usual SQL Server type | Watch for |
|---|---|---|
| true / false | BIT | Strings like "yes" are not booleans; leave them as text |
| Whole numbers | INT or BIGINT | IDs that are numbers today and text tomorrow |
| Decimals (money, prices) | DECIMAL(p,s) | Not FLOAT — 0.1 + 0.2 must equal 0.3 |
| ISO date | DATE / DATETIME2 | Strings that look like dates but are not |
| ISO date with offset | DATETIMEOFFSET, or UTC in DATETIME2 | Silently dropped offsets |
| Strings | NVARCHAR(n) sized from the data | NVARCHAR(MAX) everywhere is slow to index |
| Leftover nested JSON | NVARCHAR(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