ClickHouse has become a favorite for developers who need fast analytical queries, real‑time inserts, and effortless scalability. But integrating it into a clean, modular Go project can feel intimidating if you haven’t done it before. In this article, I’ll walk through a practical, architecture‑friendly way to add ClickHouse to a Go codebase — using the patterns from the go-simple project as inspiration.

The goal is simple: Add ClickHouse as a first‑class storage backend without breaking the project’s clean structure.

Go-simple

Go-Simple Article

Start With Docker Compose

Before writing any Go code, you need ClickHouse running locally. The easiest way is to add a service to your existing docker-compose.yaml:

yaml
services:
  clickhouse:
    image: clickhouse/clickhouse-server:24.1
    ports:
      - "8123:8123"   # HTTP interface
      - "9000:9000"   # Native TCP interface
    environment:
      CLICKHOUSE_DB: default
      CLICKHOUSE_USER: default
      CLICKHOUSE_PASSWORD: pass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
    volumes:
      - clickhouse_data:/var/lib/clickhousevolumes:
  clickhouse_data:

A few notes:

  • The native TCP port (9000) is what the Go driver uses.
  • CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 enables password authentication.
  • The default user now requires the password pass.

Once this is running, you can create tables or insert data using curl:

bash
curl -X POST "http://localhost:8123" \
  -u default:pass \
  --data-binary "SELECT 1"

Create Your Table Manually

ClickHouse is schema‑first, so define your table before writing Go code. For a product model like:

go
type ProductResponse struct {
    ID int32
    Name string
    Description string
    Price int64
}

The matching ClickHouse table is:

bash
curl -X POST "http://localhost:8123" \
  -u default:pass \
  --data-binary
  "CREATE TABLE IF NOT EXISTS products (        id Int32,        name String,        description String,        price Int64     ) ENGINE = MergeTree()     ORDER BY (id)"

Insert sample data:

bash
curl -X POST "http://localhost:8123" \
  -u default:pass \
  --data-binary
  "INSERT INTO products FORMAT Values     (1, 'Laptop', 'High-performance laptop', 129900),     (2, 'Mouse', 'Wireless mouse', 2999)"

Add ClickHouse Configuration to Your Go Project

A clean Go project keeps configuration centralized. Add a ClickHouse section:

go
type ClickHouseCfg struct {
    Host string
    Port string
    DB string
    User string
    Password string
}

Now, ClickHouse settings load the same way as SQL and Redis.

Build a ClickHouse Storage Module

Following the go-simple architecture, each backend gets its own folder:

text
internal/storage/    sql/    cache/    clickhouse/

Inside clickhouse.go:

go
package clickhouse
import (
"context"
"fmt"
"go-clickhouse/internal/config"
"go-clickhouse/internal/storage/clickhouse/product"
"github.com/ClickHouse/clickhouse-go/v2"
)
type ClickHouse struct {
    conn clickhouse.Conn
    Product *product.Repository
}
func (c *ClickHouse) Conn() clickhouse.Conn {
    return c.conn
}
func New(cfg *config.Config) (*ClickHouse, error) {
    conn, err := clickhouse.Open(&clickhouse.Options{
        Addr: []string{
            cfg.ClickHouse.Host + ":" + cfg.ClickHouse.Port
        }, Auth: clickhouse.Auth{
            Database: cfg.ClickHouse.DB, Username: cfg.ClickHouse.User, Password:
            cfg.ClickHouse.Password,
        },
    }) if err != nil {
        fmt.Println("🛑 Click House could not connect: ", err) return nil, err
    }
    if err := conn.Ping(context.Background());
    err != nil {
        conn.Close() fmt.Println("🛑 Click House could not connect: ", err) return nil, err
    }
    return &ClickHouse{
        conn:
        conn, Product: product.NewProductRepository(conn),
    }, nil
}

A key detail: clickhouse.Conn is an interface, so you store it by value, not by pointer.

Implement a Repository Layer

Just like SQL repositories, ClickHouse gets its own:

go
package product
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
)
type Product struct {
    ID int32
    Name string
    Description string
    Price int64
}
type Repository struct {
    conn clickhouse.Conn
}
func NewProductRepository(conn clickhouse.Conn) *Repository {
    return &Repository{
        conn: conn
    }
}
const productColumns = `id, name, description, price`func (r *Repository) SelectProduct(ctx
context.Context, id int32) (*Product, error) {
    var p Product err := r.conn.QueryRow(ctx,
    `        SELECT `+productColumns+`        FROM products        WHERE id = ?        LIMIT 1    `,
    id).Scan(&p.ID, &p.Name, &p.Description, &p.Price) if err != nil {
        return nil, err
    }
    return &p, nil
}

Using ScanStruct keeps the code clean and avoids manual field mapping.

Wire ClickHouse Into the Global Storage Layer

Your Storage struct becomes:

go
type Storage struct {
    SQL *sql.SQL
    Cache *cache.Cache
    ClickHouse *clickhouse.ClickHouse
}

Constructor:

go
func New(pg *sql.SQL, cache *cache.Cache, ch *clickhouse.ClickHouse) *Storage {
    return &Storage{
        SQL: pg, Cache: cache, ClickHouse: ch,
    }
}

Now your service layer can do:

go
product, err := s.storage.ClickHouse.ProductCH.GetProductByID(ctx, id)

Add Tests for the ClickHouse-Backed Endpoints

A simple test for fetching a product:

go
package test
import (
"encoding/json"
"fmt"
"go-clickhouse/internal/config"
"go-clickhouse/internal/product/dto"
"io"
"net/http"
"testing"
)
func TestProductReportClient(t *testing.T) {
    // t.Parallel() WithHttpTestServer(t,
    func() {
        cfg, err := config.NewConfig() if err != nil {
            t.Fatalf("Failed to load config: %v", err)
        }
        addr := fmt.Sprintf("http://%s:%d", cfg.HTTPAddress, cfg.HTTPPort) // the product has been
        create in different way product := dto.ProductResponse{
            ID: 1, Name: "Test Product for Client Report", Description:
            "A product created for testing client report endpoint", Price: 1999,
        }
        clientGetProductByIDWithReport(t, product, addr)
    })
}
func clientGetProductByIDWithReport(t *testing.T, product dto.ProductResponse, addr string) {
    t.Run("List Products (Client)", func(t *testing.T) {
        resp, err := http.Get(addr + "/api/v1/products/" + fmt.Sprintf("%d/report", product.ID)) if
        err != nil {
            t.Fatalf("Failed to send GET request: %v", err)
        }
        defer resp.Body.Close() if resp.StatusCode != http.StatusOK {
            t.Errorf("Expected status 200 OK, got %d", resp.StatusCode) responseBody, _ :=
            io.ReadAll(resp.Body) t.Logf(ResponseBodyMessage, string(responseBody)) return
        }
        var result dto.ProductResponse if err := json.NewDecoder(resp.Body).Decode(&result);
        err != nil {
            t.Fatalf(FailedToDecodeMessage, err)
        }
        if result.ID != product.ID {
            t.Fatal("Product not found in client list")
        }
    })
}

This ensures your ClickHouse integration works end‑to‑end.

Git commit

Go-clickhouse project

Conclusion

Adding ClickHouse to a Go project doesn’t have to be messy. By following a clean architecture — like the one used in go-simple — you can introduce a new storage backend without disrupting the rest of your system.

The key steps are:

  1. Add ClickHouse to Docker Compose
  2. Create tables manually (or via migrations)
  3. Add ClickHouse config to your Go project
  4. Build a ClickHouse storage module
  5. Implement repository methods
  6. Wire it into the global storage layer
  7. Test your endpoints end‑to‑end

The result is a clean, modular, production‑ready ClickHouse integration that feels native to your Go codebase.