All posts

April 22, 2025

SQLAlchemy: Python SQL Toolkit & ORM

PythonSQLAlchemyDatabaseORM

SQLAlchemy logo

Introduction

Database access is the backbone of every application. Every query, every transaction, every relationship traversal flows through the data layer — and the quality of that layer determines whether your application scales gracefully or collapses under complexity. SQLAlchemy gives Python developers the most powerful, flexible ORM in any language: a dual-layer architecture that provides high-level model abstractions when you want them and raw SQL expression power when you need it.

Unlike ORMs that hide the database behind opaque magic, SQLAlchemy exposes the full power of relational databases while eliminating the boilerplate of raw SQL. It powers everything from simple CRUD applications to complex multi-database reporting systems, and it integrates with every major Python web framework — FastAPI, Flask, Pyramid, and beyond.

Key Benefits

Feature SQLAlchemy Django ORM Peewee
Framework independence ✅ Any framework ❌ Django only ✅ Any framework
Raw SQL access ✅ Full expression language ⚠️ Limited ⚠️ Limited
Async support asyncio native ✅ Django async ⚠️ Partial
Multiple databases ✅ Full support ⚠️ Limited ⚠️ Limited
Migration tool ✅ Alembic ✅ Built-in ⚠️ Playhouse
Relationship loading ✅ Granular control ⚠️ Less control ⚠️ Basic
Connection pooling ✅ Built-in ✅ Built-in ⚠️ Basic
Type annotations ✅ Full (v2) ⚠️ Partial ⚠️ Partial
Community & ecosystem ✅ Largest ✅ Large ⚠️ Smaller
Learning curve ⚠️ Steeper ✅ Gentle ✅ Gentle
pip install sqlalchemy

Core Setup

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

class Base(DeclarativeBase):
    pass

engine = create_engine("sqlite:///app.db", echo=True)
Session = sessionmaker(bind=engine)

Defining Models

from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)
    email = Column(String(200), unique=True, nullable=False)
    active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)

    posts = relationship("Post", back_populates="author", cascade="all, delete-orphan")

class Post(Base):
    __tablename__ = "posts"

    id = Column(Integer, primary_key=True)
    title = Column(String(200), nullable=False)
    content = Column(String, nullable=False)
    published = Column(Boolean, default=False)
    author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)

    author = relationship("User", back_populates="posts")

# Create tables
Base.metadata.create_all(engine)

CRUD Operations

with Session() as session:
    # Create
    user = User(name="John", email="john@example.com")
    session.add(user)
    session.commit()
    session.refresh(user)

    # Read
    user = session.get(User, 1)
    all_users = session.query(User).all()

    # Update
    user.name = "John Doe"
    session.commit()

    # Delete
    session.delete(user)
    session.commit()

Querying

with Session() as session:
    # Filter
    active = session.query(User).filter(User.active == True).all()

    # Multiple conditions
    results = session.query(User).filter(
        User.active == True,
        User.name.like("J%")
    ).all()

    # Order, limit, offset
    page = session.query(User).order_by(User.name).limit(10).offset(20).all()

    # Count
    count = session.query(User).filter(User.active == True).count()

    # Join
    posts = (
        session.query(Post)
        .join(User)
        .filter(User.name == "John", Post.published == True)
        .all()
    )

    # Scalar
    user = session.query(User).filter(User.email == "john@example.com").scalar()

Relationships

One-to-Many

# Access related objects
with Session() as session:
    user = session.get(User, 1)
    for post in user.posts:  # lazy loaded by default
        print(post.title)

    # Create related object
    post = Post(title="Hello", content="World", author=user)
    session.add(post)
    session.commit()

Many-to-Many

from sqlalchemy import Table

post_tags = Table(
    "post_tags", Base.metadata,
    Column("post_id", Integer, ForeignKey("posts.id")),
    Column("tag_id", Integer, ForeignKey("tags.id"))
)

class Tag(Base):
    __tablename__ = "tags"
    id = Column(Integer, primary_key=True)
    name = Column(String(50), unique=True)

class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True)
    tags = relationship("Tag", secondary=post_tags, backref="posts")

Eager Loading

from sqlalchemy.orm import joinedload, selectinload

with Session() as session:
    # joinedload — single JOIN query
    users = (
        session.query(User)
        .options(joinedload(User.posts))
        .all()
    )

    # selectinload — separate IN query (better for collections)
    users = (
        session.query(User)
        .options(selectinload(User.posts))
        .all()
    )

Transactions

with Session() as session:
    try:
        user = User(name="Jane", email="jane@example.com")
        session.add(user)

        post = Post(title="First Post", content="Hello!", author=user)
        session.add(post)

        session.commit()
    except Exception as e:
        session.rollback()
        raise

Raw SQL

from sqlalchemy import text

with Session() as session:
    result = session.execute(
        text("SELECT * FROM users WHERE active = :active"),
        {"active": True}
    )
    rows = result.fetchall()

Use Cases

  • Web application data layers
  • Data pipelines and ETL
  • Microservice persistence
  • Complex reporting queries
  • Multi-database applications

Best Practices

  • Use with Session() context manager — always closes the session
  • Prefer selectinload over joinedload for one-to-many collections
  • Use session.get(Model, pk) over .filter().first() for primary key lookups
  • Wrap multi-step writes in explicit transactions
  • Add indexes on columns used in filter() and order_by()

Conclusion

The data layer is not an implementation detail — it is the foundation that every feature, every query, and every scaling decision rests on. SQLAlchemy gives Python developers the most complete toolkit available: expressive model definitions, composable queries, granular relationship loading, and the full power of raw SQL when the ORM abstraction is not the right tool.

For applications that need to grow — more complex queries, multiple databases, async support, or framework independence — SQLAlchemy eliminates the ceiling that simpler ORMs impose. The investment in learning its dual-layer architecture pays dividends across every project that follows.

👉 Read more on Medium

References