Streaming conversion of CSV/TSV to JSON

Recently while exploring the IMDb data set, I need to convert Tab-Separated Values (TSV) files to JSON files. Based on my experience doing that, this tutorial will show you how to do streaming transformations of CSV/TSV files into JSON by using the csv library and how to test file reading/writing with the mock-fs library. Although this post talks about tsv files, it applies equally well to csv files.

There are many existing solutions that can convert tsv or csv files to JSON. However, most of them requires holding the entire input tsv and output JSON files in memory. Although that works well for small files, the IMDb data set contains a few large files, which will take up a lot of memory if done that way. For this tutorial, we’ll use two files for demo:

  • title.basics.tsv at 422MB, which contains the title, run time, year and genres of each movie. The corresponding JSON output is 1GB. I’ll call this the “simple” input because its conversion is more straightforward.
  • title.principals.tsv at 1.25GB, which links each movie with the name, job and characters of that movie’s cast members. Because the JSON output is 2.5 GB, the memory consumption for this conversion is at least 3.75GB, which is half the available memory of most laptops these days. I’ll call this the “advanced” input because its conversion takes a bit more work.

The best solution is a streaming conversion: data is streamed from the input file through a transformer (that performs the conversion while trying to retain as little data in memory as possible) into the output file. The memory footprint will be much smaller with the streaming approach because only small chunks of data is read and held in memory for processing at any one time.

I picked the csv library for this job because it looks well-maintained and has good documentation. The library consists of a few parts, of which we’ll only use two in this tutorial:

The result is in this repo. If you want to look at the actual giant input files, run npm run download:simple and npm run download:advanced to download the simple and advanced input files, respectively, into the realInput directory. To see the conversion in action, run npm run convert:simple and npm run convert:advanced, respectively. Be warned that the conversions can take a while.

Simple example

Let’s take a look at the “simple” example of converting title.basics.tsv. The first few rows look this:

tconst	titleType	primaryTitle	originalTitle	isAdult	startYear	endYear	runtimeMinutes	genres
tt0000001	short	Carmencita	Carmencita	1	1894	\N	1	Documentary,Short
tt0000002	short	Le clown et ses chiens	Le clown et ses chiens	0	1892	\N	5	Animation,Short

where tconst is the movie ID and \N indicates no data. We want to convert it into something like this:

[{
  "tconst": "tt0000001",
  "titleType": "short",
  "primaryTitle": "Carmencita",
  "originalTitle": "Carmencita",
  "isAdult": true,
  "startYear": 1894,
  "endYear": null,
  "runtimeMinutes": 1,
  "genres": [ "Documentary", "Short" ]
}, {
  "tconst": "tt0000002",
  "titleType": "short",
  "primaryTitle": "Le clown et ses chiens",
  "originalTitle": "Le clown et ses chiens",
  "isAdult": false,
  "startYear": 1892,
  "endYear": null,
  "runtimeMinutes": 5,
  "genres": [ "Animation", "Short" ]
}]

First we set up source, which streams data from the input file, and destination, which streams data to the output file:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/simpleConvert.ts
 */
// ...
        // create empty output file. Otherwise, we wont' be able to create a writable
        // stream for output:
        await writeFile(outputPath, '', 'utf8');

        const source = fs.createReadStream(inputPath, 'utf8');
        const destination = fs.createWriteStream(outputPath, 'utf8');
        // ...

Then we create the parser, which is a Node transform stream that will read the tsv file row-by-row, associate the data in each column with the column heading and emit a JavaScript object for downstream consumers:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/simpleConvert.ts
 */
// ...
        const parser = parse({
          // Because the input is tab-delimited:
          delimiter: '\t',
          // Because we want the library to automatically associate the column name
          // with column value in each row for us:
          columns: true,
          // Because we don't want accidental quotes inside a column to be
          // interpreted as "wrapper" for that column content:
          quote: false,
        });
        // ...

The most important part of this whole operation is the transformer, which, in this simple example, just calls JSON.stringify on every row emitted by the parser:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/simpleConvert.ts
 */
// ...
        let outputIndex = 0;
        const transformer = transform((rawRow: RawRow): string => {
          const currentRecordIndex = outputIndex;
          outputIndex += 1;
          if (outputIndex % 100000 === 0 && shouldLogProgress === true) {
            console.info('processing row ', outputIndex);
          }
          const {isAdult, startYear, endYear, runtimeMinutes, genres, ...rest} = rawRow;
          const parsedRow: ParsedRow = {
            ...rest,
            isAdult: !!(isAdult === '1'),
            startYear: parseInt(startYear, 10),
            endYear: (endYear === '\N') ? null : parseInt(endYear, 10),
            runtimeMinutes: (runtimeMinutes === '\N') ? null : parseInt(runtimeMinutes, 10),
            genres: genres.split(','),
          };
          const result = (currentRecordIndex === 0) ? `[${JSON.stringify(parsedRow)}` : `,${JSON.stringify(parsedRow)}`;
          return result;
        });
        // ...

Note that we do have to take care to close the JSON list after the last movie has been written to JSON:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/simpleConvert.ts
 */
// ...
        destination.on('finish', async () => {
          if (outputIndex === 0) {
            // In this case, no row has been processed from TSV file so the
            // output should be an empty list:
            await appendFile(outputPath, '[]', 'utf8');
          } else {
            // In this case, at least one row has been processed so we just need
            // to write the closing bracket:
            await appendFile(outputPath, ']', 'utf8');
          }
          resolve();
        });
        // ...

Having set up all these pipes, now we need to connect them together to create a continuous pipeline that our data can flow through like water. We do this by literally .pipe-ing the input of one stream into the next:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/simpleConvert.ts
 */
// ...
        source.pipe(parser).pipe(transformer).pipe(destination);
        // ...

Advanced example:

In this example, the cast of a single movie is recorded over multiple rows, one for each cast member. The first few rows look like this:

tconst	ordering	nconst	category	job	characters
tt0000001	1	nm1588970	self	\N	["Herself"]
tt0000001	2	nm0005690	director	\N	\N
tt0000001	3	nm0374658	cinematographer	director of photography	\N

We want to consolidate the information about each movie’s cast members into a single JSON object like this (where nconst is the person ID):

[
  {
    "tconst": "tt0000001",
    "principals": [
      {
        "nconst": "nm1588970",
        "category": "self",
        "job": null,
        "characters": ["Herself"]
      },
      {
        "nconst": "nm0005690",
        "category": "director",
        "job": null,
        "characters": null
      },
      {
        "nconst": "nm0374658",
        "category": "cinematographer",
        "job": "director of photography",
        "characters": null
      }
    ]
  }
]

Unlike the simple example above, which uses a memory-less transformer (i.e. it doesn’t remember previous rows), this next transformer needs to do some record keeping because the rows are related. This transformer essentially needs to keep comparing the next row’s movie ID (tconst) with the previous row’s movie ID to detect when the movies change between rows. When that change happens, we create a new element in the output JSON list:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/advancedConvert.ts
 */
// ...
        let prevRow: RawRow | undefined;
        let outputIndex = 0;
        let inputRowIndex = 0;
        let outputObject!: ParsedRow;

        const transformer = transform((nextRow: RawRow) => {
          inputRowIndex += 1;
          if (inputRowIndex % 100000 === 0 && shouldLogProgress === true) {
            console.info('processing row ', inputRowIndex);
          }

          const {tconst, nconst, category, job, characters} = nextRow;

          let toBeReturned;
          if (
            // If this is the first row ...
              prevRow === undefined ||
            // ... or if the movie has changed ...
              nextRow.tconst !== prevRow.tconst) {

            // ... return previous movie;
            if (prevRow !== undefined) {
              toBeReturned = (outputIndex === 1) ?
                              `[${JSON.stringify(outputObject)}` : `,${JSON.stringify(outputObject)}`;
            }
            // ... then create a new movie:
            outputObject = {
              tconst,
              principals: [],
            };
            outputIndex += 1;
          }

          const {principals} = outputObject;
          let outputCharacters: string[] | null;
          if (characters === '\\N') {
            // This means `characters` is not provided:
            outputCharacters = null;
          } else if (characters.startsWith('[') && characters.endsWith(']')) {
            // `characters` should be interpreted as an array of strings:
            outputCharacters = JSON.parse(characters);
          } else {
            // If `characters` is a string, put it in a list:
            // (also need to remove quoted literal quotes surrounding the text):
            outputCharacters = [
              characters.replace(/^"/, '').replace(/"$/, ''),
            ];
          }
          principals.push({
            nconst,
            category,
            job: (job === '\\N') ? null : job,
            characters: outputCharacters,
          });

          prevRow = nextRow;

          if (toBeReturned !== undefined) {
            return toBeReturned;
          }
          // ...

Because we only know that we’re done with the data for each movie when we see the next one, we wouldn’t know that we have seen the last movie until the input data stream has finished. This code takes care of writing the last movie to the output file:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/advancedConvert.ts
 */
// ...

        destination.on('finish', async () => {
          if (outputIndex === 0) {
            // In this case, no row has been processed from TSV file so the
            // output should be an empty list:
            await appendFile(outputPath, '[]', 'utf8');
          } else {
            // The last row would not have been written out to the file so
            // we need to do that here. However, we do need to open a new list (with ])
            // or continue an existing list (with a comma) depending on whether the last row
            // is alos the only row:
            const lastItemToWrite = (outputIndex === 1) ?
                                      `[${JSON.stringify(outputObject)}]` :
                                      `,${JSON.stringify(outputObject)}]`;
            await appendFile(outputPath, lastItemToWrite, 'utf8');
          }
          resolve();
        });
        // ...

Test code

As usual, I use jest for testing. We use the mock-fs library to mock out the file system so that we don’t have to read from or write to real files during testing. Once mock-fs is invoked, the only files that you can read using fs.readFile are the ones that you register with mock-fs, like this:

/**
 * https://github.com/huy-nguyen/streaming-conversion-tsv-to-json/blob/58729bd5/src/__tests__/convert.js
 */
// ...
  test('With many rows of input data', async () => {
  // ...
    mockFs = require('mock-fs');
    mockFs({
      [fakeInputDir]: {
        [`${fakeInputFileName}.tsv`]: testInput,
      },
      [fakeOutputDir]: {
        [`${fakeInputFileName}.json`]: '',
      },
    });
    // ...
  });
  // ...

In the above mocked file system, the directory fakeInputDir is inside the directory containing the test script (__tests__/convert.js). The fake directory contains the fake input file that will be consumed by the converter. Don’t forget to call mockFs.restore() after each test to restore the real file system because otherwise jest will fail.

I include tests for some corner cases, such as empty input and input that consists of one header row and one data row. If you checkout the repo at this point and run npm run test, all the tests should pass.