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… | Door | Section |
|---|---|---|
| Row tuples you built in code | new TimeSeries({ rows }) | The constructor |
| A JSON payload — rows of arrays or objects | TimeSeries.fromJSON | fromJSON |
Struct-of-arrays JSON — { time: [...], close: [...] } | TimeSeries.fromColumns | JSON columnar |
| Typed arrays off a binary decoder | TimeSeries.fromColumns | Float64Array columnar |
A decoded Apache Arrow Table | TimeSeries.fromArrow | Arrow |
| Events arriving one at a time, forever | new LiveSeries(…) + push | Live data |
| Rows keyed by a value, not a time | ValueSeries.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
- TypeScript
- Rust
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'],
],
});
use pond_rs::{Column, RowFormat, TimeSeries, parse_time};
fn main() -> Result<(), pond_rs::PondError> {
// The key column is always `time`; the value columns take their kind from
// the values, so there is no schema to write.
let cpu = TimeSeries::new(
"cpu",
vec![
parse_time("2025-01-01T00:00:00Z")?,
parse_time("2025-01-01T00:01:00Z")?,
],
vec![
Column::required("cpu", vec![0.31, 0.44]),
Column::required("host", vec!["api-1", "api-1"]),
],
)?;
assert_eq!(
cpu.to_json(RowFormat::Array)?,
r#"{"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"]]}"#
);
// Times out of order are refused.
let backwards = TimeSeries::new(
"cpu",
vec![2_000, 1_000],
vec![Column::required("cpu", vec![0.5, 0.6])],
);
assert!(backwards.is_err());
Ok(())
}
TimeSeries::new has no ordering option: it refuses rows out of order. To sort rows on the way in, read them as JSON with KeyOrder::Reorder (below).
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:
- TypeScript
- Rust
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'],
]);
use pond_rs::live::{LiveOptions, LiveSeries, OrderingMode};
use pond_rs::{ColumnDef, ColumnKind};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::with_options(
"cpu",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::required("cpu", ColumnKind::Number),
ColumnDef::required("host", ColumnKind::String),
],
LiveOptions::new()
.max_events(10_000) // bounded buffer
.ordering(OrderingMode::Reorder) // accept moderately late events
.grace_window("5s"), // ... up to 5s late
)?;
live.push(1_000, [Some(0.42.into()), Some("api-1".into())])?;
live.push_many([
(1_001, [Some(0.43.into()), Some("api-1".into())]),
(1_002, [Some(0.45.into()), Some("api-2".into())]),
])?;
// A snapshot, independent of later pushes.
let batch = live.to_time_series()?;
assert_eq!(batch.len(), 3);
assert_eq!(batch.key().begin(), &[1_000, 1_001, 1_002]);
Ok(())
}
In Rust the options go in a LiveOptions; max_events is pond-ts's retention.maxEvents.
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:
| Kind | JSON 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).
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:
- TypeScript
- Rust
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'],
],
});
use pond_rs::{JsonOptions, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// `from_json` reads JSON text, schema and all.
let payload = r#"{
"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"]
]
}"#;
let cpu = TimeSeries::from_json(payload, &JsonOptions::new())?;
assert_eq!(cpu.len(), 2);
assert_eq!(cpu.column("cpu")?.numbers()?.get(1), Some(0.44));
assert_eq!(cpu.column("host")?.strings()?.get(0), Some("api-1"));
Ok(())
}
from_json takes JSON text. For JSON you have already parsed, from_json_value takes a serde_json::Value.
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 inparse.timeZone, which defaults to UTC — never the runtime's zone, which is where it differs fromDate.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.
- TypeScript
- Rust
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
use pond_rs::{JsonOptions, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let text = r#"{
"name": "cpu",
"schema": [
{"name": "time", "kind": "time"},
{"name": "cpu", "kind": "number"},
{"name": "host", "kind": "string"}
],
"rows": [
["2025-01-01T09:00", 0.42, "api-1"],
["2025-01-01T10:00", 0.51, "api-1"]
]
}"#;
// "2025-01-01 at 09:00 Madrid local" → 08:00:00 UTC.
let ts = TimeSeries::from_json(text, &JsonOptions::new().time_zone("Europe/Madrid"))?;
assert_eq!(ts.key().begin()[0], 1_735_718_400_000);
// Without a time zone the same text is read as UTC: an hour later.
let utc = TimeSeries::from_json(text, &JsonOptions::new())?;
assert_eq!(utc.key().begin()[0] - ts.key().begin()[0], 3_600_000);
Ok(())
}
Does your input need it?
| Input shape | Example | parse.timeZone? |
|---|---|---|
| Number (ms since epoch) | 1735689600000 | Ignored — 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:
- TypeScript
- Rust
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' },
});
use pond_rs::{JsonOptions, Key, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let ts = TimeSeries::from_json(
r#"{
"name": "windows",
"schema": [
{"name": "interval", "kind": "interval"},
{"name": "value", "kind": "number"},
{"name": "active", "kind": "boolean"}
],
"rows": [
{
"interval": {"value": "a", "start": "2025-01-01", "end": "2025-01-02"},
"value": 1,
"active": true
}
]
}"#,
&JsonOptions::new().time_zone("UTC"),
)?;
let Key::Interval(keys) = ts.key() else {
panic!("expected an interval key")
};
assert_eq!(keys.label.strings().unwrap().get(0), Some("a"));
assert_eq!(keys.begin, [1_735_689_600_000]);
assert_eq!(keys.end, [1_735_776_000_000]);
assert_eq!(ts.column("active")?.booleans()?.get(0), Some(true));
Ok(())
}
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):
- TypeScript
- Rust
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
],
});
use pond_rs::{JsonOptions, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let ts = TimeSeries::from_json(
r#"{
"name": "cpu",
"schema": [
{"name": "time", "kind": "time"},
{"name": "cpu", "kind": "number"},
{"name": "status", "kind": "string", "required": false}
],
"rows": [
["2025-01-01T00:00Z", 0.42, "ok"],
["2025-01-01T00:01Z", 0.51, null]
]
}"#,
&JsonOptions::new(),
)?;
assert_eq!(ts.column("status")?.strings()?.get(1), None); // a gap
// `null` in a required column is refused.
let required = r#"{
"name": "cpu",
"schema": [{"name": "time", "kind": "time"}, {"name": "cpu", "kind": "number"}],
"rows": [[0, null]]
}"#;
assert!(TimeSeries::from_json(required, &JsonOptions::new()).is_err());
Ok(())
}
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:
- TypeScript
- Rust
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" }, ...]
use pond_rs::{Column, JsonOptions, RowFormat, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"cpu",
vec![1_735_689_600_000, 1_735_689_660_000],
vec![
Column::required("cpu", vec![0.31, 0.44]),
Column::required("host", vec!["api-1", "api-1"]),
],
)?;
let compact = series.to_json(RowFormat::Array)?;
assert!(
compact.ends_with(r#""rows":[[1735689600000,0.31,"api-1"],[1735689660000,0.44,"api-1"]]}"#)
);
let keyed = series.to_json(RowFormat::Object)?;
assert!(keyed.ends_with(
r#""rows":[{"time":1735689600000,"cpu":0.31,"host":"api-1"},{"time":1735689660000,"cpu":0.44,"host":"api-1"}]}"#
));
// Both read back to the same series.
assert_eq!(
TimeSeries::from_json(&compact, &JsonOptions::new())?,
series
);
assert_eq!(TimeSeries::from_json(&keyed, &JsonOptions::new())?, series);
Ok(())
}
In Rust both formats come back as a JSON string, ready to send.
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(asnullon 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:
| Input | Door | Cost |
|---|---|---|
{ time: number[], close: number[] } | fromColumns | One copy per column |
{ time: Float64Array, close: Float64Array } | fromColumns | Zero-copy — buffers adopted |
A decoded Apache Arrow Table | fromArrow | Zero-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.
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:
- TypeScript
- Rust
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'],
},
});
use pond_rs::{Column, KeyOrder, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// Columns JSON, as it comes off the wire.
let bars = TimeSeries::from_columns_json(
r#"{
"name": "AAPL",
"schema": [
{"name": "time", "kind": "time"},
{"name": "open", "kind": "number"},
{"name": "close", "kind": "number"},
{"name": "venue", "kind": "string"}
],
"columns": {
"time": [1735689600000, 1735689660000, 1735689720000],
"open": [193.1, 193.4, 193.2],
"close": [193.4, 193.2, 193.6],
"venue": ["XNAS", "XNAS", "XNAS"]
}
}"#,
KeyOrder::Strict,
)?;
assert_eq!(bars.column("close")?.numbers()?.get(2), Some(193.6));
// Already holding one Vec per column in Rust? `TimeSeries::new` is the
// columnar door. Each Vec is moved into the series, not copied.
let same = TimeSeries::new(
"AAPL",
vec![1_735_689_600_000, 1_735_689_660_000, 1_735_689_720_000],
vec![
Column::required("open", vec![193.1, 193.4, 193.2]),
Column::required("close", vec![193.4, 193.2, 193.6]),
Column::required("venue", vec!["XNAS", "XNAS", "XNAS"]),
],
)?;
assert_eq!(same, bars);
// `null` is a gap; a column with gaps comes back optional.
let gaps = TimeSeries::from_columns_json(
r#"{
"name": "AAPL",
"schema": [
{"name": "time", "kind": "time"},
{"name": "close", "kind": "number"},
{"name": "venue", "kind": "string"}
],
"columns": {
"time": [1735689600000, 1735689660000],
"close": [193.4, null],
"venue": ["XNAS", null]
}
}"#,
KeyOrder::Strict,
)?;
assert_eq!(gaps.column("close")?.numbers()?.get(1), None);
assert_eq!(gaps.column("venue")?.strings()?.get(1), None);
assert!(!gaps.column("close")?.is_required());
Ok(())
}
Columns JSON is read more strictly in pond-rs: a cell of the wrong kind is an error, where pond-ts converts it. See Arrow and JSON.
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.
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.
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:
- TypeScript
- Rust
// 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());
use pond_rs::{Column, KeyOrder, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"cpu",
vec![0, 60_000],
vec![Column::optional("cpu", vec![Some(0.5), None])],
)?;
let wire = series.to_columns_json()?;
assert_eq!(
wire,
r#"{"name":"cpu","schema":[{"name":"time","kind":"time"},{"name":"cpu","kind":"number","required":false}],"columns":{"time":[0,60000],"cpu":[0.5,null]}}"#
);
// … over HTTP / a WebSocket …
let restored = TimeSeries::from_columns_json(&wire, KeyOrder::Strict)?;
assert_eq!(restored, series);
Ok(())
}
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:
- TypeScript
- Rust
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
use pond_rs::{Agg, Column, KeyOrder, Reducer, Sequence, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let trades = TimeSeries::new(
"trades",
vec![0, 3_600_000, 86_400_000, 90_000_000],
vec![Column::required("price", vec![10.0, 12.0, 20.0, 30.0])],
)?;
let daily = trades.aggregate(&Sequence::every("1d")?, &[Agg::of("price", Reducer::Avg)])?;
let wire = daily.to_columns_json()?;
assert!(wire.ends_with(
r#""columns":{"interval":[0,86400000],"intervalEnd":[86400000,172800000],"intervalLabel":[0,86400000],"price":[11,25]}}"#
));
// Round-trips, key and all.
let back = TimeSeries::from_columns_json(&wire, KeyOrder::Strict)?;
assert_eq!(back.key(), daily.key());
assert_eq!(back.to_columns_json()?, wire);
Ok(())
}
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
timeRangeEndalongside atimeRangekey means one array with two claimants, so bothfromColumnsandtoColumnsthrow, naming the collision. - Ordering is by
(begin, end). Equal begins must be non-decreasing inend— 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
- TypeScript
- Rust
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' });
use std::sync::Arc;
use pond_rs::TimeSeries;
use pond_rs::arrow_array::{ArrayRef, Float64Array, Int64Array, RecordBatch};
fn main() -> Result<(), pond_rs::PondError> {
// Arrow data from elsewhere, here built by hand: a `ts` field of epoch
// milliseconds and a `close` field.
let batch = RecordBatch::try_from_iter([
(
"ts",
Arc::new(Int64Array::from(vec![1_735_689_600_000, 1_735_689_660_000])) as ArrayRef,
),
(
"close",
Arc::new(Float64Array::from(vec![193.4, 193.2])) as ArrayRef,
),
])
.unwrap();
// The last argument names the key field.
let bars = TimeSeries::from_arrow("AAPL", &[batch], "ts")?;
assert_eq!(bars.key().begin(), &[1_735_689_600_000, 1_735_689_660_000]);
assert_eq!(bars.column("close")?.numbers()?.get(1), Some(193.2));
// For an IPC (`.arrow`) file or stream, `read_ipc` takes any reader.
let mut bytes = Vec::new();
bars.write_ipc(&mut bytes)?;
let again = TimeSeries::read_ipc("AAPL", bytes.as_slice(), "ts")?;
assert_eq!(again, bars);
Ok(())
}
pond-rs reads Arrow itself, through the Rust Arrow crates it re-exports (pond_rs::arrow_array and friends): from_arrow for RecordBatches you hold, read_ipc or from_ipc_bytes for an .arrow file or stream. See Arrow and JSON.
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.)
| Option | Default | Meaning |
|---|---|---|
name | 'arrow' | Series name. |
time | the 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. |
timeUnit | read from the Arrow Timestamp type | 'second' | 'millisecond' | 'microsecond' | 'nanosecond'. An explicit value always wins. |
columns | every non-time field | Value 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:
- TypeScript
- Rust
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
}),
);
use pond_rs::arrow_array::{Array, Float64Array};
use pond_rs::{Column, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"AAPL",
vec![1_735_689_600_000, 1_735_689_660_000],
vec![Column::optional("close", vec![Some(193.4), None])],
)?;
// An Arrow `RecordBatch`, ready for any Arrow-based library.
let batch = series.to_arrow()?;
let close = batch
.column_by_name("close")
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap();
assert_eq!(close.value(0), 193.4);
assert!(close.is_null(1)); // a gap is an Arrow null
Ok(())
}
to_arrow returns an Arrow RecordBatch, so there is nothing to assemble; write_ipc writes it as an .arrow file or stream.
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.
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-keyed | Value-keyed |
|---|---|
TimeSeries.fromJSON | ValueSeries.fromJSON |
TimeSeries.fromColumns | ValueSeries.fromColumns |
TimeSeries.fromArrow | ValueSeries.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.
- TypeScript
- Rust
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' });
use pond_rs::{KeyOrder, ValueSeries};
fn main() -> Result<(), pond_rs::PondError> {
// schema[0] is the axis, with kind "value".
let rows = r#"{"name": "chain",
"schema": [
{"name": "strike", "kind": "value"},
{"name": "iv", "kind": "number"},
{"name": "oi", "kind": "number"}
],
"rows": [[90, 0.31, 1200], [95, 0.28, 3400], [100, 0.26, 8800]]}"#;
let chain = ValueSeries::from_json(rows, KeyOrder::Strict)?;
let columns = r#"{"name": "chain",
"schema": [
{"name": "strike", "kind": "value"},
{"name": "iv", "kind": "number"},
{"name": "oi", "kind": "number"}
],
"columns": {"strike": [90, 95, 100], "iv": [0.31, 0.28, 0.26], "oi": [1200, 3400, 8800]}}"#;
assert_eq!(
ValueSeries::from_columns_json(columns, KeyOrder::Strict)?,
chain
);
// Arrow: name the field to use as the axis.
let mut file = Vec::new();
chain.write_ipc(&mut file)?;
assert_eq!(ValueSeries::from_ipc_bytes("chain", file, "strike")?, chain);
Ok(())
}
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.
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:
axisis required. There's no'time'field convention to fall back on —strike,frequency,depth,cumDistare all equally plausible names, and silently keying on the wrong column is worse than an error.- The axis is read unscaled. No
timeUnit, noTimeUnitto 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
Zfollowed 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:
ordering | Effect |
|---|---|
'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.
- TypeScript
- Rust
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
use pond_rs::{KeyOrder, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let feed = r#"{"name": "feed",
"schema": [{"name": "time", "kind": "time"}, {"name": "v", "kind": "number"}],
"columns": {"time": [0, 10, 5, 20], "v": [1, 2, 3, 4]}}"#;
assert!(TimeSeries::from_columns_json(feed, KeyOrder::Strict).is_err());
let clamped = TimeSeries::from_columns_json(feed, KeyOrder::Clamp)?;
assert_eq!(clamped.key().begin(), &[0, 10, 10, 20]);
assert_eq!(
clamped.column("v")?.numbers()?.values(),
&[1.0, 2.0, 3.0, 4.0]
);
let sorted = TimeSeries::from_columns_json(feed, KeyOrder::Reorder)?;
assert_eq!(sorted.key().begin(), &[0, 5, 10, 20]);
assert_eq!(
sorted.column("v")?.numbers()?.values(),
&[1.0, 3.0, 2.0, 4.0]
);
Ok(())
}
In Rust the setting is KeyOrder, passed to from_columns_json, from_json (through JsonOptions::order) and the ValueSeries doors.
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, 3becomes0, 1000, 1000, 1000. - Single-edged keys only. A
timeRange/intervalkey throws under'clamp'— there is no one right way to clamp a two-edged key. AValueSeriesaxis clamps the same way a time key does. - Non-finite keys are still rejected, never clamped; a caller's
Float64Arraykey 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
| Door | NaN / Infinity in a value column |
|---|---|
fromJSON / constructor | Rejected — throws |
withColumn with a plain array | Rejected — throws |
fromColumns / fromArrow | Treated as a gap (missing) |
withColumn with a Float64Array | Treated 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 —
TimeSeriesvsLiveSeries, 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
byValueprojection 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
intervalkeys 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.calendarfor timezone-aware daily/weekly/monthly boundaries, the thing you usually want next after ingesting local-time data.