Skip to main content

Creating series

Introduction​

Everything in pond starts with getting data in. Which door you use is decided by the shape the data arrives in — not by what you plan to do with it afterwards. Every batch door produces the same TimeSeries, with the same schema contract and the same operator surface; the live door builds a LiveSeries that snapshots to one on demand.

Your data arrives as…DoorSection
Row tuples you built in codenew TimeSeries({ rows })The constructor
A JSON payload — rows of arrays or objectsTimeSeries.fromJSONfromJSON
Struct-of-arrays JSON — { time: [...], close: [...] }TimeSeries.fromColumnsJSON columnar
Typed arrays off a binary decoderTimeSeries.fromColumnsFloat64Array columnar
A decoded Apache Arrow TableTimeSeries.fromArrowArrow
Events arriving one at a time, forevernew LiveSeries(…) + pushLive data
Rows keyed by a value, not a timeValueSeries.from*ValueSeries

Going the other way, each door has an inverse: toJSON serializes rows for the wire, toRows / toObjects hand back in-memory rows, toColumns emits the columnar-JSON envelope fromColumns reads back, and toArrow hands the raw column buffers to another columnar engine.

Two smaller doors round out the set: TimeSeries.fromEvents(events, { schema, name }) for Event objects you already hold, and TimeSeries.fromPoints(points, { schema }) for wide { ts, … } objects (the shape charts hand back).

The constructor​

import { TimeSeries } from 'pond-ts';

const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
{ name: 'host', kind: 'string' },
] as const;

const cpu = new TimeSeries({
name: 'cpu',
schema,
rows: [
[Date.parse('2025-01-01T00:00:00Z'), 0.31, 'api-1'],
[Date.parse('2025-01-01T00:01:00Z'), 0.44, 'api-1'],
],
});

The as const on the schema is load-bearing — without it, TypeScript widens the schema tuple to a regular array and the column kinds widen to string, breaking type inference across every transform.

Rows must be in non-decreasing key order — the constructor throws on out-of-order rows, and the error names the offending row. For unsorted input (a CSV read in arrival order, merged sources), pass ordering: 'reorder' to sort on construction instead of pre-sorting yourself:

const cpu = new TimeSeries({ name: 'cpu', schema, rows, ordering: 'reorder' });

The sort is stable, so rows with equal keys keep their input order. Every other ingest door takes the same ordering option and means the same thing by it — see Out-of-order keys for the third choice, 'clamp'. (sort: true still works for one release as a deprecated alias.)

For the full schema-as-contract story (kinds, narrowing, required: false), see Concepts → Series. For messy or partially-bad input, see Cleaning data.

Live data​

When events arrive incrementally rather than as a finished dataset, construct a LiveSeries with the same schema and push into it:

import { LiveSeries } from 'pond-ts';

const live = new LiveSeries({
name: 'cpu',
schema, // same schema as batch
retention: { maxEvents: 10_000 }, // bounded buffer
ordering: 'reorder', // accept moderately late events
graceWindow: '5s', // ... up to 5s late
});

live.push([Date.now(), 0.42, 'api-1']);
live.pushMany([
[Date.now() + 1, 0.43, 'api-1'],
[Date.now() + 2, 0.45, 'api-2'],
]);

live.toTimeSeries() snapshots to an immutable batch series at any time — the snapshot is independent of future pushes, and live.toJSON() is shorthand for snapshotting and serializing. Retention and ordering details are on Late data; the reference page is LiveSeries.

The rest of this page is about batch ingest, and applies to a LiveSeries only through that snapshot.

Coming from pandas​

TimeSeries.fromJSON ≈ pd.read_json(…, convert_dates=True, date_format='iso'); toJSON ≈ .to_json(orient='records'|'values', date_format='iso'); parse.timeZone ≈ .tz_localize(…) on a naive DatetimeIndex; TimeSeries.fromColumns ≈ pd.DataFrame({...}) over aligned arrays; fromArrow / toArrow ≈ pa.Table interop.

JSON​

JSON is the row-shaped door — the natural fit for a REST endpoint, and the right default. Both TimeSeries and LiveSeries round-trip through it via toJSON / fromJSON, and the shape is identical for both: consumers of the JSON don't need to know which side it came from.

A JSON-serialized TimeSeries is just three fields — the series name, the schema (the same tuple you'd pass to the constructor), and the rows:

{
"name": "cpu",
"schema": [
{ "name": "time", "kind": "time" },
{ "name": "cpu", "kind": "number" },
{ "name": "host", "kind": "string" }
],
"rows": [
[1735689600000, 0.31, "api-1"],
[1735689660000, 0.44, "api-1"]
]
}

By default rows are arrays — one per event, positional by schema column. The alternative is object rows keyed by column name (see toJSON).

The temporal key column always comes first. Its serialized form depends on its kind:

KindJSON shape
'time'number (ms since epoch) or ISO string ("2025-01-01T00:00:00Z")
'timeRange'[startMs, endMs] or { start, end }
'interval'[label, startMs, endMs] or { value, start, end }

Everything else is the obvious thing — numbers become numbers, strings become strings, arrays become arrays, undefined is serialized as null (and parsed back to undefined on ingest).

Coming from pondjs?

pondjs serialized TimeRangeEvent as a nested shape — [[startMs, endMs], data] — with the temporal key wrapped inside the event payload. pond-ts flattens: the timeRange key is the first positional element (or the timeRange key on an object row), and payload columns follow. A legacy pondjs JSON payload needs a small remap before it round-trips through TimeSeries.fromJSON.

fromJSON​

Pass the parsed JSON, get back a typed TimeSeries:

import { TimeSeries } from 'pond-ts';

const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
{ name: 'host', kind: 'string' },
] as const;

const cpu = TimeSeries.fromJSON({
name: 'cpu',
schema,
rows: [
[1735689600000, 0.31, 'api-1'],
[1735689660000, 0.44, 'api-1'],
],
});

In React, when the server emits a full pond-ts payload, the client is one line:

const payload = await (await fetch('/api/cpu')).json();
const cpu = TimeSeries.fromJSON(payload);

When the server emits just the rows, supply the schema on the client:

const rows = await (await fetch('/api/cpu/rows')).json();
const cpu = TimeSeries.fromJSON({ name: 'cpu', schema, rows });

Rows must be in non-decreasing key order, same as the constructor; pass ordering: 'reorder' for a payload that isn't guaranteed ordered.

Timestamp inputs​

fromJSON accepts two timestamp formats per event key, and they can mix freely:

  • Number — milliseconds since the Unix epoch, UTC. No ambiguity.
  • ISO string — parsed as ISO-8601, strictly (not Date.parse: a non-ISO string like 'Jan 1 2025' throws rather than guessing). If the string carries an offset (Z, +02:00), the offset is used as-is. If it doesn't (a wall-clock string like '2025-01-01T09:00'), it is read in parse.timeZone, which defaults to UTC — never the runtime's zone, which is where it differs from Date.parse. Pass the zone the clock was on, or every instant lands offset by that zone.

Wall-clock strings: parse.timeZone​

The common case for analytics data: timestamps were emitted in local time and never carried a zone offset.

const ts = TimeSeries.fromJSON({
name: 'cpu',
schema,
rows: [
// "2025-01-01 at 09:00 Madrid local" → 08:00:00 UTC.
['2025-01-01T09:00', 0.42, 'api-1'],
['2025-01-01T10:00', 0.51, 'api-1'],
],
parse: { timeZone: 'Europe/Madrid' },
});

ts.at(0)!.begin();
// 1735718400000 — i.e. 2025-01-01T08:00:00Z

Does your input need it?

Input shapeExampleparse.timeZone?
Number (ms since epoch)1735689600000Ignored — UTC by definition.
String with offset (Z)'2025-01-01T09:00Z'Ignored — offset is authoritative.
String with offset (±hh:mm)'2025-01-01T09:00+01:00'Ignored — offset is authoritative.
Wall-clock string (no offset)'2025-01-01T09:00'Read as UTC unless you pass it.

If your upstream source is a spreadsheet, a log scraper, or a database export without a zone column, you're almost certainly in the wall-clock row — pass parse.timeZone once per fromJSON call.

Object rows​

If your JSON source uses keyed objects instead of positional arrays, fromJSON accepts that shape too:

const ts = TimeSeries.fromJSON({
name: 'windows',
schema: [
{ name: 'interval', kind: 'interval' },
{ name: 'value', kind: 'number' },
{ name: 'active', kind: 'boolean' },
] as const,
rows: [
{
interval: { value: 'a', start: '2025-01-01', end: '2025-01-02' },
value: 1,
active: true,
},
],
parse: { timeZone: 'UTC' },
});

Each row is read on its own shape, so a payload that mixes object rows and array rows still ingests. Don't lean on that — pick one shape per payload; a mix usually means two producers disagree about the contract.

Missing values​

null in a cell becomes undefined on the event; a column marked required: false in the schema accepts both null and a missing key (in object rows):

const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
{ name: 'status', kind: 'string', required: false },
] as const;

TimeSeries.fromJSON({
name: 'cpu',
schema,
rows: [
['2025-01-01T00:00Z', 0.42, 'ok'],
['2025-01-01T00:01Z', 0.51, null], // status is undefined
],
});

Columns without required: false reject null at validation time — if a cell is sometimes-present, make it optional in the schema.

Note that fromJSON rejects a non-finite provided number (NaN, Infinity) outright rather than treating it as a gap. The columnar doors are looser here; see Common ingest issues.

toJSON​

Serializes back to the JSON-friendly shape.

const payload = series.toJSON();
// { name, schema, rows: [[ts, v, ...], ...] }

JSON.stringify(payload);
// One string; round-trips via TimeSeries.fromJSON(JSON.parse(...)).

Array vs object rows​

Default is array rows (smaller on the wire, faster to parse). Request object rows when readability matters more than size:

const compact = series.toJSON();
// rows: [[1735689600000, 0.31, "api-1"], ...]

const keyed = series.toJSON({ rowFormat: 'object' });
// rows: [{ time: 1735689600000, cpu: 0.31, host: "api-1" }, ...]

Both formats round-trip through fromJSON with the same output. The return type follows the option, so compact.rows is typed as tuples and keyed.rows as { time, cpu, host } records against your schema — no cast either way.

Round-trip fidelity​

series.toJSON() → JSON.stringify → JSON.parse → TimeSeries.fromJSON(…) preserves:

  • Series name
  • Every column name + kind
  • Every row value, including undefined (as null on the wire)
  • Event order
  • Key objects (Time / TimeRange / Interval) with exact timestamps

It does not preserve:

  • Comments, whitespace, or any non-payload metadata you might be tempted to attach.

One known gap: a series built with fromColumns keeps its gaps even in a column the schema marks required (the default), so its own toJSON does not read back: fromJSON throws "… is required" at the first gap. Mark such columns required: false in the schema until this is fixed (tracked as [PND-COLSREQ] in PLAN.md).

(toJSON emits the schema verbatim, so required: false flags do survive.)

If the two ends of a round-trip share the same schema tuple (as const), TypeScript's type-narrowing on .get() is preserved through the round-trip at the type level. If one end imports a different schema, the narrowing is only as good as what both ends import.

In-memory exports​

When the consumer is in-process rather than across a wire, skip the JSON shape entirely. toRows / toObjects preserve Time / TimeRange / Interval as objects instead of flattening them to numbers and tuples:

const rows = series.toRows();
// ReadonlyArray of [key, ...values] tuples with *object* keys

const objects = series.toObjects();
// ReadonlyArray of { [colName]: value } objects with *object* keys

Both are faster than toJSON (no JSON-shape conversion) and are the right choice for tests, other library code, and React components. They do not round-trip via fromJSON — use toJSON for wire-format serialization.

pond-rs has no toRows / toObjects. Walk the rows with rows() (or take one with row(i)), or read a whole column with column(name).

Columnar​

fromJSON takes row tuples, but plenty of sources hand you data struct-of-arrays already: a bulk columnar JSON payload, a protobuf message with repeated double fields, an Arrow record batch. Reshaping those back into rows just to hand them to fromJSON costs a transpose pass you don't need — and throws away the fact that pond's own storage is columnar underneath, so a columnar input can often be adopted rather than copied.

Three ways in, one ingest engine:

InputDoorCost
{ time: number[], close: number[] }fromColumnsOne copy per column
{ time: Float64Array, close: Float64Array }fromColumnsZero-copy — buffers adopted
A decoded Apache Arrow TablefromArrowZero-copy for single-chunk Float64

And two ways back out — toColumns for a JSON-shaped payload, toArrow for the buffers themselves. Each ingress pairs with its inverse, so a columnar payload round-trips.

All of them share the same rules for missing values, ordering, and ordering — see Missing values and ordering.

Supported kinds. Any key kind — a timeRange or interval key flattens into extra columns, see Two-edged keys — and number / string value columns. boolean and array columns export fine but can't be ingested back; the row doors take those.

Value-keyed data has its own door

fromColumns builds a time-keyed series. If your rows are keyed by a monotonic value instead — an options chain keyed by strike, a spectrum keyed by frequency — don't launder the axis through a fake time column: ValueSeries.fromColumns is the same door with a 'value'-kind axis column in place of time. See ValueSeries below.

JSON columnar​

The plain-array case — exactly what JSON.parse gives you for a columnar wire payload, with no transpose needed:

import { TimeSeries } from 'pond-ts';

const schema = [
{ name: 'time', kind: 'time' },
{ name: 'open', kind: 'number' },
{ name: 'close', kind: 'number' },
{ name: 'venue', kind: 'string' },
] as const;

const bars = TimeSeries.fromColumns({
name: 'AAPL',
schema,
columns: {
time: [1735689600000, 1735689660000, 1735689720000],
open: [193.1, 193.4, 193.2],
close: [193.4, 193.2, 193.6],
venue: ['XNAS', 'XNAS', 'XNAS'],
},
});

Each entry in columns is one column's values, aligned by index; every column must be the same length as the key column, and a missing or mismatched column throws naming itself. String columns are dictionary-encoded when that pays off, which is why a low-cardinality column like venue above costs almost nothing.

Plain arrays are always copied into pond's storage — the source array is yours to keep mutating afterwards.

Missing values and ordering​

A null/undefined cell, or a non-finite numeric cell (NaN, Infinity), is a gap:

TimeSeries.fromColumns({
name: 'AAPL',
schema,
columns: {
time: [1735689600000, 1735689660000],
open: [193.1, 193.4],
close: [193.4, null], // gap
venue: ['XNAS', null], // gap
},
});

The rule is identical across every columnar input shape — plain array, typed array, Arrow. It is deliberately looser than fromJSON, which rejects a non-finite provided number outright.

Columns must be in non-decreasing key order, same invariant as fromJSON — an out-of-order key throws, naming the offending index. Pass ordering: 'reorder' to sort rows by key before construction; the sort is stable, like fromJSON's. Sorting disables zero-copy adoption on every door below: a reorder needs fresh buffers. (ordering: 'clamp' keeps rows in place, so value columns stay adopted.)

Float64Array columnar​

A numeric column can be a Float64Array instead of a plain array — the shape a binary decoder (protobuf packed doubles, a fixed-point decoder after descaling) hands back directly:

const bars = TimeSeries.fromColumns({
name: 'AAPL',
schema,
columns: {
time: Float64Array.from([1735689600000, 1735689660000, 1735689720000]),
open: Float64Array.from([193.1, 193.4, 193.2]),
close: Float64Array.from([193.4, 193.2, 193.6]),
venue: ['XNAS', 'XNAS', 'XNAS'], // strings stay a string[]
},
});

This is the fast path: a Float64Array column is adopted directly, not copied — no re-materialization, no per-row intake. It's also why fromColumns is one function taking either shape rather than two separate ones: a plain-array caller and a typed-array caller want the same ingest, just with a different (adopt vs. copy) cost underneath, so the wire format changes only your decoder.

String columns have no typed-array form — hand them a string[]. Passing a typed array for a 'string'-kind column throws rather than stringifying numbers.

In Rust, Column::required(name, values) takes a Vec<f64> without copying it, as in the TimeSeries::new call in the Rust tab above.

Adopted, not copied

Because a Float64Array column is adopted by reference, mutating the source buffer after construction mutates the series, and transferring it to a worker (postMessage(msg, [buffer])) breaks the series: its values read back as missing, or operations such as slice throw. Hand fromColumns a buffer you're done writing to — freshly decoded output is the common case and is naturally safe. number[] columns are always copied, so this only applies to the typed-array path. The time key's Float64Array is adopted too. A series pushed into a default live series (ordering: 'strict', a time key) with pushSeries is kept as it is (unless it has 8 rows or fewer, or would keep a much bigger buffer alive; those are copied), so the same rule holds for as long as its rows stay in that live series.

Why this matters for interactive charts

The real cost this solves isn't raw parse speed — it's when the parse happens. Decoding a hefty series (hundreds of thousands of points) synchronously on the main thread can stall an animating or panning chart for a visible frame. Because fromColumns accepts Float64Arrays directly, a decoder can run in a Web Worker and postMessage the resulting typed arrays back as transferable objects (zero-copy) — fromColumns then adopts them on the main thread for a fraction of a millisecond per column, instead of a multi-frame freeze. A plain number[] can't transfer this way (it structured-clones), so this pattern specifically wants the columnar + typed-array path.

Columnar out: toColumns​

series.toColumns() is the inverse — the same { name, schema, columns } envelope, one plain array per column, which fromColumns takes straight back:

// In process, the round trip is typed end to end — no cast, and the result
// is a TimeSeries<typeof schema>, not a widened one.
const restored = TimeSeries.fromColumns(series.toColumns());

That assignability is the contract: toColumns()'s return type is typed per column ({ time: number[]; cpu: Array<number | null>; … }) and is accepted by fromColumns as it stands. Gaps come out as null (NaN isn't JSON, and a Float64Array doesn't stringify as an array), which is what makes the envelope safe to send:

const wire = JSON.stringify(series.toColumns());
// … over HTTP / a WebSocket / postMessage …
const parsed = JSON.parse(wire) as ReturnType<typeof series.toColumns>;
const restored = TimeSeries.fromColumns(parsed);

Across a wire the types are your assertion to make, not the library's — JSON.parse returns any, so annotate it (as above) if you want the restored series typed rather than widened.

It is dramatically cheaper than the row export. toColumns reads the columnar store directly, where toJSON materializes a row per event — measured at 100k rows × 6 columns, ~2.5–3 ms vs ~26 ms (an 8–10× gap across runs) that widens with row count, because the row door allocates per row and this one allocates per column. Reach for it whenever the consumer is column-oriented or the payload is large. In-process, going to another columnar engine, toArrow is cheaper still — ~0.006 ms — because it hands over the buffers rather than copying them.

boolean / array columns export fine but can't be ingested back (the columnar engine takes number and string). The return type says so, so that round trip fails to compile rather than at runtime; select() them out, or use the row doors.

ValueSeries has the same method with the same contract.

Two-edged keys​

A timeRange or interval key — what aggregate produces — spans two edges, and JSON has no interval-of-time type. Every columnar door flattens it into extra columns named off the key:

const daily = trades.aggregate(Sequence.every('1d'), { price: 'avg' });

daily.toColumns();
// {
// schema: [{ name: 'interval', kind: 'interval' }, { name: 'price', … }],
// columns: {
// interval: [ …begins ],
// intervalEnd: [ …ends ],
// intervalLabel: [ …labels ],
// price: [ … ],
// },
// }

TimeSeries.fromColumns(daily.toColumns()); // round-trips, key and all

A timeRange key is timeRange + timeRangeEnd; an interval key adds intervalLabel. The names are fully determined — a key column's name always equals its kind — so there's nothing to configure. Note what stays put: the schema keeps declaring the logical key, and the edge columns are derived from it rather than described by it, which is what lets the envelope round-trip as the series' own schema.

This is the same convention toArrow has always emitted (Arrow has no interval type either), so one payload spelling serves the JSON and Arrow doors alike — fromArrow(table, { keyKind: 'interval' }) reads the Arrow side back.

Two consequences worth knowing:

  • A value column may not take a derived name. A column called timeRangeEnd alongside a timeRange key means one array with two claimants, so both fromColumns and toColumns throw, naming the collision.
  • Ordering is by (begin, end). Equal begins must be non-decreasing in end — matching the row door. ordering: 'reorder' sorts the same way. Interval labels must be present in every row and all of one type, again matching the row door.

Why flattened rather than [[begin, end], …] pairs? Because the substrate stores the edges as separate buffers, a paired spelling would need a per-row un-zip on the way in — a row walk in the middle of the door that exists to avoid row walks.

Arrow​

pond does not depend on apache-arrow. You bring your own Arrow, decode the table however you like, and hand the Table over — the input is duck-typed against the small structural surface pond actually reads (ArrowTableLike, ArrowVectorLike, … all exported if you need to satisfy it from something that isn't arrow-js).

fromArrow​

import { tableFromIPC } from 'apache-arrow';
import { TimeSeries } from 'pond-ts';

const table = tableFromIPC(
await (await fetch('/api/bars.arrow')).arrayBuffer(),
);

const bars = TimeSeries.fromArrow(table, { name: 'AAPL', time: 'ts' });

The schema is derived from the Arrow fields, so you don't declare one. (You may supply S as fromArrow<MySchema>(…) for downstream typing — pond takes that on trust and does not verify the fields match, exactly as fromEvents trusts its schema.)

OptionDefaultMeaning
name'arrow'Series name.
timethe field named for keyKind ('time')Which column is the time key. Throws if absent. With a two-edged keyKind the key is read from the conventionally named fields and passing time throws.
timeUnitread from the Arrow Timestamp type'second' | 'millisecond' | 'microsecond' | 'nanosecond'. An explicit value always wins.
columnsevery non-time fieldValue columns to include, in order.
keyKind'time''timeRange' / 'interval' to read a flattened two-edged key back.
ordering'strict''reorder' sorts by time key first (disables adoption); 'clamp' carries a backwards timestamp forward. See Out-of-order keys.

Column handling. The time key accepts Timestamp, Date32/Date64, and plain int/float columns, all normalized to epoch ms: a Timestamp's raw-unit int64 is scaled by its TimeUnit, a Date32/Date64 already arrives as epoch-ms from Arrow's toArray() and passes through, and a plain number is taken as ms. A null in the time key throws. Value columns may be numeric (Float32 and int columns convert to Float64; nulls become gaps) or Utf8 strings (dict-encoded; nulls become gaps). Any other Arrow type — list, struct — throws, naming it; use columns to select a supported subset out of a mixed table.

What's zero-copy. Every single-chunk Float64 column's backing Float64Array is adopted as-is, and the time key is converted BigInt-free — Arrow's idiomatic int64 timestamps are recombined from their two int32 halves rather than Number(bigint) per row, measured ~11× faster on the time column (0.6 ms vs 6.8 ms at 500k rows; packages/core/scripts/perf-from-arrow.mjs). A table decoded from a multi-record-batch IPC stream has multi-chunk columns, whose toArray() concatenates into a fresh buffer — still correct, but a copy rather than an adopt, and the aliasing warning above doesn't apply to it. A Float64 column with nulls is also copied, once, in bulk: pond marks a missing number with NaN in the column's own buffer, so it copies Arrow's values and writes NaN at each null rather than writing into your table.

toArrow​

The other columnar door out — toColumns copies into JSON-safe arrays, this one hands over the buffers themselves. pond's columnar substrate is already laid out the way Arrow wants: the validity bitmap is LSB-first, one bit per value, 1 = valid; numeric columns are a contiguous Float64Array; booleans a packed bitmap; dict-encoded strings Int32Array indices plus a dictionary. So toArrow hands back the buffers pond is already holding — nothing is copied, reshaped, or re-encoded.

It returns { length, fields } rather than an Arrow Table, for the same reason fromArrow takes a duck-typed one: adding a dependency to hand data out would be a strange price for interop. The caller assembles per field, narrowing on type first:

import { makeData, makeVector, Float64 } from 'apache-arrow';

const { fields } = series.toArrow();
const f = fields.find((x) => x.name === 'close')!;
if (f.type !== 'float64' && f.type !== 'timestamp') throw new Error(f.type);

const vec = makeVector(
makeData({
type: new Float64(),
length: f.length,
nullCount: f.nullCount,
nullBitmap: f.nullBitmap,
data: f.values, // narrowed to Float64Array by the guard above
}),
);

From there a consumer can reach whatever engine they like — polars, DuckDB, arrow-js — for work pond doesn't do well, without pond depending on any of it and without paying a re-ingest to get there.

toArrow exports number, string and boolean value columns (a wider set than ingest accepts); anything else throws, naming the column. Pass { columns: [...] } to select a subset — the key field(s) are always included. A timeRange or interval key becomes two flat fields, <key> and <key>End, since Arrow has no interval-of-time type; interval labels ride along as <key>Label. A value column that would collide with one of those synthesized names throws rather than producing a duplicate field name.

Some cases are not fully zero-copy, named here so the cost is visible: a chunked column is materialized into one buffer first; a string column pond chose not to dictionary-encode is handed over as a plain JS array (Arrow Utf8 wants offsets into a UTF-8 byte buffer — building those is the consumer's encode); and a number column with gaps hands over its values as-is (holding NaN at each gap) but its nullBitmap is built from them on first use — pond keeps no bitmap for number columns.

The buffers are live storage

toArrow hands over pond's actual storage, not copies. Writing to one corrupts the series it came from — a TimeSeries is immutable by contract, not by defensive cloning. This is the same read-only contract column(name), keyColumn() and toFloat64Array() already carry, but it's worth restating when the buffers cross into another library. Copy first (new Float64Array(f.values)) if the consumer mutates in place.

ValueSeries​

Everything above builds a time-keyed series. When the key is a monotonic value instead — an options chain by strike, a spectrum by frequency, a ride by cumulative distance — ValueSeries is the counterpart, and every door on this page has a twin:

Time-keyedValue-keyed
TimeSeries.fromJSONValueSeries.fromJSON
TimeSeries.fromColumnsValueSeries.fromColumns
TimeSeries.fromArrowValueSeries.fromArrow(table, { axis })
series.toJSON()chain.toJSON()
series.toRows/toObjects()chain.toRows/toObjects()
series.toColumns()chain.toColumns()
series.toArrow()chain.toArrow()

They share one ingest engine, so the monotonic-key contract, ordering, the gap rules, and the zero-copy adoption behave identically — reread the sections above and substitute the axis for time. schema[0] is a 'value'-kind axis column instead of a 'time'-kind one; every direct door checks that first and names itself in the error if you got it wrong.

import { ValueSeries } from 'pond-ts';

const schema = [
{ name: 'strike', kind: 'value' }, // schema[0] is the axis
{ name: 'iv', kind: 'number' },
{ name: 'oi', kind: 'number' },
] as const;

ValueSeries.fromJSON({ name: 'chain', schema, rows });
ValueSeries.fromColumns({ name: 'chain', schema, columns });
ValueSeries.fromArrow(table, { axis: 'strike' });

Data that starts time-keyed doesn't use these doors at all — it projects with byValue(axis), which re-keys a TimeSeries onto one of its own monotonic columns. The direct doors are for rows that were never time-keyed. The full tour is the ValueSeries deep dive; what follows is only where the value axis genuinely diverges from what you just read.

JSON​

ValueSeries.fromJSON is the TimeSeries contract minus the one thing a value axis has no use for: there is no timestamp parsing and no parse.timeZone. The axis cell must be a finite number — a value axis has no calendar to interpret '2026-01-01' against — so a string axis is an error naming the row rather than a silent NaN.

Everything else carries over: array or object rows, null as missing, required: false for sometimes-present columns, ordering: 'reorder' for an unordered payload, and strict per-cell kind checking (a NaN in a number column is rejected, not read as a gap).

Columnar​

ValueSeries.fromColumns takes the same number[] / Float64Array / string[] inputs with the same adopt-vs-copy costs, and is looser than the JSON door in the same way — a non-finite number is a gap, and required isn't checked, because a decoded buffer can't tell "absent" from "not a number".

chain.toColumns() is the same envelope with the same contract as the time-keyed one — minus the two-edged case, since a value axis is always one number per row and has nothing to flatten.

Round trips are typed, and honest about what won't survive

ValueSeries.fromColumns(vs.toColumns()) and ValueSeries.fromJSON(vs.toJSON()) compile without a cast. A boolean or array column — which only a byValue projection can introduce, since the direct doors take number and string — exports fine but can't be ingested back, and the columnar round trip fails to compile rather than at runtime. The row leg is the other way round: toJSON types its boolean cells honestly, so fromJSON accepts the payload's shape and throws at ingest, naming the column and its kind.

Arrow​

ValueSeries.fromArrow(table, { axis }) differs from the time door in exactly two ways:

  • axis is required. There's no 'time' field convention to fall back on — strike, frequency, depth, cumDist are all equally plausible names, and silently keying on the wrong column is worse than an error.
  • The axis is read unscaled. No timeUnit, no TimeUnit to consult; the numbers are taken at face value. int64 still recombines BigInt-free.

chain.toArrow() is the same exporter TimeSeries.toArrow uses and returns the same { length, fields } — a 'value' axis simply exports as a plain float64 field rather than a timestamp one. The live-storage warning above applies identically.

Common ingest issues​

Wall-clock strings silently become UTC​

This is the sharpest edge on the page, because nothing goes wrong loudly:

TimeSeries.fromJSON({
name: 'cpu',
schema,
rows: [['2025-01-01T09:00', 0.42]],
// no parse.timeZone
});
// Does NOT throw: the string is read as 09:00 UTC. If the clock was on
// Madrid time, every instant is now one hour late — silently.

The library cannot tell a Madrid 09:00 from a UTC 09:00, so it takes the deterministic reading (UTC, the same on every machine) rather than the runtime's zone (different on every machine). That keeps a pipeline reproducible, but it means the mistake is quiet: the series looks right and is shifted by your offset. How you notice — a daily aggregate whose buckets start at 23:00 or 01:00 local, a chart whose morning peak sits an hour off the tick. Pin the zone where the text becomes an epoch: add parse.timeZone, or switch to an offset-qualified string ('2025-01-01T09:00+01:00') or a number (Date.parse('2025-01-01T09:00Z')).

Mixed timestamp formats​

Mixing number and ISO-string timestamps within one rows array is legal at the type level — both normalize to ms-since-epoch internally — but it's almost always a symptom of an upstream bug. Two specific risks worth naming:

  • Silent zone drift. A run of ISO strings marked with Z followed by a run of bare wall-clock strings produces events that look ordered but are actually offset by the local-vs-UTC delta of the wall-clock rows' zone.
  • Numbers are always UTC. If you mix numbers and wall-clock strings with parse.timeZone, the numbers don't pick up the zone — they stay UTC while the strings get resolved in the specified zone.

Normalize upstream before shipping the payload over the wire.

Out-of-order keys​

Every door requires non-decreasing keys and throws otherwise, naming the offending row or index. This isn't fussiness: bisect-based operators (crop, atTime, range queries) depend on the invariant, and a backwards key on a trusted columnar door is a corruption signal rather than something to silently accept.

ordering picks what happens instead:

orderingEffect
'strict' (default)Reject the input, naming the first out-of-order row.
'reorder'Stable-sort rows by key. Every column moves with its key; columnar doors lose zero-copy adoption.
'clamp'Keep rows in place; a key earlier than the previous row's takes that key (the running max). Single-edged keys only.

It is the same option, with the same words, as LiveSeries' ordering ('strict' / 'reorder'); live additionally has 'drop' for late events, and batch additionally has 'clamp'.

'reorder' is for data that is merely out of order — merged sources, a CSV in arrival order. 'clamp' is for a noisy feed where a sample's timestamp occasionally jitters backwards: the row stays where it arrived, next to the samples it was taken among, and lands on its predecessor's timestamp.

const s = TimeSeries.fromColumns({
name: 'feed',
schema,
columns: { time: [0, 10, 5, 20], v: [1, 2, 3, 4] },
ordering: 'clamp',
});
// keys: 0, 10, 10, 20 — values untouched: 1, 2, 3, 4

Know what 'clamp' costs before reaching for it:

  • It rewrites keys. A clamped row no longer carries the timestamp the source sent, and it ties its predecessor rather than getting a nudged-later key.
  • It clamps to the running max, so an early forward outlier plateaus everything behind it: 0, 1000, 2, 3 becomes 0, 1000, 1000, 1000.
  • Single-edged keys only. A timeRange / interval key throws under 'clamp' — there is no one right way to clamp a two-edged key. A ValueSeries axis clamps the same way a time key does.
  • Non-finite keys are still rejected, never clamped; a caller's Float64Array key buffer is copied before the first rewrite, never mutated.

sort: true — the option's previous spelling — is accepted for one release as a deprecated alias for ordering: 'reorder'; passing it alongside a conflicting ordering throws.

Non-finite numbers mean different things per door​

DoorNaN / Infinity in a value column
fromJSON / constructorRejected — throws
withColumn with a plain arrayRejected — throws
fromColumns / fromArrowTreated as a gap (missing)
withColumn with a Float64ArrayTreated as a gap (missing)
An operator's own result (overflow, …)Treated as a gap (missing)

The columnar doors are the trusted fast path, where a decoder's NaN is overwhelmingly a sentinel for "no reading" rather than a bad value. If you need a non-finite to be an error in columnar input, check it before ingest.

Whatever the door, a number column never holds a non-finite value: NaN, Infinity and -Infinity all mean missing. So an operator whose arithmetic overflows — a cumulative sum, a rolling / aggregate sum, a diff of MAX - (-MAX), a mapColumns mapper returning Infinity — writes a gap there (undefined on read, counted by nullCount()), and reduce() answers undefined rather than Infinity.

Mutating a buffer you handed to fromColumns​

Adoption is by reference, so this silently rewrites history:

const closes = Float64Array.from([193.4, 193.2, 193.6]);

const bars = TimeSeries.fromColumns({
name: 'AAPL',
schema,
columns: { time, open, close: closes, venue },
});

closes[0] = 0; // bars now reports 0 for the first close

Hand over a buffer you're done writing to, or pass a copy (new Float64Array(closes)). The same applies in reverse to toArrow's output. number[] columns are always copied, so they're never affected.

This can't happen in Rust: the Vec moves into the series, and the compiler stops you using it afterwards.

boolean / array columns don't survive a columnar round trip​

Every key kind goes through the columnar doors — a two-edged key flattens — but the value kinds are number and string. A boolean or array column exports fine through toColumns / toArrow and then can't come back: the ingest engine has no branch for it.

The failure is a compile error rather than a runtime one, because toColumns()'s return type encodes it — fromColumns(series.toColumns()) won't typecheck on a series carrying either kind. select() those columns out first, or round-trip through the row doors, which take every kind.

Object-row JSON from non-pond-ts sources​

If you're parsing JSON emitted by a non-pond-ts system, object rows may use different key names than your schema. Map them first:

const theirRows: Array<{ ts: number; cpu_pct: number }> = externalData;

const ours = theirRows.map((r) => ({
time: r.ts,
cpu: r.cpu_pct,
}));

TimeSeries.fromJSON({ name: 'cpu', schema, rows: ours });

The library isn't trying to guess remaps.

See also​

  • Concepts → Series — TimeSeries vs LiveSeries, schema shape, mental model.
  • ValueSeries deep dive — the full door-by-door tour of the value-keyed type, plus reading, slicing, and when not to reach for it.
  • Value axis concept — when and why a value axis, and the byValue projection route.
  • Cleaning data — what to do about the bad rows once they're in.
  • Columns — the typed-column API that columnar ingest feeds, and the other zero-copy exports (toFloat64Array, keyColumn).
  • Aggregation — where interval keys come from, the ones Two-edged keys exists to carry.
  • Array columns — array cells serialize as JSON arrays and round-trip without special handling.
  • Sequences — including Sequence.calendar for timezone-aware daily/weekly/monthly boundaries, the thing you usually want next after ingesting local-time data.