Newline-Delimited GeoJSON for Streaming Pipelines

Put one complete GeoJSON feature on each line, drop the wrapping FeatureCollection, and a file that previously had to be parsed whole becomes a stream you can process with bounded memory, split across workers, and restart from an offset. The change is small enough to apply at a pipeline boundary in a few lines of code, and it is the difference between a 40 GB export that needs a 90 GB machine and one that needs 200 MB. This page belongs to GeoJSON overhead and serialization costs, which covers why the format is expensive; this one covers how to stop it being unworkable.

Quick Reference

Property GeoJSON FeatureCollection Newline-delimited Primary Use Case
Parse memory Proportional to file size Proportional to one feature Ingesting exports larger than RAM
Splittable No — nesting spans the file Yes — any newline is a boundary Parallel conversion workers
Restartable No Yes — checkpoint a byte offset Long-running migration jobs
Appendable Rewrite the whole document Append a line Incremental capture feeds
Size on disk Baseline ~2% smaller (no wrapper) Neither is a storage format

Why JSON Resists Streaming

JSON is a self-describing grammar with no framing layer. Inside a FeatureCollection, the only thing marking the end of a feature is a } at the right nesting depth — and to know the depth you must have tracked every brace, bracket, and quoted string since byte zero, including escapes inside strings that look like structure. A parser cannot skip ahead, cannot start in the middle, and cannot know how much memory it needs until it has finished.

That is why json.load on a 40 GB export allocates tens of gigabytes: the library is doing the only correct thing, which is to build the whole value before returning it. Streaming JSON parsers avoid the allocation but hand the caller a state machine — events for “started an object”, “saw a key”, “started an array” — and reconstructing features from that is exactly the work the format should have done.

A newline solves it because a newline cannot occur inside a compact JSON scalar or structure: string content escapes it as \n, and whitespace between tokens is removed. So a line break is an unambiguous record boundary that requires no parsing to find.

Memory used while reading a 40 GB export, document-at-once versus line-at-a-time Two memory profiles over the duration of a read. The FeatureCollection profile climbs steadily as the parser accumulates the document, peaks above ninety gigabytes just before the value is returned, and only then can any feature be processed. The line-delimited profile stays flat at roughly two hundred megabytes for the entire read, because only one feature and one output batch are ever resident, and features are processed continuously from the first line rather than after the last one. Resident memory while reading a 40 GB export time through the file → memory → peak 92 GB json.load — one FeatureCollection nothing is processed until the whole value exists line-delimited — 0.2 GB, flat one feature plus one output batch resident, start to finish The flat line is also restartable and splittable — properties the rising curve cannot have at any memory size.

The practical consequences follow immediately. Splittability: any worker can be handed a byte range, skip to the first newline, and process from there, so conversion parallelises without coordination. Restartability: a job that dies at 31 GB records its offset and resumes, instead of starting over. Appendability: a capture feed appends a line rather than rewriting a document — the property that makes it a natural transport for change data capture.

Converting and Consuming

python
# Requires: geopandas>=1.0, pyarrow>=16, shapely>=2.0  (Python 3.10+)
from __future__ import annotations

import json
from pathlib import Path
from typing import Iterator

import geopandas as gpd
import pyarrow as pa
import pyarrow.parquet as pq
from shapely.geometry import shape


def to_ndjson(collection: Path, target: Path, *, chunk: int = 1 << 20) -> int:
    """Split a FeatureCollection into one compact JSON object per line.

    Done at the pipeline boundary, this is the last time the document has to be
    handled as a whole — and on inputs that already exceed memory, prefer a
    streaming JSON parser here rather than json.load.
    """
    payload = json.loads(collection.read_text(encoding="utf-8"))
    features = payload.get("features")
    if not isinstance(features, list):
        raise ValueError(f"{collection} is not a FeatureCollection")

    written = 0
    with open(target, "w", encoding="utf-8") as handle:
        for feature in features:
            # separators removes the whitespace that would otherwise let a
            # pretty-printer smuggle a newline into the middle of a record.
            handle.write(json.dumps(feature, separators=(",", ":"), ensure_ascii=False))
            handle.write("\n")
            written += 1
    return written


def stream_features(path: Path, *, start_offset: int = 0) -> Iterator[tuple[int, dict]]:
    """Yield (offset_after_line, feature) so a caller can checkpoint precisely."""
    with open(path, "r", encoding="utf-8") as handle:
        if start_offset:
            handle.seek(start_offset)
        for line_no, line in enumerate(handle, start=1):
            line = line.strip()
            if not line:
                continue
            try:
                yield handle.tell(), json.loads(line)
            except json.JSONDecodeError as exc:
                raise ValueError(
                    f"{path}: line {line_no} is not valid JSON ({exc.msg}); a "
                    f"pretty-printed feature was probably split across lines"
                ) from exc


def ndjson_to_geoparquet(
    source: Path, target: Path, *, batch: int = 100_000, crs: str = "EPSG:4326"
) -> int:
    """Convert a line-delimited stream into GeoParquet in bounded memory."""
    writer: pq.ParquetWriter | None = None
    rows: list[dict] = []
    total = 0
    try:
        for _, feature in stream_features(source):
            rows.append({
                **(feature.get("properties") or {}),
                "geometry": shape(feature["geometry"]),
            })
            if len(rows) >= batch:
                total += _flush(rows, target, crs, writer)
                writer = writer or pq.ParquetWriter(target, _schema(rows, crs))
                rows = []
        if rows:
            total += _flush(rows, target, crs, writer)
    finally:
        if writer is not None:
            writer.close()
    return total

Validation

Two properties are worth asserting mechanically, because both fail silently: every line must be a complete JSON object, and the feature count must survive the conversion.

bash
# Line count equals feature count — a pretty-printed input inflates this wildly
wc -l parcels.geojsonl
# 12,041,882

# Every line parses independently; report the first that does not
awk 'NR%1==0 { print }' parcels.geojsonl \
  | python3 -c 'import sys,json
for i, line in enumerate(sys.stdin, 1):
    try: json.loads(line)
    except Exception as e: print(f"line {i}: {e}"); break
else: print("all lines parse")'

Healthy ranges: line count exactly equals the source feature count; the largest single line should sit well under 10 MB (a larger one usually means a multipolygon that should have been simplified); and peak memory during conversion should be roughly the batch size times the average feature size, independent of the input’s total size.

How byte-range splitting works when the record boundary is a newline A file is divided into three arbitrary byte ranges handed to three workers. Each range boundary lands in the middle of a record. The rule that makes this safe is that a worker skips forward to the first newline after its start offset and reads past its end offset to finish the record it is in the middle of, so every record is processed exactly once with no coordination between workers. The same split is impossible on a FeatureCollection because the record boundary can only be found by parsing from byte zero. Three workers, one file, no coordination each vertical rule is a newline — the only structure a splitter needs to see worker 1 · bytes 0–240 starts at 0, finishes its last record worker 2 · bytes 240–480 skips to the first newline after 240 worker 3 · bytes 480–end skips to the first newline after 480 Skip-forward at the start plus read-past at the end means every record is handled exactly once. The same split against a FeatureCollection is impossible: a worker starting at byte 240 has no way to know its nesting depth without having parsed everything before it. This is the property that makes parallel conversion possible without a preliminary indexing pass. What pretty-printing does to a line-delimited reader The same feature written two ways. Compact serialisation puts the whole object on one line, so the newline is an unambiguous record boundary. Pretty-printed serialisation spreads the object over eleven indented lines, so a line-based reader sees eleven fragments, none of which is valid JSON, and reports corrupt data. The line count against the feature count is the check that catches it immediately. Compact — one line, one record {"type":"Feature","geometry":{"type":"Point","coordinates":[-2.58,51.45]},"properties":{"id":8814}} 12,041,882 lines · 12,041,882 features — the check passes Pretty-printed — eleven lines, no records { "type": "Feature", "geometry": { each line fails to parse on its own and the reader reports corrupt input 132,460,702 lines · 12,041,882 features — the check fails, which is the point of running it.

Edge Cases and Caveats

Pretty-printed input. A source that emits indented JSON puts literal newlines inside each feature, so a line-based reader sees fragments and every line fails to parse. The symptom is unmistakable once you look, but a naive pipeline reports it as corrupt data. Always serialise with compact separators, and validate line count against feature count before proceeding.

Encoding that is not UTF-8. Legacy exports frequently arrive in a regional code page, and a byte that is not valid UTF-8 will fail somewhere in the middle of a multi-hour job. Decode explicitly with a known encoding at the boundary rather than relying on the platform default — the same class of problem as DBF encoding and field name truncation.

Treating it as a destination. Line-delimited GeoJSON fixes framing and nothing else. Coordinates are still decimal text several times larger than a float64, property keys are repeated on every single feature, and there is no index of any kind. It is a transport, not a store: land the output in GeoParquet or FlatGeobuf and delete the intermediate.

Frequently Asked Questions

Why can’t I just stream a normal GeoJSON FeatureCollection?

Because a FeatureCollection is a single JSON value, and JSON has no framing: nothing in the byte stream tells a parser where one feature ends except the grammar itself. A conforming parser must therefore track nesting depth and string escaping across the whole document, and the standard libraries simply build the entire object in memory. Streaming parsers exist, but they push a state machine onto the consumer. One feature per line moves the framing into the format, where a newline is unambiguous.

Is newline-delimited GeoJSON a standard?

It is a widely implemented convention rather than a formal specification, sometimes labelled GeoJSONL, GeoJSON Text Sequences, or NDJSON of GeoJSON features. GDAL, tile builders, and most data platforms read and write it. The important rules are consistent across implementations: one complete JSON object per line, no wrapping FeatureCollection, no literal newlines inside a feature, and UTF-8 throughout.

Should line-delimited GeoJSON be a storage format or just an intermediate?

An intermediate. It fixes GeoJSON’s streaming problem and none of its other problems: coordinates are still decimal text, keys are still repeated on every feature, and there is still no index, so it remains several times larger than a compressed columnar file and cannot answer a spatial query without a full scan. Use it to move data between stages, and land the result in GeoParquet or FlatGeobuf.


← Back to GeoJSON Overhead and Serialization Costs