✳ OMNIVIEWER YAML viewer JSON viewer ← back

How to read a 20 GB YAML file

A 20 GB YAML file will never fit in memory, and YAML is indentation-significant — you can't just seek to the middle and start reading. This is a tour of the format and its grammar, then the streaming, memory-bounded algorithm that reads a file far larger than RAM, with real JavaScript you can lift.

On this page What is YAML The grammar Why 20 GB is hard Reading only what you paint A streaming parser The full algorithm Caveats

What is YAML?

YAML — a recursive acronym for “YAML Ain't Markup Language” — is a human-friendly data-serialization format. It was designed by Clark Evans, Ingy döt Net and Oren Ben-Kiki and first announced in 2001. Where JSON leans on brackets and quotes, YAML leans on indentation and line structure, which is what makes it pleasant to hand-write and hand-read.

The current specification is YAML 1.2 (2009). Its most important property for us: YAML 1.2 is a strict superset of JSON. Every valid JSON document is also valid YAML, so a YAML parser is, among other things, a JSON parser that also understands the indentation-based sugar.

YAML became the default language of configuration. If you've written a Kubernetes manifest, a GitHub Actions or GitLab CI pipeline, an Ansible playbook or a Docker Compose file, you've written YAML. And that's exactly how files get enormous: exported cluster state, aggregated CI logs turned structured, or a database dumped as one document per record can run to many gigabytes.

The grammar, in one screen

A YAML stream is one or more documents, separated by --- and optionally ended by .... Each document is a single node, and a node is one of three things:

Node kindBlock styleFlow style (JSON-like)
Mapping (object)key: value per line{key: value, k2: v2}
Sequence (array)- item per line[a, b, c]
Scalar (leaf)a plain / quoted / block string42, "hi", true

Structure comes from indentation: a node nested under a key or a - is written more indented than its parent. Tabs are forbidden for indentation — spaces only. A few more essentials:

mapping:              # a comment runs to end of line
  key: value          # nesting by indentation
  list:
    - one             # a sequence, one item per '- '
    - two
  nested: {a: 1, b: 2}         # flow mapping
  inline: [red, green, blue]   # flow sequence

quoted:  "with \n escapes"     # double: C-style escapes
literal: 'it''s raw'           # single: '' is a literal quote

block_literal: |      # '|' keeps newlines verbatim
  line one
  line two
block_folded: >       # '>' folds newlines into spaces
  this becomes
  one long line

anchored: &id { host: a, port: 80 }   # define an anchor
alias:    *id                          # reuse it (a reference)
merged:
  <<: *id           # merge key: fold the mapping in
  port: 443       # local keys win

typed: [null, ~, true, false, 0x1F, 3.14, .inf]  # core-schema scalars

Scalar typing follows the core schema: null/~ is null; true/false (and yes/no, on/off in YAML 1.1) are booleans; things that look like integers or floats become numbers (including 0x hex, .inf, .nan); everything else is a string. Explicit tags like !!str 12 can force a type.

Drop a YAML file into the OmniViewer YAML tab and it parses all of the above into JSON in front of you, plus a structural tree and stats — locally, no upload.

Why 20 GB is the hard part

The obvious approach — read the whole file → parse into objects → walk the objects — dies twice over at 20 GB:

And unlike a line-oriented format such as CSV or NDJSON, you can't seek into the middle of YAML and start parsing. Meaning depends on indentation relative to ancestors that may be gigabytes earlier, so a byte offset alone doesn't tell you how deep you are. That rules out naive random access.

The way out is to never hold the whole thing at once. Two techniques do that, and OmniViewer uses both.

Technique 1 — read only the bytes you paint

To simply look at a huge file, you don't need to parse it at all — you need the handful of kilobytes currently on screen. A File from a drop or file picker is a handle to bytes on disk, and file.slice(start, end) returns a view without reading anything. You read a slice only when you scroll to it:

// A windowed reader: fetch just the byte range a viewport needs.
async function readWindow(file, startByte, endByte) {
  const blob = file.slice(startByte, endByte);          // no I/O yet
  const buf  = await blob.arrayBuffer();                // reads ~the window only
  return new TextDecoder('utf-8').decode(new Uint8Array(buf));
}

// Painting line N means: know where line N starts, read a screen's worth.
const screen = await readWindow(file, lineOffset[N], lineOffset[N] + 64 * 1024);

This is how OmniViewer's raw and hex views open a 20 GB YAML as fast as a 2 KB one: memory stays proportional to the window, not the file. (A background pass builds a sparse index of line offsets so scrolling is instant, but it stores offsets, not content.)

Technique 2 — a streaming, event-emitting parser

Viewing is one thing; parsing 20 GB into structure without holding it all is the real prize. The trick is to parse as a stream of events rather than build one giant tree — the SAX approach, applied to YAML. You feed the parser bytes in fixed-size blocks and it emits tiny events — mappingStart, key, scalar, sequenceStart, end — that your code handles and forgets. Nothing accumulates unless you choose to keep it.

Two ideas make this work for an indentation-based grammar:

  1. Split the stream into lines, not objects. YAML's block structure is line-oriented, so a lexer that yields one logical line at a time (carrying its indentation and content) turns an unbounded byte stream into a bounded, one-line-at-a-time problem.
  2. Track depth with an indentation stack. Each line's leading-space count, compared with the stack of open containers, tells you whether you're going deeper (push), staying level (a sibling), or popping out (close one or more containers). That's the whole secret to parsing indentation with O(depth) memory — not O(file).

The line lexer

First, turn incoming blocks into complete lines. Hold a small carry buffer for the partial last line of each block; emit only whole lines:

// Feed it file blocks; it calls onLine(text) for each complete line.
function createLineLexer(onLine) {
  let carry = '';
  return {
    push(chunkText) {
      const text = carry + chunkText;
      let start = 0, nl;
      while ((nl = text.indexOf('\n', start)) !== -1) {
        onLine(text.slice(start, nl).replace(/\r$/, ''));
        start = nl + 1;
      }
      carry = text.slice(start);            // partial line waits for more bytes
    },
    end() { if (carry) onLine(carry.replace(/\r$/, '')); carry = ''; },
  };
}

The indentation stack

Now the core. For each line, measure its indent, strip a trailing comment, then reconcile it against a stack of open containers. Deeper → open a child; shallower → close containers until the indents line up; same indent → a sibling. Every branch emits an event and touches only the stack:

function createYamlEventParser(emit) {
  const stack = [];                    // open containers: { indent, kind }

  const closeTo = (indent) => {
    while (stack.length && stack[stack.length - 1].indent >= indent) {
      const top = stack.pop();
      emit(top.kind === 'seq' ? 'sequenceEnd' : 'mappingEnd');
    }
  };

  return function line(raw) {
    if (/^\s*$/.test(raw) || /^\s*#/.test(raw)) return;   // blank / comment
    if (/^---/.test(raw)) { closeTo(0); emit('documentStart'); return; }

    const indent = raw.length - raw.replace(/^ +/, '').length;
    const text   = stripComment(raw.trim());
    closeTo(indent + 1);                 // pop anything not an ancestor

    if (text.startsWith('- ')) {         // sequence item
      if (top()?.kind !== 'seq' || top().indent < indent) {
        stack.push({ indent, kind: 'seq' }); emit('sequenceStart');
      }
      emitValue(text.slice(2), indent + 2);
    } else {                             // mapping "key: value"
      const c = splitKeyValue(text);
      if (top()?.kind !== 'map' || top().indent < indent) {
        stack.push({ indent, kind: 'map' }); emit('mappingStart');
      }
      emit('key', c.key);
      if (c.value !== '') emitValue(c.value, indent);   // else child block follows
    }
  };

  function top() { return stack[stack.length - 1]; }
  function emitValue(v, indent) {
    if (v === '|' || v === '>') return;             // block scalar: gather next lines
    emit('scalar', resolveScalar(v));               // type it (null/bool/number/string)
  }
}

Because the parser never keeps more than the current path from root to cursor, its memory is O(nesting depth) — a few dozen frames — no matter whether the file is 2 KB or 20 GB. The events stream past; you decide what, if anything, to retain.

Wiring it together

Block reader → line lexer → event parser, driven by file.slice so the browser only ever holds one block plus the stack:

async function streamYaml(file, emit, blockSize = 256 * 1024) {
  const parser  = createYamlEventParser(emit);
  const lexer   = createLineLexer(parser);
  const decoder = new TextDecoder('utf-8');           // handles multi-byte splits

  for (let pos = 0; pos < file.size; pos += blockSize) {
    const buf = await file.slice(pos, pos + blockSize).arrayBuffer();
    lexer.push(decoder.decode(buf, { stream: true }));  // {stream:true} = keep partial UTF-8
  }
  lexer.end();
}

// Example: count keys in a 20 GB file without building anything.
let keys = 0;
await streamYaml(file, (event) => { if (event === 'key') keys++; });
console.log(keys, 'keys — peak memory: one 256 KB block + the stack');

That loop reads the file in 256 KB blocks (the same size OmniViewer streams for every format), so peak memory is a quarter-megabyte plus a shallow stack — whether the file is a kilobyte or twenty gigabytes. Want a filtered subset instead of a count? Keep only the events matching your path and drop the rest; the file size stops mattering.

Note the multi-byte care. A 256 KB block can end in the middle of a UTF-8 character. TextDecoder with { stream: true } holds the incomplete tail bytes until the next block completes them — skip that and you'll corrupt every character that straddles a block boundary.

What the streaming approach gives up

Streaming buys unbounded size by refusing to hold the whole document, and a few YAML features assume you can look back:

That trade is why OmniViewer's YAML tab parses a generous bounded prefix in memory (so the JSON conversion, tree and stats are instant and complete for normal files, and it tells you when a giant file was capped), while the raw and hex views stay fully windowed and open any size. It's the same split every format on the site uses: window what you view, bound what you parse.

Open the YAML viewer →Convert to JSON, tidy the source, browse the tree and stats — locally, no upload. How we auto-detect formats →The content-sniffing algorithm that recognizes pasted or typed YAML, JSON, CSV and more.

OmniViewer opens every file format in your browser — JSON, CSV, Markdown, HTML, JavaScript and more — powered by the same windowed engine as fastjsonviewer.com and hugecsv.com. YAML is one of the formats with dedicated tooling.