Time-Series
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"
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— adatetime(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=[...]),
])
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"),
)
.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:
| Table | Purpose |
|---|---|
monitoring_objects | Metadata — id, name, write_time, edit_time, meta (JSON) |
monitoring_values | Time-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.