Using the Cloud SQL Go Connector with database/sql
This guide is part of GCP Cloud SQL Connection Pooling. Cloud SQL does not accept plain connections from arbitrary networks; you reach it through the Cloud SQL Auth Proxy, through a private IP with your own TLS handling, or through a language connector that does the proxy’s job inside your process. For Go services the connector — cloud.google.com/go/cloudsqlconn — is usually the right choice, and it slots into database/sql cleanly once you know where it plugs in.
The connector handles what the sidecar handled: it fetches ephemeral client certificates from the Cloud SQL Admin API, establishes a mutually authenticated TLS connection to the instance, and rotates the certificates before they expire. What changes is that this happens in your process rather than in a separate container, which removes a deployment component and a network hop.
Registering the driver
cloudsqlconn exposes a dialer, and the pgx stdlib registration is where it attaches:
package db
import (
"context"
"database/sql"
"fmt"
"net"
"time"
"cloud.google.com/go/cloudsqlconn"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
)
func Open(ctx context.Context, instance, user, dbname string) (*sql.DB, func() error, error) {
d, err := cloudsqlconn.NewDialer(ctx,
cloudsqlconn.WithIAMAuthN(), // IAM database authentication
cloudsqlconn.WithDefaultDialOptions(
cloudsqlconn.WithPrivateIP(), // stay on the VPC
),
)
if err != nil {
return nil, nil, fmt.Errorf("dialer: %w", err)
}
cfg, err := pgx.ParseConfig(fmt.Sprintf("user=%s dbname=%s", user, dbname))
if err != nil {
return nil, nil, err
}
// Everything about host resolution and TLS is delegated to the dialer.
cfg.DialFunc = func(ctx context.Context, _, _ string) (net.Conn, error) {
return d.Dial(ctx, instance) // "project:region:instance"
}
db := stdlib.OpenDB(*cfg)
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return nil, nil, fmt.Errorf("ping: %w", err)
}
return db, d.Close, nil
}
Three details matter in that setup.
cfg.DialFunc is the whole integration. The connector does not replace the driver; it replaces the driver’s transport. Everything else — the pool, the SQL, the error handling — is ordinary database/sql.
The connection string carries no host, port, password, or TLS parameters. Supplying them is harmless but misleading, because the dialer ignores host and port entirely and manages TLS itself. A connection string with sslmode=disable in it will confuse the next person who reads it into thinking the connection is unencrypted; it is not.
d.Close must be called on shutdown. The dialer runs background goroutines that refresh certificates and instance metadata, and leaving it open leaks them. Returning it as a cleanup function makes that hard to forget.
IAM database authentication
WithIAMAuthN() switches from a database password to an IAM principal. The service account’s email — minus the .gserviceaccount.com suffix — becomes the database user:
-- run once as a superuser
CREATE USER "orders-api@my-project.iam" WITH LOGIN;
GRANT CONNECT ON DATABASE orders TO "orders-api@my-project.iam";
GRANT USAGE ON SCHEMA public TO "orders-api@my-project.iam";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public
TO "orders-api@my-project.iam";
cloudsqlconn.NewDialer(ctx, cloudsqlconn.WithIAMAuthN())
// user = "orders-api@my-project.iam"
The service account also needs roles/cloudsql.client and roles/cloudsql.instanceUser on the project, and the Cloud SQL Admin API must be enabled. Missing instanceUser is the most common cause of an authentication failure that looks like a database permission problem.
The operational advantage is that there is no password to rotate, store, or leak. The operational cost is that authentication now depends on token acquisition, which can fail independently of the database — so a connection failure may be an IAM problem rather than a database one, and your error handling should not assume otherwise.
Token acquisition happens per connection, which is a second reason to prefer a warm pool: SetMaxIdleConns equal to SetMaxOpenConns means steady traffic pays no token cost, while a pool that shrinks and regrows pays it repeatedly.
Pool settings that suit the connector
| Setting | Value | Reason |
|---|---|---|
SetMaxOpenConns |
this instance’s share of the instance’s max_connections |
Cloud SQL derives the ceiling from machine type |
SetMaxIdleConns |
equal to MaxOpenConns |
Avoids repeated handshake and token cost |
SetConnMaxLifetime |
30 minutes | Long is fine — the connector refreshes certificates independently |
SetConnMaxIdleTime |
5 minutes | Releases genuinely unused connections |
The lifetime guidance differs from the load-balancer case deliberately. There is no intermediate device silently reaping connections here — the connector maintains the TLS session and refreshes certificates without dropping the connection — so a short lifetime buys nothing and costs a full handshake plus a token per cycle. Thirty minutes is a reasonable cap; the general reasoning about where that number comes from is in tuning ConnMaxLifetime and ConnMaxIdleTime in Go.
Cloud SQL’s max_connections is derived from machine type and is adjustable as a database flag. Check the actual value rather than assuming, then divide across the fleet:
SHOW max_connections;
db-custom-4-15360, max_connections = 400
reserved for operations 40
available to applications 360
replicas 24
per-replica MaxOpenConns 15
Cloud Run and serverless callers
Cloud Run adds a wrinkle: instances are frozen between requests and scale to zero. A connector dialer with background refresh goroutines does not run while the instance is frozen, and a pool holding connections through a freeze may find them stale on thaw.
The settings that suit Cloud Run:
db.SetMaxOpenConns(3) // per-instance concurrency is low
db.SetMaxIdleConns(3)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute) // shorter, so frozen connections age out
The binding constraint is max_instances x MaxOpenConns against the database ceiling, and Cloud Run’s autoscaler will happily take you past it during a traffic spike. Set max_instances from the connection budget as well as from cost — the same reasoning as in Serverless Function Connection Management.
Use cloudsqlconn.WithLazyRefresh() for environments where background goroutines cannot run reliably. It defers certificate refresh to connection time instead of running it on a timer, which is exactly what a frozen execution environment needs.
Common failure patterns and remediation
| Symptom | Underlying cause | Remediation |
|---|---|---|
failed to get instance at startup |
Cloud SQL Admin API disabled, or missing cloudsql.client |
Enable the API; grant both client and instanceUser |
| Authentication fails with IAM auth | Database user not created, or name includes the full suffix | Create the user as the email minus .gserviceaccount.com |
| Connections work locally, fail in the Kubernetes cluster | Private IP requested from a network without access | Confirm WithPrivateIP() matches the instance’s configuration |
| Goroutine leak after shutdown | Dialer never closed | Call dialer.Close() in the shutdown path |
| Stale connections after Cloud Run thaw | Pool held connections through a freeze | Lower ConnMaxIdleTime; consider lazy refresh |
too many connections after a traffic spike |
max_instances x MaxOpenConns exceeds the ceiling |
Derive max_instances from the connection budget |
| High latency on the first query per instance | Cold pool plus token acquisition | Keep MaxIdleConns equal to MaxOpenConns; warm on startup |
| Confusing TLS settings in the DSN | Host, port and sslmode present but ignored | Remove them; the dialer owns the transport |
Verifying it works
Two checks confirm the wiring rather than assuming it.
Confirm the connection really is going through the connector by checking the client address the server sees. With private IP the address is your pod’s; with the connector over public IP it is the Google front end. Either way, an unexpected address means traffic is taking a path you did not intend:
SELECT usename, client_addr, application_name, backend_start
FROM pg_stat_activity
WHERE backend_type = 'client backend'
ORDER BY backend_start DESC;
Confirm the pool is behaving by reading DBStats in the same way as any other Go pool. WaitCount and WaitDuration growing means the pool is too small for the workload; MaxIdleClosed growing means MaxIdleConns is below MaxOpenConns and you are paying handshakes plus tokens on every burst:
s := db.Stats()
log.Printf("open=%d inuse=%d idle=%d wait=%d waitDur=%s idleClosed=%d",
s.OpenConnections, s.InUse, s.Idle, s.WaitCount, s.WaitDuration, s.MaxIdleClosed)
Export those as gauges alongside the instance’s own connection count so the two views can be compared — the dashboard pattern is in Prometheus and Grafana Pool Metrics.
Operational boundary
The connector changes how connections are established. It does not change how many the instance can accept, and it does not pool anything itself — database/sql remains the pool, with all the sizing constraints that implies. If a large fleet exceeds the instance’s ceiling, the answer is a smaller per-instance pool or a proxy in front of the database, not a connector setting.
It also does not remove the need for retry logic. Cloud SQL performs maintenance and failover, and during those windows connections fail. Short of an external proxy that holds connections across the event, the application must retry idempotent work with backoff.
Frequently Asked Questions
Does the connector replace the Cloud SQL Auth Proxy?
Do I need a password with IAM authentication?
WithIAMAuthN() uses the service account’s token. The database user is the account email minus the .gserviceaccount.com suffix, and the account needs both cloudsql.client and cloudsql.instanceUser.What connection lifetime should I use?
Why does my DSN have no host?
project:region:instance name and manages the transport. Host, port, and TLS parameters in the connection string are ignored.Do I still need to close the dialer?
Close() in the shutdown path.How does this behave on Cloud Run?
WithLazyRefresh() so refresh happens at connection time instead.Does the connector pool connections?
database/sql’s job; the connector only supplies the transport. All the usual SetMaxOpenConns reasoning applies unchanged.Related
- GCP Cloud SQL Connection Pooling — the parent guide on Cloud SQL connection limits.
- Go database/sql Pool Internals — the pool the connector sits underneath.
- Tuning ConnMaxLifetime and ConnMaxIdleTime in Go — choosing the age caps above.
- Serverless Function Connection Management — the Cloud Run constraints in full.
- Connection Pool Sizing Formulas — dividing the instance ceiling across replicas.