Convert TSV to NDJSON

Converting TSV to NDJSON writes each row of a tab-separated export as one complete JSON document on its own line, which is the input format Elasticsearch, OpenSearch, MongoDB and BigQuery all name. Line breaks inside a value are escaped rather than breaking the record, and it runs in your browser.

  • Where it runs In your browser. The file is never uploaded.
  • Lossless Nothing is discarded. The NDJSON holds exactly what the TSV held.
  • File size limit Up to 100 MB per file, free, without an account.

Up to 100 files at once. Mixed formats are fine.

The input format a document store actually names

Search indexes and document stores do not take delimited files. Elasticsearch and OpenSearch ingest through a bulk endpoint that reads newline-delimited JSON. MongoDB’s import tool reads one JSON document per line by default. BigQuery has a load format called NEWLINE_DELIMITED_JSON and will not accept anything shaped like an array. None of them will look at a tab-separated file.

That is not arbitrary. A document store holds documents, and a document has named fields with types, which a delimited row does not. The conversion is therefore a change of model as much as a change of syntax: each row stops being a position in a table and becomes a self-describing object that can be indexed, queried by field, and stored alongside documents that do not have the same fields at all.

One line, one document, with no array around it

The output is one complete JSON object per line and nothing else — no opening bracket, no commas between records, no closing bracket. That absence is the format. It lets a loader read a line, index it, discard it and move on, without ever holding the file.

It also means the file is not valid JSON as a whole, and a parser expecting an array will fail on the second line. That is not a defect; it is the property that lets a hundred-million-line file be loaded by a process with a small memory limit. If something downstream rejects it, the thing to check is whether that consumer wanted an array, in which case the source table should be converted to JSON instead.

Header names become field names, and some will be refused

The header row supplies the field names exactly as written. `Product title` stays `Product title`, spaces included, and `price (USD)` keeps its brackets. Nothing is renamed or normalised, because a converter that quietly rewrote your field names would make the loaded documents impossible to reconcile with the source.

Several destinations are fussier than JSON is. A dot in a field name is treated as a path separator by Elasticsearch and by MongoDB’s dot notation, so a column called `price.usd` produces a nested field rather than a flat one and a mapping conflict shortly after. BigQuery requires field names to start with a letter or underscore and to contain only letters, digits and underscores, and will reject the load outright otherwise. Fixing the header row in the source before converting is one edit; fixing a million documents after loading is not.

The types the mapping will see, and where they come from

JSON has types and a tab-separated file does not, so the parser infers. Values that read as numbers become JSON numbers, `true` and `false` become booleans, an empty field becomes null, and everything else stays a string. For a catalogue that means prices and stock counts arrive numeric and are aggregatable without a mapping override, which is usually what you want.

The inference is wrong in the same place it is always wrong: identifiers. A SKU of `00123` becomes the number 123, an ISBN without hyphens becomes a large integer, and a code written like `1e5` becomes 100000. In a search index that is worse than usual, because a numeric field is not analysed and will not match a term query for the original string. Prefix or quote those columns in the export, or fix them in a `jq` pass over the converted file before loading.

The action lines the bulk endpoint wants and this file lacks

The output is documents only. Elasticsearch and OpenSearch `_bulk` expect a metadata line before each document — a small object naming the operation and usually the index — so posting this file directly produces a parse error on the second line and a stack of confusing messages after it.

It is a single pass to add: `jq -c '{index:{}}, .'` over the converted file interleaves an action line before every document, and the endpoint takes it from there. Everything else on this page’s list needs no such step. `mongoimport --type json` reads the file as it is, a BigQuery load job with NEWLINE_DELIMITED_JSON reads it as it is, and a Logstash or ingest pipeline reading lines reads it as it is.

Free text is the payload, and it survives intact

The columns being indexed are usually the awkward ones: product descriptions, article bodies, review text, annotation fields. In a tab-separated file those are precisely the values that break things — a pasted tab adds a column, a line break ends the row early, and the file’s row count stops agreeing with itself between tools.

In JSON they are ordinary string content. A tab is escaped as \t, a newline as \n, a quotation mark is escaped, and none of them can affect the structure of the document, because the structure lives in the punctuation of the object rather than in the characters of the data. For a search load that is the whole reason the conversion is safe: the text you index is the text that was in the field, not the text up to the first character that happened to be significant.

Nulls, absent fields and the mapping conflict they produce

An empty field is written as `null` rather than as an empty string, and the keys come from the header row rather than from the rows below it: a line carrying more fields than the header does not invent names for the surplus, it puts them into one `__parsed_extra` array on that document alone.

Both of those interact with a mapping. Elasticsearch ignores a null for the purposes of inferring a field type, so a column that is null for the first several thousand documents and numeric afterwards can end up mapped from the first non-null value it happens to see — and if that value is a footnote rather than a number, every later document in that field fails to index. Defining the mapping explicitly before the load is the fix, and reading a sample of the converted file with `head -n 100` is how you find out which fields need it.

How much the NDJSON grows, and why it matters less here

Repeating every field name on every line makes the file bigger, often substantially so on a wide table of short values. On a text-heavy catalogue the effect is muted, because the descriptions dominate the bytes and the field names are noise beside them.

It also matters less than it would elsewhere, because this file is a transport artefact rather than a store. It exists to be read once by a loader and then deleted; the durable copy is the index or the table it produced. Compression removes most of the difference in transit in any case, and every destination named here accepts a gzipped file.

The export is converted on this page and nowhere else

Both the tab-separated reader and the JSON writer are plain JavaScript loaded by this page, so no request carries the file. A catalogue before launch, an unpublished dataset or an extract containing customer text is not copied to a third party in order to change its shape.

The ceiling is 100 MB a file, and a hundred files to a drop. The whole table is built in memory before anything is written, which puts tens of megabytes comfortably in range and a file near the cap at the point where a browser tab strains. For an export past 100 MB, a streaming reader in a script is the right instrument — and since the destination is a streaming load, that script is frequently the thing you were going to end up writing anyway.

When a delimited file or Parquet is the better load

BigQuery, Redshift and Snowflake all read delimited text directly and will load a tab-separated file faster than JSON documents, because they do not have to parse a field name on every row. If the destination is a warehouse table with a schema you already defined, the conversion is buying you nothing.

Parquet is the better answer again when the same data will be queried repeatedly rather than ingested once, since it stores the field names once in a footer and the values in typed columns. NDJSON earns its place where the destination is genuinely document-shaped — a search index, a document store, an ingest pipeline reading lines — which is exactly the case this page is for.

How to turn a TSV export into NDJSON documents

  1. Drop the TSV export onto this page, or click to choose one.
  2. Each row is written as one JSON document per line, in your browser.
  3. Check a sample with head, then load it with mongoimport, a BigQuery job or jq into _bulk.

TSV and NDJSON: a wide table rewritten as documents

TSV compared with NDJSON
TSVNDJSON
Full nameTab-Separated ValuesNewline-Delimited JSON
File extension.tsv, .tab.ndjson, .jsonl
Media typetext/tab-separated-valuesapplication/x-ndjson
First published19932013
SpecificationIANA text/tab-separated-values
LicensingOpen standardOpen standard
Standing todayCurrentCurrent
Opens in a browserNo browserNo browser
Considered insteadCSV, JSONJSON, CSV

What survives

Nothing is discarded. TSV and NDJSON both store their content losslessly, so the conversion is a change of packaging rather than a change of quality, and it can be repeated without accumulating damage.

Opening the result

pandas reads both TSV and NDJSON, so there is a way to check the result against the original without a second tool.

What each format is for

TSV was published in 1993. The specification is IANA text/tab-separated-values, and it is worth reading if the file has to outlive the tool that wrote it.

NDJSON dates from 2013. jq and pandas all read it.

TSV was published in 1993 and NDJSON in 2013. The older one is generally the safer file to hand to somebody; the newer one usually does the job in fewer bytes.

TSV to NDJSON: field names, types and bulk loads

Are my TSV files uploaded anywhere?

No. This conversion runs entirely inside your browser, so the file never leaves your device. You can confirm it yourself: open the network tab of your browser's developer tools and convert something. You will see the page load, plus the analytics and advertising the site is paid for with — and nothing carrying your file.

Can I post this straight to the Elasticsearch _bulk endpoint?

Not as it stands. The output is one document per line with no action metadata, and _bulk expects an index or create line before each document. A single jq pass adds them, and every other consumer here — mongoimport, a BigQuery load job, OpenSearch ingest — takes the file unchanged.

Does mongoimport accept this?

Yes, with --type json and no other flag. It reads one JSON document per line by default, which is exactly what this produces.

What types will the documents have?

Whatever the parser inferred from the text: numbers where a value read as a number, booleans for true and false, null for an empty field, and strings for everything else. A tab-separated file carries no types, so this is inference rather than translation.

What happens to a field name with a space or a dot in it?

It is used exactly as the header row wrote it. Dots are the ones to watch, because several document stores treat a dot in a field name as a path separator. Rename those columns in the header before converting.

Are line breaks inside a value safe?

Yes. A line break inside a field is escaped as \n inside the JSON string, so a record can never span two lines however messy the free-text columns are. That is the guarantee the format exists to make.

Is the export uploaded?

No. The conversion is plain JavaScript running on this page, so nothing carrying the file leaves your machine. The free tier takes files up to 100 MB each, a hundred to a drop.

More about these formats