SQLAlchemy 2.0 #2 Engines and Transactions: Connection Pools and the Two Commit Patterns
In part 1 we created an engine and ran queries with text(). This part is about what happens underneath. Knowing exactly how the connection pool works and when transactions begin and commit is what keeps you steady once you climb up to the ORM. The answers to the production classics — “connection pool exhausted,” “I committed but nothing changed” — all live in this layer.
The connection pool: what the engine actually does #
Database connections are expensive. TCP setup, authentication, and session initialization cost milliseconds, and the server caps how many concurrent connections it will take. So instead of opening a fresh connection every time, the engine keeps them in a connection pool and reuses them.
from sqlalchemy import create_engine
engine = create_engine(
"postgresql+psycopg2://user:pw@localhost/mydb",
pool_size=5, # connections kept in the pool (default 5)
max_overflow=10, # extra connections opened when the pool is empty (default 10)
pool_timeout=30, # seconds to wait when nothing is available (default 30)
pool_pre_ping=True, # check liveness before lending a connection
)It works like a lending library. engine.connect() borrows a connection from the pool, and when the with block ends the connection is returned, not closed. Three values are worth knowing.
- pool_size + max_overflow is the effective ceiling. With defaults, a process can hold at most 15 concurrent connections; beyond that, requests wait
pool_timeoutseconds and then raiseTimeoutError. The usual culprit behind “pool exhausted” errors is connections that never got returned — borrowed withoutwithand never closed — eating the pool. - Multiply by the number of processes. Four gunicorn workers mean four pools, and up to 60 connections for the database to absorb. Budget this against the database’s own
max_connections. - pool_pre_ping filters out connections that the server silently dropped while they sat idle. If you meet “MySQL server has gone away”-type errors, check this option first.
SQLite file databases have almost no connection cost, so pool settings rarely matter there. Pools become a real concern with PostgreSQL and MySQL across a network.
Transactions: there is no autocommit #
The governing rule in 2.0 is that transactions are always explicit. DBAPI drivers are not autocommit by nature, and SQLAlchemy does not paper over that. If you do not commit, the block ends in a rollback.
from sqlalchemy import text
# This INSERT disappears — it was never committed.
with engine.connect() as conn:
conn.execute(text("INSERT INTO memo (body) VALUES ('temp')"))
# block ends → rollbackThere are two patterns for committing.
# Pattern 1: commit-as-you-go — commit exactly where you choose
with engine.connect() as conn:
conn.execute(text("INSERT INTO memo (body) VALUES ('first')"))
conn.commit() # everything up to here is final
conn.execute(text("INSERT INTO memo (body) VALUES ('second')"))
conn.commit() # second transaction finalized
# Pattern 2: begin-once — the whole block is one transaction
with engine.begin() as conn:
conn.execute(text("INSERT INTO memo (body) VALUES ('third')"))
conn.execute(text("INSERT INTO memo (body) VALUES ('fourth')"))
# commits if the block exits normally, rolls back on exceptionThe production default is pattern 2 (engine.begin()). It matches the whole point of a transaction — all-or-nothing — and makes forgetting to commit impossible. Pattern 1 is for cases like long batch jobs where one connection needs several transaction boundaries as intermediate save points.
Defining tables: MetaData and Table #
In Core, a table is a Table object, and the catalog of tables lives in a MetaData.
from sqlalchemy import MetaData, Table, Column, Integer, String, ForeignKey
metadata = MetaData()
user_table = Table(
"user_account",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(30), nullable=False),
Column("email", String(100), nullable=False, unique=True),
)
address_table = Table(
"address",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", ForeignKey("user_account.id"), nullable=False),
Column("email_address", String(100), nullable=False),
)
metadata.create_all(engine) # CREATE TABLE only for tables that do not existcreate_all() is convenient for learning and prototypes, but it never alters existing tables. Schema changes like adding a column belong to Alembic, which part 7 covers.
CRUD with Core: SQL as Python expressions #
Unlike text(), Core expressions compose SQL from Python objects. Typos no longer hide inside strings, and parameter binding is automatic.
from sqlalchemy import insert, select, update, delete
# INSERT
with engine.begin() as conn:
conn.execute(
insert(user_table),
[
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"},
],
)
# SELECT
with engine.connect() as conn:
stmt = select(user_table).where(user_table.c.name == "Alice")
for row in conn.execute(stmt):
print(row.id, row.name, row.email)
# UPDATE and DELETE
with engine.begin() as conn:
conn.execute(
update(user_table)
.where(user_table.c.email == "bob@example.com")
.values(name="Robert")
)
conn.execute(delete(user_table).where(user_table.c.id == 99))- Columns are accessed as
table.c.column_name.user_table.c.name == "Alice"does not produce a boolean — it produces a SQL condition object. The Python operators are overloaded. - Passing a list of dictionaries to
insert()becomes an executemany that inserts multiple rows at once — the basic form for bulk loading. - With
echo=Trueon, you can see what SQL each expression compiles to, and confirm that everywhere()condition becomes a bound parameter (?or%(name)s).
This select() syntax is exactly what the ORM uses from the next part on. The only change is that user_table.c.name becomes User.name. This is the payoff of learning Core first in 2.0.
Summary #
- An engine is, in substance, a connection pool.
pool_size + max_overflowis the per-process concurrency ceiling; multiply by worker count and reconcile with the database’smax_connections. - The main cause of pool exhaustion is unreturned connections. Always borrow with
with. - There is no autocommit. Default to
engine.begin()wrapping the block in one transaction, and useconnect()+commit()only when you need mid-stream commits. - Tables are defined with
TableandMetaData, and manipulated with theinsert,select,update, anddeleteexpressions. Conditions are written with Python operators but compile to bound parameters. - Next up: declaring the same tables as ORM classes with
Mappedandmapped_column.