Transactions, Commits, and Rollbacks in SQLModel: A Mental Model That Actually Makes Sense
Learn how to effectively manage database transactions in Python using SQLModel through an intuitive Git-based mental model and proven optimization patterns.
Every developer working with databases has experienced that frustrating moment when their application grinds to a halt because of connection issues or unexplained slowdowns.
If you've ever wondered why your SQLModel operations are crawling along or connections are being dropped, you're not alone.
The problem isn't your code's logic - it's how you're managing database sessions.
I learned this the hard way after creating bottlenecks by constantly opening new connections instead of properly managing and reusing sessions.
By the end of this post, you'll understand:
commit() or rollback()The best part? You'll never again wonder why your application is suddenly crawling along due to connection pool exhaustion.
The Git Model: A Better Way to Think About Database Transactions
Database transactions can be confusing until you have the right mental model.
Here's the simplest way to understand what's happening:
Your SQLModel session works almost exactly like Git.
| SQLModel Action | Git Equivalent | What's Happening |
|---|---|---|
session.add(user) | git add | Stages changes but doesn't save them permanently |
session.commit() | git commit && git push | Makes changes permanent and visible to everyone |
session.rollback() | git reset --hard | Discards all uncommitted changes |
Think of it like this: Just as you wouldn't create a new Git repo for every file change, you shouldn't create new database sessions for every operation.
Not only does this analogy make transactions clearer, it also explains why your app slows to a crawl when you keep creating new sessions instead of reusing them.
Sessions: Your Database Workspace
A session in SQLModel represents your connection to the database for a series of operations:
from sqlmodel import Session
from .database import engine
def create_user():
with Session(engine) as session:
# Your database operations here
new_user = User(name="Alice")
session.add(new_user)
session.commit() # Don't forget this!This with block ensures:
But here's what most tutorials miss: without that commit() call, your changes exist only in memory - never reaching the actual database.
The Lifecycle of a Database Change
Understanding the journey your data takes clarifies when and why to commit:
1. Adding Objects: Just Staging Changes
When you create a new record:
new_user = User(name="Alice")
session.add(new_user)You've only told SQLModel: "I want to insert this object."
This is equivalent to git add - you're staging the change, not saving it permanently.
Your data is not in the database yet.
2. Querying Without Committing: The Confusing Part
Here's where things get tricky.
Within the same session, you can query your new object even without committing:
session.add(new_user)
# This works - even though it's not committed!
found_user = session.exec(select(User).where(User.name == "Alice")).first()This works because SQLModel maintains an identity map of objects in memory and flushes changes when needed.
But:
rollback(), the changes disappear3. Committing: Making It Real
session.commit()This is where the magic happens - your changes are now permanently stored in the database.
Think of this as both git commit and git push combined:
4. Rollback: Your Safety Net
Before committing, if you realize you've made a mistake:
session.rollback()This discards all uncommitted changes in the current transaction - like git reset --hard before pushing.
Important: Once you've committed changes, rollback() can't remove them.
You'd need a new transaction to revert that data.
Practical Patterns for Real Applications
Now that you understand the mental model, here are battle-tested patterns for common scenarios:
Pattern 1: The Basic Transaction
Use this when you want several operations to succeed or fail together:
with Session(engine) as session:
try:
# Multiple operations in one transaction
user = User(name="Alice")
session.add(user)
post = Post(title="First Post", user_id=user.id)
session.add(post)
# Commit only after all operations succeed
session.commit()
except Exception as e:
session.rollback() # Revert partial changes
# Log error, handle exception, etc.Pattern 2: Multiple Transactions in One Session
Sometimes you want to save progress incrementally:
with Session(engine) as session:
# Transaction 1
alice = User(name="Alice")
session.add(alice)
session.commit() # Alice is now safely in the DB
try:
# Transaction 2
bob = User(name="Bob")
session.add(bob)
# Potentially risky operation
result = risky_operation()
session.commit() # Bob is saved
except Exception:
session.rollback() # Only Bob is rolled back, Alice remainsThis approach saves partial progress, ensuring some data persists even if later operations fail.
Pattern 3: High-Performance Batch Operations
For bulk imports or analytics tasks:
BATCH_SIZE = 1000
with Session(engine) as session:
for i, item in enumerate(large_dataset):
user = User(name=f"User {item['name']}")
session.add(user)
# Commit in batches to avoid memory issues
if i % BATCH_SIZE == 0:
session.commit()
# Commit any remaining items
session.commit()This pattern prevents memory bloat when processing large datasets and saves progress incrementally.
Common Pitfalls and Their Solutions
Pitfall 1: The Detached Object
with Session(engine) as session:
user = User(name="Alice")
session.add(user)
session.commit()
# Session is now closed
user.name = "Alicia" # This change won't be saved!Solution: Reattach the object to a new session before modifying:
with Session(engine) as session:
# Reattach the user to this session
session.add(user)
user.name = "Alicia"
session.commit()Pitfall 2: Forgetting to Close Sessions
# Bad practice - session never closed
session = Session(engine)
user = session.exec(select(User).first())Solution: Always use context managers (with statements) to ensure proper cleanup:
with Session(engine) as session:
user = session.exec(select(User)).first()Pitfall 3: Not Handling Errors Properly
# Risky approach
with Session(engine) as session:
# If this raises an exception, we never reach commit
user = process_data()
session.add(user)
session.commit()Solution: Use try/except blocks to handle errors and ensure rollback:
with Session(engine) as session:
try:
user = process_data()
session.add(user)
session.commit()
except Exception as e:
session.rollback()
# Handle or log the errorFinal Thoughts: When to Commit?
The most common question is: "How often should I commit?"
Rule of thumb: Commit after each logical unit of work.
add() creates unnecessary database overhead.Think of a commit as drawing a line under a completed task.
When one logical operation is done (creating a user and their initial posts, for example), commit those changes together.
Summary: The SQLModel Transaction Cheatsheet
| Action | What It Does | Best Practice |
|---|---|---|
session.add(obj) | Stages an object for insertion/update | Use for all new or modified objects |
session.flush() | Sends pending changes to DB (automatically called by SQLModel when needed) | Rarely needed explicitly |
session.commit() | Permanently saves all changes | Call after completing a logical unit of work |
session.rollback() | Discards uncommitted changes | Call when something goes wrong |
with Session() as session: | Creates and automatically closes session | Always use this pattern |
Remember the Git analogy: add, commit, push.
In SQLModel, it's add, commit - two steps that keep your data safe and your mental model clear.
Effective database management in Python requires understanding both the SQLModel API and fundamental transaction principles.
By following best practices around session management, error handling, and strategic commits, you can build robust and reliable database interactions.
Want to stay updated on more content like this? Subscribe to my newsletter below.