Raw view: Sqlalchemy

No invisible, zero-width, or bidi-control characters detected.

---
description: This guide enforces modern SQLAlchemy 2.x best practices for Python applications, ensuring type-safe, performant, and maintainable database interactions.
globs: **/*.py
---
# SQLAlchemy Best Practices (2.x Style)

This document outlines the definitive best practices for using SQLAlchemy 2.x in our projects. We exclusively adopt the "2-style" API, leveraging its fully typed, declarative features for robust and maintainable code.

## 1. Code Organization and Data Modeling

Always define your ORM models using the modern `DeclarativeBase` and `Mapped` annotations. Keep model definitions in a dedicated `models.py` or `orm_models/` directory.

### 1.1 Declarative Models with Type Annotations

Use `DeclarativeBase` as your base class and `Mapped` for all ORM-mapped attributes. This enables static type checking and clear schema definition.

❌ BAD: Legacy `declarative_base()` function and untyped `Column`.
```python
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String) # No type hint
```

✅ GOOD: Modern `DeclarativeBase` and `Mapped` for explicit typing.
```python
from typing import List, Optional
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import String, ForeignKey, func, DateTime

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    fullname: Mapped[Optional[str]]
    created_at: Mapped[DateTime] = mapped_column(DateTime, insert_default=func.now())

    addresses: Mapped[List["Address"]] = relationship(back_populates="user", cascade="all, delete-orphan")

class Address(Base):
    __tablename__ = "address"
    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))

    user: Mapped["User"] = relationship(back_populates="addresses")
```

### 1.2 Mixins for Common Fields

Implement common fields like timestamps, soft-delete flags, or version counters using mixins.

```python
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import DateTime, func

class TimestampMixin:
    created_at: Mapped[datetime] = mapped_column(DateTime, insert_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime, insert_default=func.now(), onupdate=func.now())

class SoftDeleteMixin:
    deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None)

class User(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    # ... other fields
```

## 2. Session Management

The `Session` is your unit of work. Always use it as a context manager to ensure proper transaction handling and resource cleanup.

### 2.1 Context Manager for Sessions

Wrap all database operations within a `with Session(engine) as session:` block. This guarantees `commit()` on success and `rollback()` on error, followed by `close()`.

❌ BAD: Manual session handling, prone to leaks and uncommitted transactions.
```python
session = Session(engine)
try:
    user = User(name="Alice")
    session.add(user)
    session.commit()
except Exception:
    session.rollback()
finally:
    session.close()
```

✅ GOOD: `Session` as a context manager.
```python
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine

engine = create_engine("sqlite:///./test.db")
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def create_user(name: str):
    with Session() as session:
        user = User(name=name)
        session.add(user)
        session.commit() # Flushes changes and commits transaction
        session.refresh(user) # Refresh to get auto-generated IDs
        return user
```

### 2.2 Explicit Transaction Blocks

For complex operations requiring multiple steps within a single transaction, use `session.begin()`.

```python
def transfer_funds(from_account_id: int, to_account_id: int, amount: float):
    with Session() as session:
        with session.begin(): # Explicit transaction block
            from_account = session.get(Account, from_account_id)
            to_account = session.get(Account, to_account_id)

            if not from_account or not to_account:
                raise ValueError("Account not found")
            if from_account.balance < amount:
                raise ValueError("Insufficient funds")

            from_account.balance -= amount
            to_account.balance += amount
        # Transaction commits here if no exceptions, or rolls back if an error occurs
    print(f"Transferred {amount} from {from_account_id} to {to_account_id}")
```

## 3. Querying and Optimization

Always use the `select()` construct for queries and prioritize eager loading for relationships to avoid N+1 problems.

### 3.1 Use `select()` for All Queries

The `Query` object is deprecated in 2.x. Use `select()` for all ORM and Core queries.

❌ BAD: Using the legacy `session.query()` API.
```python
users = session.query(User).filter(User.name == "Alice").all()
```

✅ GOOD: Using `select()` with `session.execute()`.
```python
from sqlalchemy import select

def get_user_by_name(session: Session, name: str) -> Optional[User]:
    stmt = select(User).where(User.name == name)
    return session.scalar(stmt) # Use scalar for single result, one_or_none for one, all for list
```

### 3.2 Eager Loading Relationships

Prevent N+1 query issues by eagerly loading related objects using `selectinload` or `joinedload`. `selectinload` is generally preferred for collections.

❌ BAD: Lazy loading in a loop, leading to N+1 queries.
```python
users = session.scalars(select(User)).all()
for user in users:
    print(f"{user.name} has {len(user.addresses)} addresses") # Each access triggers a new query
```

✅ GOOD: Eager loading with `selectinload`.
```python
from sqlalchemy.orm import selectinload

def get_users_with_addresses(session: Session) -> List[User]:
    stmt = select(User).options(selectinload(User.addresses))
    return session.scalars(stmt).all()

# Now, accessing user.addresses won't trigger additional queries
users = get_users_with_addresses(session)
for user in users:
    print(f"{user.name} has {len(user.addresses)} addresses")
```

## 4. Data Manipulation

The ORM Unit of Work pattern handles inserts, updates, and deletes efficiently.

### 4.1 Adding and Updating Objects

Add new objects with `session.add()`. Modifications to existing objects are tracked automatically.

```python
def update_user_name(session: Session, user_id: int, new_name: str):
    user = session.get(User, user_id) # Efficiently get by primary key
    if user:
        user.name = new_name # Change is tracked
        session.commit()
    return user

def add_new_user_with_address(session: Session, name: str, email: str):
    user = User(name=name, fullname=name)
    address = Address(email_address=email, user=user) # Relationship automatically links
    session.add(user) # Adding user also adds address due to cascade
    session.commit()
    return user
```

### 4.2 Deleting Objects

Delete objects using `session.delete()`.

```python
def delete_user(session: Session, user_id: int):
    user = session.get(User, user_id)
    if user:
        session.delete(user)
        session.commit()
        print(f"User {user_id} deleted.")
    else:
        print(f"User {user_id} not found.")
```

### 4.3 Bulk Operations (Use Sparingly)

Only use bulk operations (`session.bulk_insert_mappings`, `session.execute(insert/update/delete)`) when performance is paramount and ORM event handling (e.g., `onupdate` hooks) is not required.

```python
from sqlalchemy import insert

def bulk_insert_users(session: Session, user_data: List[dict]):
    # This bypasses ORM object creation and event hooks.
    # Use when you have raw data and need speed.
    session.execute(insert(User), user_data)
    session.commit()

# Example user_data: [{"name": "Charlie", "fullname": "Charlie Brown"}, ...]
```

## 5. Migrations and Type Checking

Integrate Alembic for schema migrations and ensure static type checking with Mypy or Ruff.

### 5.1 Alembic for Schema Evolution

Always use Alembic to manage database schema changes. Never manually alter the database schema.

```bash
# Initialize Alembic (once per project)
alembic init -t async migrations

# Generate a new migration script
alembic revision --autogenerate -m "Add new_column to user_account"

# Apply migrations
alembic upgrade head
```

### 5.2 Static Type Checking

Leverage Python's `typing` and SQLAlchemy's `Mapped` for comprehensive type checking with tools like Mypy or Ruff. This catches schema mismatches and API misuses early.

```python
# mypy.ini or pyproject.toml configuration for mypy
[mypy]
plugins = sqlalchemy.ext.mypy.plugin
```