README
¶
libsql-client-go
A maintained, pure-Go libSQL/Hrana client for Turso Cloud. No cgo, no native libraries, builds as a static binary on distroless.
This is a fork of tursodatabase/libsql-client-go,
which upstream deprecated in favour of go-libsql.
That successor requires cgo, which is a hard blocker for anyone shipping
CGO_ENABLED=0 static binaries, and buys nothing for a remote-only client that
never touches an embedded replica. Upstream issue
#141 is a user
making exactly that objection.
So this fork exists to keep the pure-Go path alive, and to fix the bugs upstream is no longer accepting patches for.
What is fixed here
Two optional database/sql interfaces the driver never advertised. Both
omissions appear accidental rather than considered: driver.Validator has never
appeared anywhere in upstream's history, and the PR that added ping states in its
first line that the methods "satisfy the sql driver interface", which they do not.
driver.Validator — the connection pool recycled dead connections.
On the remote HTTP transport the server closes the Hrana stream after each
statement, so a connection is spent the moment its query returns. Without a
Validator, database/sql assumes every returned connection is healthy and pools
it; the next caller fails with stream is closed: driver: bad connection.
DB.retry hides this most of the time — two attempts with cachedOrNewConn,
then one with alwaysNewConn that opens something fresh. It stops hiding it
once the pool saturates: past MaxOpenConns, alwaysNewConn cannot open
anything, so it queues and is handed the next connection another goroutine
returns, which is also spent. All three attempts fail and the error reaches the
caller. The symptom is intermittent 500s on your widest fan-out, at a
concurrency level far below anything Turso itself strains at — the service
benchmarks clean to 64 concurrent at 422 q/s with zero errors. It is easy to
misread as a Turso limit. It is not one.
driver.Pinger — DB.Ping() never reached the server.
The interface requires exactly Ping(ctx context.Context) error. The driver
offered Ping() error and PingContext(ctx) error, matching neither, so
database/sql detected no Pinger and DB.Ping() returned as soon as it could
take a connection from the pool — verifying nothing over the network. Measured:
a ping returned in 2µs where a real query took 48.8ms. If you built a
keepalive on DB.Ping() to hold a suspending instance awake, it was doing
nothing.
Install
go get github.com/WillowWorks-io/libsql-client-go
import _ "github.com/WillowWorks-io/libsql-client-go/libsql"
db, err := sql.Open("libsql", "libsql://your-db.turso.io?authToken=...")
Migrating from upstream is a path swap and nothing else — the package layout and API are unchanged:
s|github.com/tursodatabase/libsql-client-go|github.com/WillowWorks-io/libsql-client-go|
Credentials: URL or option, and who wins
The auth token may come from either the connection URL or an option:
// both of these work
libsql.NewConnector("libsql://db.turso.io?authToken=" + tok)
libsql.NewConnector("libsql://db.turso.io", libsql.WithAuthToken(tok))
Upstream NewConnector rejected the first form outright — while
Driver.Open, the other entry point to the same driver, accepted it. Since
Turso issues connection strings with ?authToken= in them, the rejected shape
was the common one, and migrating from sql.Open to NewConnector failed at
startup. Both are accepted here.
If both are given and they differ, the option wins. A connection string is usually handed to a program by its platform — an injected secret, an env var — so the option is the half the caller can actually change; making them edit the URL first would defeat the point of having an option.
The conflict is logged at WARN via slog, because the other way to end up
here is a half-finished credential rotation, and that should not be discovered
later as an auth failure. The values are never logged: a warning that printed
them would turn a config smell into a secret in your log store.
The same precedence applies to ?tls= and WithTls. A TLS setting merely
implied by the URL scheme is not a conflict and is overridden quietly.
Never log the connection URL
Turso embeds the auth token in the connection string, so the natural thing to write is the dangerous thing:
return fmt.Errorf("opening database %q: %w", databaseURL, err) // leaks a live credential
That line writes a working token into whatever collects the error — log aggregator, error tracker, CI transcript, a terminal someone screenshots. It is easy to miss because the URL reads as configuration rather than a secret.
Use RedactURL:
return fmt.Errorf("opening database %q: %w", libsql.RedactURL(databaseURL), err)
It replaces authToken / auth_token / jwt and any userinfo password with
REDACTED, keeps everything else so the message stays diagnostic, and refuses
to return input it could not parse. As a backstop it scans the result for
anything JWT-shaped — a token under an unrecognised parameter name — and
withholds the URL entirely rather than hand it back.
Optional: keep a suspending instance warm
A Turso instance suspends when nothing queries it, and waking costs about a second and a half — measured 49ms after 30s idle, 1.83s after 60s. The first request after a quiet stretch eats that, for reasons that have nothing to do with the query being run.
WithKeepAlive holds the instance awake, and is adaptive: it issues a
statement only when nothing else has, so it stays silent while real traffic is
already keeping the database warm. On a usage-billed service, a blind ticker is
waste you pay for.
c, err := libsql.NewConnector(dsn, libsql.WithKeepAlive(25*time.Second))
if err != nil { ... }
db := sql.OpenDB(c)
defer db.Close() // also stops the keepalive
Pick an interval under the suspend threshold, which measured between 30 and 60 seconds; 25s leaves margin for a late tick.
It is entirely opt-in. A connector built without it behaves exactly as
before: no goroutine, no queries of its own. It is also ignored for file:
databases and for a non-positive interval, so the same options can be passed for
a local and a remote database without branching.
Note the activity signal is taken at the connector, so it sees every query the application makes without any call site reporting in — and it is per connector, so one busy database will not keep another's instance from being tickled.
One more thing worth knowing
http.Transport.MaxIdleConnsPerHost defaults to 2, and this driver sends
every query to one host through http.DefaultClient. Past two concurrent
queries the surplus connections cannot be kept idle, so each subsequent query
pays a full TCP+TLS handshake (~200ms) instead of one round trip (~50ms). This
lands hardest on fan-out workloads. Raising it is worth more than any
database/sql pool setting:
if t, ok := http.DefaultTransport.(*http.Transport); ok {
t.MaxIdleConnsPerHost = 32
}
Measured effect: p50 69.7ms → 53.2ms at 8-wide, 193ms → 116.5ms at 32-wide.
Scope
Remote Turso Cloud over HTTP is what this fork supports and tests. The ws://
transport is inherited but effectively dead — the driver negotiates only
hrana1 and Turso now rejects it (expected handshake response status code 101 but got 400). It is retained for self-hosted sqld users; if you rely on it,
please open an issue, otherwise it will likely be removed.
Embedded replicas are out of scope — that is what go-libsql and its cgo
bindings are for.
Status and maintenance
Actively maintained. This is not a drive-by fork.
Turso is production infrastructure for everything WillowWorks builds, so this
client is a dependency we run in anger rather than one we publish and forget.
Bugs that affect us get fixed here first, and CI runs on every push against a
real libsql-server container.
What that commits to:
- Correctness fixes and Turso Cloud compatibility. If the Hrana wire protocol shifts and this client breaks, fixing it is not optional for us.
- Issues get a reply. Not necessarily a fix, but an honest answer about whether it is in scope and whether anyone is working on it.
- Semver, and no surprise breaks. The API is upstream's; keeping it that way is a feature, since it makes migrating a module path swap.
What it does not commit to:
- Embedded replicas. Out of scope, permanently. That needs cgo and
go-libsqlalready does it. - Feature parity with whatever Turso ships next. If Turso Cloud moves off Hrana entirely, this client's job ends and the README will say so plainly rather than quietly rotting.
If you are depending on this and something is missing, open an issue -- knowing who is out there is what tells us where to spend effort.
Credit
All original work is by the libSQL authors and 30 contributors, MIT licensed, full history preserved. See LICENSE.
Turso + Go
SQLite for Production. Powered by libSQL.
Turso · Quickstart · Examples · Docs · Discord · Blog & Tutorials
Documentation
- Turso Quickstart — Learn how create and connect your first database.
- SDK Quickstart — Learn how to install and execute queries using the libSQL client.
What is Turso?
Turso is a SQLite-compatible database built on libSQL, the Open Contribution fork of SQLite. It enables scaling to hundreds of thousands of databases per organization and supports replication to any location, including your own servers, for microsecond-latency access.
Learn more about what you can do with Turso: