Why This Error Happens
In Go, a transaction handle (*sql.Tx) is a one-shot object. Once you call Commit() or Rollback(), the transaction is finished and the underlying database connection returns to the pool. If you try to use that same handle again for an Exec, Query, or even another Commit, Go triggers this specific error.
Think of it like a physical contract. Once it is signed or shredded, you cannot add new clauses to it. You must start a brand new transaction for any subsequent work.
Common Root Causes
Debugging this usually involves tracing the lifecycle of your tx variable. Most developers run into this in one of three ways:
1. The Defer Order Conflict
This is the most frequent culprit. You might call tx.Commit() at the end of a successful function, but you also have a defer tx.Rollback() at the top. While database/sql is designed to make Rollback() a no-op after a commit, specific error-checking logic in your defer block can catch the sql.ErrTxDone signal and treat it as a failure.
2. Branching Logic Errors
Complex if-else structures often hide double-completion bugs. For example, you might commit inside an if block but forget to return immediately. The code then continues to a second database call or a final Commit() at the bottom of the function, causing a crash on the second attempt.
3. Loop Failures
If you are processing 100 rows inside a single transaction and the 5th row triggers a Rollback(), the 6th iteration will fail. It won't fail because of the data, but because the transaction it’s trying to use is already dead.
Proven Solutions
Solution 1: The Standard Idiomatic Pattern
The most robust way to handle transactions is to use defer for safety and only commit at the very last line of success. If any error occurs earlier, the function returns, and the defer ensures the database doesn't have a hanging connection.
func adjustBalance(db *sql.DB, userID int, amount float64) error {
tx, err := db.Begin()
if err != nil {
return err
}
// This is your safety net. It does nothing if tx.Commit() is called first.
defer tx.Rollback()
// Example: Deducting $50.00 from a user account
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, userID)
if err != nil {
return err
}
// Finalize the work. After this line, the transaction is closed.
return tx.Commit()
}
Solution 2: Silent Rollbacks in Closures
If you need to log errors during a rollback but want to avoid the "already committed" noise, use a closure. This pattern ignores the sql.ErrTxDone error, which is the internal name for the "already committed or rolled back" message.
defer func() {
err := tx.Rollback()
if err != nil && err != sql.ErrTxDone {
log.Printf("Actual rollback error: %v", err)
}
}()
Solution 3: Guarding Branching Logic
Always pair your Commit() calls with a return statement. If your function is getting too long (e.g., over 50 lines), split the business logic into smaller functions. Pass the tx object into those sub-functions so the main function remains the sole "owner" of the transaction lifecycle.
How to Verify the Fix
Don't just assume it's fixed. Test these two scenarios specifically:
- **The Happy Path:** Run a successful operation and check your database. Ensure the `Commit()` happened and no errors appear in your console.
- **The Failure Path:** Intentionally trigger a database error (like a unique constraint violation). Confirm that the `Rollback()` fired and that your logs show the *original* database error, not the secondary "transaction already committed" error.
Key Takeaways
- **One-time use:** Treat every `*sql.Tx` as a single-use tool. Once you finish, discard it.
- **Defer early:** Place your `defer tx.Rollback()` immediately after `db.Begin()` to prevent connection leaks during panics.
- **Check for ErrTxDone:** Filter out this specific error in your logging to keep your production logs clean.
- **Keep it short:** Aim for transactions that last milliseconds, not seconds, to avoid locking rows and hitting logic traps.

