Pure-Go database/sql driver for DuckDB's Quack remote protocol.
Status: alpha (v0.1.x). The Quack protocol itself is pre-1.0 (target DuckDB v2.0, 2026-09). This driver is pinned to DuckDB v1.5.3 servers and breaking wire-format changes are expected through v2.0. Production users should treat the wire layer as load-bearing and lock to specific DuckDB releases.
- Highlights
- Install
- Quick start
- Bulk insert with
Conn.Append - bun ORM
- DSN
- Type mapping
- Limitations & gotchas
- Development
- References
- License
- No CGO. No
libduckdbembedded. The driver speaks HTTP/2 to a remote DuckDB server, so it cross-compiles like any other pure-Go binary. - Zero third-party dependencies in the core driver. Only the Go standard library —
net/http,encoding/binary,database/sql/driver. database/sql/drivercompatible. Works withsql.Open("quack", dsn),*sql.DBconnection pooling, prepared statements (client-side), and transactions.- Bulk-insert path —
Conn.Appendships rows as a single column-major DataChunk in one round-trip. Measured ~1000× faster than parameterized INSERT for batches. - bun ORM support via
sqlitedialect. - Golden-fixture-locked wire format. 38 captured request/response pairs in
testdata/golden/guard against silent protocol drift. Unit tests run without a server.
go get github.com/zzir/quackdriverRequirements:
- Go 1.22+
- DuckDB CLI v1.5.3+ with the Quack extension (
INSTALL quack; LOAD quack;) for the server side
Start a server (in one terminal):
bash scripts/start-quack-server.sh
# or:
duckdb -cmd "INSTALL quack; LOAD quack; CALL quack_serve('quack:127.0.0.1:9494', token=>'super_secret');"Use it from Go:
package main
import (
"context"
"database/sql"
"fmt"
"log"
_ "github.com/zzir/quackdriver"
)
func main() {
db, err := sql.Open("quack", "quack://127.0.0.1:9494?token=super_secret")
if err != nil {
log.Fatal(err)
}
defer db.Close()
rows, err := db.QueryContext(context.Background(), "SELECT 42 AS i, 'hello' AS s")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var i int
var s string
if err := rows.Scan(&i, &s); err != nil {
log.Fatal(err)
}
fmt.Println(i, s)
}
}Use
127.0.0.1, notlocalhost. Go's resolver prefers IPv6 (::1), butquack_servebinds IPv4 only — you will getconnection refusedif you writequack://localhost:9494in the DSN.
More example programs in examples/ (each is go run ./examples/<name>):
| Example | What it shows |
|---|---|
basic |
Connection, DDL/DML, Tx, multi-chunk fetch |
types |
All 13 primitive types round-tripped |
nested |
LIST / STRUCT / ARRAY / MAP / DECIMAL / INTERVAL decoding |
timeutc |
TIME → time.Duration, INTERVAL helpers |
append |
High-level Conn.Append API |
append-rich |
APPEND with DECIMAL / HUGEINT / UUID / INTERVAL / LIST / ARRAY |
append-struct |
APPEND with STRUCT / MAP / 2D ARRAY |
bench-append |
INSERT vs APPEND throughput benchmark |
bun |
bun ORM integration (separate go.mod) |
Quack supports a wire-level APPEND_REQUEST path that bypasses SQL parsing and binding entirely — rows go straight from the client into the server's table buffer as a column-major DataChunk. For batches this is vastly faster than INSERT VALUES:
INSERT 10000 rows: 1.86s (5,384 rows/sec)
APPEND 10000 rows: 1.78ms (5,612,198 rows/sec)
speedup: 1042×
The high-level API:
import "github.com/zzir/quackdriver"
conn, _ := db.Conn(ctx)
defer conn.Close()
err := conn.Raw(func(d any) error {
return d.(*quackdriver.Conn).Append(ctx, "users",
[]quackdriver.AppendColumn{
{Name: "id", Type: "BIGINT"},
{Name: "profile", Type: "STRUCT(name VARCHAR, score DECIMAL(9,2))"},
{Name: "tags", Type: "VARCHAR[]"},
{Name: "result", Type: "UNION(num INTEGER, err VARCHAR)"},
},
[][]any{
{int64(1), map[string]any{"name": "alice", "score": "98.5"}, []any{"go", "duckdb"}, map[string]any{"num": 42}},
{int64(2), map[string]any{"name": "bob", "score": "73.2"}, []any{"sql"}, map[string]any{"err": "timeout"}},
},
)
})The Type field accepts the full DuckDB type grammar:
| Form | Notes |
|---|---|
BOOLEAN, INTEGER, BIGINT, VARCHAR, ... |
Simple names (case-insensitive). |
DECIMAL(18, 4) |
Width 1–38, scale 0..width. |
HUGEINT, UHUGEINT, UUID, INTERVAL |
128-bit / UUID / temporal interval. |
DATE, TIMESTAMP, TIMESTAMP_MS, TIMESTAMP_NS |
Accept time.Time. |
INTEGER[], LIST(INTEGER) |
LIST; accepts []any. |
INTEGER[3] |
Fixed-size ARRAY; accepts []any of exact length. |
STRUCT(a INTEGER, b VARCHAR) |
Accepts map[string]any. |
MAP(VARCHAR, INTEGER) |
Accepts map[string]any or []any of {key, value} entries. |
UNION(num INTEGER, str VARCHAR) |
Accepts map[string]any with exactly one non-nil variant key. |
Pass nil for SQL NULL anywhere.
Use uptrace/bun with sqlitedialect:
import (
"database/sql"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
_ "github.com/zzir/quackdriver"
)
type User struct {
bun.BaseModel `bun:"table:users"`
ID int64 `bun:"id,pk,type:BIGINT"` // see gotcha below
Name string `bun:"name,notnull"`
Active bool `bun:"active"`
}
sqldb, _ := sql.Open("quack", "quack://127.0.0.1:9494?token=super_secret")
db := bun.NewDB(sqldb, sqlitedialect.New())bun gotcha —
INTEGERvsBIGINT. bun's sqlitedialect mapsint64Go fields to SQLINTEGER, which is 64-bit in SQLite but 32-bit in DuckDB. Scans ofint32values back intoint64then fail. Annotateint64columns explicitly withtype:BIGINT.
Full working example in examples/bun/.
quack://host:port?token=<token>&tls=<bool>&timeout=<duration>
quack:host:port?token=<token>
quack:[::1]:9494?token=<token> # IPv6
| Param | Default | Notes |
|---|---|---|
token |
(none) | Auth token, must be at least 4 characters. |
tls |
false |
If true, the driver uses https://. |
timeout |
30s |
Per-request HTTP timeout. Accepts any time.Duration string. |
| DuckDB type | Go type returned to Rows.Scan |
|---|---|
BOOLEAN |
bool |
TINYINT / SMALLINT / INTEGER / BIGINT |
int8 / int16 / int32 / int64 |
UTINYINT / USMALLINT / UINTEGER / UBIGINT |
uint8 / uint16 / uint32 / uint64 |
HUGEINT, UHUGEINT |
*big.Int |
FLOAT, DOUBLE |
float32, float64 |
VARCHAR, CHAR, UUID |
string |
BLOB, BIT, GEOMETRY |
[]byte |
DATE |
time.Time (UTC midnight) |
TIMESTAMP, TIMESTAMP_S/MS/NS, TIMESTAMP WITH TIME ZONE |
time.Time (UTC) |
TIME, TIME_NS |
time.Duration (since midnight) |
TIME WITH TIME ZONE |
message.TimeTzValue (raw 64-bit packed bits) |
INTERVAL |
message.IntervalValue (months / days / micros) |
DECIMAL(w, s) |
string (e.g. "12345.67") |
LIST<T>, ARRAY<T, N> |
[]any |
STRUCT<...> |
map[string]any |
MAP<K, V> |
[]any of map[string]any{"key": ..., "value": ...} |
UNION<...> |
map[string]any including a "" tag field (uint8) |
NULL (any column) |
nil |
| DuckDB type tag | Accepted Go values |
|---|---|
BOOLEAN |
bool |
TINYINT … BIGINT |
Any signed integer; widened, range-checked. |
UTINYINT … UBIGINT |
Any unsigned integer; widened, range-checked. |
HUGEINT, UHUGEINT |
*big.Int, string (base-10), or any integer. |
FLOAT, DOUBLE |
float32, float64, int |
VARCHAR |
string, []byte |
BLOB |
[]byte, string |
UUID |
string (8-4-4-4-12 hex) |
DATE |
time.Time |
TIMESTAMP* |
time.Time |
INTERVAL |
time.Duration or message.IntervalValue |
DECIMAL(w, s) |
string, *big.Int, numeric. Scale is auto-applied. |
LIST(T), T[] |
[]any |
ARRAY<T, N>, T[N] |
[]any of exact length |
STRUCT(name TYPE, ...) |
map[string]any keyed by field name |
MAP(K, V) |
map[string]any (string keys) or []any of {"key", "value"} |
UNION(name TYPE, ...) |
map[string]any with exactly one non-nil variant |
| Any column | nil for SQL NULL |
- No server-side prepared statements. Quack v1.5.3 doesn't expose a bind path. The driver substitutes parameters as SQL literals in
internal/sqlfmtbefore sending. Treat that package as the entire injection-defense surface — any new types accepted byFormatValuemust double-escape strings. localhostresolves to IPv6 first; the server binds IPv4 only. Use127.0.0.1in DSNs and tests.- bun +
int64columns must be taggedtype:BIGINT(see bun ORM). - FSST-compressed vectors are rejected with a clear error. They appear when DuckDB enables them for
VARCHARcolumns; if you hit one,CALL enable_logging('Quack')server-side or cast the column to a different type. TIME WITH TIME ZONEdecodes to raw 64-bit bits — the offset packing in v1.5.3 isn't documented well enough to expose accessors safely. Cast to VARCHAR server-side for parsed display today.LastInsertIdreturnsErrNoLastInsertID;RowsAffectedreturns(0, nil). Quack does not transmit either today.- Typed
Scanfor nested types is not implemented yet — LIST/STRUCT/MAP/ARRAY come back as[]any/map[string]any. Use theConn.Appendhigh-level API to round-trip them.
The whole development loop runs locally — there is no CI yet.
make test # build + vet + go test + every example, auto-starts server
make test-unit # just `go test ./...` (no server needed)
make test-examples # just the example programs (auto-starts server)
make test-bun # bun ORM example (separate go.mod)
make server # start a Quack server in the foreground
make server-bg # start in the background; reuse if already up
make stop # kill whatever is listening on QUACK_PORT
make bench # APPEND vs INSERT throughput benchmark
make dump-wire # regenerate testdata/golden/*.bin
make help # all targets + variable valuesThe auto-managed server is conservative: if a server is already up on QUACK_PORT (default 9494), make test reuses it and never kills it. Only servers make itself started are torn down.
quackdriver/
├── quackdriver.go, conn.go, stmt.go, rows.go, tx.go, value.go ← driver.* surface
├── append.go, appendtype.go ← high-level APPEND API
├── internal/
│ ├── codec/ — BinarySerializer (ULEB/SLEB, object framing, hugeint)
│ ├── logicaltype/ — DuckDB type system (LogicalType + ExtraInfo)
│ ├── message/ — 10 Quack messages + DataChunk encode/decode
│ ├── transport/ — URI parser + HTTP/2 client
│ └── sqlfmt/ — SQL literal escaper (security-critical)
├── cmd/dump-wire/ — captures wire fixtures from a live server
├── testdata/golden/ — request/response byte fixtures (committed)
├── examples/ — runnable end-to-end programs
└── doc/PROTOCOL.md — wire-format cheat sheet
The layering is strict and bottom-up: codec → logicaltype → message → transport → driver surface. New protocol knowledge belongs at the lowest layer that can host it.
testdata/golden/*.bin are immutable request/response byte captures from a known-good server, used by go test ./internal/message/ to detect protocol drift without a live server. Regenerate when intentionally bumping the server version:
make dump-wire
git diff testdata/golden # any byte change is a protocol drift — investigate firstPlease include:
- The DuckDB server version (
SELECT version();or thequack_serve(...)startup banner). - The exact DSN and the SQL statement (or
Conn.Appendarguments) that triggered the issue. - A
make dump-wirecapture (testdata/golden/) if the regression is wire-related.
The Quack protocol is alpha and the upstream docs are thin. These are the load-bearing references:
| Source | Use for |
|---|---|
duckdb/duckdb-quack — src/include/quack_message.json |
Authoritative message schema (field IDs, types). |
tobilg/quack-protocol (TypeScript) |
Cleanest algorithm reference — codec, logical types, vector encoding. |
gizmodata/quack-jdbc (Java) |
Structural reference — the Java package layout mirrors this driver's internal/ tree. |
tobilg's wire-format gist |
Narrative protocol overview. |
Maxxen's BinarySerializer gist |
DuckDB BinarySerializer byte layout. |
| DuckDB docs — Quack overview | Official user-facing protocol docs. |
Local: doc/PROTOCOL.md |
Distilled wire-format cheat sheet (what this driver implements). |
This driver translates the TypeScript implementation's algorithms into Go, cross-checked against the Java structural conventions and verified byte-for-byte against the official server output (see testdata/golden/). It would not exist without those three upstream projects — credit and respect to their maintainers.
See CHANGELOG.md. The current release is v0.1.0-alpha.1.
MIT. See LICENSE.