Fix DatabaseError: sql: database is closed in Echo
This error occurs when a query is executed on a database connection that has been closed, typically because db.Close() is called prematurely or the connection pool is misconfigured. Fix it by managing the database lifecycle properly with graceful shutdown, passing the db via dependency injection, and ensuring Close is only called when the application exits.
Reading the Stack Trace
Here's what each line means:
- main.ListProducts({0x1029e4f80, 0x14000226000}) /app/handlers/product.go:18 +0x1c4: The ListProducts handler at line 18 attempts a query on a database connection that has already been closed.
- database/sql.(*DB).QueryContext(0x14000118300, {0x1029f0ea0, 0x14000196040}, {0x1028f1e60, 0x17}, {0x0, 0x0, 0x0}): Go's database/sql package detects that the DB handle is closed and returns an error instead of executing the query.
- database/sql.(*DB).query(0x14000118300, {0x1029f0ea0, 0x14000196040}, {0x1028f1e60, 0x17}, {0x0, 0x0, 0x0}): The internal query method checks the closed flag before acquiring a connection from the pool.
Common Causes
1. db.Close() called too early
Using defer db.Close() in main() closes the database before the server finishes handling in-flight requests.
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
defer db.Close() // closes immediately when main returns
e := echo.New()
e.GET("/api/products", ListProducts)
go e.Start(":8080")
// main returns, db.Close() runs, server still running
}
2. Global db variable reassigned to nil
A test or init function accidentally sets the global db variable to nil.
var db *sql.DB
func resetDB() {
db.Close()
db = nil // subsequent queries panic
}
3. No graceful shutdown
The server stops abruptly without waiting for in-flight requests to complete, causing queries to fail mid-execution.
func main() {
e := echo.New()
e.Start(":8080") // no signal handling, no graceful shutdown
}
The Fix
Use graceful shutdown with signal handling. Start the server in a goroutine and wait for SIGINT/SIGTERM. On shutdown, call e.Shutdown to finish in-flight requests, then close the database connection. This ensures db.Close() only runs after all handlers complete.
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
defer db.Close()
e := echo.New()
e.GET("/api/products", ListProducts)
e.Start(":8080")
}
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
if err := db.Ping(); err != nil {
log.Fatal(err)
}
e := echo.New()
e.GET("/api/products", NewListProducts(db))
// Graceful shutdown
go func() {
if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
log.Fatal(err)
}
db.Close()
log.Println("server stopped gracefully")
}
Testing the Fix
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func TestListProducts_Success(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "Widget")
mock.ExpectQuery("SELECT").WillReturnRows(rows)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/products", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := NewListProducts(db)
err := handler(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestListProducts_DBClosed(t *testing.T) {
db, _, _ := sqlmock.New()
db.Close()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/products", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := NewListProducts(db)
err := handler(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
}
Run your tests:
go test ./handlers/... -v
Pushing Through CI/CD
git checkout -b fix/echo-database-error,git add main.go handlers/product.go,git commit -m "fix: implement graceful shutdown to prevent database closed errors",git push origin fix/echo-database-error
Your CI config should look something like this:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go mod download
- run: go vet ./...
- run: go test ./... -race -coverprofile=coverage.out
- run: go build ./...
The Full Manual Process: 18 Steps
Here's every step you just went through to fix this one bug:
- Notice the error alert or see it in your monitoring tool
- Open the error dashboard and read the stack trace
- Identify the file and line number from the stack trace
- Open your IDE and navigate to the file
- Read the surrounding code to understand context
- Reproduce the error locally
- Identify the root cause
- Write the fix
- Run the test suite locally
- Fix any failing tests
- Write new tests covering the edge case
- Run the full test suite again
- Create a new git branch
- Commit and push your changes
- Open a pull request
- Wait for code review
- Merge and deploy to production
- Monitor production to confirm the error is resolved
Total time: 30-60 minutes. For one bug.
Or Let bugstack Fix It in Under 2 minutes
Every step above? bugstack does it automatically.
Step 1: Install the SDK
go get github.com/bugstack/sdk
Step 2: Initialize
import "github.com/bugstack/sdk"
func init() {
bugstack.Init(os.Getenv("BUGSTACK_API_KEY"))
}
Step 3: There is no step 3.
bugstack handles everything from here:
- Captures the stack trace and request context
- Pulls the relevant source files from your GitHub repo
- Analyzes the error and understands the code context
- Generates a minimal, verified fix
- Runs your existing test suite
- Pushes through your CI/CD pipeline
- Deploys to production (or opens a PR for review)
Time from error to fix deployed: Under 2 minutes.
Human involvement: zero.
Try bugstack Free →No credit card. 5-minute setup. Cancel anytime.
Deploying the Fix (Manual Path)
- Run go test ./... locally to confirm database handling works.
- Open a pull request with the graceful shutdown changes.
- Wait for CI checks to pass on the PR.
- Have a teammate review and approve the PR.
- Merge to main and verify graceful shutdown in staging.
Frequently Asked Questions
BugStack validates the shutdown sequence, tests with closed database mocks, and ensures in-flight requests complete before the database closes before marking it safe to deploy.
BugStack never pushes directly to production. Every fix goes through a pull request with full CI checks, so your team can review it before merging.
Dependency injection makes handlers testable with mock databases, prevents global state issues, and makes the database lifecycle explicit.
10-30 seconds is typical. It should be long enough for your slowest handler to complete but short enough for orchestrators like Kubernetes to not force-kill the pod.