architecture2024-06-08·9·297/348

SQLAlchemy 세션 관리 문제 해결

Solving SQLAlchemy session management issues — connection leaks, detached instances, stale data, and the correct session lifecycle patterns.

SQLAlchemy 세션 관리 문제 해결

Introduction

SQLAlchemy's session management is the single most common source of confusion for developers using the library. Sessions are designed to represent a "work" unit — a transaction that groups multiple database operations together. But the session lifecycle is subtle, and getting it wrong leads to connection leaks, stale data, detached instance errors, and memory growth.

I spent weeks debugging session management issues in a Lisbon-based fintech project. The application was using SQLAlchemy with FastAPI, and database connections were leaking under load. The root cause was that sessions were being created but not properly closed in error paths.

Environment

SQLAlchemy 2.0.29
Python 3.12.3
PostgreSQL 15.4
FastAPI 0.111.0

Problem

Error 1: DetachedInstanceError

from sqlalchemy.orm import Session

def get_user(db: Session, user_id: int):
    user = db.query(User).get(user_id)
    return user

user = get_user(db, 1)
print(user.name)  # Works
db.close()
print(user.name)  # DetachedInstanceError!
sqlalchemy.orm.exc.DetachedInstanceError: 
Instance  is not bound to a Session; 
attribute access operation cannot proceed

Error 2: Connection pool exhaustion

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

engine = create_engine("postgresql://user:pass@localhost/db")
Session = sessionmaker(bind=engine)

def process_data():
    db = Session()
    result = db.query(Data).all()
    # Forgot to close session!
    return result

# After many calls, connection pool is exhausted
# sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached

Error 3: Stale data after commit

def update_user(db: Session, user_id: int, name: str):
    user = db.query(User).get(user_id)
    user.name = name
    db.commit()
    # user.name is still the old value in some cases!
    print(user.name)  # Might print old name

Error 4: Multiple sessions in same thread

from sqlalchemy.orm import Session, scoped_session, sessionmaker

engine = create_engine("postgresql://user:pass@localhost/db")
SessionLocal = sessionmaker(bind=engine)

# This creates a new session each time — no thread safety
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Analysis

SQLAlchemy sessions have a specific lifecycle that must be followed.

Session states:

  1. Transient: Created but not associated with any identity map
  2. Pending: Added to session but not flushed
  3. Persistent: Committed to database
  4. Detached: Removed from session (after close/expire)

The session lifecycle:

create -> add objects -> flush -> commit -> close
                                    |
                                    v
                             objects become detached

Why connections leak:

When a session is created but not closed, the underlying database connection is never returned to the pool. Under load, this exhausts the connection pool.

Why stale data occurs:

SQLAlchemy caches objects in the identity map. After a commit, the cached objects may not reflect the committed state unless you explicitly refresh them.

Solution

Fix 1: Use scoped sessions for thread safety

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

engine = create_engine(
    "postgresql://user:pass@localhost/db",
    pool_size=5,
    max_overflow=10,
    pool_pre_ping=True,  # Verify connections before use
)

SessionLocal = sessionmaker(bind=engine)
ScopedSession = scoped_session(SessionLocal)

def get_db():
    db = ScopedSession()
    try:
        yield db
    finally:
        db.remove()  # Properly closes and removes session

Fix 2: FastAPI dependency injection pattern

from fastapi import Depends
from sqlalchemy.orm import Session

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)
    return user

Fix 3: Refresh after commit

def update_user(db: Session, user_id: int, name: str):
    user = db.query(User).get(user_id)
    user.name = name
    db.commit()
    db.refresh(user)  # Reload from database
    print(user.name)  # Now shows new name
    return user

Fix 4: Use expire_on_commit=False for read-heavy applications

SessionLocal = sessionmaker(
    bind=engine,
    expire_on_commit=False  # Objects remain usable after commit
)

def get_user_data(db: Session, user_id: int):
    user = db.query(User).get(user_id)
    db.commit()
    # user is still accessible without DetachedInstanceError
    return user.name

Fix 5: Proper session lifecycle in complex operations

from sqlalchemy.orm import Session
from contextlib import contextmanager

@contextmanager
def get_session():
    """Provide a transactional scope around operations."""
    db = SessionLocal()
    try:
        yield db
        db.commit()
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

# Usage
def complex_operation():
    with get_session() as db:
        user = db.query(User).get(1)
        order = Order(user_id=user.id, total=100)
        db.add(order)
        # Commit happens automatically if no exception
        # Rollback happens automatically on exception

Fix 6: Connection pool monitoring

from sqlalchemy import event

@event.listens_for(engine, "checkout")
def ping_connection(dbapi_connection, connection_proxy, connection_record):
    """Verify connection is alive before use."""
    connection_record.info['ping_count'] = connection_record.info.get('ping_count', 0) + 1

# Monitor pool status
print(engine.pool.status())

Lessons Learned

  • Always close sessions — use try/finally or context managers.
  • Use scoped_session in multi-threaded applications for thread safety.
  • Call db.refresh(obj) after commit if you need to read the committed state.
  • Use expire_on_commit=False for read-heavy applications to avoid DetachedInstanceError.
  • Monitor connection pool status in production to catch leaks early.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.