CentauroDB Logo
Advanced

Performance Guide

Indexes, view tuning, and model-level optimizations to get the most out of CentauroDB.

CentauroDB stores objects as JSON blobs. Without any tuning you get zero-migration flexibility — but raw JSON queries do a full table scan on every read. This guide covers the tools that turn that into fast, indexed access.

Index the fields you query

Indexing is the single biggest lever for read performance. There are two ways to create indexes:

Via create_index() — create expression indexes on any field:

coll.create_index("unit")       # idempotent — safe to call on every startup
coll.create_index("location")

This produces an expression index:

CREATE INDEX idx_monitoring_unit ON monitoring_objects (json_extract(meta, '$.unit'))

When to index: Add indexes for any field you filter, sort, or aggregate on frequently. Common candidates include status fields, category fields, timestamps, and foreign keys (parent_ref fields).

Indexes live on the objects table, not on views — they are fully independent. See Views: Indexing fields for details.

Limit view columns with include_fields

By default a view extracts every field from the model. On wide models this means parsing the full JSON blob for every row — even when you only need two columns.

# 2 json_extract() calls per row instead of 10
coll.create_view("lite", Sensor, include_fields=["unit", "location"])

Pair with create_index() for maximum impact:

coll.create_view(
    "lite",
    Sensor,
    include_fields=["unit", "location"],
)
coll.create_index("unit")

Disable assignment validation for hot paths

CentauroModel enables validate_assignment=True by default, which re-runs Pydantic validation every time you set an attribute. This catches type errors early but adds overhead in tight loops.

from pydantic import ConfigDict

class SensorReading(CentauroModel):
    __centauro_name__ = "SensorReading"
    model_config = ConfigDict(validate_assignment=False, populate_by_name=True)
    unit: str = ""
    value: float = 0.0

Best for models that are created, immediately written, and never mutated after — such as append-only readings or log entries.

Skip value hydration when you don't need it

Reading a TimeSeriesCollection object with the default hydrate_values=True fires a second SELECT against the values table. If you only need metadata, skip it:

obj = coll.read_object_by_id(42, Metric, hydrate_values=False)
obj.unit       # available immediately
obj.values     # empty — not loaded
obj.df         # raises ValuesNotHydratedError until refresh() is called

This is especially useful when listing many objects for a dashboard:

# Fast: no values query per object
all_metrics = coll.read_objects(Metric, hydrate_values=False)
for m in all_metrics:
    print(m.unit, m.row.edit_time)

Batch-populate values before writing

CentauroDB writes all obj.values in a single executemany call. Populate the list fully before calling write_object rather than appending and updating repeatedly:

# Efficient: one INSERT executemany
metric = Metric(
    unit="kg",
    values=[CentauroValues(time=t, value=v) for t, v in readings],
)
coll.write_object(metric)

# Inefficient: N separate UPDATE round-trips
metric = Metric(unit="kg")
coll.write_object(metric)
for t, v in readings:
    metric.values = [CentauroValues(time=t, value=v)]
    coll.update_object(metric, values_mode="append")

Choose the right values_mode for updates

When calling update_object on a TimeSeriesCollection, the values_mode parameter has significant performance implications:

append (default)

INSERT OR IGNORE — skips duplicates without error. Best for incremental ingestion where new timestamps are added over time.

upsert

INSERT OR REPLACE / ON CONFLICT DO UPDATE — overwrites existing timestamps. Use when values can change (corrections, recalculations).

replace

DELETE all values then bulk insert. Fastest for full-replacement scenarios, but briefly removes all historical data.

Query via views for complex filters

read_objects uses json_extract() in the WHERE clause and benefits from expression indexes. For multi-condition queries, creating a view and running raw SQL is often cleaner and can leverage query planner optimizations:

# Python DSL — good for simple filters
results = coll.read_objects(Sensor.fields.unit == "Celsius")

# SQL via view — better for aggregations, JOINs, GROUP BY
df = coll.sql_select("""
    SELECT location, AVG(threshold) AS avg_threshold
    FROM monitoring_view_dashboard
    WHERE unit = 'Celsius'
    GROUP BY location
    ORDER BY avg_threshold DESC
""")

Split collections for table-level performance

By default, one collection per project is the right starting point. But as data grows, a single _objects table can become a bottleneck. Splitting into multiple collections gives each its own table, which helps in three specific scenarios:

Write-volume skew

If one model type produces far more rows than the rest (100x+), every read_objects call on the quiet types still scans past the hot type's rows (even with an index on name, the B-tree is larger). A dedicated collection keeps the hot table separate:

# Before — one table, 10M SensorReading rows slow down Config reads
app = Collection(engine, "app")

# After — hot writer gets its own table
config  = Collection(engine, "config")          # small, fast scans
readings = TimeSeriesCollection(engine, "readings")  # millions of rows, isolated

Independent retention

When you need to purge old data for one model type but keep others, a separate table lets you DELETE or DROP cheaply without locking or fragmenting the shared table:

logs    = Collection(engine, "logs")       # purge weekly
settings = Collection(engine, "settings")  # keep forever

WAL contention (SQLite)

SQLite allows one writer at a time. If two parts of your app write frequently, putting them in the same collection means they compete for the same table lock. Separate collections don't eliminate the global write lock, but they reduce the time each write holds it (smaller indexes to update, smaller pages to flush).

Do not split related models that use parent_ref / children_of — these only work within a single collection. See Choosing a collection name for the full decision guide.

Use in-memory SQLite for testing

The :memory: engine skips all disk I/O. Use it for unit tests and batch processing:

# Unit tests — fast, isolated, no cleanup
engine = Engine(":memory:")

# Batch jobs — process → query → discard
engine = Engine(":memory:")
coll = Collection(engine, "results")
# ... heavy processing ...

For file-based SQLite, CentauroDB automatically enables PRAGMA journal_mode=WAL, which allows readers and a single writer to operate concurrently.

WAL mode is enabled automatically for file-based SQLite databases. No configuration needed.

PostgreSQL JSONB for read-heavy workloads

When using PostgreSQL, CentauroDB stores the meta column as JSONB — pre-parsed binary JSON. This is faster for queries because the database doesn't re-parse the blob for each extraction.

Switching from SQLite requires no code changes:

engine = Engine("postgresql://user:pass@host/dbname")

On PostgreSQL, expression indexes use the ->> operator:

CREATE INDEX idx_monitoring_unit ON monitoring_objects ((meta->>'unit'))

Use create_index() to create them automatically.

Summary

TechniqueWhen to use
create_index(field)Any field you filter or sort frequently (speeds up both views and read_objects)
include_fields on viewsWide models where you only query a few fields
validate_assignment=FalseAppend-only / high-throughput write models
hydrate_values=FalseReading many series objects without needing their values
Batch-populate .valuesBulk ingestion of time-series data
values_mode="replace"Full replacement of time-series data
Split collectionsWrite-volume skew, independent retention, WAL contention
In-memory engineTests and ephemeral batch jobs
PostgreSQL + JSONBHigh read concurrency, large datasets