How to Connect to Cassandra from Go with gocql

Connect to Cassandra from Go using the gocql driver by creating a cluster and establishing a session.

When the standard library isn't enough

You're building a service that ingests high-volume telemetry or manages a distributed catalog. You chose Cassandra because it handles writes at scale and survives node failures without locking up. Now you need Go to talk to it. You reach for database/sql, the familiar interface from relational databases, and realize it doesn't fit. Cassandra's data model and consistency guarantees don't map cleanly to the generic SQL adapter. You need a driver that speaks Cassandra's native protocol.

Enter gocql. It's the de facto Go driver for Cassandra. It gives you direct access to the cluster topology, load balancing policies, and consistency levels. It's powerful, but it requires you to understand how the connection pool works. Misconfigure the cluster, and you'll drown in connection timeouts or exhaust file descriptors. Get it right, and your queries fly.

How gocql talks to the cluster

Go's database/sql acts as a universal translator. You write standard SQL, and the driver adapts it to the backend. gocql skips the translator. It implements the Cassandra native protocol directly. This means you get features that generic adapters can't expose, like custom load balancing, batch statements, and precise control over consistency.

The driver revolves around two objects. The Cluster is the blueprint. You define contact points, authentication, timeouts, and retry policies. When you call CreateSession, the driver connects to those points, discovers the rest of the ring, and builds a connection pool to every node. The Session is your handle to that pool. It's thread-safe and distributes queries across connections based on the partition key.

The session is expensive to create. It involves network handshakes and metadata synchronization. You create one session at application startup and share it across all goroutines. You never create a session per request.

The session is the bridge. Build it once, cross it often.

Minimal connection example

Here's the bare minimum to establish a session. You pass contact points, set a consistency level, and create the session.

package main

import (
    "log"
    "github.com/gocql/gocql"
)

// ConnectToCassandra initializes a session to the cluster.
func ConnectToCassandra(hosts []string) (*gocql.Session, error) {
    // NewCluster creates a ClusterConfig with defaults for the given contact points.
    // It sets the port to 9042 and enables host discovery.
    cluster := gocql.NewCluster(hosts...)

    // Consistency determines how many replicas must respond for a read or write to succeed.
    // Quorum requires a majority, balancing latency and durability for most workloads.
    cluster.Consistency = gocql.Quorum

    // CreateSession connects to the contact points, discovers the topology,
    // and opens a connection pool to all nodes in the ring.
    session, err := cluster.CreateSession()
    if err != nil {
        return nil, err
    }

    return session, nil
}

func main() {
    session, err := ConnectToCassandra([]string{"127.0.0.1"})
    if err != nil {
        log.Fatal(err)
    }
    defer session.Close()

    log.Println("Connected to Cassandra")
}

Create the session at startup. Close it on shutdown. Never in between.

What happens under the hood

When CreateSession runs, the driver connects to the hosts you listed. It asks each node for cluster metadata: the list of all nodes, their data centers, the keyspaces, and the replication strategy. It builds a map of the token ring.

Next, the driver opens connections. By default, gocql opens multiple connections per host. This is the connection pool. The number of connections depends on the NumConns setting and the number of cores on the node. The session distributes queries across these connections. If a node goes down, the driver detects the failure via heartbeat and routes traffic to other replicas.

The session object holds all this state. It runs background goroutines to monitor host health and refresh metadata. If you forget to call session.Close, those goroutines keep running. The driver also holds open TCP connections. Leaking a session leaks goroutines and file descriptors.

The driver manages the ring. You manage the queries.

Tuning for production

Defaults work for local development, but production clusters need explicit configuration. gocql exposes knobs for retries, speculative execution, and timeouts.

import (
    "time"
    "github.com/gocql/gocql"
)

// ConfigureCluster sets up advanced options for production workloads.
func ConfigureCluster(cluster *gocql.ClusterConfig) {
    // Port defaults to 9042. Explicitly set it if your cluster uses a non-standard port.
    cluster.Port = 9042

    // Timeout controls the maximum time to wait for a query response.
    // Set this based on your SLA requirements to prevent hanging requests.
    cluster.Timeout = 5 * time.Second

    // RetryPolicy determines how the driver handles transient failures.
    // DefaultRetryPolicy retries on read/write timeouts and unavailable errors.
    cluster.RetryPolicy = &gocql.SimpleRetryPolicy{NumRetries: 3}

    // SpeculativeExecutionPolicy retries slow queries to a different replica.
    // This reduces tail latency for hot partitions.
    cluster.SpeculativeExecutionPolicy = &gocql.RetrySpeculativeExecutionPolicy{
        Num:  2,
        Each: 500 * time.Millisecond,
    }
}

gofmt is the standard. Don't argue about indentation; let the tool decide. Most editors run it on save.

Realistic query execution

Real code needs context, error handling, and structured scanning. Here's how you run a query and handle the result safely.

package main

import (
    "context"
    "fmt"
    "github.com/gocql/gocql"
)

// User represents a row in the users table.
type User struct {
    ID    string // Strings are cheap to pass by value; don't use *string.
    Name  string
    Email string
}

// QueryUser executes a SELECT query and scans the result into a struct.
func QueryUser(session *gocql.Session, ctx context.Context, userID string) (User, error) {
    var user User

    // Query creates a query object bound to the session.
    // It prepares the statement but doesn't execute it yet.
    query := session.Query(`SELECT id, name, email FROM users WHERE id = ?`, userID)

    // Context allows the caller to cancel the query or enforce a deadline.
    // Always pass context to respect cancellation signals from the HTTP layer.
    query = query.Context(ctx)

    // Scan executes the query and decodes the first row into the provided pointers.
    // If no rows match, gocql returns gocql.ErrNotFound.
    err := query.Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return User{}, err
    }

    return user, nil
}

func main() {
    // Assume session is initialized elsewhere.
    // session, _ := ConnectToCassandra([]string{"127.0.0.1"})
    // defer session.Close()

    ctx := context.Background()
    user, err := QueryUser(session, ctx, "user-123")
    if err != nil {
        if err == gocql.ErrNotFound {
            fmt.Println("User not found")
            return
        }
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Printf("Found user: %s (%s)\n", user.Name, user.Email)
}

Context is plumbing. Run it through every long-lived call site.

Pitfalls and error handling

gocql exposes the raw protocol, which means errors can be specific. If you pass the wrong type to Scan, the compiler might not catch it if you use interfaces, but gocql returns a runtime error. The compiler rejects the program with undefined: gocql if you forget the import. If you try to scan a string column into an integer variable, gocql returns an error like cannot scan string into int.

Connection limits are a common trap. gocql opens multiple connections per host. If you have a large cluster or many services connecting, you can hit OS file descriptor limits. The error manifests as too many open files or connection refused errors from the database. You can tune NumConns to reduce the footprint, but lowering it too much hurts throughput.

Consistency levels matter. Using gocql.One makes reads fast but risks stale data. Using gocql.All makes writes safe but blocks if any replica is slow. gocql.Quorum is the safe default. If you change consistency per query, be aware that it affects latency.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Check the error immediately.

The worst goroutine bug is the one that never logs.

When to use gocql

Use gocql when you need direct access to Cassandra's native protocol and advanced features like custom load balancing or batch statements. Use database/sql with a Cassandra driver when you want a generic interface and are doing simple CRUD operations without needing Cassandra-specific optimizations. Use a connection pool wrapper when you need to manage connection limits explicitly across multiple services sharing the same cluster. Use prepared statements when you execute the same query repeatedly; the driver caches the preparation on the server side to reduce overhead.

Pick the tool that matches the protocol. Direct access wins for Cassandra.

Where to go next