Pydantic Models — No Migrations. Ever.
Store Pydantic models as JSON. Query them with plain SQL. Change your schema whenever you want — old data keeps working.
This is what you're doing right now
Every time your schema changes with a traditional ORM, this is the ceremony:
- Edit model class
- Run
alembic revision --autogenerate -m "add location field" - Review the generated migration
- Run
alembic upgrade head - Repeat for staging. Repeat for production.
- Hope nothing breaks.
With CentauroDB, you edit the model and you're done. No step 2. No step 3. No step 6. Old data still loads. New data uses the new schema. Views update with one call.
# That's it. Add a field, add a default. Done.
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
unit: str = ""
location: str = "unknown" # old rows get "unknown" automatically
What you get out of the box
Zero Migrations
Add, remove, or rename fields — no migration files, no ALTER TABLE, no downtime. Old data still loads. New data uses the new shape.
Auto SQL Views
One call turns your JSON into real SQL columns. Query with DBeaver, Metabase, Jupyter, or raw SELECT * FROM.
Built-in Time-Series
Sensor readings, financial ticks, metrics — TimeSeriesCollection stores them and gives you a Polars DataFrame in one line.
Pydantic v2 Native
Your models are standard Pydantic classes. Validation on every read and write. No new ORM to learn.
SQLite + PostgreSQL
Start with a single .db file. Move to PostgreSQL by changing one connection string. Same code, same API.
Async Ready
AsyncCollection drops into FastAPI and any async framework. No wrapper hacks, no thread pool juggling.
Four schema changes. Zero migrations.
Your model is live and the database is full of rows. Now change it. Each tab below is a schema change that would cost you an Alembic migration — here you edit the class, redeploy, and the old rows keep loading. The last lines of each example are the proof.
Old rows don't have the new key — Pydantic fills in the default on read.
# v1 — in production, thousands of rows already written
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
unit: str = ""
# v2 — add the field, give it a default. That's the whole change.
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
unit: str = ""
location: str = "unknown"
# the old rows? Still load fine:
sensors = coll.read_objects(Sensor)
sensors[0].location # "unknown" — filled in on read
Old rows still carry the key in their JSON — Pydantic silently ignores it.
# v1 — in production, rows stored with unit AND location
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
unit: str = ""
location: str = ""
# v2 — delete the field. Done.
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
unit: str = ""
# old JSON still contains "location" — it's skipped on read:
sensors = coll.read_objects(Sensor)
sensors[0].unit # loads cleanly, stale key ignored
renamed_from() reads old JSON keys under the new name — no data rewrite.
from centaurodb import renamed_from
# v1 — stored rows as {"temp": 42.0}
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
temp: float = 0.0
# v2 — rename it, point at the old key
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
temperature: float = renamed_from("temp", default=0.0)
# rows written as {"temp": 42.0} load under the new name:
sensors = coll.read_objects(Sensor)
sensors[0].temperature # 42.0
__centauro_name__ decouples the Python class name from the DB identity.
# v1 — shipped with this name
class Sensor(CentauroModel):
__centauro_name__ = "Sensor"
unit: str = ""
# v2 — class renamed; __centauro_name__ keeps the DB identity
class SensorReading(CentauroModel):
__centauro_name__ = "Sensor" # same identity, same data
unit: str = ""
# every row written as Sensor comes back as SensorReading:
readings = coll.read_objects(SensorReading)