Engine, Connection, Session
The three objects you’ll touch every day. Engine is the factory; Connection is one open DB conn (with a transaction); Session is the ORM’s working scope (which holds a Connection while alive).
Engine — the factory
from sqlalchemy import create_engine
engine = create_engine(
"postgresql+psycopg://user:pw@host:5432/dbname",
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=False,
)
The Engine is one object per process, holding:
- The dialect (postgres / mysql / sqlite-specific behavior).
- The DBAPI driver (psycopg, asyncpg, pymysql, etc.) referenced by the URL
dialect+driver://.... - The connection pool (default
QueuePoolfor sync engines).
Create the engine once at startup, share it across the app. Don’t create one per request — the whole point is to pool connections.
URL format:
dialect+driver://user:password@host:port/database
postgresql+psycopg://alice:pw@db.internal:5432/myapp
postgresql+asyncpg://alice:pw@db.internal:5432/myapp # async
mysql+pymysql://...
sqlite:///./app.db # file-based
sqlite:///:memory: # in-memory (tests)
Connection — one open DB conn
with engine.connect() as conn:
result = conn.execute(select(users))
for row in result:
print(row)
# implicit transaction; need explicit commit
conn.commit()
A Connection:
- Holds a single DBAPI connection checked out from the pool.
- Has a transaction (autobegun on first statement).
- Must be closed (
withblock handles it). - Returns the underlying DBAPI conn to the pool on close.
Two transaction styles (1.x had multiple; 2.0 settled on these):
# "Commit as you go" — explicit commit when you want
with engine.connect() as conn:
conn.execute(insert(users).values(name="alice"))
conn.commit()
# "Begin once" — block-scoped transaction
with engine.begin() as conn:
conn.execute(insert(users).values(name="alice"))
# auto-commits at block exit, or auto-rollback on exception
engine.begin() is the idiomatic short form for “do this in one transaction.”
Session — the ORM working scope
from sqlalchemy.orm import Session
with Session(engine) as session:
user = User(name="alice")
session.add(user)
session.commit()
A Session:
- Acquires a connection (lazily) from the pool when needed.
- Holds an identity map (one Python object per primary key per session).
- Tracks dirty objects (Unit of Work — flushed on commit).
- Manages a transaction (begin/commit/rollback).
Session ≠ connection. Multiple sessions don’t share state. One session uses one connection at a time (lazily, may not even check out a connection until you query).
Session lifecycle
session = Session(engine)
try:
user = User(name="alice")
session.add(user) # pending
session.flush() # sends INSERT to DB; row exists in this transaction
print(user.id) # populated
session.commit() # transaction commits; objects are "expired" by default
except Exception:
session.rollback()
raise
finally:
session.close()
Or the idiomatic context-managed form:
with Session(engine) as session:
with session.begin():
user = User(name="alice")
session.add(user)
# commit at inner block exit
# close at outer block exit
session.begin() as a context manager handles commit/rollback automatically. The outer Session(...) handles cleanup.
See 08_session_lifecycle.md for the full state diagram.
sessionmaker — the factory pattern
For applications, you don’t construct Session(engine) directly in every handler. Use sessionmaker:
from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(bind=engine, autoflush=True, expire_on_commit=True)
# Anywhere in your code:
with SessionLocal() as session:
...
The configured SessionLocal is callable; each call returns a fresh session bound to your engine. One sessionmaker per app.
For FastAPI dependency injection (the canonical pattern):
def get_session():
with SessionLocal() as session:
yield session
@app.get("/users/{uid}")
def get_user(uid: int, session: Session = Depends(get_session)):
return session.get(User, uid)
The yield keeps the session open for the duration of the request and closes it after.
scoped_session — thread-local sessions (legacy)
Pre-FastAPI Flask apps often used:
from sqlalchemy.orm import scoped_session
Session = scoped_session(sessionmaker(bind=engine))
# Session() returns the same session for the same thread
scoped_session is a registry keyed on thread (by default). Flask-SQLAlchemy uses this under the hood. Modern recommendation for new code: explicit session passing or DI, not thread-local globals.
For async code, scoped_session uses task-local instead of thread-local (see 11_async_sqlalchemy.md).
echo, logging, and SQL inspection
engine = create_engine("...", echo=True) # prints every SQL statement
engine = create_engine("...", echo="debug") # also prints results
For production, use Python logging instead:
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine.Engine").setLevel(logging.INFO)
For one-off statement inspection:
stmt = select(User).where(User.name == "alice")
print(stmt.compile(engine, compile_kwargs={"literal_binds": True}))
# SELECT users.id, users.name FROM users WHERE users.name = 'alice'
literal_binds inlines parameter values (good for debugging; bad for production logs — SQL injection if you reuse).
Connection pool basics
By default create_engine uses QueuePool with:
pool_size=5— persistent connections kept open.max_overflow=10— extra connections allowed beyond pool_size (closed when returned).pool_timeout=30— seconds to wait for a connection.pool_recycle=-1— never recycle (set to ~3600 to refresh hourly, dodges MySQL’swait_timeout).pool_pre_ping=False— set toTrueto test connection liveness before use.
engine = create_engine(
"postgresql+psycopg://...",
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
See 10_connection_pooling.md for sizing and pgbouncer interaction.
Three things, three lifetimes
| Object | Lifetime | Per |
|---|---|---|
Engine |
process — created at startup | app |
Session |
one logical unit of work | request / job / transaction |
Connection |
one transaction | usually held by session |
Anti-patterns:
Engineper request → no pool, slow.Sessionshared across requests/threads → race conditions, leaked state.Connectionoutliving a transaction → leaks pool slots.
Common pitfalls
- Forgetting
session.commit()— changes never flush. Withautobegin(default 2.0), the transaction stays open until commit/rollback. - Holding a session across HTTP request boundaries — long-held transactions hold locks; mid-flight commits fail strangely.
- Creating an Engine per request — defeats pooling; each request waits on TCP+TLS handshake to DB.
- Using
expire_on_commit=True(the default) and accessing attributes after commit — triggers a SELECT or raisesDetachedInstanceError. See 14_common_pitfalls.md. - Sharing a Session across threads — Session is not thread-safe.
Common interview confusions
- “Engine == Connection.” — no. Engine is a factory + pool; Connection is one checked-out DB conn.
- “Session is the same as Connection.” — no. Session is an ORM concept (identity map, change tracking) that holds a Connection while active.
- “You can use one Engine across processes.” — usually not safely. After
os.fork(), the pool’s TCP connections are invalid in the child. Recreate or dispose the engine in worker startup.
Interview angle
- “What’s the difference between Engine, Connection, and Session?” — Engine is the process-level factory + connection pool. Connection is one DB connection with a transaction. Session is the ORM’s working scope — identity map, change tracking, holds a Connection while active.
- “How long does a Session live?” — for one logical unit of work: usually one HTTP request, one job, one transaction. Closed at the end. Don’t share across requests.
- “Why use
sessionmakerinstead ofSession(engine)directly?” — captures defaults (engine, autoflush, expire_on_commit) once; callingSessionLocal()creates a configured session anywhere. - “What does
engine.begin()do vsengine.connect()?” —connect()gives you a connection with autobegin transaction; you must commit/rollback.begin()is a context manager that auto-commits on success, rollbacks on exception. - “How do connections get returned to the pool?” — when the Connection is closed (or its
withblock ends). The Session does this when closed. Pool keepspool_sizeconnections persistent; extras (up tomax_overflow) are opened on demand and closed on return. - “Why might
pool_pre_ping=Truematter?” — pooled connections can go stale (DB restart, network blip, MySQL’swait_timeoutcloses them). Pre-ping does a cheap test before handing out; without it, the first query gets a stale-connection error.