🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)
🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)

🔧 Programmierung 🕛 kürzlich 11 Min Lesezeit
0

Typed ORM with automatic migrations: Fitz vs SQLAlchemy + Alembic + Pydantic

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

For a typed ORM with automatic migrations in Python you need to maintain 3 sources of truth (SQLAlchemy + Pydantic + Alembic). In Fitz it's ONE type with decorators. Plus 8× RPS and 5× less memory than SQLAlchemy in a reproducible benchmark.







The double (triple) typing of the Python stack



For each entity of your DB in a modern FastAPI you have to maintain:





  • models.py — SQLAlchemy model (defines the schema).


  • schemas.py — Pydantic models (defines request/response).


  • alembic/versions/*.py — autogenerated migrations.




CODE
# models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime

Base = declarative_base()

class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, nullable=False, unique=True)
name = Column(String, nullable=False)
role = Column(String, nullable=False, default="user")
created_at = Column(DateTime, default=datetime.utcnow)
posts = relationship("Post", back_populates="user")

class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=False)
body = Column(String, nullable=False)
user = relationship("User", back_populates="posts")









CODE
# schemas.py
from pydantic import BaseModel
from datetime import datetime

class UserCreate(BaseModel):
email: str
name: str
role: str = "user"

class UserOut(BaseModel):
id: int
email: str
name: str
role: str
created_at: datetime

class Config:
from_attributes = True

class PostCreate(BaseModel):
title: str
body: str

class PostOut(BaseModel):
id: int
user_id: int
title: str
body: str

class Config:
from_attributes = True

class UserWithPosts(UserOut):
posts: list[PostOut] = []









CODE
# main.py
@app.post("/users", response_model=UserOut)
async def create_user(user: UserCreate, db: AsyncSession = Depends(get_session)):
new_user = User(**user.model_dump())
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user

@app.get("/users/{id}", response_model=UserWithPosts)
async def get_user(id: int, db: AsyncSession = Depends(get_session)):
result = await db.execute(
select(User).options(selectinload(User.posts)).where(User.id == id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "user not found")
return user






Three files. Two models of the same entity maintained in parallel. And for the schema:




CODE
$ alembic revision --autogenerate -m "add user role"
# Review the generated file — autogenerate sometimes gets confused
$ alembic upgrade head






Plus configuring Alembic (env.py, alembic.ini, target_metadata...).






The same thing in Fitz






CODE
@table("users")
type User {
@primary id: Int,
email: Str,
name: Str,
role: Str = "user",
created_at: Str = "now()",
@has_many("Post", "user_id") posts: List<Post>,
}

@table("posts")
type Post {
@primary id: Int,
user_id: Int,
title: Str,
body: Str,
@belongs_to user: User?,
}

@post("/users")
async fn create_user(db: DbConn, user: User) -> Result<User> {
return User.insert(db, user).await
}

@get("/users/{id}")
async fn get_user(db: DbConn, id: Int) -> Result<User> {
return User.where(fn(u) => u.id == id).preload("posts").first(db).await
}






ONE type. ONE source of truth. The compiler generates:




  • The parameterized SQL for queries.

  • The OpenAPI schema from the same type.

  • The body JSON deserializer from the same type.

  • The validation of required / nullable / default fields.



And the DB schema:




CODE
$ fitz db diff
+ CREATE TABLE users (
+ id BIGSERIAL PRIMARY KEY,
+ email TEXT NOT NULL,
+ name TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+ CREATE TABLE posts (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id),
+ title TEXT NOT NULL,
+ body TEXT NOT NULL
+ );

$ fitz db migrate
✓ applied migration 20260616_120300_initial.sql









The raw table









































































Item Python (SQLAlchemy + Alembic + Pydantic) Fitz
Sources of truth per entity 3 (models.py, schemas.py, alembic/) 1 (@table type)
Generate schema alembic revision --autogenerate -m "..." fitz db diff
Apply alembic upgrade head fitz db migrate
Rollback alembic downgrade -1 fitz db rollback
Query DSL
.filter(User.role == "admin") (ORM DSL)

.where(fn(u) => u.role == "admin") (closure-to-SQL at compile time)
Eager loading .options(selectinload(User.posts)) .preload("posts")
Postgres operators
User.email.ilike(...), .contains(...), etc.
Same —u.email.ilike(...), u.tags.has(...)
Transactions
with session.begin(): + remember to flush
db.transaction(fn(tx) async { ... }).await
Body request validation Separate Pydantic Same type
OpenAPI schema Pydantic infer Auto from @table type
Driver libpq via psycopg2/asyncpg
Pure Rust (no libpq, no tokio-postgres)
Performance vs SQLAlchemy baseline 8× RPS, 5× less memory (bench below)





Piece by piece






Closure-to-SQL at compile time



This is what took me the most effort and what I'm proudest of. SQLAlchemy translates its DSL to SQL at runtime — each .filter(User.role == "admin") builds the AST at runtime, takes overhead, allocates, and emits SQL.



Fitz does it at compile time:




CODE
let admins = User.where(fn(u) => u.role == "admin")
.order_by(fn(u) => u.created_at, "desc")
.limit(10)
.all(db).await?






The compiler analyzes the closure fn(u) => u.role == "admin" and emits, once at binary compile time, the constant SQL string:




CODE
SELECT id, email, name, role, created_at FROM users
WHERE role = $1 ORDER BY created_at DESC LIMIT 10






Plus the code that binds $1 = "admin". Zero runtime overhead for SQL construction — the string already exists in the binary as &'static str.



Comparable in performance to Diesel or sqlx (which also generate SQL at compile time via macros), but with natural language syntax instead of procedural macros.






Native Postgres operators



SQLAlchemy:




CODE
admins = session.query(User).filter(User.email.ilike("%@example.com")).all()
tagged = session.query(User).filter(User.tags.contains(["admin"])).all()
pro_users = session.query(User).filter(User.metadata["plan"].astext == "pro").all()






Fitz (operators are methods on the field):




CODE
let admins = User.where(fn(u) => u.email.ilike("%@example.com")).all(db).await?
let tagged = User.where(fn(u) => u.tags.has("admin")).all(db).await?
let pro_users = User.where(fn(u) => u.metadata.get("plan") == "pro").all(db).await?






Operators covered:




























Category Methods
String
.like(p), .ilike(p), .starts_with(p), .ends_with(p), .contains(p) (with auto-escape of %/_)
Lists/IN
.is_in([...]), .is_null(), .is_not_null()
Arrays
.has(x) (?), .contains_all(xs) (@>), .contained_in(xs) (<@)
JSONB
.has_key(k) (?), .has_all_keys(ks) (?&), .has_any_keys(ks) (`?\





Relations + eager loading



SQLAlchemy:

{% raw %}



CODE
class User(Base):
posts = relationship("Post", back_populates="user", lazy="select")

class Post(Base):
user = relationship("User", back_populates="posts")

# Eager loading
users = await db.execute(
select(User).options(selectinload(User.posts))
)





Plus configuring lazy= correctly to avoid the oldest N+1 in the world (lazy queries firing one per user).



Fitz:




CODE
@table("users")
type User {
@primary id: Int,
name: Str,
@has_many("Post", "user_id") posts: List<Post>,
}

@table("posts")
type Post {
@primary id: Int,
user_id: Int,
@belongs_to user: User?,
}

// Explicit eager loading (default is NOT eager — no surprises)
let users = User.preload("posts").all(db).await?






.preload("posts") emits a single batched query with WHERE user_id IN (...) and builds the Fitz structs. The default is non-eager: if you don't preload, the virtual field posts is empty and you know you didn't hit the DB.



Typo in the field name:




CODE
let users = User.preload("postss").all(db).await?
// ^^^^^^^ compile error: User has no relation "postss"






The compiler statically checks the match. SQLAlchemy tells you at runtime when the request lands.






Transactions



SQLAlchemy:




CODE
async with db.begin():
user = User(...)
db.add(user)
await db.flush() # remember to flush or user.id is empty
post = Post(user_id=user.id, ...)
db.add(post)






Fitz:




CODE
let result = db.transaction(fn(tx) async {
let user = User.insert(tx, User { id: 0, name: "Ada", role: "user" }).await?
let post = Post.insert(tx, Post { id: 0, user_id: user.id, title: "hi", body: "..." }).await?
return Ok(post)
}).await






The block inside db.transaction(...) receives a tx: DbConn that writes against the transaction. If the closure returns Err, rollback; if it returns Ok, commit. If the closure panics, automatic rollback.



And User.insert(...) returns the row with the BIGSERIAL id already assigned, no "remember to flush".






Declarative migrations



Change to the type:




CODE
 @table("users")
type User {
@primary id: Int,
email: Str,
name: Str,
role: Str = "user",
+ avatar_url: Str?,
created_at: Str = "now()",
}






Diff:




CODE
$ fitz db diff
+ ALTER TABLE users ADD COLUMN avatar_url TEXT;






Apply:




CODE
$ fitz db migrate
✓ applied migration 20260616_153020_add_user_avatar_url.sql






Rollback:




CODE
$ fitz db rollback
✓ reverted migration 20260616_153020_add_user_avatar_url.sql






The compiler compares the @table types in your code with the live schema and emits idempotent diffs. The migration is committed (it's a .sql file) for review in the PR. The OPS phase is still your responsibility (deploy timing, downtime planning for ALTER COLUMN type that rewrites the table, etc.) — but the SQL is already generated and reviewed.



In Alembic, --autogenerate sometimes generates incorrect SQL (especially for constraints, composite indexes, complex defaults) and you're always told to review the file manually. Fitz emits the diff without touching the user's SQL — you see it and you apply it.






How fast is it? — reproducible benchmark



Empty promises are easy. The repo ships — Fitz + native ORM.


  • .



    Why Fitz is fast: constant SQL at compile time (zero runtime overhead for building queries), pure Rust Postgres driver (no libpq overhead), JSON serialization with concrete types (no reflection), Arc<Mutex<>> for shared HTTP state (multi-thread without GIL).



    Why SQLAlchemy is slow in this test: identity map, lazy loading flags, events, column descriptors — many features that earn their cost when you need them, not when you don't.






    What Fitz does NOT give you (yet)



    Honesty about the debts:





    • Postgres only. No MySQL, no SQLite. Postgres is the choice for the ecosystem shape (clean binary protocol, rich types, JSONB, arrays). MySQL/SQLite are for when demand appears.


    • BelongsTo in .preload(...) — only HasMany/HasOne in eager loading for now. BelongsTo eager: residual debt.


    • Composite PKs@primary only on one field. Tables with composite PKs (rare in new projects): not supported.


    • Postgres JSON operators in .where — the most used ones are there (.has_key, .contains_json, .get), but jsonb_path_query and similar only via raw db.query(...).


    • Read replicas / sharding. The conn pool is against a single DB. For read replicas you have to coordinate by hand.


    • Zero-downtime migrations for NOT NULL columns without default. The diff emits it but the OPS phase remains manual.


    • Performance tooling (EXPLAIN ANALYZE integrated in LSP) — not in MVP.






    Escape hatch: raw SQL when you need it



    For complex CTEs, window functions, queries the ORM doesn't cover:




    CODE
    let result = db.query("
    WITH ranked AS (
    SELECT id, name, ROW_NUMBER() OVER (PARTITION BY role ORDER BY created_at DESC) AS rn
    FROM users
    )
    SELECT * FROM ranked WHERE rn <= 3
    ", []).await?






    db.query(sql, params) returns List<Map<Str, Any>>. No static checking of SQL or column names — you know. For 90% of CRUD you use the typed ORM; for crazy queries, escape hatch.






    Closing



    The most legitimate complaint against ORMs is "they add a layer of abstraction that you then have to break to do serious queries". SQLAlchemy solves this with a highly expressive DSL, at the cost of runtime overhead and learning curve. Diesel/sqlx solve it by generating SQL at compile time, at the cost of procedural macros and verbosity.



    Fitz takes the Diesel/sqlx route but with language syntax (not macros), a type checker that validates the closure-to-SQL, and schema migrations integrated as a binary subcommand. And because it's in the language, the same @table type is used as an HTTP request body, as a response, as an argument to User.insert(...), as an element of List<User> that serializes to JSON — ONE source of truth.



    If your project fits in Postgres and you don't need composite PKs or read replicas in the short term, Fitz's ORM closes the cycle "HTTP request → DB → response" with fewer moving parts and better performance than the typical Python stack.



    If your project needs one of the "not in MVP" items, Python interop is still a valid option (from python import sqlalchemy), or the debt in question lives in the roadmap.






    This closes the series of 9 posts: intro, tutorial, deployment, CLI builder, WebSockets, cron jobs, auth, observability, and ORM. The whole stack. What's next are language releases + chapter 31 of the guide (Postgres + native ORM) if you want to go deeper.



    Repo:

    Dedicated DB and ORM doc (~2600 LoC, 30 sections):

    Vollständiger Original-Bericht
    Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
    ↗ Original-Artikel auf dev.to lesen
  • Wie bewertest du diesen Beitrag?
    1 Klick Feedback
    Teilen mit Netzwerk & Team:

    Community-Analysen & Experten-Meinungen 0

    Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
    Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
    Community Pulse: Relevanz-Einschätzung
    1 Klick Experten-Votum
    🔴 Akute Relevanz 0%
    🟡 In Evaluierung 0%
    🟢 Keine Auswirkung 0%
    Spannende Innovation 0%
    Verwandte Story-Cluster & Quellen (Vektor-KI)
    Port 8095 Engine
    1 Quelle
    Hackers Just Poisoned the Rust Supply Chain | Threat Wire
    1 Quelle
    Hackers Found a Way Into Humanoid Robots | Threat Wire
    1 Quelle
    Bits und so #1021 (Passwort für Laufwerk)
    Ähnliche Beiträge
    🔍 Verwandte News

    Auch interessante Nachrichten Typed ORM with automatic migrations: Fitz vs SQLAlchemy + Alembic + Pydantic

    Thematisch verwandte Begriffe: Typed, with, automatic, migrations · 6 Treffer

    Laden...

    Videos werden geladen ...

    Laden...

    Beiträge werden geladen ...

    Laden...

    Videos werden geladen ...

    Laden...

    Beiträge werden geladen ...

    Laden...

    Videos werden geladen ...

    Laden...

    Beiträge werden geladen ...

    Laden...

    Videos werden geladen ...

    Laden...

    Beiträge werden geladen ...

    Laden...

    Videos werden geladen ...