View Categories

Turn a Folder of JSON Files Into SQL Tables

26 min read

JSONL FOLDER CONNECTOR

Turn a Folder of JSON Files Into SQL Tables

JSON is nested and SQL is flat, so the real question is not whether your logs can be read but what shape you get. Query Streams flattens nested objects into ordinary columns with a policy you can see and change — then you query the folder with SQL.

Nested objects become columns Depth 2 by default, up to 8 Arrays kept verbatim Nothing uploaded
/var/log/events
{“order”: {
  “id”: 90124,
  “site”: { “region”: “eu-west” }
}}
order_id order_site_region + every other leaf

Query Streams is a secure, real-time database integration platform that turns a folder of newline-delimited JSON files into live SQL tables you can query from Microsoft Excel, Google Sheets, Airtable and your AI assistant. Learn more at QueryStreams.com and sign up for free to query your first folder of JSON log files in minutes.

Reading JSON is not the hard part. Every tool can read JSON. The hard part is that JSON is a tree and a SQL table is a rectangle, so the moment you want to query JSON files with SQL — or get them into a spreadsheet — somebody has to decide how the tree becomes columns. Most tools make that decision silently, and you discover what they chose when a number is missing.

The JSONL Folder connector makes that decision explicit. It flattens nested objects into ordinary columns under a policy that is written into the connector, visible in the table definition, and adjustable with one setting. The folders this suits are the ones that fill up on their own: webhook captures, API dumps, application logs, and anything a queue or an event bus writes one record at a time.

What one JSON record actually becomes

This is the single most useful thing to look at, so here it is before anything else. On the left is one line from a JSONL file, pretty-printed for legibility. On the right are the columns you get from it at the default flatten depth.

One line of the file
{
  "event_id": "evt_8812",
  "ts": 1787903642,
  "type": "order.paid",
  "actor": { "id": 4417, "role": "customer" },
  "order": {
    "id": 90124,
    "total": 149.99,
    "site": {
      "region": "eu-west",
      "bay": { "aisle": "A7", "slot": 12 }
    }
  },
  "tags": [ "priority", "gift" ]
}
Columns at depth 2
event_id VARCHAR ts BIGINT type VARCHAR actor_id BIGINT actor_role VARCHAR order_id BIGINT order_total DOUBLE order_site_region VARCHAR order_site_bay JSON tags JSON

Three things are worth noticing. Column names are the path joined with underscores, so order.site.region becomes order_site_region — a name you can type in a spreadsheet without quoting it. The bay object sits one level deeper than the policy allows, so it is kept whole as a JSON column rather than being dropped. And tags is an array, which is never flattened at any depth.

Each column also keeps its original dot path alongside the flattened name. The name is what you write SQL against; the dot path is what you address when you want to rename a column, retype it or exclude it. Nothing about the JSON is guessed at query time — the whole mapping is fixed when the folder is first connected.

Depth counts objects opened, not keys in the path. At depth 2 the connector opens an object, and an object inside that object. That is why order.site.region is a column — two objects were opened to reach a plain value — while order.site.bay would need a third and is pinned as JSON instead.

Choosing a flatten depth

Flatten depth is the setting worth thinking about for a minute before you save, because it decides the shape of the table rather than the way it is displayed. The default is 2, the floor is 1 and the ceiling is 8. Flattening cannot be switched off: 0 is refused when you save, so it is a mistake rather than a shortcut.

1
The floor

One level of object opened, and the lowest the setting goes. Useful when the payload is wide and you only read the top of it, leaving the rest as one JSON column to dip into.

actor_id order_id order_site JSON
2
The default

Two levels of object opened. Enough for the envelope-plus-payload shape almost every event log uses, without exploding into hundreds of columns.

order_id order_site_region order_site_bay JSON
8
The ceiling

Eight levels, and no setting goes higher. Beyond that a record has stopped being tabular data, and anything still nested is kept as JSON rather than refused.

order_site_bay_aisle order_site_bay_slot deeper still JSON

Raise the depth when the values you actually want to filter and total live below the cut — a warehouse identifier three levels down that you need in a GROUP BY is the usual reason. Leave it alone, or lower it, when the payload is deep but you only ever read the top of it: every extra level is more columns to look at, and a wide flattened table is harder to work with in a spreadsheet than a narrow one with a JSON column you dip into occasionally.

Changing the depth later is allowed and safe, but it is a change of definition rather than a display preference: the folder is re-sampled and the table is rebuilt against the new policy. That is deliberate. The alternative — quietly reinterpreting data that is already in the table — would mean the same query returned different columns depending on when it ran.

Depth is the only setting that decides how many columns you get, which is why it is pinned along with the shape. The rest of the card — the table name, the table layout, which files are in scope and the manifest overrides — is set out in the settings reference further down. An option the connector does not recognise is rejected by name rather than ignored, on the grounds that a setting silently doing nothing is indistinguishable from one that is working.

What is never flattened, and why that is the right answer

Some values have no honest column representation, and the policy keeps them as JSON regardless of the depth you set. Nothing is discarded — the value is preserved verbatim and you read into it with SQL when you need to.

Value in the recordWhat you get
An array, at any depthOne JSON column holding the array exactly as written. Arrays have no fixed length, so there is no honest number of columns to spread them across.
An object deeper than the flatten depth you setOne JSON column holding that whole sub-object.
A wide, open-ended object read as a mapOne JSON column. Objects used as dictionaries have keys that differ per record, so they are data rather than structure.
A value with no single type across the fileOne JSON column, rather than a cast that would have to choose a winner.

A JSON column is a normal column: it appears in the table definition, it lands in your spreadsheet, and the SQL engine’s JSON functions work on it. Reaching into one costs a line of SQL, not a re-configuration.

Reading inside the JSON columns
SELECT event_id,
       order_site_region,
       json_extract_string(order_site_bay, '$.aisle') AS aisle,
       json_array_length(tags)                        AS tag_count
FROM   events
WHERE  type = 'order.paid'
  AND  json_extract_string(order_site_bay, '$.aisle') = 'A7';

If you find yourself writing that extraction in every query, that is the signal to raise Flatten depth by one and let order_site_bay_aisle be a real column instead.

The two ways flattening can fail

Flattening a tree into names is not always possible, and when it is not, the connector says so instead of producing a table that is subtly wrong. There are two failure modes worth recognising on sight, because both have a one-line fix.

A flattened path collides with a real column

Underscores are how the path becomes a name, which means two different paths can arrive at the same name. A record carrying both a top-level user_id and a nested user object with an id inside it produces user_id twice: once as itself, once from user.id.

There is no safe way to pin that. Picking one would mean losing the other, and worse, you would not be told which. So the connector refuses and names both paths: paths user_id and user.id both flatten to column user_id. Lower the depth so the nested one stays inside a JSON column, or rename one of them in the connector’s overrides — the dot path is exactly what an override addresses.

The same key is a value in some records and an object in others

This one is endemic to logs that have been through a schema change. Yesterday’s files carry "actor": "system". Today’s carry "actor": {"id": 4417, "role": "customer"}. One folder, one field, two irreconcilable shapes: actor cannot be both a column of its own and the parent of actor_id.

The connector detects that one path is a prefix of another and refuses, naming the field and an example of the conflict. The fix depends on what you want: narrow the file pattern so one connector covers the old shape and another covers the new, or switch the connector to one table per layout so the two eras get a table each.

A dot inside a field name is a refusal too, and it is a common one. Telemetry writers love keys like http.status and db.system. Because the connector addresses columns by dot path, a field literally named http.status is indistinguishable from a status field inside an http object — and the read would return empty values rather than an error. That is exactly the kind of silently-wrong result the connector exists to prevent, so it refuses and names the field. Rename it in the producer, or exclude those files.

Two more disagreements are refused on the same principle. A field that is a number in one file and text in another is named rather than cast, because casting would have to pick a winner. And a file that cannot be read as newline-delimited JSON at all is recorded individually and skipped, so one corrupt log does not stop the other nine hundred from pinning.

SituationWhen it is caughtWhat happens
Two paths flatten to one column nameFirst connectionRefuses and names both paths.
A key is a value in one file, an object in anotherFirst connectionRefuses and names the field.
A field name contains a literal .First connection, and again per file laterRefuses at first connection; a file that grows one later is parked.
A field’s type differs between filesFirst connectionRefuses, naming both types and both files.
A new field appears in one fileEvery syncThat file is parked and the new path is named.
A file drops a field it used to haveEvery syncThat file is parked, rather than reading nulls into your totals.
One malformed line in a fileEvery syncThe whole file is parked. A file is all-in or all-out, never half.

Refusals concern the whole folder and happen when you connect it; parking concerns one file and happens on any sync. Both are recorded, and the files_events table names the file and the exact disagreement — the general mechanics of pinning and parking are covered in how File Set connectors work.

JSONL, not JSON: what the folder must contain

The name matters here. This connector reads newline-delimited JSON — one complete JSON object per line, no commas between them and no wrapping brackets. It is the format almost every logger, queue consumer and streaming export already writes, whatever the file extension says.

Newline-delimited: one object per line
{"event_id":"evt_8810","ts":1787903601,"type":"order.created"}
{"event_id":"evt_8811","ts":1787903622,"type":"payment.authorised"}
{"event_id":"evt_8812","ts":1787903642,"type":"order.paid"}

A single file containing one large JSON array — the pretty-printed [ { ... }, { ... } ] shape an API returns when you save its response — is not newline-delimited JSON, and this connector will not read it. It is recorded as unreadable when you connect the folder and parked on later syncs, with the reason given rather than left as an empty table. If that is what your folder holds, the practical fix is to have whatever writes the files emit one object per line, which is a one-line change in most exporters.

Encoding is not a question here, unlike a folder of CSV exports. JSON is UTF-8 by definition, so there is no encoding to detect and no setting to get wrong. Bytes that are not valid UTF-8 park the file before the parser is allowed near them. If your folder is delimited text rather than JSON, the sibling guide on combining multiple CSV files into one table covers the encoding detection that job needs.

Why every line is read, not just the first few

When the folder is first connected, the connector samples up to 64 files — and reads each of those files completely rather than peeking at the first few hundred lines. That is a deliberate cost, and the reason is specific to JSON.

A JSONL file has no header. Its shape is whatever its records happen to contain, so a bounded sample would make the column list depend on where in the file an unusual record happens to sit. Connect on Monday and order.discount_code appears at line 40; connect on Tuesday when it first appears at line 4,000 and the column is not there at all. Reading the whole file removes that coin-toss entirely.

The same full read happens on each changed file at sync time, for a sharper reason: a field that appears late in a file would otherwise vanish without trace. The strict typed read the connector uses ignores fields it was not told about, so an extra field past a sample boundary would not fail loudly — it would simply not be there. Reading the whole file means a new field parks the file and gets named. One extra parse per changed file buys that, and only changed files are ever re-read.

When the folder holds several event shapes

Event folders are frequently not one shape but several, because one consumer writes everything it sees to one directory. Rather than forcing agreement, switch the connector from one pinned table to one table per layout. Files are grouped by their exact flattened shape and each group gets its own table, up to 25 distinct layouts.

Two details are particular to JSON here. The flatten depth stays a single policy for the whole connector — one depth, applied to every layout — because the depth is what defines a shape in the first place; grouping by shape would be meaningless if each group could be flattened differently. And the cross-file conflicts described above stop being conflicts: a key that is a value in some files and an object in others simply produces two layouts, which is precisely what you wanted. Files that cannot be pinned on their own, such as one with a dotted field name, are skipped individually instead of stopping the rest.

Table names come from the filenames, with trailing dates and serial numbers trimmed, so a group of orders_2026_08_*.jsonl files lands in a table called orders. Names are fixed the first time they are assigned and are kept across later re-samples, so a table your saved queries already reference will not be renamed underneath them. A file whose shape matches none of the pinned layouts is parked until you touch the connector’s settings, which re-samples the folder and admits the new layout as an additional table.

Getting JSON into Microsoft Excel and Google Sheets

Once the folder is flattened it is an ordinary SQL table, which is what makes JSON to Excel a non-event: you run a saved query from the add-in and the rows land in the sheet. There is no import wizard, no expanding of record columns, and nothing to repeat next month — a refresh picks up every log file written since.

A month of event logs, aggregated
SELECT order_site_region,
       COUNT(*)         AS paid_orders,
       SUM(order_total) AS gross
FROM   events
WHERE  type = 'order.paid'
  AND  ts >= 1785542400
GROUP  BY order_site_region
ORDER  BY gross DESC;

Every row also carries _source_file and a unique _row_id, so a figure that looks wrong can be traced to the exact log file that produced it — grouping by _source_file is usually the quickest way to find the day something changed. And because the folder is a connector like any other, the flattened event table joins to your PostgreSQL orders table or your Stripe payments in a single read-only statement, with nothing loaded into a warehouse first.

  • Microsoft Excel and Google Sheets — run the saved query from the add-in and the flattened rows land in the sheet, refreshed on demand.
  • Airtable, Smartsheet, Baserow, SeaTable and Anvil — scheduled syncs push log data into the tool your team already uses.
  • Nova AI — ask a question about the logs in plain English and get SQL plus a chart.
  • Claude, Cursor, ChatGPT and Grok — through the Query Streams MCP server, an AI assistant can query the folder directly.
  • REST API — turn a saved query over your event logs into an endpoint for an internal application.
If a flattened leaf holds money, retype it. A JSON number arrives as a floating point value by default, and floating point cannot hold most decimal amounts exactly — sum enough of them and the total drifts by a fraction of a cent. Retype the pinned leaf to an exact decimal and the value is parsed straight from the JSON text at that type, with no floating point step in between. It is one override on order.total, and totals then add up to the cent.

Every JSONL setting, and when to change it

These are the settings that belong to the JSONL Folder card itself. Everything a folder connector shares with the other formats — root folders, recursion and max depth, exclude patterns, symlinks, the filename parse pattern, folder tokens, the scan interval, hashing, the delete policy, event retention, the scan limits and the four ingest caps — is documented once in the File Set connector guide rather than repeated here.

Several settings are hidden until you tick “Show advanced options”. Hashing, the delete policy, event retention, the scan limits, the four ingest caps and manifest overrides all live behind that toggle. If a setting named below is not on screen, that is why.

Table name

Required on this card. A JSONL folder is a single-table connector — every record in every matching file unions into one table — so this is the name you will type in SQL: events, webhooks, whatever the folder actually holds. Letters, digits and underscores, and it may not start with a digit.

Choose it deliberately, because it is the name every saved query, every Nova question and every spreadsheet refresh will use from then on. The one case where it stops being the whole story is one table per layout, described below, where each group of files gets a table named after the files themselves.

Flatten depth

Default: 2. Valid range: 1 to 8. Leaving the box empty rides the agent’s own default rather than pinning a number of your own. What each depth does to your columns is set out earlier in this guide; what follows is the practical side of choosing one.

Zero is refused by name. Flattening cannot be turned off, so if what you want is the agent default, clear the box — do not type 0. Nine or higher is refused too, and refused when you save rather than later at sync time, so you find out while you are still looking at the form instead of in a sync report an hour afterwards. Anything nested deeper than the depth you choose is not lost: it lands as JSON text in a single column.

When to raise it. When the records have genuinely nested structure you need to filter or group on. If the value you want is user.address.city, that is three objects deep, and at depth 2 you will be extracting it out of a JSON column in every single query — set 3 and user_address_city becomes a real column instead.

When to lower it to 1. Wide event payloads where you only care about the top-level fields. Depth 2 against a payload with four hundred keys gives you four hundred columns to scroll past in a spreadsheet; depth 1 gives you the handful you actually read plus one JSON column holding the rest, which is very much easier to work with.

The trade-off, plainly: depth is pinned. Changing it later re-samples the folder and rebuilds the table — nothing is lost and nothing breaks, but it is not a display preference you flick back and forth while you browse. A minute spent deciding which values you will genuinely filter on is worth more than any adjustment afterwards.

Table layout

Default: one pinned layout, which expects every file in scope to flatten to the same shape. The alternative is one table per layout, which groups the files by their flattened shape and gives each group its own table, up to 25 distinct layouts.

The case for switching is the one described under When the folder holds several event shapes above: a single directory that one consumer writes several kinds of event into. In that mode the cross-file conflicts covered earlier stop being refusals and become separate tables instead, which is usually what you wanted in the first place.

Changing this setting re-samples the folder and rebuilds, by design. Table names already in use are carried across the rebuild, so existing saved queries keep working.

Include patterns

Seeded to **/*.jsonl on this card, and this is the single most likely reason for staring at a connector that found nothing. Newline-delimited JSON has no agreed file extension. Depending on what wrote the files you will find .jsonl, .ndjson, .log or plain .json — and the seeded pattern catches only the first of those. If your files end in anything else, add a line for it before you save. One glob per line, and the lines are additive.

Include patterns — one glob per line
**/*.jsonl
**/*.ndjson
**/*.json
logs/**/*.log

The extension is not what makes a file valid, though, so widen the pattern to match how your tool names things rather than in the hope that a name will change what is inside. The requirement is the format described above — one complete JSON object per line, no commas between records and no wrapping brackets. A .jsonl file holding a pretty-printed array is still refused; a .log file holding one object per line reads perfectly well.

Manifest overrides (JSON)

Behind Show advanced options. This is raw JSON applied at the moment the shape is pinned, so an override becomes part of the table definition rather than something re-applied on every query. Three keys are accepted, all optional, and columns are addressed by their original dot path rather than by the flattened column name.

Manifest overrides
{
  "renames": { "user.id": "nested_user_id" },
  "retypes": { "order.total": "DECIMAL(18,4)" },
  "exclude": [ "order.site.bay" ]
}

For a JSONL folder this is the practical fix for two situations described earlier in this guide. renames resolves a flattened dot path colliding with a real column: rename one of the two paths and the refusal goes away without having to lower the depth for the whole table. exclude drops a noisy nested branch you are never going to query, which is often a better answer than reducing the depth and losing the fields you do want alongside it. retypes is what the note on money above refers to — force a flattened leaf to an exact decimal and the value is parsed straight from the JSON text at that type.

Emptying the box clears your overrides and returns the table to the shape that was sampled.

Setting it up

You need a Query Streams account and the Network Agent installed on a machine that can see the folder — a log server, a workstation with the share mapped, or your own laptop. JSONL Folder connectors require Network Agent 2.6 or newer.

  1. Install the agent from the download page if it is not already running.
  2. In the portal, add a data connector and choose JSONL Folder.
  3. Enter the folder path as the agent’s machine sees it, with a file pattern such as events_*.jsonl if the folder holds more than you want in one table.
  4. Leave Flatten depth at 2 for a first look, and check the include pattern matches your file extension. You will know within one query whether you want the depth raised.
  5. Save. The agent samples the folder, pins the flattening policy and reports the columns it produced — read that list before you write any SQL, because it tells you exactly what your JSON became.
  6. Open the query builder, pick the connector and query the table, or ask Nova AI to write the SQL for you.

Read-only is enforced rather than promised: a query must be a single statement, and only SELECT, WITH, PRAGMA, DESCRIBE and EXPLAIN are accepted. Your log files are never written, moved or renamed, and the parsed data is cached encrypted on the agent’s own machine.

Frequently Asked Questions

How do I get JSON data into Microsoft Excel? +
Connect the folder as a JSONL Folder connector, which turns the files into a SQL table with nested objects flattened into columns, then run a saved query from the Query Streams add-in for Microsoft Excel or Google Sheets. The rows arrive as ordinary cells with ordinary column headings — no import wizard, no record columns to expand — and a refresh picks up every file written since. The same table is available in Excel Online, Google Sheets, Airtable and your AI assistant at the same time.
What does flattening actually do to a nested object? +
It turns each nested path into one column whose name is the path joined with underscores. {"order":{"id":1}} becomes a column called order_id; {"order":{"site":{"region":"eu-west"}}} becomes order_site_region. The original dot path is kept alongside the name, and that path is what you address when renaming, retyping or excluding a column.
What is Flatten depth and what should I set it to? +
It is how many levels of nested object the connector will open. The default is 2, which suits the envelope-plus-payload shape most event logs use; the range is 1 to 8. Flattening cannot be turned off — 0 is refused when you save, and so is 9 or anything above it, so you find out on the form rather than in a sync report. Start at 2, look at the columns you got, and raise it only if the values you want to filter or total are still inside a JSON column. Changing it re-samples the folder and rebuilds the table rather than reinterpreting data already there.
Can it read a .json file containing one big array? +
No. This connector reads newline-delimited JSON — one complete object per line, no commas between records and no wrapping brackets. A single pretty-printed array is a different format; it is reported as unreadable when you connect the folder and parked on later syncs, with the reason stated rather than left as a silently empty table. The usual fix is to have the exporter write one object per line, which most can do with a single setting.
How are JSON arrays handled? +
Arrays are never flattened, at any depth. Each one becomes a single JSON column holding the array exactly as written, which is the only honest answer — an array has no fixed length, so there is no correct number of columns to spread it across. Nothing is lost: you read into it with the engine’s JSON functions, for example json_array_length(tags) for a count or json_extract_string(tags, '$[0]') for the first element.
Why was my folder refused for “colliding column names”? +
Because two different JSON paths flatten to the same column name — classically a top-level user_id alongside a nested user.id, both of which want to be called user_id. Pinning one would silently lose the other, so the connector refuses and names both paths. Lower Flatten depth so the nested one stays inside a JSON column, or rename one path in the connector’s manifest overrides.
What if a field is a plain value in some records and an object in others? +
That is a named refusal, because actor cannot be both a column and the parent of actor_id. It usually means the folder spans a schema change. Either narrow the file pattern so one connector covers each era, or switch to one table per layout and let the two shapes have a table each — in that mode the conflict dissolves, because different shapes are simply different tables.
My log fields have dots in their names, like http.status. Will that work? +
Not as they stand. Columns are addressed by dot path, so a field literally named http.status cannot be distinguished from a status field inside an http object, and reading it would return empty values rather than an error. Rather than risk that, the connector refuses and names the field. Either flatten the naming in whatever writes the logs, or exclude those files with the file pattern.
A new field appeared in today’s log file. What happens? +
That file is parked and the new path is named, while every other file keeps syncing. The alternative would be worse: the strict read ignores fields it was not told about, so an unnoticed new field would simply be absent from your table. Once you decide what the field should be, changing the connector’s settings re-samples the folder and pins it as a column. Files that are missing a field they never had read as empty instead — only a file that drops a field it previously carried is parked.
Do amounts in JSON stay exact? +
A JSON number is read as a floating point value by default, which cannot represent most decimal amounts exactly. Where a flattened leaf holds money, retype it to an exact decimal in the connector’s overrides: the value is then parsed directly from the JSON text at that type, with no floating point step in between, so large sums do not drift by fractions of a cent.
Are my JSON files uploaded or modified? +
Neither. The files stay where they are and are never written, moved or renamed. Parsed data is cached encrypted on the same machine as the Network Agent, and only the rows a query returns leave your network, over an outbound connection the agent opens itself. Queries must be a single SELECT, WITH, PRAGMA, DESCRIBE or EXPLAIN statement — anything that could write is rejected before it runs.

Get Started

Query your JSON log folder with SQL

Point the agent at a folder of newline-delimited JSON and get a flat SQL table with nested objects opened into columns — queryable from Microsoft Excel, Google Sheets, Airtable or your AI assistant. Your files never leave your network.

Related guides: How to query a folder of files with SQL | Combine multiple CSV files into one table | Download the Query Streams Agent | All File Set Connector guides

Category: File Set Connectors

Tags: json to excel, query json files with sql, jsonl, newline delimited json, json log files, flatten json, jsonl folder connector, nested json to columns

Meta Description: Turn a folder of JSONL files into SQL tables. Nested objects flatten into columns, no uploads.

Updated on August 27, 2026

Powered by BetterDocs