← Back to DevBytes

Migrating from Legacy Frameworks to SQLAlchemy

Understanding the Migration Landscape

Migrating from legacy frameworks to SQLAlchemy represents a fundamental shift in how your application interacts with databases. Legacy frameworks often include raw SQL string manipulation, outdated ORMs like Django's older QuerySet patterns, homegrown data access layers, or even deprecated libraries such as mysql-python or psycopg2 used without abstraction. SQLAlchemy, by contrast, provides a mature, dual-mode ORM and Core abstraction layer that decouples database logic from business logic, supports multiple database backends, and offers powerful query composition, connection pooling, and schema reflection tools.

This tutorial walks through a complete migration path: assessing your existing codebase, mapping legacy patterns to SQLAlchemy equivalents, handling edge cases like raw SQL dependencies and stored procedures, and finally validating the migrated layer through testing. Every code example is self-contained and ready to adapt to your own project.

Why This Migration Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Security Against SQL Injection

Legacy frameworks frequently rely on string concatenation for query building. A single unescaped user input can compromise your entire database. SQLAlchemy's parameterized queries and expression-based query construction eliminate injection vectors by design. Consider this legacy pattern:

# LEGACY: dangerously built query
customer_id = request.get('id')
query = f"SELECT * FROM customers WHERE id = {customer_id}"
cursor.execute(query)

The SQLAlchemy equivalent automatically parameterizes inputs:

# SQLAlchemy Core
from sqlalchemy import select, customers_table

stmt = select(customers_table).where(customers_table.c.id == customer_id)
result = connection.execute(stmt)

Database Portability

Legacy codebases often embed database-specific SQL (MySQL's NOW(), PostgreSQL's ILIKE, or SQL Server's TOP). SQLAlchemy abstracts these differences through dialect-aware compilation. Migrating means you can switch from MySQL to PostgreSQL with minimal code changes—the same func.now() call generates the correct dialect-specific syntax automatically.

Maintainability and Testability

Homegrown data access layers typically mix connection management, error handling, and business logic into monolithic functions. SQLAlchemy separates these concerns: Engine handles connections, Session manages transactions, and models or tables define schema. This separation enables unit testing with in-memory SQLite, mock sessions, or fixture factories—something nearly impossible with tightly coupled legacy code.

Step-by-Step Migration Strategy

Step 1: Inventory Your Legacy Data Access Layer

Before writing a single line of SQLAlchemy code, catalog every database interaction in your application. Group them into categories:

Create a spreadsheet or markdown document mapping each legacy query to its location in the codebase. This inventory becomes your migration checklist and prevents regressions.

Step 2: Set Up SQLAlchemy's Dual-Mode Architecture

SQLAlchemy offers two primary interfaces: Core (schema-centric, table-level operations) and ORM (object-centric, model-level operations). For migration, start with Core because it maps most closely to raw SQL patterns. You can layer the ORM on top later once the Core foundation is stable.

Install SQLAlchemy and a database driver:

pip install sqlalchemy psycopg2-binary  # for PostgreSQL
# or
pip install sqlalchemy pymysql          # for MySQL

Create a single database.py module that initializes the engine and provides a connection context:

# database.py
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
import os

DATABASE_URL = os.environ.get(
    "DATABASE_URL",
    "postgresql://user:password@localhost:5432/mydb"
)

engine: Engine = create_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,      # verify connections before use
    echo=False               # set to True for SQL debugging during migration
)

def get_connection():
    """Context manager yielding a connection from the pool."""
    with engine.connect() as conn:
        yield conn

Step 3: Reflect Existing Schema as Table Objects

Rather than manually declaring every column, use SQLAlchemy's reflection to introspect your existing database. This gives you accurate Table objects that match the legacy schema exactly, including column types, nullable constraints, and defaults.

# schema_reflection.py
from sqlalchemy import MetaData, Table, inspect
from database import engine

# Create a MetaData instance bound to your existing schema
metadata_obj = MetaData(schema="public")

# Reflect all tables from the database
metadata_obj.reflect(bind=engine)

# Access reflected tables by name
customers_table = Table("customers", metadata_obj, autoload_with=engine)
orders_table = Table("orders", metadata_obj, autoload_with=engine)

# Inspect a table's columns for documentation
inspector = inspect(engine)
columns = inspector.get_columns("customers")
for col in columns:
    print(f"Column: {col['name']}, Type: {col['type']}, Nullable: {col['nullable']}")

This approach eliminates the tedious and error-prone process of manually transcribing legacy schemas. The reflected tables serve as the foundation for all subsequent Core queries.

Step 4: Migrate Simple CRUD Operations

Begin with the most straightforward operations. Replace raw INSERT, SELECT, UPDATE, and DELETE statements with their Core equivalents. This builds momentum and lets you establish patterns before tackling complex queries.

Legacy INSERT Example

# legacy_insert.py
cursor = connection.cursor()
cursor.execute(
    "INSERT INTO customers (name, email, created_at) VALUES (%s, %s, NOW())",
    (customer_name, customer_email)
)
customer_id = cursor.lastrowid
connection.commit()

SQLAlchemy Core INSERT

# sqlalchemy_insert.py
from sqlalchemy import insert
from database import engine
from schema_reflection import customers_table

stmt = insert(customers_table).values(
    name=customer_name,
    email=customer_email,
    created_at=func.now()
)

with engine.begin() as conn:
    result = conn.execute(stmt)
    customer_id = result.inserted_primary_key[0]
    # Transaction commits automatically on block exit

Notice that engine.begin() handles transaction boundaries automatically—no manual commit() or rollback() calls required. The inserted_primary_key property returns the auto-generated ID, even for composite keys.

Legacy SELECT with Conditional Logic

# legacy_select.py
query = "SELECT id, name, email FROM customers WHERE 1=1"
params = []

if status_filter:
    query += " AND status = %s"
    params.append(status_filter)
if date_from:
    query += " AND created_at >= %s"
    params.append(date_from)

query += " ORDER BY created_at DESC LIMIT 50"
cursor.execute(query, params)
rows = cursor.fetchall()

SQLAlchemy Core SELECT with Composable Conditions

# sqlalchemy_select.py
from sqlalchemy import select, and_

stmt = select(
    customers_table.c.id,
    customers_table.c.name,
    customers_table.c.email
)

conditions = []
if status_filter:
    conditions.append(customers_table.c.status == status_filter)
if date_from:
    conditions.append(customers_table.c.created_at >= date_from)

if conditions:
    stmt = stmt.where(and_(*conditions))

stmt = stmt.order_by(customers_table.c.created_at.desc()).limit(50)

with engine.connect() as conn:
    rows = conn.execute(stmt).fetchall()

This composable approach eliminates the fragile string concatenation pattern. Each condition is an independent clause object that SQLAlchemy safely combines and parameterizes.

Step 5: Migrate Complex Joins and Aggregations

Multi-table queries with joins, GROUP BY clauses, and aggregate functions represent the next challenge. SQLAlchemy's expression language handles these through explicit join objects and aggregate function wrappers.

Legacy Join Query

# legacy_join.py
query = """
    SELECT c.name, COUNT(o.id) as order_count, SUM(o.total) as total_spent
    FROM customers c
    LEFT JOIN orders o ON c.id = o.customer_id
    WHERE c.created_at >= '2023-01-01'
    GROUP BY c.id, c.name
    HAVING COUNT(o.id) > 0
    ORDER BY total_spent DESC
"""
cursor.execute(query)
results = cursor.fetchall()

SQLAlchemy Core Join with Aggregation

# sqlalchemy_join.py
from sqlalchemy import func, select
from schema_reflection import customers_table, orders_table

join_condition = customers_table.c.id == orders_table.c.customer_id

stmt = select(
    customers_table.c.name,
    func.count(orders_table.c.id).label("order_count"),
    func.sum(orders_table.c.total).label("total_spent")
).select_from(
    customers_table.join(
        orders_table, join_condition, isouter=True
    )
).where(
    customers_table.c.created_at >= "2023-01-01"
).group_by(
    customers_table.c.id,
    customers_table.c.name
).having(
    func.count(orders_table.c.id) > 0
).order_by(
    func.sum(orders_table.c.total).desc()
)

with engine.connect() as conn:
    results = conn.execute(stmt).fetchall()

The Core expression language preserves the logical structure of the SQL while making each clause explicit. You can extract the generated SQL for debugging by printing the statement:

print(stmt.compile(compile_kwargs={"literal_binds": True}))

Step 6: Handle Stored Procedures and Database Functions

Legacy applications often call stored procedures directly. SQLAlchemy supports this through the func namespace and the callproc method on connections. You can wrap these calls to maintain compatibility while gradually migrating logic into application code.

Legacy Stored Procedure Call

# legacy_procedure.py
cursor.callproc("calculate_monthly_rewards", [customer_id, month, year])
cursor.execute("SELECT @reward_points, @discount_tier")
results = cursor.fetchone()

SQLAlchemy Stored Procedure Wrapper

# sqlalchemy_procedure.py
from sqlalchemy import text

# Approach 1: Use text() for direct execution
proc_sql = text("CALL calculate_monthly_rewards(:cust_id, :month, :year)")
with engine.connect() as conn:
    conn.execute(
        proc_sql,
        {"cust_id": customer_id, "month": month, "year": year}
    )
    # Retrieve output parameters if applicable
    result = conn.execute(
        text("SELECT @reward_points, @discount_tier")
    ).fetchone()

# Approach 2: Gradually refactor procedure logic into Python
# using SQLAlchemy transactions with multiple statements

For database functions (as opposed to procedures that modify state), use the func accessor directly:

# Calling a PostgreSQL function from SQLAlchemy
from sqlalchemy import func, select

stmt = select(
    func.jsonb_extract_path_text(
        customers_table.c.metadata_json,
        '$.preferences.theme'
    ).label("theme")
).where(
    customers_table.c.id == customer_id
)

Step 7: Transition from Core to ORM (Optional but Recommended)

Once all Core queries are stable, you can introduce the ORM layer for domains where object-oriented patterns provide clear benefits—typically CRUD-heavy modules, user profiles, or content management. The ORM uses the same engine and metadata foundation you've already established.

Define ORM models that map to your reflected tables:

# models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Numeric
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.sql import func

Base = declarative_base()

class Customer(Base):
    __tablename__ = "customers"
    __table_args__ = {"schema": "public"}

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(255), nullable=False)
    email = Column(String(255), unique=True, nullable=False)
    status = Column(String(50), default="active")
    created_at = Column(DateTime, server_default=func.now())

    orders = relationship("Order", back_populates="customer", lazy="selectin")

    def __repr__(self):
        return f""

class Order(Base):
    __tablename__ = "orders"
    __table_args__ = {"schema": "public"}

    id = Column(Integer, primary_key=True, autoincrement=True)
    customer_id = Column(
        Integer,
        ForeignKey("customers.id", ondelete="CASCADE"),
        nullable=False
    )
    total = Column(Numeric(10, 2), nullable=False)
    created_at = Column(DateTime, server_default=func.now())

    customer = relationship("Customer", back_populates="orders")

    def __repr__(self):
        return f""

Create a session factory for ORM operations:

# session_manager.py
from sqlalchemy.orm import sessionmaker, Session
from database import engine

SessionLocal = sessionmaker(
    bind=engine,
    autocommit=False,
    autoflush=False,
    expire_on_commit=False
)

def get_session() -> Session:
    """Yield a session and ensure proper cleanup."""
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

Now you can rewrite a legacy service function using the ORM:

# service_layer.py
from models import Customer, Order
from session_manager import get_session

def get_top_customers_by_spending(min_total: float, limit: int = 10):
    """Replace legacy reporting query with ORM relationship traversal."""
    session = next(get_session())
    try:
        results = (
            session.query(
                Customer.name,
                func.count(Order.id).label("order_count"),
                func.sum(Order.total).label("total_spent")
            )
            .join(Order, Customer.id == Order.customer_id)
            .group_by(Customer.id, Customer.name)
            .having(func.sum(Order.total) >= min_total)
            .order_by(func.sum(Order.total).desc())
            .limit(limit)
            .all()
        )
        return [
            {"name": r.name, "orders": r.order_count, "spent": float(r.total_spent)}
            for r in results
        ]
    finally:
        session.close()

Step 8: Migrate Dynamic Query Builders

Legacy systems often feature complex query builder classes that concatenate SQL strings based on runtime filters. These are prime candidates for SQLAlchemy's composable expression system. The key insight: every clause (where, order_by, group_by) can be built incrementally and conditionally applied.

Legacy Dynamic Builder Pattern

# legacy_query_builder.py
class ReportQueryBuilder:
    def __init__(self):
        self.select_clause = "SELECT * FROM orders"
        self.where_clauses = ["1=1"]
        self.order_clause = "ORDER BY created_at DESC"
        self.limit_clause = ""
        self.params = []

    def add_date_filter(self, start_date, end_date):
        self.where_clauses.append("AND created_at BETWEEN %s AND %s")
        self.params.extend([start_date, end_date])
        return self

    def add_status_filter(self, status):
        self.where_clauses.append("AND status = %s")
        self.params.append(status)
        return self

    def add_limit(self, limit):
        self.limit_clause = f"LIMIT {int(limit)}"
        return self

    def build(self):
        query = f"{self.select_clause} WHERE {' '.join(self.where_clauses)} {self.order_clause} {self.limit_clause}"
        return query, tuple(self.params)

SQLAlchemy Composable Builder Pattern

# sqlalchemy_query_builder.py
from sqlalchemy import select, and_
from schema_reflection import orders_table

class ReportQueryBuilder:
    def __init__(self):
        self.stmt = select(orders_table)
        self.conditions = []
        self.order_by_clauses = [orders_table.c.created_at.desc()]
        self.limit_value = None

    def add_date_filter(self, start_date, end_date):
        self.conditions.append(
            orders_table.c.created_at.between(start_date, end_date)
        )
        return self

    def add_status_filter(self, status):
        self.conditions.append(orders_table.c.status == status)
        return self

    def add_limit(self, limit):
        self.limit_value = limit
        return self

    def build(self):
        if self.conditions:
            self.stmt = self.stmt.where(and_(*self.conditions))
        if self.order_by_clauses:
            self.stmt = self.stmt.order_by(*self.order_by_clauses)
        if self.limit_value:
            self.stmt = self.stmt.limit(self.limit_value)
        return self.stmt

The composable builder returns a statement object that can be executed directly or further refined. No string manipulation, no parameter tuple tracking—just pure expression composition.

Best Practices During Migration

Run Both Systems in Parallel with a Feature Flag

Never rip out the legacy data access layer in one deploy. Introduce a configuration flag that routes read queries through SQLAlchemy while writes remain on the legacy path (or vice versa). This lets you validate correctness in production with minimal blast radius:

# dual_read_strategy.py
import os
from legacy_db import LegacyDataAccess
from sqlalchemy_queries import get_customer_by_id

USE_SQLALCHEMY_READS = os.environ.get("USE_SQLALCHEMY_READS", "false") == "true"

def get_customer(customer_id: int):
    if USE_SQLALCHEMY_READS:
        return get_customer_by_id(customer_id)
    else:
        return LegacyDataAccess.get_customer_by_id(customer_id)

Compare Results Automatically in Test Suite

For every migrated query, write a test that executes both the legacy and SQLAlchemy versions against the same database state and asserts identical results. This catches subtle differences in NULL handling, default value application, or rounding behavior:

# test_migration_parity.py
def test_customer_query_parity():
    # Run legacy query
    legacy_results = legacy_get_active_customers()

    # Run SQLAlchemy query
    sa_results = sa_get_active_customers()

    # Normalize both to sorted tuples for comparison
    legacy_tuples = sorted(
        [(r['id'], r['name'], r['email']) for r in legacy_results]
    )
    sa_tuples = sorted(
        [(r.id, r.name, r.email) for r in sa_results]
    )
    assert legacy_tuples == sa_tuples, "Migration parity check failed"

Use Literal Bind Rendering for Debugging

During migration, you'll need to verify that generated SQL matches expectations. Enable literal bind rendering temporarily to see the exact query with values inlined:

# debug_sql.py
from sqlalchemy.dialects import postgresql

stmt = select(customers_table).where(customers_table.c.status == "active")
compiled = stmt.compile(
    dialect=postgresql.dialect(),
    compile_kwargs={"literal_binds": True}
)
print(str(compiled))
# Output: SELECT customers.id, customers.name, customers.email
#         FROM customers
#         WHERE customers.status = 'active'

Be careful not to use literal_binds in production logs—it's for development debugging only.

Preserve Transaction Boundaries Exactly

Legacy code often has idiosyncratic transaction patterns: nested commits, manual savepoints, or connection reuse across function calls. Map these precisely to SQLAlchemy's transaction contexts. Use engine.begin() for atomic units, session.begin_nested() for savepoints, and connection.execute() within explicit connection.begin() blocks when you need fine-grained control.

# explicit_transaction_control.py
from database import engine

def multi_step_operation(data_batch):
    """Legacy pattern: explicit BEGIN/COMMIT/ROLLBACK."""
    with engine.connect() as conn:
        with conn.begin():
            try:
                conn.execute(insert_stmt, data_batch[:10])
                conn.execute(update_stmt, {"status": "processing"})
                # Nested savepoint for risky operation
                with conn.begin_nested():
                    conn.execute(delete_stmt, {"ids": stale_ids})
                    # If this fails, savepoint rolls back
                    # but outer transaction continues
            except Exception:
                conn.rollback()
                raise
            else:
                conn.commit()

Handle Legacy SQL Functions with Hybrid Approaches

When you encounter database-specific functions deeply embedded in legacy SQL, don't try to rewrite them all at once. Use text() constructs for those fragments and wrap them in SQLAlchemy expressions:

# hybrid_approach.py
from sqlalchemy import text, select

# Legacy window function wrapped in text()
windowed_sql = text(
    "ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC)"
).label("row_num")

stmt = select(
    orders_table.c.id,
    orders_table.c.customer_id,
    windowed_sql
).select_from(orders_table)

# This query uses a legacy window function inside a modern Core expression

Over time, you can replace these text() fragments with native SQLAlchemy constructs as equivalents become available or as you refactor the underlying logic.

Document Schema Drift Between Legacy and SQLAlchemy

During migration, you may discover that the actual database schema differs from what the legacy code assumes—perhaps a column allows NULL but the legacy code never checks, or a default value exists in the database but the legacy code overrides it in application logic. Document every such discrepancy. Use SQLAlchemy's inspection tools to surface the ground truth:

# schema_audit.py
from sqlalchemy import inspect
from database import engine

inspector = inspect(engine)
for table_name in inspector.get_table_names(schema="public"):
    columns = inspector.get_columns(table_name, schema="public")
    for col in columns:
        defaults = col.get("default")
        nullable = col["nullable"]
        print(
            f"{table_name}.{col['name']}: "
            f"nullable={nullable}, default={defaults}"
        )

Common Migration Pitfalls and How to Avoid Them

Pitfall 1: Ignoring Implicit COMMIT Behavior

Legacy database drivers often operate in autocommit mode where every statement implicitly commits. SQLAlchemy defaults to transactional mode. If legacy code relies on autocommit for intermediate state visibility (e.g., between an INSERT and a subsequent SELECT in a different connection), you must explicitly commit or use engine.begin() as appropriate.

Pitfall 2: Overlooking Connection Pool Configuration

Legacy applications frequently open and close connections liberally, sometimes creating dozens of connections per request. SQLAlchemy's connection pool needs proper sizing. Start with pool_size=5, max_overflow=10 and monitor with pool_pre_ping=True. Use pool_recycle for databases that aggressively close idle connections (looking at you, MySQL).

Pitfall 3: Mixing Core and ORM Session Management Incorrectly

If you introduce the ORM, keep Core connections and ORM sessions separate. Don't pass a Core connection to an ORM operation expecting session semantics. Use session.connection() when you need raw SQL execution within an ORM session's transaction boundary.

Pitfall 4: Blindly Porting Dynamic SQL Without Testing All Branches

Legacy dynamic query builders often have edge cases triggered by specific filter combinations. Write parameterized tests that exercise every combination of optional filters. A single missing and_() wrapper or misplaced parenthesis can produce valid SQL that returns incorrect results.

Conclusion

Migrating from a legacy framework to SQLAlchemy is not merely a syntax replacement exercise—it's an architectural transformation that improves security, testability, and database portability. By following the step-by-step approach outlined here—inventorying legacy queries, reflecting schemas, migrating CRUD operations first, handling complex joins, wrapping stored procedures, and optionally introducing the ORM—you can execute a gradual, validated migration without disrupting production systems. The best practices of parallel execution with feature flags, automated parity testing, and meticulous transaction boundary mapping ensure that every migrated query behaves identically to its legacy counterpart. The result is a codebase where database interactions are composable, debuggable, and safe, freeing your team to focus on features rather than wrestling with string-concatenated SQL.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles