Types through CTEs, window functions and views: ClickHouse and DuckDB
Every file of the ClickHouse and DuckDB versions side by side, with the lines that differ marked. 9 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
ClickHouse
DuckDB
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: colony4
- name: colony5
dialect: clickhouse5
dialect: duckdb6
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
ClickHouse
DuckDB
1
CREATE SEQUENCE gull_ids;2
3
CREATE SEQUENCE theft_ids;4
1
CREATE TABLE gulls (5
CREATE TABLE gulls (2
id UInt64,6
id BIGINT PRIMARY KEY DEFAULT nextval('gull_ids'),3
name String,7
name VARCHAR NOT NULL,4
parent_id Nullable(UInt64),8
parent_id BIGINT REFERENCES gulls (id),5
beach String9
beach VARCHAR NOT NULL6
) ENGINE = MergeTree() ORDER BY id;10
);7
11
8
CREATE TABLE thefts (12
CREATE TABLE thefts (9
id UInt64,13
id BIGINT PRIMARY KEY DEFAULT nextval('theft_ids'),10
gull_id UInt64,14
gull_id BIGINT NOT NULL REFERENCES gulls (id),11
snack String,15
snack VARCHAR NOT NULL,12
stolen_on Date16
stolen_on VARCHAR NOT NULL13
) ENGINE = MergeTree() ORDER BY id;17
);14
18
15
CREATE VIEW beach_records AS19
CREATE VIEW beach_records AS16
SELECT20
SELECT17
gulls.beach,21
gulls.beach,18
uniqExact(gulls.id) AS gulls,22
COUNT(DISTINCT gulls.id) AS gulls,19
count(thefts.id) AS thefts,23
COUNT(thefts.id) AS thefts,20
max(thefts.stolen_on) AS last_theft24
MAX(thefts.stolen_on) AS last_theft21
FROM gulls25
FROM gulls22
LEFT JOIN thefts ON thefts.gull_id = gulls.id26
LEFT JOIN thefts ON thefts.gull_id = gulls.id23
GROUP BY gulls.beach27
GROUP BY gulls.beach;24
SETTINGS join_use_nulls = 1;db/migrations/0001_gulls.down.sqldiffers
ClickHouse
DuckDB
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
ClickHouse · db/queries/gulls.sql
DuckDB · db/queries/gulls.sql
1
-- sgl.query(HatchGull, exec)1
-- sgl.query(HatchGull, exec)2
INSERT INTO gulls (id, name, parent_id, beach)2
INSERT INTO gulls (name, parent_id, beach) VALUES ($1, $2, $3);3
VALUES ({id:UInt64}, {name:String}, {parent_id:Nullable(UInt64)}, {beach:String});RecordTheftdiffers
ClickHouse · db/queries/gulls.sql
DuckDB · db/queries/gulls.sql
1
-- sgl.query(RecordTheft, exec)1
-- sgl.query(RecordTheft, exec)2
INSERT INTO thefts (id, gull_id, snack, stolen_on)2
INSERT INTO thefts (gull_id, snack, stolen_on) VALUES ($1, $2, $3);3
VALUES ({id:UInt64}, {gull_id:UInt64}, {snack:String}, {stolen_on:Date});FamilyTreediffers
ClickHouse · db/queries/gulls.sql
DuckDB · 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 = {root:UInt64}5
WHERE id = $16
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.id14
ORDER BY family.generation, family.id;15
SETTINGS join_use_nulls = 1;TheftLeaguediffers
ClickHouse · db/queries/gulls.sql
DuckDB · 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 stolen3
SELECT gulls.name, gulls.beach, COUNT(thefts.id) AS thefts4
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
stolen,11
thefts,12
rank() OVER (PARTITION BY beach ORDER BY stolen DESC) AS beach_rank,12
RANK() OVER (PARTITION BY beach ORDER BY thefts DESC) AS beach_rank,13
sum(stolen) OVER (PARTITION BY beach) AS beach_thefts13
SUM(thefts) OVER (PARTITION BY beach) AS beach_thefts14
FROM totals14
FROM totals15
ORDER BY beach, beach_rank, name15
ORDER BY beach, beach_rank, name;16
SETTINGS join_use_nulls = 1;BeachRecordsidentical
ClickHouse · db/queries/gulls.sql
DuckDB · 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
ClickHouse
DuckDB
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 uint645
Name string4
Name string6
ParentID *uint645
ParentID *int647
Beach string6
Beach string8
}7
}RecordTheftdiffers
ClickHouse
DuckDB
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
ID uint644
GullID int645
GullID uint646
Snack string5
Snack string7
StolenOn time.Time6
StolenOn string8
}7
}FamilyTreediffers
ClickHouse
DuckDB
1
func (queries *Queries) FamilyTree(ctx context.Context, root uint64) ([]FamilyTreeRow, error)1
func (queries *Queries) FamilyTree(ctx context.Context, id int64) ([]FamilyTreeRow, error)2
2
3
type FamilyTreeRow struct {3
type FamilyTreeRow struct {4
Name string `json:"name"`4
Name string `json:"name"`5
Generation uint8 `json:"generation"`5
Generation int32 `json:"generation"`6
Parent *string `json:"parent"`6
Parent *string `json:"parent"`7
}7
}TheftLeaguediffers
ClickHouse
DuckDB
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
Stolen uint64 `json:"stolen"`6
Thefts int64 `json:"thefts"`7
BeachRank uint64 `json:"beach_rank"`7
BeachRank int64 `json:"beach_rank"`8
BeachThefts uint64 `json:"beach_thefts"`8
BeachThefts *big.Int `json:"beach_thefts"`9
}9
}BeachRecordsdiffers
ClickHouse
DuckDB
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 uint64 `json:"gulls"`5
Gulls int64 `json:"gulls"`6
Thefts uint64 `json:"thefts"`6
Thefts int64 `json:"thefts"`7
LastTheft *time.Time `json:"last_theft"`7
LastTheft *string `json:"last_theft"`8
}8
}The program
example.godiffers
ClickHouse
DuckDB
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"9
8
10
"seagull.sh/seagull/examples/clickhouse/019_ctes_and_windows/internal/store"9
"seagull.sh/seagull/examples/duckdb/019_ctes_and_windows/internal/store"11
)10
)12
11
13
// run hatches a colony of gulls, records their snack thefts and reads them back three12
// run hatches a colony of gulls, records their snack thefts and reads them back three14
// ways. Seagull follows each column's type and nullability through the SQL, by13
// ways. Seagull follows each column's type and nullability through the SQL:15
// ClickHouse's own rules:16
//14
//17
// - FamilyTree's recursive CTE starts from one gull and joins its chicks generation by15
// - FamilyTree's recursive CTE starts from one gull and joins its chicks generation by18
// generation. The literal 0 is a UInt8 and adding 1 to a UInt8 gives a UInt16, so16
// generation; generation is an integer from 0 AS generation and generation + 1, and19
// Seagull makes Generation a uint16. The server keeps the first SELECT's UInt8 for a17
// parent is *string because the LEFT JOIN finds no parent for the first gull;20
// recursive CTE's column, and database/sql converts each value to the wider field.18
// - TheftLeague counts thefts in a CTE, then ranks gulls on each beach with RANK() and21
// parent is *string because the LEFT JOIN, with join_use_nulls = 1, finds no parent19
// totals the beach with SUM() OVER; RANK is a BIGINT that is never NULL, so int64.22
// for the first gull;20
// DuckDB widens the SUM of a BIGINT to a 128-bit HUGEINT, which arrives as *big.Int,23
// - TheftLeague counts thefts in a CTE, so stolen is uint64, then ranks gulls on each21
// and nil stands for NULL, as SQL sums no rows to NULL;24
// 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;27
// - BeachRecords reads the beach_records view from the migration as if it were a table,22
// - BeachRecords reads the beach_records view from the migration as if it were a table,28
// and its columns keep the types the view's own SELECT gives them.23
// and its columns keep the types the view's own SELECT gives them.29
//24
//30
// Takes database (*sql.DB) which is a migrated database.25
// Takes database (*sql.DB) which is a migrated database.31
// Takes output (io.Writer) which receives the three listings.26
// Takes output (io.Writer) which receives the three listings.32
//27
//33
// Returns error when a query fails.28
// Returns error when a query fails.34
func run(ctx context.Context, database *sql.DB, output io.Writer) error {29
func run(ctx context.Context, database *sql.DB, output io.Writer) error {35
queries := store.New(database)30
queries := store.New(database)36
31
37
gulls := []store.HatchGullParams{32
gulls := []store.HatchGullParams{38
{ID: 1, Name: "Steven", ParentID: nil, Beach: "Porthmeor"},33
{Name: "Steven", ParentID: nil, Beach: "Porthmeor"},39
{ID: 2, Name: "Gwen", ParentID: new(uint64(1)), Beach: "Porthmeor"},34
{Name: "Gwen", ParentID: new(int64(1)), Beach: "Porthmeor"},40
{ID: 3, Name: "Jago", ParentID: new(uint64(1)), Beach: "Porthminster"},35
{Name: "Jago", ParentID: new(int64(1)), Beach: "Porthminster"},41
{ID: 4, Name: "Kerensa", ParentID: new(uint64(2)), Beach: "Porthmeor"},36
{Name: "Kerensa", ParentID: new(int64(2)), Beach: "Porthmeor"},42
{ID: 5, Name: "Tristan", ParentID: nil, Beach: "Porthminster"},37
{Name: "Tristan", ParentID: nil, Beach: "Porthminster"},43
{ID: 6, Name: "Morwenna", ParentID: nil, Beach: "Gwithian"},38
{Name: "Morwenna", ParentID: nil, Beach: "Gwithian"},44
}39
}45
for _, gull := range gulls {40
for _, gull := range gulls {46
if err := queries.HatchGull(ctx, gull); err != nil {41
if err := queries.HatchGull(ctx, gull); err != nil {47
return fmt.Errorf("hatching %s: %w", gull.Name, err)42
return fmt.Errorf("hatching %s: %w", gull.Name, err)48
}43
}49
}44
}50
thefts := []store.RecordTheftParams{45
thefts := []store.RecordTheftParams{51
{ID: 1, GullID: 1, Snack: "chips", StolenOn: day(1)},46
{GullID: 1, Snack: "chips", StolenOn: "2026-07-01"},52
{ID: 2, GullID: 1, Snack: "pasty", StolenOn: day(3)},47
{GullID: 1, Snack: "pasty", StolenOn: "2026-07-03"},53
{ID: 3, GullID: 1, Snack: "ice cream", StolenOn: day(4)},48
{GullID: 1, Snack: "ice cream", StolenOn: "2026-07-04"},54
{ID: 4, GullID: 2, Snack: "chips", StolenOn: day(2)},49
{GullID: 2, Snack: "chips", StolenOn: "2026-07-02"},55
{ID: 5, GullID: 3, Snack: "pasty", StolenOn: day(2)},50
{GullID: 3, Snack: "pasty", StolenOn: "2026-07-02"},56
{ID: 6, GullID: 3, Snack: "chips", StolenOn: day(5)},51
{GullID: 3, Snack: "chips", StolenOn: "2026-07-05"},57
{ID: 7, GullID: 5, Snack: "sandwich", StolenOn: day(3)},52
{GullID: 5, Snack: "sandwich", StolenOn: "2026-07-03"},58
}53
}59
for _, theft := range thefts {54
for _, theft := range thefts {60
if err := queries.RecordTheft(ctx, theft); err != nil {55
if err := queries.RecordTheft(ctx, theft); err != nil {61
return fmt.Errorf("recording a theft: %w", err)56
return fmt.Errorf("recording a theft: %w", err)62
}57
}63
}58
}64
59
65
family, err := queries.FamilyTree(ctx, 1)60
family, err := queries.FamilyTree(ctx, 1)66
if err != nil {61
if err != nil {67
return fmt.Errorf("tracing Steven's family: %w", err)62
return fmt.Errorf("tracing Steven's family: %w", err)68
}63
}69
fmt.Fprintln(output, "Steven's family:")64
fmt.Fprintln(output, "Steven's family:")70
for _, member := range family {65
for _, member := range family {71
fmt.Fprintf(output, " generation %d %-8s parent %s\n", member.Generation, member.Name, orNull(member.Parent))66
fmt.Fprintf(output, " generation %d %-8s parent %s\n", member.Generation, member.Name, orNull(member.Parent))72
}67
}73
68
74
league, err := queries.TheftLeague(ctx)69
league, err := queries.TheftLeague(ctx)75
if err != nil {70
if err != nil {76
return fmt.Errorf("ranking thieves: %w", err)71
return fmt.Errorf("ranking thieves: %w", err)77
}72
}78
fmt.Fprintln(output, "theft league:")73
fmt.Fprintln(output, "theft league:")79
for _, entry := range league {74
for _, entry := range league {80
fmt.Fprintf(output, " %-13s #%d %-8s %d of %d\n", entry.Beach, entry.BeachRank, entry.Name, entry.Stolen, entry.BeachThefts)75
fmt.Fprintf(output, " %-13s #%d %-8s %d of %s\n", entry.Beach, entry.BeachRank, entry.Name, entry.Thefts, entry.BeachThefts)81
}76
}82
77
83
records, err := queries.BeachRecords(ctx)78
records, err := queries.BeachRecords(ctx)84
if err != nil {79
if err != nil {85
return fmt.Errorf("reading beach records: %w", err)80
return fmt.Errorf("reading beach records: %w", err)86
}81
}87
fmt.Fprintln(output, "beach records:")82
fmt.Fprintln(output, "beach records:")88
for _, record := range records {83
for _, record := range records {89
lastTheft := "NULL"84
fmt.Fprintf(output, " %-13s gulls %d, thefts %d, last theft %s\n", record.Beach, record.Gulls, record.Thefts, orNull(record.LastTheft))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)94
}85
}95
return nil86
return nil96
}87
}97
88
98
// day returns a date in July 2026, at midnight UTC. A Date parameter binds the calendar89
// orNull formats a nullable field, or returns NULL when the field is nil.99
// date a time has in its own location.100
//101
// Takes date (int) which is the day of the month.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
//90
//110
// Takes value (*string) which is a nullable field.91
// Takes value (*T) which is a nullable field.111
//92
//112
// Returns string which is the text to print.93
// Returns string which is the text to print.113
func orNull(value *string) string {94
func orNull[T any](value *T) string {114
if value == nil {95
if value == nil {115
return "NULL"96
return "NULL"116
}97
}117
return *value98
return fmt.Sprint(*value)118
}99
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | ClickHouse | DuckDB |
|---|---|---|---|
| HatchGull | parameter parent_id | *uint64 | *int64 |
| RecordTheft | parameter gull_id | uint64 | int64 |
| RecordTheft | parameter stolen_on | time.Time | string |
| FamilyTree | column generation | uint8 | int32 |
| TheftLeague | column beach_rank | uint64 | int64 |
| TheftLeague | column beach_thefts | uint64 | *big.Int |
| BeachRecords | column gulls | uint64 | int64 |
| BeachRecords | column thefts | uint64 | int64 |
| BeachRecords | column last_theft | *time.Time | *string |