CentauroDB Logo
Advanced

Time-Series

Store and query time-series data with TimeSeriesCollection, Polars DataFrames, and flexible update modes.

CentauroDB has first-class support for time-series data. Metadata lives in a JSON blob (like regular models), while timestamped values are stored in a separate normalized table. This guide walks you through a complete workflow — from defining a model to ingesting data, querying with Polars, and choosing the right update strategy.

Defining a time-series model

Use CentauroModelSeries instead of CentauroModel. Each instance represents one series of (time, value) pairs alongside its metadata:

from centaurodb import CentauroModelSeries, CentauroValues

class TemperatureSensor(CentauroModelSeries):
    __centauro_name__ = "TemperatureSensor"
    location: str = ""
    unit: str = "Celsius"
Each object tracks one measurement series. If you need temperature and humidity for the same location, create a separate model and object for each — see Tracking multiple series below.

Writing time-series data

Create an object with values and write it to a TimeSeriesCollection:

from centaurodb import Engine, TimeSeriesCollection

engine = Engine("monitoring.db")
coll = TimeSeriesCollection(engine, "monitoring")

reading = TemperatureSensor(
    location="pelion",
    values=[
        CentauroValues(time="2025-06-01T08:00:00", value=22.5),
        CentauroValues(time="2025-06-01T09:00:00", value=24.1),
        CentauroValues(time="2025-06-01T10:00:00", value=26.3),
    ],
)
coll.write_object(reading)

Each CentauroValues has two fields:

  • time — a datetime (ISO timestamp strings are coerced automatically)
  • value — a numeric value (float)

You can also batch-write multiple objects in a single atomic transaction:

coll.write_objects([
    TemperatureSensor(location="pelion", values=[...]),
    TemperatureSensor(location="thessaly", values=[...]),
])
Populate all values before calling write_object — CentauroDB writes them in a single executemany call. This is much faster than appending one at a time via update_object. See the Performance guide for details.

Using Polars DataFrames as input

If your data is already in a Polars DataFrame, pass it directly — no conversion needed:

import polars as pl

df = pl.DataFrame({
    "time": ["2025-06-01T08:00:00", "2025-06-01T09:00:00", "2025-06-01T10:00:00"],
    "value": [22.5, 24.1, 26.3],
})

reading = TemperatureSensor(location="pelion", values=df)
coll.write_object(reading)

CentauroDB validates and converts the DataFrame to a list of CentauroValues under the hood.

Reading time-series data

Read objects back with their values automatically hydrated:

loaded = coll.read_object_by_id(reading.row.id, TemperatureSensor)
print(loaded.location)        # "pelion"
print(len(loaded.values))     # 3
print(loaded.values[0].time)  # datetime(2025, 6, 1, 8, 0)

Reading metadata only

When you need just the metadata (e.g., listing sensors on a dashboard), skip value hydration for faster reads:

sensor = coll.read_object_by_id(1, TemperatureSensor, hydrate_values=False)
print(sensor.location)  # "pelion" — available immediately
print(sensor.values)    # [] — not loaded

This avoids the extra SELECT against the values table per object. Especially useful when reading many objects:

# Fast overview — no values loaded
all_sensors = coll.read_objects(TemperatureSensor, hydrate_values=False)
for s in all_sensors:
    print(f"{s.location}: {s.unit}")

Polars DataFrame output

Access your time-series data as a Polars DataFrame with the .df property:

loaded = coll.read_object_by_id(1, TemperatureSensor)
df = loaded.df
print(df)
# ┌─────────────────────┬───────┐
# │ time                ┆ value │
# │ ---                 ┆ ---   │
# │ datetime[μs]        ┆ f64   │
# ╞═════════════════════╪═══════╡
# │ 2025-06-01 08:00:00 ┆ 22.5  │
# │ 2025-06-01 09:00:00 ┆ 24.1  │
# │ 2025-06-01 10:00:00 ┆ 26.3  │
# └─────────────────────┴───────┘

From here you can use the full Polars API — resample, rolling averages, joins:

# Compute hourly statistics
hourly = df.group_by_dynamic("time", every="1h").agg(
    pl.col("value").mean().alias("avg_temp"),
    pl.col("value").max().alias("max_temp"),
)
The .df property requires Polars to be installed (pip install "centaurodb[polars]"). Accessing .df on an object read with hydrate_values=False raises ValuesNotHydratedError — call coll.refresh(obj) first to load the values.

Update modes

When updating a time-series object, the values_mode parameter controls how new values interact with existing ones:

append (default)

Inserts new timestamps, silently skips duplicates. Use for incremental ingestion where each batch adds new data points:

reading.values = [
    CentauroValues(time="2025-06-01T11:00:00", value=27.8),
    CentauroValues(time="2025-06-01T12:00:00", value=28.2),
]
coll.update_object(reading, values_mode="append")
# The 3 original values are preserved, 2 new ones added

upsert

Inserts new timestamps, overwrites existing ones. Use when values can be corrected or recalculated:

reading.values = [
    CentauroValues(time="2025-06-01T10:00:00", value=25.9),  # corrected value
    CentauroValues(time="2025-06-01T13:00:00", value=29.0),  # new point
]
coll.update_object(reading, values_mode="upsert")

replace

Deletes all existing values and inserts fresh. Use for full series replacement:

coll.update_object(reading, values_mode="replace")
replace mode permanently removes all existing values for the object before inserting the new list. Historical data is lost — use with caution in production.

Tracking multiple series

Since each CentauroModelSeries object represents one series, model different measurement types as separate classes:

class TemperatureSeries(CentauroModelSeries):
    __centauro_name__ = "TemperatureSeries"
    location: str = ""

class HumiditySeries(CentauroModelSeries):
    __centauro_name__ = "HumiditySeries"
    location: str = ""

coll = TimeSeriesCollection(engine, "monitoring")

temp = TemperatureSeries(location="pelion", values=[
    CentauroValues(time="2025-06-01T08:00:00", value=22.5),
])
humidity = HumiditySeries(location="pelion", values=[
    CentauroValues(time="2025-06-01T08:00:00", value=65.0),
])

coll.write_object(temp)
coll.write_object(humidity)

# Query each series independently
temps = coll.read_objects(TemperatureSeries)
humids = coll.read_objects(HumiditySeries)

Both series share the same collection (and can use the same views and indexes), but are stored independently with their own values.

Querying time-series objects

All the query features from Collections work with time-series collections — filtering, like, is_in, OrGroup, aggregates:

# All sensors in greenhouse_a
results = coll.read_objects(
    TemperatureSeries.fields.location == "pelion"
)

# Latest reading across all locations
latest = coll.read_latest_object(TemperatureSeries)

Database schema

A TimeSeriesCollection named "monitoring" creates two tables:

TablePurpose
monitoring_objectsMetadata — id, name, write_time, edit_time, meta (JSON)
monitoring_valuesTime-series — data_object_id, time, value, write_time

The (data_object_id, time) composite key in the values table ensures no duplicate timestamps per object.