Nullability through joins, COALESCE and aggregates: ClickHouse and DuckDB
Every file of the ClickHouse and DuckDB versions side by side, with the lines that differ marked. 3 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 sighting_ids;4
1
CREATE TABLE gulls (5
CREATE TABLE gulls (2
id UInt64,6
id BIGINT PRIMARY KEY DEFAULT nextval('gull_ids'),3
ring String,7
ring VARCHAR NOT NULL UNIQUE,4
nickname Nullable(String)8
nickname VARCHAR5
) ENGINE = MergeTree() ORDER BY id;9
);6
10
7
CREATE TABLE sightings (11
CREATE TABLE sightings (8
id UInt64,12
id BIGINT PRIMARY KEY DEFAULT nextval('sighting_ids'),9
gull_id UInt64,13
gull_id BIGINT NOT NULL REFERENCES gulls (id),10
seen_on Date,14
seen_on DATE NOT NULL,11
place String15
place VARCHAR NOT NULL12
) ENGINE = MergeTree() ORDER BY id;16
);db/migrations/0001_gulls.down.sqldiffers
ClickHouse
DuckDB
1
DROP TABLE sightings;1
DROP TABLE sightings;2
DROP TABLE gulls;2
DROP TABLE gulls;3
DROP SEQUENCE sighting_ids;4
DROP SEQUENCE gull_ids;Queries
RingGulldiffers
ClickHouse · db/queries/gulls.sql
DuckDB · db/queries/gulls.sql
1
-- sgl.query(RingGull, exec)1
-- sgl.query(RingGull, exec)2
INSERT INTO gulls (id, ring, nickname) VALUES ({id:UInt64}, {ring:String}, {nickname:Nullable(String)});2
INSERT INTO gulls (ring, nickname) VALUES ($1, $2);RecordSightingdiffers
ClickHouse · db/queries/gulls.sql
DuckDB · db/queries/gulls.sql
1
-- sgl.query(RecordSighting, exec)1
-- sgl.query(RecordSighting, exec)2
INSERT INTO sightings (id, gull_id, seen_on, place) VALUES ({id:UInt64}, {gull_id:UInt64}, {seen_on:Date}, {place:String});2
INSERT INTO sightings (gull_id, seen_on, place) VALUES ($1, $2, $3);ListSightingsidentical
ClickHouse · db/queries/gulls.sql
DuckDB · db/queries/gulls.sql
1
-- sgl.query(ListSightings, many)1
-- sgl.query(ListSightings, many)2
SELECT gulls.ring, gulls.nickname, sightings.place2
SELECT gulls.ring, gulls.nickname, sightings.place3
FROM gulls3
FROM gulls4
LEFT JOIN sightings ON sightings.gull_id = gulls.id4
LEFT JOIN sightings ON sightings.gull_id = gulls.id5
ORDER BY gulls.id, sightings.seen_on;5
ORDER BY gulls.id, sightings.seen_on;SummariseGullsdiffers
ClickHouse · db/queries/gulls.sql
DuckDB · db/queries/gulls.sql
1
-- sgl.query(SummariseGulls, many)1
-- sgl.query(SummariseGulls, many)2
SELECT2
SELECT3
ring,3
gulls.ring,4
coalesce(nickname, concat('ring ', ring)) AS known_as,4
COALESCE(gulls.nickname, 'ring ' || gulls.ring) AS known_as,5
count(seen_on) AS times_seen,5
COUNT(sightings.id) AS times_seen,6
max(seen_on) AS last_seen6
MAX(sightings.seen_on) AS last_seen7
FROM gulls7
FROM gulls8
LEFT JOIN sightings ON sightings.gull_id = gulls.id8
LEFT JOIN sightings ON sightings.gull_id = gulls.id9
GROUP BY gulls.id, ring, nickname9
GROUP BY gulls.id, gulls.ring, gulls.nickname10
ORDER BY gulls.id10
ORDER BY gulls.id;11
SETTINGS join_use_nulls = 1;What sgl generates
RingGulldiffers
ClickHouse
DuckDB
1
func (queries *Queries) RingGull(ctx context.Context, params RingGullParams) error1
func (queries *Queries) RingGull(ctx context.Context, params RingGullParams) error2
2
3
type RingGullParams struct {3
type RingGullParams struct {4
ID uint645
Ring string4
Ring string6
Nickname *string5
Nickname *string7
}6
}RecordSightingdiffers
ClickHouse
DuckDB
1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error2
2
3
type RecordSightingParams struct {3
type RecordSightingParams struct {4
ID uint644
GullID int645
GullID uint646
SeenOn time.Time5
SeenOn time.Time7
Place string6
Place string8
}7
}ListSightingsdiffers
ClickHouse
DuckDB
1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)2
2
3
type ListSightingsRow struct {3
type ListSightingsRow struct {4
Ring string `json:"ring"`4
Ring string `json:"ring"`5
Nickname *string `json:"nickname"`5
Nickname *string `json:"nickname"`6
Place string `json:"place"`6
Place *string `json:"place"`7
}7
}SummariseGullsdiffers
ClickHouse
DuckDB
1
func (queries *Queries) SummariseGulls(ctx context.Context) ([]SummariseGullsRow, error)1
func (queries *Queries) SummariseGulls(ctx context.Context) ([]SummariseGullsRow, error)2
2
3
type SummariseGullsRow struct {3
type SummariseGullsRow struct {4
Ring string `json:"ring"`4
Ring string `json:"ring"`5
KnownAs string `json:"known_as"`5
KnownAs string `json:"known_as"`6
TimesSeen uint64 `json:"times_seen"`6
TimesSeen int64 `json:"times_seen"`7
LastSeen *time.Time `json:"last_seen"`7
LastSeen *time.Time `json:"last_seen"`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"8
"time"9
9
10
"seagull.sh/seagull/examples/clickhouse/004_nullability/internal/store"10
"seagull.sh/seagull/examples/duckdb/004_nullability/internal/store"11
)11
)12
12
13
// run rings three gulls, records where two of them were seen, and lists them twice.13
// run rings three gulls, records where two of them were seen, and lists them twice.14
// Seagull decides each field's nullability from the SQL, and a field that can be NULL is14
// Seagull decides each field's nullability from the SQL, and a field that can be NULL is15
// a pointer:15
// a pointer:16
//16
//17
// - gulls.nickname is Nullable(String), so Nickname is *string, as a parameter and a17
// - gulls.nickname has no NOT NULL, so Nickname is *string, as a parameter and a18
// column; a ClickHouse column is never NULL unless its type is Nullable(T);18
// column;19
// - ListSightings leaves join_use_nulls at the server's default, 0, so its LEFT JOIN19
// - sightings.place is NOT NULL, but the LEFT JOIN gives a gull with no sightings a20
// fills an unmatched row with each column's default value instead of NULL. Place is a20
// NULL place, so Place is *string;21
// String column, so it stays a string, and Gwen, who was never seen, gets an empty21
// - COALESCE with a non-NULL last argument is never NULL, so KnownAs is string;22
// one;22
// - COUNT is never NULL, but MAX over no rows is, so TimesSeen is int64 and LastSeen is23
// - SummariseGulls sets join_use_nulls = 1, so the unmatched row is NULL: count skips23
// *time.Time, as seen_on is a DATE.24
// it and gives 0, and max over no dates gives NULL, so TimesSeen is uint64 and25
// LastSeen is *time.Time. Without the setting, count would see Gwen's default row and26
// give 1;27
// - coalesce with a fallback that is never NULL is never NULL, so KnownAs is string.28
//24
//29
// Takes database (*sql.DB) which is a migrated database.25
// Takes database (*sql.DB) which is a migrated database.30
// Takes output (io.Writer) which receives both listings.26
// Takes output (io.Writer) which receives both listings.31
//27
//32
// Returns error when a query fails.28
// Returns error when a query fails.33
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 {34
queries := store.New(database)30
queries := store.New(database)35
31
36
gulls := []store.RingGullParams{32
gulls := []store.RingGullParams{37
{ID: 1, Ring: "GB1001", Nickname: new("Steven")},33
{Ring: "GB1001", Nickname: new("Steven")},38
{ID: 2, Ring: "GB1002", Nickname: nil},34
{Ring: "GB1002", Nickname: nil},39
{ID: 3, Ring: "GB1003", Nickname: new("Gwen")},35
{Ring: "GB1003", Nickname: new("Gwen")},40
}36
}41
for _, gull := range gulls {37
for _, gull := range gulls {42
if err := queries.RingGull(ctx, gull); err != nil {38
if err := queries.RingGull(ctx, gull); err != nil {43
return fmt.Errorf("ringing %s: %w", gull.Ring, err)39
return fmt.Errorf("ringing %s: %w", gull.Ring, err)44
}40
}45
}41
}46
sightings := []store.RecordSightingParams{42
sightings := []store.RecordSightingParams{47
{ID: 1, GullID: 1, SeenOn: day(1), Place: "the pier"},43
{GullID: 1, SeenOn: day(1), Place: "the pier"},48
{ID: 2, GullID: 1, SeenOn: day(3), Place: "the chip shop"},44
{GullID: 1, SeenOn: day(3), Place: "the chip shop"},49
{ID: 3, GullID: 2, SeenOn: day(2), Place: "the harbour wall"},45
{GullID: 2, SeenOn: day(2), Place: "the harbour wall"},50
}46
}51
for _, sighting := range sightings {47
for _, sighting := range sightings {52
if err := queries.RecordSighting(ctx, sighting); err != nil {48
if err := queries.RecordSighting(ctx, sighting); err != nil {53
return fmt.Errorf("recording a sighting: %w", err)49
return fmt.Errorf("recording a sighting: %w", err)54
}50
}55
}51
}56
52
57
rows, err := queries.ListSightings(ctx)53
rows, err := queries.ListSightings(ctx)58
if err != nil {54
if err != nil {59
return fmt.Errorf("listing sightings: %w", err)55
return fmt.Errorf("listing sightings: %w", err)60
}56
}61
fmt.Fprintln(output, "ring nickname place")57
fmt.Fprintln(output, "ring nickname place")62
for _, row := range rows {58
for _, row := range rows {63
fmt.Fprintf(output, "%-7s %-9s %s\n", row.Ring, orNull(row.Nickname), orEmpty(row.Place))59
fmt.Fprintf(output, "%-7s %-9s %s\n", row.Ring, orNull(row.Nickname), orNull(row.Place))64
}60
}65
61
66
summaries, err := queries.SummariseGulls(ctx)62
summaries, err := queries.SummariseGulls(ctx)67
if err != nil {63
if err != nil {68
return fmt.Errorf("summarising gulls: %w", err)64
return fmt.Errorf("summarising gulls: %w", err)69
}65
}70
fmt.Fprintln(output, "\nring known as times seen last seen")66
fmt.Fprintln(output, "\nring known as times seen last seen")71
for _, summary := range summaries {67
for _, summary := range summaries {72
lastSeen := "NULL"68
lastSeen := "NULL"73
if summary.LastSeen != nil {69
if summary.LastSeen != nil {74
lastSeen = summary.LastSeen.Format(time.DateOnly)70
lastSeen = summary.LastSeen.Format(time.DateOnly)75
}71
}76
fmt.Fprintf(output, "%-7s %-12s %-11d %s\n", summary.Ring, summary.KnownAs, summary.TimesSeen, lastSeen)72
fmt.Fprintf(output, "%-7s %-12s %-11d %s\n", summary.Ring, summary.KnownAs, summary.TimesSeen, lastSeen)77
}73
}78
return nil74
return nil79
}75
}80
76
81
// day returns a date in May 2026, at midnight UTC. A Date parameter binds the calendar77
// day returns a date in May 2026, the month the sightings were made.82
// date a time has in its own location.83
//78
//84
// Takes date (int) which is the day of the month.79
// Takes dayOfMonth (int) which is the day.85
//80
//86
// Returns time.Time which is the date.81
// Returns time.Time which is midnight UTC on that day.87
func day(date int) time.Time {82
func day(dayOfMonth int) time.Time {88
return time.Date(2026, time.May, date, 0, 0, 0, 0, time.UTC)83
return time.Date(2026, time.May, dayOfMonth, 0, 0, 0, 0, time.UTC)89
}84
}90
85
91
// orNull returns the text a nullable field holds, or NULL when the field is nil.86
// orNull returns the text a nullable field holds, or NULL when the field is nil.92
//87
//93
// Takes value (*string) which is a nullable field.88
// Takes value (*string) which is a nullable field.94
//89
//95
// Returns string which is the text to print.90
// Returns string which is the text to print.96
func orNull(value *string) string {91
func orNull(value *string) string {97
if value == nil {92
if value == nil {98
return "NULL"93
return "NULL"99
}94
}100
return *value95
return *value101
}96
}102
103
// orEmpty returns a string field's text, or (empty) when it holds the empty string, the104
// default value ClickHouse gives a String column in an unmatched outer-join row.105
//106
// Takes value (string) which is a String column.107
//108
// Returns string which is the text to print.109
func orEmpty(value string) string {110
if value == "" {111
return "(empty)"112
}113
return value114
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | ClickHouse | DuckDB |
|---|---|---|---|
| RecordSighting | parameter gull_id | uint64 | int64 |
| ListSightings | column place | string | *string |
| SummariseGulls | column times_seen | uint64 | int64 |