Go / Gin

How to Resolve "database is locked" Error in Go GORM / SQLite

4 min read by DebuggedIt

Quick answer

Your Go service using GORM with SQLite throws "database is locked" under any real concurrent load, even though the same queries work fine one at a time. SQLite...

Your Go service using GORM with SQLite throws "database is locked" under any real concurrent load, even though the same queries work fine one at a time. SQLite allows only one writer at a time by design, and Go's default connection pooling actively fights against that constraint unless you configure it correctly.

The Problem

A write operation fails intermittently, usually under concurrent requests:

2026/08/07 15:04:22 /app/repository/user.go:42
[error] failed to create record: database is locked

panic: database is locked

goroutine 24 [running]:
main.(*UserRepo).Create(...)
	/app/repository/user.go:42 +0x1a3

It gets worse specifically under load testing or when multiple goroutines hit the database simultaneously:

$ go test -run TestConcurrentWrites -v
--- FAIL: TestConcurrentWrites (2.14s)
    user_test.go:58: Error: database is locked (5) (SQLITE_BUSY)

Why It Happens

SQLite is a single-file, embedded database with no separate server process arbitrating access β€” the database engine itself only allows one writer to hold the file lock at a time, and by default it fails immediately (rather than waiting) when a second writer tries to acquire it while one is already in progress. This collides badly with Go's default behavior of using a connection pool, since:

  • Go's database/sql package (which GORM sits on top of) defaults to allowing multiple open connections, but SQLite can only really use one for writing at a time β€” so concurrent goroutines create multiple connections that immediately contend for the same file lock.
  • Long-running transactions hold the write lock the entire time they're open, so any other write attempt during that window fails instantly instead of queueing.
  • Default SQLite journal mode (DELETE) locks the whole database file during writes, rather than allowing reads to continue during a write, which WAL mode is specifically designed to fix.
  • A read query inside the same code path as a pending write transaction, on the same connection pool, can deadlock against itself under the default settings.

The Fix

The single most effective fix is limiting the connection pool to exactly one connection, since SQLite can't meaningfully use more than one writer anyway β€” this forces Go to serialize access instead of creating contention:

sqlDB, err := db.DB()
if err != nil {
    log.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)

Next, enable Write-Ahead Logging (WAL) mode, which allows reads to proceed concurrently with a single writer instead of blocking everything on every write:

db, err := gorm.Open(sqlite.Open("app.db?_journal_mode=WAL"), &gorm.Config{})

Also set a busy timeout so SQLite waits and retries briefly instead of failing instantly when the file is momentarily locked by another connection:

db, err := gorm.Open(sqlite.Open("app.db?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{})

Keep transactions as short as possible β€” don't do slow work like external API calls or heavy computation inside an open GORM transaction, since every millisecond it stays open is a millisecond every other write attempt is blocked or failing:

// Bad: slow external call inside the transaction
db.Transaction(func(tx *gorm.DB) error {
    tx.Create(&order)
    callSlowPaymentAPI() // holds the write lock the whole time
    return nil
})

// Better: do slow work first, keep the transaction minimal
result, err := callSlowPaymentAPI()
if err != nil {
    return err
}
db.Transaction(func(tx *gorm.DB) error {
    order.PaymentID = result.ID
    return tx.Create(&order).Error
})

Still Not Working?

If you've enabled WAL mode, set a busy timeout, and limited the connection pool but still see occasional locks under heavy concurrent write load, SQLite may genuinely not be the right fit for your service's write concurrency needs β€” WAL mode helps a lot with concurrent reads during writes, but it doesn't change the fundamental one-writer-at-a-time limit. For services with meaningfully concurrent writes, a client-server database like PostgreSQL handles locking far more gracefully:

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

If migrating isn't an option right now, consider adding a retry wrapper around write operations specifically, so transient lock contention gets retried automatically instead of failing the whole request outright:

func createWithRetry(db *gorm.DB, user *User, attempts int) error {
    var err error
    for i := 0; i < attempts; i++ {
        err = db.Create(user).Error
        if err == nil || !strings.Contains(err.Error(), "database is locked") {
            return err
        }
        time.Sleep(time.Duration(i+1) * 50 * time.Millisecond)
    }
    return err
}