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:
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=1enables password authentication.- The
defaultuser now requires the passwordpass.
Once this is running, you can create tables or insert data using curl:
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:
type ProductResponse struct {
ID int32
Name string
Description string
Price int64
}The matching ClickHouse table is:
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:
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:
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:
internal/storage/ sql/ cache/ clickhouse/Inside clickhouse.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:
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:
type Storage struct {
SQL *sql.SQL
Cache *cache.Cache
ClickHouse *clickhouse.ClickHouse
}Constructor:
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:
product, err := s.storage.ClickHouse.ProductCH.GetProductByID(ctx, id)Add Tests for the ClickHouse-Backed Endpoints
A simple test for fetching a product:
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:
- Add ClickHouse to Docker Compose
- Create tables manually (or via migrations)
- Add ClickHouse config to your Go project
- Build a ClickHouse storage module
- Implement repository methods
- Wire it into the global storage layer
- Test your endpoints end‑to‑end
The result is a clean, modular, production‑ready ClickHouse integration that feels native to your Go codebase.
