Types through CTEs, window functions and views: DuckDB and ClickHouse
Every file of the DuckDB and ClickHouse versions side by side, with the lines that differ marked. 9 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
DuckDB
ClickHouse
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: colony4
- name: colony5
dialect: duckdb5
dialect: clickhouse6
migrations: db/migrations6
migrations: db/migrations7
queries: db/queries7
queries: db/queries8
go:8
go:9
package: store9
package: store10
out: internal/store10
out: internal/storeMigrations
db/migrations/0001_gulls.up.sqldiffers
DuckDB
ClickHouse
1
CREATE SEQUENCE gull_ids;2
3
CREATE SEQUENCE theft_ids;4
5
CREATE TABLE gulls (1
CREATE TABLE gulls (6
id BIGINT PRIMARY KEY DEFAULT nextval('gull_ids'),2
id UInt64,7
name VARCHAR NOT NULL,3
name String,8
parent_id BIGINT REFERENCES gulls (id),4
parent_id Nullable(UInt64),9
beach VARCHAR NOT NULL5
beach String10
);6
) ENGINE = MergeTree() ORDER BY id;11
7
12
CREATE TABLE thefts (8
CREATE TABLE thefts (13
id BIGINT PRIMARY KEY DEFAULT nextval('theft_ids'),9
id UInt64,14
gull_id BIGINT NOT NULL REFERENCES gulls (id),10
gull_id UInt64,15
snack VARCHAR NOT NULL,11
snack String,16
stolen_on VARCHAR NOT NULL12
stolen_on Date17
);13
) ENGINE = MergeTree() ORDER BY id;18
14
19
CREATE VIEW beach_records AS15
CREATE VIEW beach_records AS20
SELECT16
SELECT21
gulls.beach,17
gulls.beach,22
COUNT(DISTINCT gulls.id) AS gulls,18
uniqExact(gulls.id) AS gulls,23
COUNT(thefts.id) AS thefts,19
count(thefts.id) AS thefts,24
MAX(thefts.stolen_on) AS last_theft20
max(thefts.stolen_on) AS last_theft25
FROM gulls21
FROM gulls26
LEFT JOIN thefts ON thefts.gull_id = gulls.id22
LEFT JOIN thefts ON thefts.gull_id = gulls.id27
GROUP BY gulls.beach;23
GROUP BY gulls.beach24
SETTINGS join_use_nulls = 1;db/migrations/0001_gulls.down.sqldiffers
DuckDB
ClickHouse
1
DROP VIEW beach_records;1
DROP VIEW beach_records;2
DROP TABLE thefts;2
DROP TABLE thefts;3
DROP TABLE gulls;3
DROP TABLE gulls;4
DROP SEQUENCE theft_ids;5
DROP SEQUENCE gull_ids;Queries
HatchGulldiffers
DuckDB · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(HatchGull, exec)1
-- sgl.query(HatchGull, exec)2
INSERT INTO gulls (name, parent_id, beach) VALUES ($1, $2, $3);2
INSERT INTO gulls (id, name, parent_id, beach)3
VALUES ({id:UInt64}, {name:String}, {parent_id:Nullable(UInt64)}, {beach:String});RecordTheftdiffers
DuckDB · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(RecordTheft, exec)1
-- sgl.query(RecordTheft, exec)2
INSERT INTO thefts (gull_id, snack, stolen_on) VALUES ($1, $2, $3);2
INSERT INTO thefts (id, gull_id, snack, stolen_on)3
VALUES ({id:UInt64}, {gull_id:UInt64}, {snack:String}, {stolen_on:Date});FamilyTreediffers
DuckDB · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(FamilyTree, many)1
-- sgl.query(FamilyTree, many)2
WITH RECURSIVE family AS (2
WITH RECURSIVE family AS (3
SELECT id, name, parent_id, 0 AS generation3
SELECT id, name, parent_id, 0 AS generation4
FROM gulls4
FROM gulls5
WHERE id = $15
WHERE id = {root:UInt64}6
UNION ALL6
UNION ALL7
SELECT gulls.id, gulls.name, gulls.parent_id, family.generation + 17
SELECT gulls.id, gulls.name, gulls.parent_id, family.generation + 18
FROM gulls8
FROM gulls9
JOIN family ON gulls.parent_id = family.id9
JOIN family ON gulls.parent_id = family.id10
)10
)11
SELECT family.name, family.generation, parents.name AS parent11
SELECT family.name, family.generation, parents.name AS parent12
FROM family12
FROM family13
LEFT JOIN gulls AS parents ON parents.id = family.parent_id13
LEFT JOIN gulls AS parents ON parents.id = family.parent_id14
ORDER BY family.generation, family.id;14
ORDER BY family.generation, family.id15
SETTINGS join_use_nulls = 1;TheftLeaguediffers
DuckDB · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(TheftLeague, many)1
-- sgl.query(TheftLeague, many)2
WITH totals AS (2
WITH totals AS (3
SELECT gulls.name, gulls.beach, COUNT(thefts.id) AS thefts3
SELECT gulls.name, gulls.beach, count(thefts.id) AS stolen4
FROM gulls4
FROM gulls5
LEFT JOIN thefts ON thefts.gull_id = gulls.id5
LEFT JOIN thefts ON thefts.gull_id = gulls.id6
GROUP BY gulls.id, gulls.name, gulls.beach6
GROUP BY gulls.id, gulls.name, gulls.beach7
)7
)8
SELECT8
SELECT9
name,9
name,10
beach,10
beach,11
thefts,11
stolen,12
RANK() OVER (PARTITION BY beach ORDER BY thefts DESC) AS beach_rank,12
rank() OVER (PARTITION BY beach ORDER BY stolen DESC) AS beach_rank,13
SUM(thefts) OVER (PARTITION BY beach) AS beach_thefts13
sum(stolen) OVER (PARTITION BY beach) AS beach_thefts14
FROM totals14
FROM totals15
ORDER BY beach, beach_rank, name;15
ORDER BY beach, beach_rank, name16
SETTINGS join_use_nulls = 1;BeachRecordsidentical
DuckDB · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(BeachRecords, many)1
-- sgl.query(BeachRecords, many)2
SELECT beach, gulls, thefts, last_theft FROM beach_records ORDER BY beach;2
SELECT beach, gulls, thefts, last_theft FROM beach_records ORDER BY beach;What sgl generates
HatchGulldiffers
DuckDB
ClickHouse
1
func (queries *Queries) HatchGull(ctx context.Context, params HatchGullParams) error1
func (queries *Queries) HatchGull(ctx context.Context, params HatchGullParams) error2
2
3
type HatchGullParams struct {3
type HatchGullParams struct {4
ID uint644
Name string5
Name string5
ParentID *int646
ParentID *uint646
Beach string7
Beach string7
}8
}RecordTheftdiffers
DuckDB
ClickHouse
1
func (queries *Queries) RecordTheft(ctx context.Context, params RecordTheftParams) error1
func (queries *Queries) RecordTheft(ctx context.Context, params RecordTheftParams) error2
2
3
type RecordTheftParams struct {3
type RecordTheftParams struct {4
GullID int644
ID uint645
GullID uint645
Snack string6
Snack string6
StolenOn string7
StolenOn time.Time7
}8
}FamilyTreediffers
DuckDB
ClickHouse
1
func (queries *Queries) FamilyTree(ctx context.Context, id int64) ([]FamilyTreeRow, error)1
func (queries *Queries) FamilyTree(ctx context.Context, root uint64) ([]FamilyTreeRow, error)2
2
3
type FamilyTreeRow struct {3
type FamilyTreeRow struct {4
Name string `json:"name"`4
Name string `json:"name"`5
Generation int32 `json:"generation"`5
Generation uint8 `json:"generation"`6
Parent *string `json:"parent"`6
Parent *string `json:"parent"`7
}7
}TheftLeaguediffers
DuckDB
ClickHouse
1
func (queries *Queries) TheftLeague(ctx context.Context) ([]TheftLeagueRow, error)1
func (queries *Queries) TheftLeague(ctx context.Context) ([]TheftLeagueRow, error)2
2
3
type TheftLeagueRow struct {3
type TheftLeagueRow struct {4
Name string `json:"name"`4
Name string `json:"name"`5
Beach string `json:"beach"`5
Beach string `json:"beach"`6
Thefts int64 `json:"thefts"`6
Stolen uint64 `json:"stolen"`7
BeachRank int64 `json:"beach_rank"`7
BeachRank uint64 `json:"beach_rank"`8
BeachThefts *big.Int `json:"beach_thefts"`8
BeachThefts uint64 `json:"beach_thefts"`9
}9
}BeachRecordsdiffers
DuckDB
ClickHouse
1
func (queries *Queries) BeachRecords(ctx context.Context) ([]BeachRecordsRow, error)1
func (queries *Queries) BeachRecords(ctx context.Context) ([]BeachRecordsRow, error)2
2
3
type BeachRecordsRow struct {3
type BeachRecordsRow struct {4
Beach string `json:"beach"`4
Beach string `json:"beach"`5
Gulls int64 `json:"gulls"`5
Gulls uint64 `json:"gulls"`6
Thefts int64 `json:"thefts"`6
Thefts uint64 `json:"thefts"`7
LastTheft *string `json:"last_theft"`7
LastTheft *time.Time `json:"last_theft"`8
}8
}The program
example.godiffers
DuckDB
ClickHouse
1
package main1
package main2
2
3
import (3
import (4
"context"4
"context"5
"database/sql"5
"database/sql"6
"fmt"6
"fmt"7
"io"7
"io"8
"time"8
9
9
"seagull.sh/seagull/examples/duckdb/019_ctes_and_windows/internal/store"10
"seagull.sh/seagull/examples/clickhouse/019_ctes_and_windows/internal/store"10
)11
)11
12
12
// run hatches a colony of gulls, records their snack thefts and reads them back three13
// run hatches a colony of gulls, records their snack thefts and reads them back three13
// ways. Seagull follows each column's type and nullability through the SQL:14
// ways. Seagull follows each column's type and nullability through the SQL, by15
// ClickHouse's own rules:14
//16
//15
// - FamilyTree's recursive CTE starts from one gull and joins its chicks generation by17
// - FamilyTree's recursive CTE starts from one gull and joins its chicks generation by16
// generation; generation is an integer from 0 AS generation and generation + 1, and18
// generation. The literal 0 is a UInt8 and adding 1 to a UInt8 gives a UInt16, so17
// parent is *string because the LEFT JOIN finds no parent for the first gull;19
// Seagull makes Generation a uint16. The server keeps the first SELECT's UInt8 for a18
// - TheftLeague counts thefts in a CTE, then ranks gulls on each beach with RANK() and20
// recursive CTE's column, and database/sql converts each value to the wider field.19
// totals the beach with SUM() OVER; RANK is a BIGINT that is never NULL, so int64.21
// parent is *string because the LEFT JOIN, with join_use_nulls = 1, finds no parent20
// DuckDB widens the SUM of a BIGINT to a 128-bit HUGEINT, which arrives as *big.Int,22
// for the first gull;21
// and nil stands for NULL, as SQL sums no rows to NULL;23
// - TheftLeague counts thefts in a CTE, so stolen is uint64, then ranks gulls on each24
// beach with rank() OVER, a UInt64, and totals the beach with sum() OVER, a UInt6425
// too, since ClickHouse sums unsigned integers to UInt64 and sums no rows to 0, not26
// NULL. Neither can be NULL, so BeachRank and BeachThefts are plain uint64;22
// - BeachRecords reads the beach_records view from the migration as if it were a table,27
// - BeachRecords reads the beach_records view from the migration as if it were a table,23
// and its columns keep the types the view's own SELECT gives them.28
// and its columns keep the types the view's own SELECT gives them.24
//29
//25
// Takes database (*sql.DB) which is a migrated database.30
// Takes database (*sql.DB) which is a migrated database.26
// Takes output (io.Writer) which receives the three listings.31
// Takes output (io.Writer) which receives the three listings.27
//32
//28
// Returns error when a query fails.33
// Returns error when a query fails.29
func run(ctx context.Context, database *sql.DB, output io.Writer) error {34
func run(ctx context.Context, database *sql.DB, output io.Writer) error {30
queries := store.New(database)35
queries := store.New(database)31
36
32
gulls := []store.HatchGullParams{37
gulls := []store.HatchGullParams{33
{Name: "Steven", ParentID: nil, Beach: "Porthmeor"},38
{ID: 1, Name: "Steven", ParentID: nil, Beach: "Porthmeor"},34
{Name: "Gwen", ParentID: new(int64(1)), Beach: "Porthmeor"},39
{ID: 2, Name: "Gwen", ParentID: new(uint64(1)), Beach: "Porthmeor"},35
{Name: "Jago", ParentID: new(int64(1)), Beach: "Porthminster"},40
{ID: 3, Name: "Jago", ParentID: new(uint64(1)), Beach: "Porthminster"},36
{Name: "Kerensa", ParentID: new(int64(2)), Beach: "Porthmeor"},41
{ID: 4, Name: "Kerensa", ParentID: new(uint64(2)), Beach: "Porthmeor"},37
{Name: "Tristan", ParentID: nil, Beach: "Porthminster"},42
{ID: 5, Name: "Tristan", ParentID: nil, Beach: "Porthminster"},38
{Name: "Morwenna", ParentID: nil, Beach: "Gwithian"},43
{ID: 6, Name: "Morwenna", ParentID: nil, Beach: "Gwithian"},39
}44
}40
for _, gull := range gulls {45
for _, gull := range gulls {41
if err := queries.HatchGull(ctx, gull); err != nil {46
if err := queries.HatchGull(ctx, gull); err != nil {42
return fmt.Errorf("hatching %s: %w", gull.Name, err)47
return fmt.Errorf("hatching %s: %w", gull.Name, err)43
}48
}44
}49
}45
thefts := []store.RecordTheftParams{50
thefts := []store.RecordTheftParams{46
{GullID: 1, Snack: "chips", StolenOn: "2026-07-01"},51
{ID: 1, GullID: 1, Snack: "chips", StolenOn: day(1)},47
{GullID: 1, Snack: "pasty", StolenOn: "2026-07-03"},52
{ID: 2, GullID: 1, Snack: "pasty", StolenOn: day(3)},48
{GullID: 1, Snack: "ice cream", StolenOn: "2026-07-04"},53
{ID: 3, GullID: 1, Snack: "ice cream", StolenOn: day(4)},49
{GullID: 2, Snack: "chips", StolenOn: "2026-07-02"},54
{ID: 4, GullID: 2, Snack: "chips", StolenOn: day(2)},50
{GullID: 3, Snack: "pasty", StolenOn: "2026-07-02"},55
{ID: 5, GullID: 3, Snack: "pasty", StolenOn: day(2)},51
{GullID: 3, Snack: "chips", StolenOn: "2026-07-05"},56
{ID: 6, GullID: 3, Snack: "chips", StolenOn: day(5)},52
{GullID: 5, Snack: "sandwich", StolenOn: "2026-07-03"},57
{ID: 7, GullID: 5, Snack: "sandwich", StolenOn: day(3)},53
}58
}54
for _, theft := range thefts {59
for _, theft := range thefts {55
if err := queries.RecordTheft(ctx, theft); err != nil {60
if err := queries.RecordTheft(ctx, theft); err != nil {56
return fmt.Errorf("recording a theft: %w", err)61
return fmt.Errorf("recording a theft: %w", err)57
}62
}58
}63
}59
64
60
family, err := queries.FamilyTree(ctx, 1)65
family, err := queries.FamilyTree(ctx, 1)61
if err != nil {66
if err != nil {62
return fmt.Errorf("tracing Steven's family: %w", err)67
return fmt.Errorf("tracing Steven's family: %w", err)63
}68
}64
fmt.Fprintln(output, "Steven's family:")69
fmt.Fprintln(output, "Steven's family:")65
for _, member := range family {70
for _, member := range family {66
fmt.Fprintf(output, " generation %d %-8s parent %s\n", member.Generation, member.Name, orNull(member.Parent))71
fmt.Fprintf(output, " generation %d %-8s parent %s\n", member.Generation, member.Name, orNull(member.Parent))67
}72
}68
73
69
league, err := queries.TheftLeague(ctx)74
league, err := queries.TheftLeague(ctx)70
if err != nil {75
if err != nil {71
return fmt.Errorf("ranking thieves: %w", err)76
return fmt.Errorf("ranking thieves: %w", err)72
}77
}73
fmt.Fprintln(output, "theft league:")78
fmt.Fprintln(output, "theft league:")74
for _, entry := range league {79
for _, entry := range league {75
fmt.Fprintf(output, " %-13s #%d %-8s %d of %s\n", entry.Beach, entry.BeachRank, entry.Name, entry.Thefts, entry.BeachThefts)80
fmt.Fprintf(output, " %-13s #%d %-8s %d of %d\n", entry.Beach, entry.BeachRank, entry.Name, entry.Stolen, entry.BeachThefts)76
}81
}77
82
78
records, err := queries.BeachRecords(ctx)83
records, err := queries.BeachRecords(ctx)79
if err != nil {84
if err != nil {80
return fmt.Errorf("reading beach records: %w", err)85
return fmt.Errorf("reading beach records: %w", err)81
}86
}82
fmt.Fprintln(output, "beach records:")87
fmt.Fprintln(output, "beach records:")83
for _, record := range records {88
for _, record := range records {84
fmt.Fprintf(output, " %-13s gulls %d, thefts %d, last theft %s\n", record.Beach, record.Gulls, record.Thefts, orNull(record.LastTheft))89
lastTheft := "NULL"90
if record.LastTheft != nil {91
lastTheft = record.LastTheft.Format(time.DateOnly)92
}93
fmt.Fprintf(output, " %-13s gulls %d, thefts %d, last theft %s\n", record.Beach, record.Gulls, record.Thefts, lastTheft)85
}94
}86
return nil95
return nil87
}96
}88
97
89
// orNull formats a nullable field, or returns NULL when the field is nil.98
// day returns a date in July 2026, at midnight UTC. A Date parameter binds the calendar99
// date a time has in its own location.90
//100
//91
// Takes value (*T) which is a nullable field.101
// Takes date (int) which is the day of the month.92
//102
//103
// Returns time.Time which is the date.104
func day(date int) time.Time {105
return time.Date(2026, time.July, date, 0, 0, 0, 0, time.UTC)106
}107
108
// orNull returns the text a nullable field holds, or NULL when the field is nil.109
//110
// Takes value (*string) which is a nullable field.111
//93
// Returns string which is the text to print.112
// Returns string which is the text to print.94
func orNull[T any](value *T) string {113
func orNull(value *string) string {95
if value == nil {114
if value == nil {96
return "NULL"115
return "NULL"97
}116
}98
return fmt.Sprint(*value)117
return *value99
}118
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | DuckDB | ClickHouse |
|---|---|---|---|
| HatchGull | parameter parent_id | *int64 | *uint64 |
| RecordTheft | parameter gull_id | int64 | uint64 |
| RecordTheft | parameter stolen_on | string | time.Time |
| FamilyTree | column generation | int32 | uint8 |
| TheftLeague | column beach_rank | int64 | uint64 |
| TheftLeague | column beach_thefts | *big.Int | uint64 |
| BeachRecords | column gulls | int64 | uint64 |
| BeachRecords | column thefts | int64 | uint64 |
| BeachRecords | column last_theft | *string | *time.Time |