The premature cleanup trap
You write a Go program to fetch data from a database. You run it. The terminal prints sql: database is closed. You check the code. You called db.Close(). You think you are doing the right thing by cleaning up resources. The database driver disagrees.
The error means you attempted to execute a query after the database pool manager was shut down. The fix is ordering: close the database only when no goroutine will ever touch it again. In a long-running server, that means closing the database during graceful shutdown, after the HTTP server has stopped accepting requests. In a script, it means closing the database after all queries have returned.
The pool manager, not a connection
Go's database/sql package hides a connection pool behind the *sql.DB type. A common misconception is that *sql.DB represents a single TCP connection to the database. It does not. It is a pool manager. It holds a set of open connections, creates new ones when needed, and reuses idle ones.
When you call sql.Open, you are not connecting to the database. You are validating the connection string and creating the pool manager. The first actual network connection happens when you run a query or call db.Ping.
Calling db.Close() demolishes the pool manager. It marks the pool as closed and closes all idle connections. Once closed, the pool cannot be reopened. Any subsequent call to db.Query, db.Exec, or db.Prepare checks the closed flag and returns an error immediately. The runtime does not attempt a network call. This fast failure prevents goroutines from hanging while waiting for a response from a dead connection.
Think of sql.DB as a bus depot. The depot manages a fleet of buses. sql.Open builds the depot. db.Close() locks the gates and parks the buses. If a passenger tries to board after the gates are locked, the attendant turns them away instantly. You cannot reopen the depot from the rubble. You need a new one.
Minimal example
The error appears when the close call happens before the query. The compiler cannot catch this because the types are correct. The error surfaces at runtime.
package main
import (
"database/sql"
"fmt"
"log"
)
func main() {
// sql.Open validates the DSN and returns a pool manager.
// It does not open a network connection yet.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
// Closing here marks the pool as unusable.
// The pool manager is now dead.
db.Close()
// This query checks the closed flag locally.
// It returns an error without touching the network.
rows, err := db.Query("SELECT 1")
if err != nil {
// Output: Error: sql: database is closed
fmt.Println("Error:", err)
return
}
defer rows.Close()
}
The error message sql: database is closed is a runtime error, not a compiler error. The compiler sees valid types and allows the program to build. The failure happens when the code runs. The database/sql package checks the internal state of the *sql.DB struct and rejects the operation.
Walkthrough of the failure
When db.Close() executes, the pool manager sets an internal atomic flag to indicate it is closed. It then iterates over the connection pool and closes any idle connections. Active connections are left alone until they return to the pool, at which point they are closed.
When db.Query runs after Close(), the function acquires a lock, checks the closed flag, sees it is true, releases the lock, and returns the error. No goroutine is spawned to fetch a connection. No SQL is sent to the database. The overhead is minimal.
This design protects your program. If the pool allowed queries after close, those queries might hang indefinitely or corrupt state. The error is a safety mechanism. It tells you that your lifecycle management is out of order.
Realistic example: graceful shutdown
In a web server, the database must stay open while requests are being processed. Closing it too early causes in-flight requests to fail. The correct pattern is to shut down the HTTP server first, wait for active requests to finish, and then close the database.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// getHandler returns an HTTP handler that queries the database.
// The handler captures the shared db pool in its closure.
func getHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Query the database using the shared pool.
// If the database is closed, this returns an error.
rows, err := db.Query("SELECT 1")
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Success")
}
}
func main() {
// Open the database pool once at startup.
// Share this single instance across all handlers.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
// Register the handler with the database pool.
http.HandleFunc("/", getHandler(db))
// Start the HTTP server in a goroutine.
server := &http.Server{Addr: ":8080"}
go func() {
log.Println("Server starting on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for interrupt signal to trigger graceful shutdown.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
// Create a context with a timeout for the shutdown process.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Shutdown the HTTP server.
// This stops accepting new requests and waits for active ones to complete.
if err := server.Shutdown(ctx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
// Close the database only after the server has stopped.
// This ensures no new queries can start and in-flight queries have finished.
if err := db.Close(); err != nil {
log.Printf("Database close error: %v", err)
}
log.Println("Shutdown complete")
}
The key is the order of operations. server.Shutdown blocks until all active requests return. Once the server is down, no handler can run. At that point, db.Close() is safe. The database pool is drained and closed without affecting any client.
Pitfalls and common mistakes
The defer trap is the most frequent cause of this error. Developers often write defer db.Close() at the top of main. This works if main runs to completion and no goroutines outlive main. It breaks if you pass the database to a goroutine that continues running after main returns. The goroutine will eventually query a closed database.
If your program spawns background workers that use the database, you must coordinate the shutdown. Signal the workers to stop, wait for them to finish, and then close the database. Using a context to cancel long-running tasks is the standard approach.
Another issue is testing. If you close the database in one test function, subsequent tests that reuse the same database instance will fail. The error sql: database is closed will appear in unrelated tests. The fix is to create a fresh database pool for each test or to close the database only in a cleanup function that runs after all tests complete.
Goroutine races are expected, not bugs. If one goroutine calls db.Close() and another calls db.Query(), the query will return the error. This is correct behavior. The error is the signal that the resource is gone. You do not need mutexes around db.Close() or db.Query. The *sql.DB type is safe for concurrent use. The internal locking handles the race. The query simply sees the closed flag and fails.
Convention aside: the community accepts verbose error handling because it makes the unhappy path visible. Writing if err != nil { return err } is standard. Ignoring errors with _ hides failures. In the minimal example, ignoring the error from db.Query would mask the problem. Always check errors from database operations.
Decision matrix
Use db.Close() when the program is terminating and no goroutines hold references to the database pool.
Use sql.Open when you need to initialize the connection pool manager at the start of your application.
Use a single shared *sql.DB instance when multiple goroutines need to access the database concurrently.
Use db.Ping() when you need to verify the database connection is alive before running critical queries.
Avoid calling db.Close() inside a function that returns the database handle to a caller.
Avoid defer db.Close() when the database is passed to goroutines that may outlive the current function scope.
The database pool is a shared resource. Manage its lifecycle at the application level, not at the function level. Close it once, at the very end.