Cleaning Data
Real ingestion is messy. Timestamps drift, values arrive null,
duplicate rows slip in, the source produces gaps that aggregation
later turns into undefined cells. This page is the place to look
when you need to fix a TimeSeries before downstream transforms
can reason over it.
What's covered:
- Schema first —
required: false— declare optional cells before reaching for any cleaning op. - Filter out bad rows —
.filter()patterns. - Fill the gaps —
.fill()strategies. - Deduplicate —
.dedupe()policies and the multi-entity story. - Regularize to a grid —
.materialize()to emit one row per sequence bucket without picking a fill policy. - End-to-end multi-entity cleaning pipeline — the
full
partitionBy + dedupe + fill(maxGap)chain in one place. - Ingest edges — pointers to where wall-clock timestamps, time zones, and missing values are handled.
Schema first — required: false
Most cleaning starts with knowing which columns can have missing
cells. Declare those columns required: false in your schema —
otherwise the type system narrows their cell values to T (not
T | undefined), and fill/dedupe won't have anything to
operate on:
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number', required: false }, // ← optional
{ name: 'host', kind: 'string' }, // ← required (always present)
] as const;
When ingesting JSON or CSV-derived rows, missing cells arrive as
null on the wire and become undefined after
TimeSeries.fromJSON(...). (For row-array construction with
new TimeSeries({ rows }), the RowForSchema type currently doesn't
honor required: false — go through fromJSON with null cells
when you need optional values, or see the known limitation in the
0.8.2 changelog.)
Filter out bad rows
filter() is the simplest cleaning tool — keep events that match a
predicate, drop the rest. Same schema in, same schema out, fewer
rows.
// Drop events whose value is missing.
const present = cpu.filter((e) => e.get('cpu') !== undefined);
// Drop obvious sensor errors (negative CPU, value over 100%).
const sane = cpu.filter((e) => {
const v = e.get('cpu');
return v !== undefined && v >= 0 && v <= 1;
});
// Drop events from a known-bad host.
const trusted = events.filter((e) => e.get('host') !== 'staging-1');
Filtering early means downstream transforms never see the bad rows. For exploration, also pull just the bad rows into a separate series to inspect:
const bad = cpu.filter((e) => {
const v = e.get('cpu');
return v === undefined || v < 0 || v > 1;
});
console.log(bad.length, 'suspect rows');
Fill the gaps
fill() replaces undefined values that aggregation, alignment, or
upstream sources leave behind. The typical post-aggregate cleanup.
Strategies
'hold'— forward-fill: carry the last known value. Leadingundefinedruns stay unfilled (nothing to carry).'bfill'— backward-fill: pull in the next known value. Trailingundefinedruns stay unfilled.'linear'— time-interpolate between the two nearest known values on either side. Leading and trailingundefinedruns stay unfilled (no extrapolation).'zero'— fill with0. Numeric columns only.
A single strategy applies to every value column:
series.fill('hold');
series.fill('linear');
series.fill('zero');
Per-column mapping targets specific columns; other columns pass
through with their undefineds intact:
series.fill({ cpu: 'linear', host: 'hold' });
Literal fill values (anything that's not a strategy string) set the column to that value:
series.fill({ cpu: 0, host: 'unknown' });
Gap caps — limit (cells) and maxGap (duration)
fill() operates on gaps — runs of consecutive undefined cells
in one column. Both options cap how big a gap can be before fill
gives up:
// Count-based: gap of at most 3 cells fits the cap
series.fill('hold', { limit: 3 });
// Duration-based: gap whose temporal span (prev-known to next-known)
// is at most 5 minutes fits the cap
series.fill('linear', { maxGap: '5m' });
// Both — the more restrictive wins
series.fill('linear', { limit: 3, maxGap: '5m' });
All-or-nothing semantics. A gap either fits the caps or it doesn't:
- Gap fits → fill the entire gap with the strategy.
- Gap exceeds either cap → leave the entire gap unfilled.
This is the v0.9.0 default. Earlier versions partially filled
(limit: 3 on a 5-cell gap filled 3, left 2 unfilled). Partial
fill propagated stale 'hold' values past their useful lifetime
and produced misleading 'linear' interpolations across long
outages. All-or-nothing is honest: if the gap is too big to bridge,
leave the unknown unknown.
// Series with one 10-minute outage gap:
const cpu = series.fill('linear', { maxGap: '5m' });
// 10-min gap exceeds 5-min cap → outage remains undefined.
// A naive linear interp would have invented a smooth slope across
// data we don't have.
Typical post-aggregate pipeline
const hourly = series
.aggregate(Sequence.every('1h'), { cpu: 'avg', host: 'last' })
.fill({ cpu: 'linear', host: 'hold' }, { maxGap: '3h' });
Multi-entity series — see partitionBy
fill() walks one chronological event sequence. On a multi-entity
series (events for multiple hosts interleaved by time), neighbors
cross entity boundaries — host-A's missing cell would interpolate
against host-B's value as a "neighbor."
Use partitionBy(col) to scope fill to
within each entity:
series.partitionBy('host').fill({ cpu: 'linear' }).collect();
See Concepts → Partitioning for the full hazard discussion.
Deduplicate
dedupe() collapses events that share a key. Real-world ingest
produces duplicates: WebSocket replays, Kafka at-least-once, retried
HTTP fetches, polling overlaps. The default resolution is
last-wins (matches the "newer event supersedes" intuition).
For series carrying multiple entities (host, region, device id),
bare dedupe() will collapse events from different entities at
the same timestamp as if they were duplicates of each other. Always
pair it with partitionBy
on multi-entity series before reaching for the operator.
For single-entity series, the default key is the event's full key
(timestamp for time-keyed series; begin+end for time-range and
interval keys; the labeled value is part of identity for
intervals):
// Single-entity series — collapse same-timestamp duplicates, keep last.
const clean = events.dedupe();
Multi-entity dedupe — partition first
If your series carries multiple entities, you almost always want
(time + entity) as the dedupe key, not time alone. Bare
dedupe() would collapse host=A@t and host=B@t as if they were
duplicates of each other.
Pair it with partitionBy(col):
// Per-host dedupe — same time AND same host is the duplicate key.
const clean = events.partitionBy('host').dedupe({ keep: 'last' }).collect();
Resolution policies — keep
keep | Behavior |
|---|---|
'last' (default) | Keep the last event at each timestamp. Matches WebSocket replay / "newer wins" intuition. |
'first' | Keep the first event encountered. |
'error' | Throw on the first duplicate seen. Useful when duplicates indicate an upstream shape problem. |
'drop' | Discard every event at any duplicate timestamp. Conservative — never invents a survivor. |
{ min: 'col' } | Keep the event with the smallest value at the named numeric column. Ties keep first. |
{ max: 'col' } | Keep the event with the largest value at the named numeric column. |
(events) => Event | Custom resolver. Receives all duplicates (length ≥ 2), returns one. Use for merge logic. |
// Throw on duplicates — useful in ingestion pipelines that want loud failures.
events.dedupe({ keep: 'error' });
// Keep the highest-CPU sample when two reports race at the same instant.
events.dedupe({ keep: { max: 'cpu' } });
// Custom merge: average duplicate cpu samples; keep the last host name.
events.dedupe({
keep: (events) => {
const last = events[events.length - 1];
const avg =
events.reduce((a, e) => a + (e.get('cpu') ?? 0), 0) / events.length;
return last.set('cpu', avg);
},
});
Chains compose
dedupe returns the same schema, so it sits naturally before any
other transform:
events
.partitionBy('host')
.dedupe({ keep: 'last' })
.fill({ cpu: 'linear' })
.rolling('5m', { cpu: 'avg' })
.collect();
Regularize to a grid — materialize
When source events arrive at irregular times — polling drift, mixed
sample rates, sparse alerts — you often want to project the data
onto a regular grid (one row per minute, one row per second, etc.)
before downstream interpolation, charting, or alignment with another
series. materialize does exactly this: emit one time-keyed row
per sequence bucket, populated from a chosen source event in the
bucket, with undefined cells where no source event existed.
series.materialize(Sequence.every('1m'));
// → one row per minute. Empty buckets emit undefined for all
// value columns. No fill policy applied — that's a separate step.
materialize is the missing piece between dedupe and fill:
| Operator | Creates rows? | Picks fill values? |
|---|---|---|
dedupe | no | no (keeps survivor) |
materialize | yes | no |
fill | no | yes |
align | yes | yes (mandatory) |
align smooshes "make the grid" with "choose how to fill it" — the
fill method ('hold' or 'linear') is mandatory. materialize
does only the first step, leaving fill policy as an explicit
follow-up. Use align when you want both at once; use
materialize when you want gap-capped fill afterwards.
Options
sample('begin' | 'center' | 'end', default'begin') — bucket anchor for the output time. Mirrorsalign's convention.select('first' | 'last' | 'nearest', default'last') — which source event in each bucket wins.'first'/'last'pick the boundary event bybegin()order.'nearest'picks the source event whosebegin()is closest to the bucket's sample time among events in the bucket — it never reaches across into a neighboring bucket. All three modes use half-open[bucket.begin, bucket.end)membership; an empty bucket emitsundefinedregardless ofselect.range(TemporalLike, defaultseries.timeRange()) — bounded slice for procedural sequences (Sequence.every(...)).
Multi-entity — partition first
On a series carrying multiple entities, the partitioned variant auto-populates the partition column on every output row, including rows from buckets where the partition had no source event:
events
.partitionBy('host')
.materialize(Sequence.every('1m'))
.fill({ cpu: 'linear' }, { maxGap: '3m' })
.collect();
// Every row carries its host even on empty buckets — no need for
// a follow-up `.fill({ host: 'hold' })` step that would fail when
// every event in a partition lies in a long-outage gap.
End-to-end multi-entity cleaning pipeline
The canonical multi-host cleaning chain — covers the three most common ingest hazards (cross-entity neighbor leaks, duplicate rows from retried polls, fabrication across long outages) in one pipeline:
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number', required: false },
{ name: 'host', kind: 'string' },
] as const;
const raw = TimeSeries.fromJSON({
name: 'cpu',
schema,
rows /* JSON rows from your ingest */,
});
const cleaned = raw
// Scope every following stateful op to within each host.
.partitionBy('host')
// Collapse duplicates that share (time, host); keep newest.
.dedupe({ keep: 'last' })
// Linear-interpolate cpu, but only across short gaps. A 30-minute
// outage stays unfilled — no fabricated slope across data we don't
// have.
.fill({ cpu: 'linear' }, { maxGap: '5m' })
// Materialize back to a regular TimeSeries.
.collect();
Each step removes one class of error:
| Step | Hazard removed |
|---|---|
partitionBy('host') | Stateful ops below stop crossing entity boundaries — host-A no longer interpolates against host-B's neighboring value. |
dedupe({ keep: 'last' }) | Retried HTTP polls / WebSocket replays at the same (time, host) collapse to the newest. |
fill({ cpu: 'linear' }, { maxGap: '5m' }) | Short gaps fill cleanly; long outages stay undefined — no invented slopes across data you don't have. |
.collect() | Returns a TimeSeries<S> ready for downstream rendering or aggregation. |
Ingest edges
Most "the data is wrong" problems happen at the boundary, not inside pipelines. The relevant docs:
- Time zones / wall-clock strings — see
JSON ingest and time zones → Wall-clock strings.
The decision table there ("does this input need
parse.timeZone?") is the single most useful artifact for ingest-side time bugs. nullvsundefined— see JSON ingest → Missing values. Wire-formatnullbecomesundefinedon read; columns markedrequired: falseaccept it.- Mixed timestamp formats — see JSON ingest → Pitfalls. Mixing offset-qualified strings with bare wall-clock strings is a silent zone-drift footgun.
Cleaning the data after ingest can't recover from a wrong-zone parse. Get the boundary right first.