Memory-efficient ORM bridge: pydantic dataclasses + SQLAlchemy Core. FastAPI compatible.
Project description
SQLDataclass
Memory-efficient ORM bridge: pydantic dataclasses + SQLAlchemy Core. FastAPI compatible.
Define your models once — like SQLModel — but get the memory footprint of plain dataclasses. SQLDataclass uses pydantic dataclasses (slots=True) under the hood, with full pydantic validation, relationships, and native FastAPI support.
Performance
All benchmarks run on SQLite with 10,000 rows and 20 fields per row. Reproducible via
src/sqldataclass/tests/performance_tests/.
The benchmarks below compare two model types from this package:
SQLDataclass— built on pydantic dataclasses withslots=True. Minimal memory overhead, no__dict__.SQLDataclass SQLModel— built on PydanticBaseModel. Same SQL table mapping and convenience methods, but gives you the full BaseModel API (model_dump,model_validate, JSON schema, etc.). Imported asfrom sqldataclass import SQLModel.
Both are compared against SQLAlchemy ORM and tiangolo's SQLModel.
Object construction (20 fields, 10k objects, no DB)
| Approach | B/row | Time |
|---|---|---|
| dict | 578 | 22 ms |
stdlib dataclass (slots=True) |
306 | 27 ms |
pydantic dataclass (slots=True) |
306 | 191 ms |
| SQLDataclass | 322 | 66 ms |
| SQLAlchemy ORM | 1,690 | 246 ms |
| Pydantic BaseModel | 2,914 | 64 ms |
SQLDataclass SQLModel |
2,914 | 67 ms |
| SQLModel (tiangolo) | 4,538 | 916 ms |
Database loading — SQLite (10k rows, 20 fields)
| Approach | B/row | Time |
|---|---|---|
| Raw SQL → dict | 963 | 124 ms |
| Raw SQL → stdlib dataclass | 691 | 136 ms |
| Raw SQL → pydantic dataclass | 691 | 307 ms |
SQLDataclass load_all |
708 | 211 ms |
SQLDataclass SQLModel load_all |
1,204 | 170 ms |
SQLAlchemy ORM Session.query |
2,098 | 167 ms |
SQLModel (tiangolo) session.exec |
2,410 | 169 ms |
Times include query execution + object construction. Both SQLDataclass and SQLModel load_all bypass pydantic's __init__ overhead — SQLDataclass uses validate_python (Rust fast path), SQLModel uses direct __dict__ hydration (like the SA ORM does). SQLModel load_all uses 1.7x less memory than SA ORM and 2x less than tiangolo's SQLModel while matching their speed.
Complex models with relationships (100 teams, 5k heroes, 20 tags, SQLite)
Teams with heroes (one-to-many, 100 teams + 5k heroes):
| Library | Memory | Load time | Notes |
|---|---|---|---|
| SQLDataclass | 1.2 MB | 13 ms | Two-query + back-ref stitching, no session |
| SQLAlchemy ORM + joinedload | 8.1 MB | 19 ms | JOIN-based, needs session |
Heroes with team + tags (many-to-one + many-to-many, 5k heroes, 20 tags, 3 tags/hero):
| Library | Memory | Load time | Notes |
|---|---|---|---|
| SQLDataclass | 3.7 MB | 53 ms | PK-cached deduplication, no session |
| SQLAlchemy ORM + eager | 12.3 MB | 70 ms | Identity map deduplication |
When to use what
- Simple/flat models — SQLDataclass wins on both memory and speed (3-14x less memory)
- One-to-many — SQLDataclass wins (6.7x less memory, faster, with automatic back-references)
- Many-to-many — SQLDataclass wins (3.3x less memory, faster, PK-based deduplication)
Summary
| Benchmark | SQLDataclass | SQLDataclass SQLModel |
vs SQLAlchemy ORM | vs SQLModel (tiangolo) |
|---|---|---|---|---|
| DB loading (memory) | 3x less | 1.7x less | baseline | 1.1x less |
| DB loading (speed) | same | same | baseline | same |
| One-to-many (memory) | 6.7x less | — | baseline | — |
| Many-to-many (memory) | 3.3x less | — | baseline | — |
| Object construction (memory) | 5x less | 1.7x more | baseline | 1.6x less |
| Object construction (speed) | 4x faster | 4x faster | baseline | 14x faster |
SQLDataclass vs SQLDataclass
SQLModel: For DB loading,SQLModeluses direct__dict__hydration (like the SA ORM) — 1,204 B/row, beating both SA ORM (2,098) and tiangolo (2,410). For object construction (no DB),SQLModeluses 2,914 B/row due toBaseModel.__dict__overhead vsSQLDataclass's 322 B/row (slots=True). UseSQLDataclassfor maximum memory efficiency; useSQLModelwhen you need the full BaseModel API (model_dump,model_validate, JSON schema, etc.).
Why the difference?
slots=Truepydantic dataclasses — no__dict__, minimal per-instance overheadvalidate_pythonfast path — bypasses pydantic's__init__wrapper, 40% faster thancls(**row)- SQLAlchemy Core, not ORM — no session, no identity map, no state tracking overhead
- PK-cached M2M deduplication — same tag = same instance, matching ORM's identity map benefit
- Two-query collections — one-to-many loaded via
WHERE fk IN (...), not expensive JOINs - Back-reference stitching —
hero.team = parentset directly, zero extra queries
Install
pip install sqldataclass
For PostgreSQL upsert support:
pip install sqldataclass[postgres]
Quick start
Define your model
One class. That's it. No separate schema and domain model.
from sqldataclass import SQLDataclass, Field
class Hero(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
secret_name: str
age: int | None = None
This creates:
- A pydantic dataclass with
slots=True(validation, FastAPI compat, minimal memory) - A SQLAlchemy Table (for DDL and queries, never instantiated)
Setup
from sqlalchemy import create_engine
engine = create_engine("sqlite:///app.db")
SQLDataclass.metadata.create_all(engine)
# Bind the engine once — conn becomes optional everywhere
SQLDataclass.bind(engine)
Insert data
hero = Hero(name="Spider-Man", secret_name="Peter Parker")
hero.insert()
# Bulk insert
Hero.insert_many(objects=[
Hero(name="Iron Man", secret_name="Tony Stark", age=45),
Hero(name="Thor", secret_name="Thor Odinson", age=1500),
])
Query data
# Load all
heroes = Hero.load_all()
# Filter
heroes = Hero.load_all(where=Hero.c.age > 100)
# Load one
hero = Hero.load_one(where=Hero.c.name == "Spider-Man")
# Order
heroes = Hero.load_all(where=Hero.c.age > 25, order_by=Hero.c.name)
Explicit connections (when you need transaction control)
with engine.begin() as conn:
hero1.insert(conn)
hero2.insert(conn)
# both commit together, or both rollback on error
Working with existing databases
If your tables already exist (managed by Alembic, a DBA, or another service), skip metadata.create_all() — just define models that match the existing schema:
from sqldataclass import SQLDataclass, Field
# Match your existing table and column names exactly
class User(SQLDataclass, table=True):
__tablename__ = "users" # must match the existing table name
id: int = Field(primary_key=True)
email: str
name: str
is_active: bool = True
engine = create_engine("postgresql+psycopg2://user:pass@host/mydb")
SQLDataclass.bind(engine)
# No create_all() needed — read and write directly
users = User.load_all(where=User.c.is_active == True)
User.update({"is_active": False}, where=User.c.id == 42)
You don't need to define every column — only the ones you read or write. Columns not in your model are simply ignored.
Migrations with Alembic
SQLDataclass uses standard SQLAlchemy MetaData, so Alembic works out of the box for schema migrations.
Setup:
pip install alembic
alembic init migrations
Edit migrations/env.py — point Alembic at your SQLDataclass metadata:
from sqldataclass import SQLDataclass
# Import your models so they register their tables
from myapp.models import Hero, Team # noqa: F401
target_metadata = SQLDataclass.metadata
Generate and run migrations:
# Auto-generate migration from model changes
alembic revision --autogenerate -m "add heroes table"
# Apply migration
alembic upgrade head
Example: adding a column to an existing model
Start with:
class Hero(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
Later, add a field:
class Hero(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
power: str = "" # new column
age: int | None = None # new nullable column
Then generate and apply the migration:
alembic revision --autogenerate -m "add power and age to hero"
alembic upgrade head
Alembic auto-detects the diff and generates:
def upgrade():
op.add_column('hero', sa.Column('power', sa.String(), nullable=False))
op.add_column('hero', sa.Column('age', sa.Integer(), nullable=True))
def downgrade():
op.drop_column('hero', 'age')
op.drop_column('hero', 'power')
This works for any schema change: adding/removing columns, renaming tables, changing types, adding indexes, etc.
Use create_all() for development/testing and Alembic for production deployments.
Relationships
SQLDataclass supports all common relationship patterns, loaded eagerly via SQLAlchemy Core — no ORM session required.
Many-to-one
from sqldataclass import SQLDataclass, Field, Relationship
class Team(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
class Hero(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
team: Team | None = Relationship() # auto-JOINed on load
hero = Hero.load_one(where=Hero.c.name == "Spider-Man")
print(hero.team.name) # "Avengers"
The FK column (team_id) is created automatically from the relationship declaration. If you need to control the column name or set the FK without loading the full object, declare it explicitly:
class Hero(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
team_id: int = Field(foreign_key="team.id") # explicit FK
team: Team | None = Relationship()
Cascading insert
When inserting a model with many-to-one relationships, related objects are inserted automatically. Unpersisted related objects (PK is None) are inserted first, and their generated PK is copied into the FK column — no manual ordering required:
avengers = Team(name="Avengers")
hero = Hero(name="Spider-Man", team=avengers)
hero.insert() # inserts avengers first, then hero with team_id set
print(avengers.id) # 1 — PK populated by the DB
print(hero.team_id) # 1 — FK set automatically
Already-persisted related objects (PK is not None) are not re-inserted — only the FK is copied:
avengers = Team(name="Avengers")
avengers.insert() # insert separately
hero1 = Hero(name="Iron Man", team=avengers)
hero1.insert() # avengers already has a PK, so only hero1 is inserted
hero2 = Hero(name="Thor", team=avengers)
hero2.insert() # same — avengers is not inserted again
Cascading insert works recursively for nested relationships and has zero ongoing memory cost — it's a one-shot tree walk at insert time, not a session or identity map.
One-to-many
class Team(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
heroes: list[Hero] = Relationship(back_populates="team")
team = Team.load_one(where=Team.c.name == "Avengers")
print([h.name for h in team.heroes]) # ["Iron Man", "Thor"]
One-to-many uses a two-query strategy (not JOIN-then-group) for memory efficiency — one query for parents, one WHERE fk IN (...) for all children.
Many-to-many
class HeroTeamLink(SQLDataclass, table=True):
hero_id: int = Field(primary_key=True, foreign_key="hero.id")
team_id: int = Field(primary_key=True, foreign_key="team.id")
class Hero(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
teams: list[Team] = Relationship(link_model=HeroTeamLink)
class Team(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
heroes: list[Hero] = Relationship(link_model=HeroTeamLink)
hero = Hero.load_one(where=Hero.c.name == "Wolverine")
print([t.name for t in hero.teams]) # ["Avengers", "X-Men"]
Discriminated unions
For polymorphic data stored in separate tables:
from typing import Literal
class NormalData(SQLDataclass, table=True):
id: int = Field(primary_key=True, foreign_key="participant.id")
behavior: Literal["normal"] = "normal"
p_max: float = 0.0
class BatteryData(SQLDataclass, table=True):
id: int = Field(primary_key=True, foreign_key="participant.id")
behavior: Literal["battery"] = "battery"
capacity: float = 0.0
class Participant(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
behavior: str # discriminator column
data: NormalData | BatteryData = Relationship(discriminator="behavior")
p = Participant.load_one(where=Participant.c.name == "Alice")
print(type(p.data).__name__) # "NormalData"
print(p.data.p_max) # 100.0
Single-table inheritance
Store multiple subtypes in one table with a discriminator column. Child classes can add their own fields (auto-appended as nullable columns):
class Vehicle(SQLDataclass, table=True):
__discriminator__ = "type" # enables single-table inheritance
id: int | None = Field(default=None, primary_key=True)
type: str = ""
name: str = ""
class Car(Vehicle): # just inherit — no extra keywords
doors: int | None = None # auto-added to Vehicle's table
class Truck(Vehicle):
payload: float | None = None # auto-added to Vehicle's table
# Insert — discriminator auto-set from class name
Car(name="Civic", doors=4).insert() # type="car"
Truck(name="F-150", payload=1000).insert() # type="truck"
# Subtype queries — auto-filtered
cars = Car.load_all() # only cars
trucks = Truck.load_all() # only trucks
# Polymorphic query — returns correct subtypes
all_vehicles = Vehicle.load_all() # [Car(...), Truck(...), ...]
type(all_vehicles[0]) # <class 'Car'>
# Scoped update/delete
Car.update({"doors": 2}, where=Car.c.name == "Civic")
Truck.delete() # only deletes trucks
Override the default discriminator value with __discriminator_value__:
class Motorcycle(Vehicle):
__discriminator_value__ = "moto" # instead of default "motorcycle"
wheel_count: int | None = None
Use with FastAPI
Pydantic dataclasses are first-class citizens in FastAPI — no conversion needed:
from fastapi import FastAPI
app = FastAPI()
@app.get("/heroes", response_model=list[Hero])
def get_heroes():
return Hero.load_all()
@app.get("/heroes/{hero_id}", response_model=Hero)
def get_hero(hero_id: int):
return Hero.load_one(where=Hero.c.id == hero_id)
Data-only models (API schemas)
Models without table=True are pure pydantic dataclasses — useful for request bodies:
class HeroCreate(SQLDataclass):
name: str
secret_name: str
age: int | None = None
@app.post("/heroes", response_model=Hero)
def create_hero(data: HeroCreate):
hero = Hero(name=data.name, secret_name=data.secret_name, age=data.age)
hero.insert()
return hero
Field options
Field() accepts both pydantic and SQLAlchemy parameters:
class User(SQLDataclass, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(unique=True, index=True, max_length=255)
name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=0, le=200)
team_id: int | None = Field(default=None, foreign_key="teams.id")
# Not stored in DB — only exists on the Python object
display_name: str = Field(default="", column=False)
is_cached: bool = Field(default=False, column=False)
| Parameter | Type | Description |
|---|---|---|
primary_key |
bool |
Mark as primary key |
index |
bool |
Create database index |
unique |
bool |
Add unique constraint |
foreign_key |
str |
Foreign key reference (e.g. "users.id") |
nullable |
bool |
Override nullable inference |
sa_type |
TypeEngine |
Override SQLAlchemy column type |
default |
Any |
Default value |
ge, le, gt, lt |
float |
Pydantic numeric validators |
min_length, max_length |
int |
Pydantic string validators |
pattern |
str |
Pydantic regex pattern |
column |
bool |
False = field exists on Python object but not in DB |
Relationship options
Relationship() marks a field as loaded from a related table — not stored as a column.
| Parameter | Type | Description |
|---|---|---|
back_populates |
str |
Inverse field name on the child model |
link_model |
type |
Link table class for many-to-many |
discriminator |
str |
Column name for discriminated unions |
order_by |
str |
Column name to sort collection children by |
default |
Any |
Default value (None for many-to-one, [] for collections) |
Custom type annotations
SQLDataclass doesn't bundle domain-specific type annotations (e.g. numpy), but you can define your own in your project and use them seamlessly with pydantic's Annotated types:
# your_project/annotations.py
from functools import partial
from typing import Annotated
import numpy as np
import numpy.typing as npt
from pydantic import BeforeValidator, PlainSerializer
def _to_np_array(dtype, x):
return np.asarray(x, dtype=dtype)
class Np:
"""Numpy type annotations with auto-serialization."""
float64 = Annotated[
np.float64,
PlainSerializer(float, return_type=float, when_used="always"),
BeforeValidator(np.float64),
]
int64 = Annotated[
np.int64,
PlainSerializer(int, return_type=int, when_used="always"),
BeforeValidator(np.int64),
]
class Array:
float64 = Annotated[
npt.NDArray[np.float64],
PlainSerializer(lambda x: x.tolist(), return_type=list[float], when_used="always"),
BeforeValidator(partial(_to_np_array, np.float64)),
]
Then use them in your models:
from sqldataclass import SQLDataclass
from your_project.annotations import Np
class Measurement(SQLDataclass):
score: Np.float64
readings: Np.Array.float64
m = Measurement(score=9.5, readings=[1.0, 2.0, 3.0])
m.dump() # {"score": 9.5, "readings": [1.0, 2.0, 3.0]}
This pattern works for any custom type — numpy, pandas, domain objects, etc. Pydantic's Annotated + BeforeValidator/PlainSerializer handles the conversion automatically.
API reference
Model methods
All methods accept an optional conn parameter. If omitted, a connection is auto-created from the bound engine (see SQLDataclass.bind(engine)).
| Method | Type | Description |
|---|---|---|
SQLDataclass.bind(engine) |
classmethod | Bind engine — makes conn optional everywhere |
Model.select() |
classmethod | Build a SELECT query |
Model.load_all(conn=, where=, order_by=) |
classmethod | Load all matching rows with relationships |
Model.load_one(conn=, where=) |
classmethod | Load one row or None |
Model.insert_many(conn=, objects=) |
classmethod | Bulk insert |
Model.update(values, conn=, where=) |
classmethod | Update matching rows, returns count |
Model.delete(conn=, where=) |
classmethod | Delete matching rows, returns count |
instance.insert(conn=) |
instance | Insert this row |
instance.to_dict(exclude_keys=) |
instance | Flat dict for SQL |
instance.upsert(conn=, index_elements=) |
instance | PostgreSQL upsert |
Model.c |
attribute | Column access for WHERE clauses |
Model.metadata |
attribute | SQLAlchemy MetaData |
Low-level bridge API
For advanced use cases, the underlying bridge functions are also available:
| Function | Description |
|---|---|
load_all(conn, query, cls) |
Execute query, construct instances inline |
fetch_all(conn, query) |
Execute query, return list[dict] |
fetch_one(conn, query) |
Execute query, return single dict or None |
insert_row(conn, table_class, values) |
Insert a single row |
insert_many(conn, table_class, rows) |
Insert multiple rows |
flatten_for_table(obj, exclude_keys=) |
Flatten dataclass to dict |
nest_fields(data, field_name, keys) |
Reshape flat dict for nested models |
format_discriminated(data, cls, ...) |
Reshape flat row for discriminated unions |
Design philosophy
- One class, one definition — no separate SQL schema and domain model
- Memory-first — pydantic dataclasses with
slots=Truematch stdlib dataclass footprint - SQLAlchemy Core, not ORM — explicit queries, no hidden state tracking
- Relationships without a session — eager loading via JOINs and two-query strategy
- FastAPI native — pydantic dataclasses work as response models out of the box
- Escape hatches — low-level bridge API available when you need full control
Acknowledgements
SQLDataclass was born from combining two lines of work:
SQLModel by Sebastián Ramírez and its contributors provided the inspiration for the single-class API — one model definition that serves as both the database schema and the pydantic data model. SQLDataclass recreates this developer experience while targeting lower memory consumption by building on pydantic dataclasses and SQLAlchemy Core instead of the full ORM.
Requirements
- Python 3.13+
- pydantic >= 2.0
- sqlalchemy >= 2.0
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqldataclass-0.1.7.tar.gz.
File metadata
- Download URL: sqldataclass-0.1.7.tar.gz
- Upload date:
- Size: 35.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b833d953d12bc9f979002e868171cf4a5e118021106c6a0511ad4cb655ac7fc
|
|
| MD5 |
db512eec13b23965b8418dc38aee1138
|
|
| BLAKE2b-256 |
c93c8f70250c2a3edc064ecae8d0a4491b17794c6586e7060bdf1fd3c450dfc8
|
File details
Details for the file sqldataclass-0.1.7-py3-none-any.whl.
File metadata
- Download URL: sqldataclass-0.1.7-py3-none-any.whl
- Upload date:
- Size: 39.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c530593fc805636289e3f643ce8d9a8b81b4a76e1fc065edea456d68cbe502c0
|
|
| MD5 |
61d268bcab473a2cd6220df749226478
|
|
| BLAKE2b-256 |
7db52a7909af44d5edfc543512403f6d632b06a80ccfb904e1b3790b25b8a704
|