Relations
CentauroDB supports one-to-many relationships between models. A child declares a parent_ref field that stores the parent's row.id, and the collection provides helpers to link, write, and query children. This guide walks through the entire workflow with a practical example.
parent_ref vs nested models
CentauroDB gives you two ways to represent related data. Choosing the right one depends on whether the child data has an independent lifecycle.
Use parent_ref when:
- The child entity has its own lifecycle — it can be created, updated, queried, or deleted independently of the parent
- You need to query children without loading the parent (via
children_oforread_objectswith a filter) - The relationship is one-to-many and the child set can grow over time (e.g. Server → Logs, Project → Tasks)
- You want the child visible in views with its own indexed columns
- You want to keep the history of changes — each child is its own row with its own
write_timeandedit_time
from centaurodb import Engine, Collection, CentauroModel, parent_ref
class Server(CentauroModel):
__centauro_name__ = "Server"
hostname: str = ""
class Log(CentauroModel):
__centauro_name__ = "Log"
server: int | None = parent_ref(Server)
level: str = "info"
message: str = ""
engine = Engine()
coll = Collection(engine, "infra")
# Write the parent
srv = Server(hostname="pegasus-01")
coll.write_object(srv)
# Each log is written independently — its own row, its own timestamp
coll.write_child(Log(level="info", message="Started"), parent=srv)
coll.write_child(Log(level="error", message="Disk full"), parent=srv)
coll.write_child(Log(level="info", message="Recovered"), parent=srv)
# Query logs independently — no need to load the server
errors = coll.read_objects(
Log.fields.server == srv.row.id,
Log.fields.level == "error",
)
Use nested Pydantic models when:
- The nested data has no meaning outside the parent — it is always loaded, saved, and deleted together with it
- You never need to query or filter on the nested objects independently (they live inside the parent's JSON blob)
- The data is bounded and small (e.g. user preferences, a config object inside a server)
- You want atomic reads/writes — one
read_objectcall returns the complete aggregate - You don't need change history — updating the parent overwrites the nested data in place
from pydantic import BaseModel
from centaurodb import Engine, Collection, CentauroModel
class Preferences(BaseModel):
theme: str = "dark"
language: str = "en"
notifications: bool = True
class User(CentauroModel):
__centauro_name__ = "User"
name: str = ""
prefs: Preferences = Preferences() # embedded — no separate table
tags: list[str] = [] # simple embedded list
engine = Engine()
coll = Collection(engine, "app")
# Write the user — preferences are stored inside the same JSON blob
user = User(name="Chiron", prefs=Preferences(theme="light"))
coll.write_object(user)
# Update preferences by mutating the parent and updating
user.prefs.language = "el"
user.tags.append("admin")
coll.update_object(user)
# Previous preferences are overwritten — no history kept
Rules of thumb
- If you'd put a foreign key in SQL (Server → Logs) → use
parent_ref - If you'd embed a sub-document in MongoDB (User → Preferences) → use a nested model
- If you need a trail of changes over time → use
parent_ref(each child has its own timestamps) - If you're unsure, start nested — it's simpler. Promote to
parent_refonly when you need independent queries or the child set becomes large
parent_ref relationship.Defining a relationship
Imagine you're building a quest tracker. Quests have many tasks:
from centaurodb import CentauroModel, parent_ref
class Quest(CentauroModel):
__centauro_name__ = "Quest"
name: str = ""
status: str = "active"
class Task(CentauroModel):
__centauro_name__ = "Task"
quest: int | None = parent_ref(Quest)
title: str = ""
done: bool = False
parent_ref(Quest) creates a nullable int field that defaults to None. CentauroDB enforces that it's set before the child can be written — you'll get an UnlinkedParentError if you try to persist an unlinked child.
You can forward any Pydantic field keyword arguments:
class Task(CentauroModel):
__centauro_name__ = "Task"
quest: int | None = parent_ref(Quest, description="FK to quest")
parent_ref also accepts an on_delete keyword (default "restrict") that controls what happens when you delete a parent that still has children — see Deleting parents and children below.
None to preserve the zero-migration guarantee. Enforcement happens at write/update time, not at model creation.Linking children to parents
There are two approaches, depending on your workflow.
Approach 1: Pass the parent object directly
You can assign a persisted parent object directly — CentauroDB auto-resolves it to row.id:
from centaurodb import Engine, Collection
engine = Engine()
coll = Collection(engine, "quests")
quest = Quest(name="The Odyssey")
coll.write_object(quest)
# At construction
task = Task(title="Escape the Cyclops", quest=quest)
print(task.quest) # quest.row.id (int)
coll.write_object(task)
# Or via assignment
task2 = Task(title="Resist the Sirens")
task2.quest = quest # resolves to quest.row.id
coll.write_object(task2)
Approach 2: One-step with write_child
Link and write in a single call:
task3 = Task(title="String the great bow")
coll.write_child(task3, parent=quest)
# Both linked and persisted
print(task3.quest) # quest.row.id
print(task3.row.id) # auto-assigned
write_child auto-discovers the matching parent_ref field by inspecting the child's model class.
UnlinkedParentError for details.Querying children
Using children_of
Read all children linked to a parent:
tasks = coll.children_of(quest, Task)
for t in tasks:
print(t.title, "✓" if t.done else "○")
# Escape the Cyclops ○
# Resist the Sirens ○
# String the great bow ○
Pass raw=True to get raw database rows instead of model instances.
Using read_objects with conditions
For more complex queries, combine the parent filter with other conditions:
# All tasks for this quest
results = coll.read_objects(
Task.fields.quest == quest.row.id
)
# Unfinished tasks for this quest
results = coll.read_objects(
Task.fields.quest == quest.row.id,
Task.fields.done == False,
)
# Combine with OR: tasks involving monsters or navigation
results = coll.read_objects(
Task.fields.quest == quest.row.id,
(Task.fields.title.like("%Cyclops%")) | (Task.fields.title.like("%Sirens%")),
)
Update validation
update_object also validates parent refs. Nulling out a parent_ref and trying to update raises UnlinkedParentError:
task = coll.read_object_by_id(1, Task)
task.quest = None
coll.update_object(task) # raises UnlinkedParentError
Deleting parents and children
By default, parent_ref uses on_delete="restrict": deleting a parent that still has children raises ParentHasChildrenError, and the parent row is left untouched. This prevents orphaned children pointing at an id that no longer exists.
Delete the children first, then the parent:
coll.delete_object(quest) # raises ParentHasChildrenError — tasks still exist
# Correct order: children first
coll.delete_objects(Task.fields.quest == quest.row.id)
coll.delete_object(quest) # works
"cascade" and "set_null" are accepted as on_delete values at class definition, but a delete that would trigger them raises NotImplementedError. The call shape is committed now so future releases can implement them without breaking your model definitions — don't rely on them yet.This restrict-by-default behavior is a deliberate change from the never-published 0.5.0 internal snapshot, which silently orphaned children when their parent was deleted.
Inheritance
Subclasses inherit parent_ref fields from their parent class:
class UrgentTask(Task):
__centauro_name__ = "UrgentTask"
deadline: str = ""
# UrgentTask inherits the quest parent_ref from Task
urgent = UrgentTask(title="Defeat Scylla before dawn", deadline="2025-06-15")
coll.write_child(urgent, parent=quest)
children = coll.children_of(quest, UrgentTask)
Multiple parent refs
A child model can reference multiple parent types:
class Crew(CentauroModel):
__centauro_name__ = "Crew"
name: str = ""
class Assignment(CentauroModel):
__centauro_name__ = "Assignment"
quest: int | None = parent_ref(Quest)
crew: int | None = parent_ref(Crew)
role: str = ""
Pass both parents at construction, or assign them individually before writing:
coll.write_object(crew)
# At construction
assignment = Assignment(role="captain", quest=quest, crew=crew)
coll.write_object(assignment)
# Or assign individually
assignment2 = Assignment(role="oarsman")
assignment2.quest = quest
assignment2.crew = crew
coll.write_object(assignment2)
parent_ref fields must be set before writing or updating. If any remain None, it raises UnlinkedParentError.When a child has multiple parent_ref fields pointing to the same parent type, write_child raises TypeError — set the fields directly instead.
Views and indexes on relations
parent_ref fields work with views and indexes like any other field:
coll.create_view("board", Task, include_fields=["quest", "title", "done"])
coll.create_index("quest")
Query parent-child relationships via SQL:
df = coll.sql_select("""
SELECT title, done
FROM quests_view_board
WHERE quest = ?
ORDER BY title
""", (quest.row.id,))
Keep related models in one collection
Parent-child models must live in the same collection. This is not an arbitrary rule — it follows directly from CentauroDB's single-collection query model: each query targets one {name}_objects table, and parent-child relationships are expressed as filters on that table's meta JSON, not as cross-table JOINs. Row IDs are per-table (an autoincrement counter inside each {name}_objects), so a child row pointing at "id 1" only makes sense relative to the one collection that owns both rows. Mixing collections would silently mismatch unrelated IDs.
# Good — parent and child in the same collection
coll = Collection(engine, "quests")
coll.write_object(quest)
coll.write_child(task, parent=quest)
children = coll.children_of(quest, Task)
# Bad — separate collections break the relationship
quests = Collection(engine, "quests")
tasks = Collection(engine, "tasks")
# IDs in quests_objects and tasks_objects are independent
# — a quest with id=1 and an unrelated object with id=1
# in another collection could create false matches
Async support
All relation methods are available on AsyncCollection and AsyncTimeSeriesCollection:
from centaurodb import AsyncCollection, Engine
engine = Engine(":memory:", check_same_thread=False)
coll = AsyncCollection(engine, "quests")
await coll.write_object(quest)
await coll.write_child(task, parent=quest)
tasks = await coll.children_of(quest, Task)
See the Async guide for setup and usage patterns.