A developer spent months migrating code only to discover a buried commit that destroyed atomicity. The post, titled "A stray commit buried multiple levels deep cost me months," details how manual commits inside transaction contexts break the atomicity guarantee, and offers concrete enforcement strategies using AST analysis and flake8 plugins.
The core problem: a DBAccess class uses a transaction() context manager, but inside the loop, create_main_records and create_details_records eventually call commit(), undermining the outer transaction. The author notes that no one knows these helpers commit, leading to silent breakage.
Another issue: passing ORM models outside the DB layer. In the "Silent Frenemy" example, setting a property on a DB model triggers a silent write on context exit, which can cause unexpected commits. The "Daddy's Milk Run" example shows what happens when transactions are commented out entirely—data vanishes, like a dad leaving for milk.
The author blames the programmer, not the framework: "DO NOT sprinkle in DB sessions or transactions like salt-bae." They argue that the DB abstraction layer must own commits and transactions, and that DB models should never leave that layer.
To enforce these rules, the author proposes two methods:
-
AST-based tests: Use Python's
astmodule to parse source files and ban manualcommit()calls. Example code scans forast.Callnodes where the function iscommit(attribute or name) and fails the test if found. -
flake8 plugin: Implement a similar check as a flake8 extension, registering it via entry points. The plugin yields a violation (e.g., "DB001 manual commit()") for each occurrence.
The author recommends AST tests when you need whole-codebase rules or can't modify the shared linter config. Otherwise, flake8 is the standard.
For detecting DB models returned from the DB layer, AST isn't enough because of cast() and lying return annotations. The author suggests an LLM pass in CI: a script dumps public functions, and an LLM answers whether any return DB models. If yes or maybe, human review; if no, pass.
The author's key takeaway: "The DB abstraction layer owns the commits and transactions. Everything else is a workaround." They also recommend the book "Domain-Driven Design" for better modeling.
The post is a rant, but it's practical. The code examples are real and the enforcement strategies are actionable. If you've ever debugged a transaction that didn't roll back, this is for you.
The Hidden Enemy: Manual Commits Inside Transactions
In the first example, create_records wraps a loop in transaction(), but create_main_records and create_details_records are helpers that eventually call commit(). This breaks atomicity: if the second helper fails, the first's changes are already committed, leaving partial data.
The author emphasizes that this is a code organization problem, not an ORM issue. The fix is to never call commit() manually when using context managers or decorators. Instead, let the context manager handle it.
The Silent Frenemy: Passing DB Models Around
In the second example, fetch_records returns DB models, and the caller sets db_models[0].yo_mama_fat = True. This triggers a silent write on context exit. The developer didn't intend a write, but the ORM's dirty tracking does it anyway.
The author argues that DB models should never leave the DB layer. Return domain models instead, or use projections. This prevents accidental writes and keeps the transaction boundary clear.
Daddy's Milk Run: No Transaction at All
In the third example, the transaction is commented out. The same property assignment now does nothing on exit, and the data is lost. The author calls this "data poofs into the ether."
The lesson: if you're not using transactions, you're risking data loss. But the real lesson is to centralize transaction management.
Enforcing Boundaries with AST and flake8
The author provides two enforcement mechanisms. The AST test is a simple unit test that walks all parsed source files and fails if any commit() call is found. The flake8 plugin does the same but integrates with your linter.
Here's the AST test code (simplified):
class TestDBBoundaries:
def test_no_manual_commits(self):
violations = []
for path, tree in parsed_source_files():
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if (isinstance(func, ast.Attribute) and func.attr == "commit") or (isinstance(func, ast.Name) and func.id == "commit"):
violations.append(f"{path}:{node.lineno} manual commit()")
assert not violations, "\n".join(violations)
And the flake8 plugin:
import ast
class BanManualCommits:
def __init__(self, tree):
self.tree = tree
def run(self):
for node in ast.walk(self.tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if (isinstance(func, ast.Attribute) and func.attr == "commit") or (isinstance(func, ast.Name) and func.id == "commit"):
yield node.lineno, node.col_offset, "DB001 manual commit()", type(self)
To register the flake8 plugin, add to pyproject.toml:
[project.entry-points."flake8.extension"]
DB0 = "flake8_db_boundaries:BanManualCommits"
The author suggests using AST tests when you need a codebase-wide rule and can't touch the shared linter config. Otherwise, flake8 is easier to adopt.
The LLM Check for DB Models
AST can't catch DB models being returned because of cast() and lying annotations. The author proposes an LLM pass in CI: a script dumps the DB layer's public functions, and an LLM answers: "does anything return a DB model instead of a domain model?" If yes or maybe, human review; if no, pass.
This is a pragmatic use of LLMs—not agentic, just a deterministic prompt.
The author concludes: "The DB abstraction layer owns the commits and transactions. Everything else is a workaround." If you follow that rule, you avoid months of refactoring.
For further reading, the author recommends "Domain-Driven Design: Tackling Complexity in the Heart of Software."
If you've ever been burned by a hidden commit, this post is a wake-up call. Enforce the boundaries with automated checks, and you'll sleep better.