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.

Sidecar proxy versus in-process connector Top: the application connects to a local Auth Proxy container over localhost, which then establishes the authenticated TLS connection to Cloud SQL. Bottom: the connector runs inside the application process and establishes that connection directly, removing the extra hop and the extra container. The connector removes a container and a hop, not a security boundary Auth Proxy sidecar application process dials 127.0.0.1:5432 proxy container holds the credentials Cloud SQL instance mutual TLS terminated here Go connector application process — dialer, certificate refresh and TLS all in-process database/sql pool sits directly on top of it Cloud SQL instance same mutual TLS Connection count against the instance is identical either way — the connector changes the path, not the arithmetic.
The connector moves the proxy's work into your process. The instance sees the same number of connections either way.

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.

What happens on each new connection Opening a connection acquires a service account token, exchanges it with the Cloud SQL Admin API for an ephemeral client certificate, then establishes mutual TLS to the instance. A warm pool pays this once rather than per request. Every new connection pays the token and certificate path service account token fetched from the metadata server Admin API exchange ephemeral client certificate mutual TLS handshake to the instance address pooled connection reused until the age cap This is why MaxIdleConns should equal MaxOpenConns: a pool that shrinks and regrows repeats every step. It is also why a long connection lifetime is correct here — nothing upstream is severing idle connections.
Each new connection walks the full token and certificate path, which is the argument for keeping the pool warm.

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.

Three ways to reach Cloud SQL Three options compared: the language connector for single-language services, the Auth Proxy sidecar for polyglot or non-Go workloads, and direct private IP where you manage TLS and there is no IAM integration. Pick the access path from what else runs in the pod language connector no extra container IAM auth built in one language per integration best for a single-language service and for Cloud Run Auth Proxy sidecar language agnostic IAM auth supported one more container to run best for polyglot pods and for tools you cannot modify direct private IP fewest moving parts you manage TLS and passwords no IAM database auth best when the VPC is the boundary and secrets are already managed All three produce the same connection count at the instance, so the sizing arithmetic is unchanged.
Three access paths with the same connection arithmetic. The choice is about deployment shape and authentication, not capacity.

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?
For Go services, yes — it does the same certificate handling and TLS work in-process. The sidecar is still the right answer for polyglot pods and for tools you cannot modify.
Do I need a password with IAM authentication?
No. 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?
Longer than you would behind a load balancer — 30 minutes is fine. The connector refreshes certificates without dropping connections, so a short lifetime only adds handshake and token cost.
Why does my DSN have no host?
The dialer resolves the instance from its 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?
Yes. It runs background goroutines for certificate and metadata refresh. Call Close() in the shutdown path.
How does this behave on Cloud Run?
Instances freeze between requests, so background refresh does not run reliably. Use a small pool, a shorter idle time, and consider WithLazyRefresh() so refresh happens at connection time instead.
Does the connector pool connections?
No. Pooling is entirely database/sql’s job; the connector only supplies the transport. All the usual SetMaxOpenConns reasoning applies unchanged.