12.4. Database Sessions
Table of Contents
Understanding Database Sessions
When you use an ORM, you almost never talk to the database directly. Instead, you work through a database session.
A session is the unit that:
- Knows how to connect to the database.
- Tracks objects you load and modify.
- Decides when to send SQL to the database.
- Wraps work in transactions.
If you understand sessions clearly, you avoid many confusing bugs like “my data did not save” or “why did this delete happen so late?”.
This chapter will focus on sessions in the context of a typical Python ORM, especially SQLAlchemy, but the concepts apply to most ORMs.
What Is a Database Session?
You can think of a database session as:
- A conversation between your application and the database.
- A kind of workspace where:
- You load objects from the database.
- You change them in memory.
- You decide when to commit or roll back those changes.
Typical flow:
- Open a session.
- Run queries and modify objects.
- Commit or roll back.
- Close the session.
A database session is not the same as a user session in web apps.
A database session is about communication with the database.
A user session is about tracking a logged in user.
Sessions vs Connections
A common confusion: session vs connection.
- Connection: A low level TCP connection to the database server.
- Session: A higher level object that:
- Uses one or more connections.
- Manages ORM objects, identity map, and transactions.
In SQLAlchemy terms:
Enginemanages connections, often with a connection pool.Sessionsits on top and uses connections from the engine when needed.
You rarely handle connections directly when using an ORM. You create and use sessions.
Example with SQLAlchemy style code:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("postgresql+psycopg2://user:password@localhost/dbname")
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
# Create a session
db = SessionLocal() # This is a session, not a raw DB connectionThe session will ask the engine for a connection when it actually needs to talk to the database, for example when executing a query or a flush.
The Identity Map: One Row, One Object
A key feature of ORM sessions is the identity map.
Inside a session, for each database row, there is at most one Python object that represents it.
For example, consider a User table and a User ORM model:
user1 = db.query(User).filter(User.id == 1).first()
user2 = db.query(User).filter(User.id == 1).first()
print(user1 is user2) # TrueWithin the same session:
user1anduser2are the same Python object.- If you change
user1.name, you will also see that change when readinguser2.name, because they are the same object.
This identity map is maintained per session. If you use two different sessions, each can have its own copy of the same row:
db1 = SessionLocal()
db2 = SessionLocal()
user1 = db1.query(User).get(1)
user2 = db2.query(User).get(1)
print(user1 is user2) # False, different sessions, different objectsThis is why it is important to understand session scope, which we will cover soon.
Session Lifecycle: From Creation to Close
A typical session lifecycle looks like this:
- Create a session (often per web request).
- Use it for queries and changes.
- Commit or rollback.
- Close it.
Example in pseudo SQLAlchemy code:
db = SessionLocal() # 1. create
try:
# 2. use it
user = db.query(User).filter(User.email == "test@example.com").first()
user.name = "New Name"
db.commit() # 3. commit changes
except:
db.rollback() # rollback on error
raise
finally:
db.close() # 4. close alwaysImportant points:
- Always close sessions. Either manually or using context managers, or a framework integration.
- On exceptions, roll back the session before reusing it.
Transactions and Sessions
In most ORMs, a session controls transactions.
- When you start using the session for database work, it starts a transaction implicitly.
session.commit()ends the current transaction and starts a new one for future operations.session.rollback()undoes all uncommitted changes in that transaction and also ends it.
A commit sends pending changes to the database permanently.
A rollback cancels all changes since the last commit.
Example:
db = SessionLocal()
user = User(email="a@example.com")
db.add(user) # not in the database yet, only in session
db.commit() # INSERT is executed here, transaction ends
user.name = "Alice"
db.commit() # UPDATE is executed hereIf an error happens before the second commit:
db = SessionLocal()
try:
user = User(email="b@example.com")
db.add(user)
# Some error happens here
raise ValueError("Oops")
db.commit()
except:
db.rollback() # user is not inserted at all
finally:
db.close()Flushing vs Committing
Sessions have two related but different actions: flush and commit.
- Flush: Sends pending SQL statements to the database, but does not finish the transaction.
- Commit: Flushes if needed, then ends the transaction.
In SQLAlchemy:
- When you call
session.commit(), it automatically callssession.flush()first. - Flush can also happen automatically before certain operations, like a query, if
autoflush=True.
You might explicitly call flush() if you need database generated values before committing, for example an auto increment id.
Example:
user = User(email="c@example.com")
db.add(user)
print(user.id) # None, not yet flushed or committed
db.flush() # send INSERT to DB, but still in the same transaction
print(user.id) # Now has the generated ID from the database
db.commit() # transaction is now completedA flush uses the current transaction. If you roll back after flushing, those changes are undone.
Object States Inside a Session
ORMs track the state of objects while they are attached to a session. Common states:
| State | Description | Example |
|---|---|---|
| transient | New object, not attached to any session, no DB row | user = User(email="x@y.com") before add() |
| pending | Attached to session, will be inserted on flush | After session.add(user) but before flush / commit |
| persistent | Exists in database and attached to a session | After commit, or after being loaded via a query |
| detached | Was persistent, but session is closed or object removed | After session.close() or session.expunge(user) |
| deleted | Marked for deletion in this session, will be deleted on flush | After session.delete(user) before commit |
You usually do not need to manage these states manually, but you should know they exist so behavior makes sense.
Example:
user = User(email="x@y.com") # transient
db.add(user) # pending
db.commit() # becomes persistent
db.close() # user object now detachedIf you modify a detached object, the session will not see it automatically. You must reattach it if you want to persist changes, which you will see in the next section.
Adding, Updating, and Deleting with a Session
Adding new records
To add a new row:
- Create a model instance.
- Add it to the session.
- Commit.
user = User(email="new@example.com")
db.add(user)
db.commit()
db.refresh(user) # often used in SQLAlchemy to get updated values
print(user.id) # has database generated ID
db.refresh(user) reloads the object from the database. Some frameworks do this for you, others require it explicitly.
Updating existing records
You usually update by:
- Querying the object.
- Changing attributes.
- Committing.
user = db.query(User).filter(User.id == 1).first()
user.name = "Updated Name"
db.commit() # ORM detects that 'name' changed and issues an UPDATE
The ORM tracks dirty objects (objects with changed attributes). On flush or commit, it generates UPDATE only for those objects and fields that changed.
Deleting records
To delete:
- Query the object.
- Mark it for deletion.
- Commit.
user = db.query(User).filter(User.id == 1).first()
db.delete(user)
db.commit() # ORM issues DELETE
Before commit, the object is in the deleted state.
Merging Detached Objects
Sometimes you have an object that is not attached to a session anymore (detached). For example:
- You loaded it in a previous request.
- You serialized and then deserialized it.
- The session it belonged to was closed.
To apply changes from a detached object, you should merge it into a new session.
Example:
# Suppose we got this user object from somewhere, but it is detached.
user = User(id=1, name="New Name")
db = SessionLocal()
managed_user = db.merge(user) # managed_user is the persistent version
db.commit()
What merge() does conceptually:
- Looks for an existing row in the database with the same primary key.
- Loads the managed object from the database.
- Copies in changes from your detached object.
- Returns the managed instance (attached to the session).
Do not modify detached objects and expect changes to be saved automatically.
You must attach them to a session again, for example with session.merge().
Session Scope: How Long Should a Session Live?
A crucial design choice is the scope of a session: how long you keep a single session open.
Typical scopes:
| Scope | Description | Common use case |
|---|---|---|
| Per operation | Create and close inside each function | Simple scripts, batch tools |
| Per request | One session for each HTTP request | Web APIs and web applications |
| Global / long lived | One session reused across many requests | Usually a bad idea in web backends |
Why “per request“ is recommended for web backends
For a web API, a common pattern is:
- At the start of a request, create a session.
- Use it to handle that request only.
- Commit or roll back and close it at the end of the request.
This isolates database work per request and makes it:
- Easier to manage transactions.
- Safer under concurrency.
- Easier to clean up resources.
Example pattern in FastAPI style code:
# Usually in some db.py
from sqlalchemy.orm import sessionmaker
from .database import engine
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()Then in endpoints:
from fastapi import Depends
@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
return userHere:
- FastAPI creates a session per request through
get_db. - After the request finishes, it automatically closes the session.
Why global sessions are dangerous
If you keep one global session for the whole application:
- Different requests could interfere with each other.
- Transactions might accidentally cover multiple requests.
- You can end up with stale data, since the identity map keeps objects in memory.
- Error handling and rollbacks become messy.
In multi threaded servers, one global session can also cause thread safety problems if it is not configured correctly.
Session Configuration: autoflush and autocommit
In SQLAlchemy and similar ORMs, sessions have some important configuration options. Two common ones:
autoflush
- If
autoflush=True, the session automatically flushes pending changes before certain operations, especially before a query. - This ensures that queries see the latest changes that are still in the session but not yet committed.
Example:
SessionLocal = sessionmaker(bind=engine, autoflush=True, autocommit=False)
db = SessionLocal()
user = User(email="auto@example.com")
db.add(user)
# With autoflush=True, this query triggers a flush first
count = db.query(User).count()
If autoflush=False, you might have to call session.flush() yourself when needed.
autocommit
- Old SQLAlchemy patterns sometimes used
autocommit=True. - In modern setups for backend development you almost always use
autocommit=False. - With
autocommit=False, every transaction is explicit, and you callcommit()yourself.
For web backends, use sessions with autocommit=False.
Control commit() and rollback() explicitly.
Handling Sessions in Errors and Exceptions
It is very important to handle exceptions correctly with sessions.
Pattern:
db = SessionLocal()
try:
# perform DB work
db.commit()
except:
db.rollback()
raise
finally:
db.close()Why this matters:
- After an exception, the current transaction is broken. You must call
rollback()before using the session again. - If you do not close, you may leak resources.
Example of a bug:
db = SessionLocal()
# First transaction
user = User(email="bad@example.com")
db.add(user)
# This will raise an exception, for example due to a DB constraint
db.commit()
# Now the transaction is in a failed state.
# If you keep using `db` without rollback, you will get confusing errors.Correct approach:
db = SessionLocal()
try:
user = User(email="bad@example.com")
db.add(user)
db.commit()
except:
db.rollback()
raise
finally:
db.close()In a framework like FastAPI, you often handle this in:
- A dependency that manages the session.
- Or middleware that wraps the request lifecycle.
Example: Using Sessions in a Short Script
Here is a complete example of using sessions in a small standalone script with SQLAlchemy style code.
Assume you have a User model and engine.
from sqlalchemy.orm import sessionmaker
from models import User
from database import engine
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def create_user(email: str, name: str):
db = SessionLocal()
try:
user = User(email=email, name=name)
db.add(user)
db.commit()
db.refresh(user)
return user
except:
db.rollback()
raise
finally:
db.close()
def rename_user(user_id: int, new_name: str):
db = SessionLocal()
try:
user = db.query(User).get(user_id)
if not user:
return None
user.name = new_name
db.commit()
return user
except:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
u = create_user("demo@example.com", "Demo")
print("Created:", u.id, u.name)
u2 = rename_user(u.id, "New Demo")
print("Renamed:", u2.id, u2.name)Patterns shown:
- One session per operation.
- Proper commit, rollback, and close.
- Use of
db.refresh()to get updated data from the database.
Common Pitfalls with Sessions
Here are some issues you are likely to run into as a beginner and how to think about them.
“My data did not save”
Possible reasons:
- You forgot to call
commit(). - You used a session that you rolled back after an error, but then never retried.
- You changed a detached object that is not attached to any session.
Check:
- Did you commit after changes?
- Is the object attached to the session you are committing?
“I see stale data”
Possible reasons:
- You are reusing a long lived session with an identity map full of old objects.
- The session thinks it already knows the row and does not reload from the database.
Fix:
- Use a per request session pattern.
- If necessary, use
session.refresh()on a specific object.
“Weird thread errors” in web apps
Possible reason:
- Sharing the same
Sessioninstance across threads or async tasks.
Fix:
- Use a separate session per request, and do not share sessions between threads.
Summary
You have seen that a database session in an ORM is:
- A high level interface to the database, on top of raw connections.
- Responsible for:
- Tracking objects and their state.
- Managing the identity map.
- Handling transactions, flush, commit, and rollback.
- Usually scoped per request in web backends.
Key ideas to remember:
- Use one session per web request or per unit of work.
- Always commit or rollback, then close the session.
- Sessions manage transactions, do not rely on autocommit.
- Detached objects do not save themselves, you must attach or merge them.
With a solid understanding of sessions, working with models, queries, and transactions in your backend becomes much more predictable and less error prone.
Views: 5
KAHIBARO