Advanced Usage
Nested Models with Foreign Keys
Store models that contain other Pydantic models by using foreign key relationships:
from pydantic import BaseModel
from pydantic_sqlite import DataBase
class Address(BaseModel):
uuid: str
street: str
city: str
country: str
class Person(BaseModel):
uuid: str
name: str
address: Address
db = DataBase()
# Optionally add the related Address first (not required — when the nested model's PK is "uuid", the default, pydantic-sqlite auto-creates the foreign table on the first add)
address = Address(uuid="addr-1", street="123 Main St", city="Berlin", country="Germany")
db.add("Addresses", address)
# Add Person with foreign_tables parameter (plain str = PK defaults to "uuid")
person = Person(uuid="person-1", name="Alice", address=address)
db.add("Persons", person, foreign_tables={"address": "Addresses"})
# Retrieve - the Address is automatically reconstructed
for p in db("Persons"):
print(f"{p.name} lives in {p.address.city}")
Custom Primary Keys
By default, pydantic-sqlite looks for a uuid field as the primary key. You can use any field as the primary key:
from pydantic import BaseModel
from pydantic_sqlite import DataBase
class Car(BaseModel):
vin: str # Vehicle Identification Number
model: str
year: int
db = DataBase()
car = Car(vin="12345ABCDE", model="Tesla Model 3", year=2024)
# Specify the primary key with pk parameter
db.add("Cars", car, pk="vin")
# Query and retrieve
for car in db("Cars"):
print(f"{car.year} {car.model}")
Multiple Levels of Nesting
You can nest models at multiple levels, each with its own primary key:
from pydantic import BaseModel
from pydantic_sqlite import DataBase
class Wheel(BaseModel):
batch_id: str
diameter: int
class Car(BaseModel):
series_number: str
model: str
wheels: list[Wheel]
class Garage(BaseModel):
garage_id: str
owner: str
car: Car
db = DataBase()
# Create wheels
wheels = [
Wheel(batch_id="W1", diameter=18),
Wheel(batch_id="W2", diameter=18),
Wheel(batch_id="W3", diameter=18),
Wheel(batch_id="W4", diameter=18),
]
# Add wheels to database
for wheel in wheels:
db.add("Wheels", wheel, pk="batch_id")
# Create car with wheels
car = Car(series_number="SN123", model="Model S", wheels=wheels)
db.add("Cars", car, pk="series_number", foreign_tables={"wheels": "Wheels"})
# Create garage with car
garage = Garage(garage_id="G1", owner="Alice", car=car)
db.add("Garages", garage, pk="garage_id", foreign_tables={"car": "Cars"})
# Retrieve - full object hierarchy is reconstructed
for g in db("Garages"):
print(f"{g.owner}'s {g.car.model} has {len(g.car.wheels)} wheels")
Cascading Deletes
By default, delete and delete_where only remove rows from the specified table. Rows in foreign tables that are referenced by nested models stay untouched:
from pydantic import BaseModel
from pydantic_sqlite import DataBase
class Address(BaseModel):
uuid: str
street: str
city: str
class Person(BaseModel):
uuid: str
name: str
address: Address
db = DataBase()
address = Address(uuid="addr-1", street="123 Main St", city="Berlin")
person = Person(uuid="person-1", name="Alice", address=address)
db.add("Addresses", address)
db.add("Persons", person, foreign_tables={"address": "Addresses"})
# Default: only the Person row is deleted
db.delete("Persons", "person-1")
# -> the Address 'addr-1' remains in the 'Addresses' table
Pass cascade=True to also delete nested rows in foreign tables. Only rows that are exclusively referenced by the deleted row(s) are removed:
db = DataBase()
address = Address(uuid="addr-1", street="123 Main St", city="Berlin")
db.add("Addresses", address)
db.add("Persons", Person(uuid="person-1", name="Alice", address=address), foreign_tables={"address": "Addresses"})
db.delete("Persons", "person-1", cascade=True)
# -> the Address 'addr-1' is deleted as well, because no other row references it
Nested rows that are shared between multiple rows are kept:
db = DataBase()
address = Address(uuid="addr-1", street="123 Main St", city="Berlin")
db.add("Addresses", address)
db.add("Persons", Person(uuid="person-1", name="Alice", address=address), foreign_tables={"address": "Addresses"})
db.add("Persons", Person(uuid="person-2", name="Bob", address=address), foreign_tables={"address": "Addresses"})
db.delete("Persons", "person-1", cascade=True)
# -> the Address stays, because 'person-2' still references it
delete_where supports the same cascade parameter:
db.delete_where("Persons", where="name = :name", where_args={"name": "Alice"}, cascade=True)
Cascading deletes are recursive and also work with list fields, e.g. a Garage containing cars: list[Car] — deleting the garage with cascade=True removes the exclusively referenced cars as well.
Keyed Nested Collections: dict[str, BaseModel]
Models can also contain dictionaries of nested models. The nested models are stored in a foreign table (like list[BaseModel]), while the parent row stores a JSON mapping of {key: primary_key}. The keys are preserved as-is, so arbitrary labels work:
from decimal import Decimal
from pydantic import BaseModel
from pydantic_sqlite import DataBase
class Position(BaseModel):
symbol: str
quantity: int
avg_cost: Decimal
class Portfolio(BaseModel):
strategy_id: str
positions: dict[str, Position] = {}
db = DataBase()
# First, add the related Position to its table (or omit — auto-created when using the tuple form to declare the primary key)
position = Position(symbol="AAPL", quantity=4, avg_cost=Decimal("25.5"))
db.add("Positions", position, pk="symbol")
# Add Portfolio with foreign_tables parameter (tuple form when PK is not "uuid")
portfolio = Portfolio(strategy_id="m", positions={"AAPL": position})
db.add("Portfolios", portfolio, pk="strategy_id", foreign_tables={"positions": ("Positions", "symbol")})
# Retrieve - the Position objects are fully reconstructed
record = db.model_from_table("Portfolios", "m")
print(record.positions["AAPL"].avg_cost) # 25.5
Dict keys may be arbitrary labels and do not have to match the primary keys of the nested models:
portfolio = Portfolio(strategy_id="m", positions={"main": position})
db.add("Portfolios", portfolio, pk="strategy_id", foreign_tables={"positions": ("Positions", "symbol")})
record = db.model_from_table("Portfolios", "m")
print(record.positions["main"].symbol) # AAPL
dict[str, Primitive] fields (e.g. dict[str, str]) also round-trip correctly without any foreign_tables entry.
Auto-Created Foreign Tables
Foreign tables no longer need to exist before they are first referenced. Like host tables, they are created automatically from the nested model's type annotation on the first add(). This works for BaseModel, list[BaseModel] and dict[str, BaseModel] fields, even when the collection is empty:
db = DataBase()
# Empty portfolio — the "Positions" table is created on the fly
portfolio = Portfolio(strategy_id="m")
db.add("Portfolios", portfolio, pk="strategy_id", foreign_tables={"positions": ("Positions", "symbol")})
# Positions can be added afterwards, and re-adding the portfolio automatically stores the {key: primary_key} mapping
db.add("Positions", Position(symbol="AAPL", quantity=4), pk="symbol")
record = db.model_from_table("Portfolios", "m")
print(record.positions) # {}
Declaring the primary key
Each foreign_tables entry can be either:
- a plain table name (
str) — the primary key defaults to"uuid"; the nested model must have auuidfield - a
(table_name, pk)tuple — declares the primary key explicitly, as above
Use the tuple form whenever the nested model's primary key is not "uuid". Deeper nested levels (a foreign table whose own model contains further nested BaseModels) still need to be registered before the enclosing table is added, because their foreign table names are not derivable from the type annotation alone.
When a model gains new fields, add() extends the existing table with the missing columns automatically instead of raising a "no such column" error.
SQConfig: Custom Object Conversion
For models you don't want to store in separate tables, use SQConfig with the special_insert flag to store them as strings:
from pydantic import BaseModel, field_validator
from pydantic_sqlite import DataBase
from uuid import uuid4
class Address(BaseModel):
street: str
city: str
class SQConfig:
special_insert: bool = True
def convert(obj):
return f"{obj.street},{obj.city}"
class Person(BaseModel):
uuid: str
name: str
address: Address
@field_validator('address', mode="before")
def validate_address(cls, v):
if isinstance(v, Address):
return v
street, city = v.split(',')
return Address(street=street, city=city)
db = DataBase()
person = Person(
uuid=str(uuid4()),
name="Bob",
address=Address(street="456 Oak Ave", city="Hamburg")
)
db.add("Persons", person)
# The address is stored as a string, not a foreign key
# But when retrieved, it's reconstructed as an Address object
for p in db("Persons"):
print(f"{p.name}: {p.address.city}")
FailSafeDataBase: Error Recovery
The FailSafeDataBase context manager automatically creates a database snapshot if an exception occurs:
from pydantic_sqlite import FailSafeDataBase
from pydantic import BaseModel
from uuid import uuid4
class User(BaseModel):
uuid: str
username: str
with FailSafeDataBase("users.db", snapshot_suffix="_snapshot.db") as db:
user1 = User(uuid=str(uuid4()), username="alice")
db.add("Users", user1)
user2 = User(uuid=str(uuid4()), username="bob")
db.add("Users", user2)
# If an exception occurs here, a snapshot is automatically saved
# as users_snapshot.db
if True: # Some error condition
raise Exception("Something went wrong!")
After running this, you'll have:
- users.db - The original database
- users_snapshot.db - A snapshot with the data as it was before the exception
If you run the script again and an error occurs, the snapshot file is incremented to users_snapshot(1).db.