Using a GUI with Turso and libSQL: URLs, tokens, transactions

Connect a desktop SQL client to Turso or self-hosted libSQL: libsql:// URLs, auth tokens, transactions over HTTP, large results and embedded replicas.

By

libSQL is an open-source fork of SQLite that adds, among other things, a network server. Turso is the hosted service built on it. Together they make SQLite usable in places it never used to fit: a database per tenant, a database at the edge, a small app's whole backend. What they do not make obvious is how to point a desktop SQL client at one, because a libSQL database is neither a local file nor a traditional server with a username and password.

This post explains what you need to connect a GUI to Turso or a self-hosted libSQL server, what the auth token is, how the SQLite dialect behaves over the network, and where a desktop client fits alongside features like embedded replicas.

How clients talk to libSQL

A libSQL server (the self-hosted one is called sqld) speaks a protocol called Hrana, and one of its transports is plain HTTP: the client posts a JSON pipeline of statements and gets JSON results back. That design choice shapes everything about connecting a GUI:

  • There is no username. Access is a bearer token sent with each request, or nothing at all on a local server that has auth turned off.
  • The address is a URL. Turso databases live at libsql://<database>-<org>.turso.io, served over HTTPS on port 443. A self-hosted sqld listens on port 8080 by default.
  • Nothing stays open between requests. Each script is its own exchange, which matters for transactions (more below).

Getting a URL and a token from Turso

With the Turso CLI installed and logged in, two commands give you everything a client needs:

# the database URL
turso db show shop --url
# libsql://shop-acme.turso.io

# a token for that database
turso db tokens create shop

# safer for browsing: read-only, and it expires
turso db tokens create shop --read-only --expiration 7d

Treat the token like a password, because it is one. For a GUI you mostly use to look at data, the --read-only flag is worth the extra few characters: the server then refuses writes whatever the client sends. An expiration keeps a forgotten token from living forever.

Running libSQL locally

For development you do not need Turso at all. The libSQL server runs in Docker:

docker run -d --name libsql -p 8080:8080 ghcr.io/tursodatabase/libsql-server:latest

That gives you http://127.0.0.1:8080 with no token required, which is the quickest way to try the rest of this post.

Connecting SQLly to libSQL or Turso

SQLly has a native libSQL driver that speaks the Hrana HTTP pipeline directly, so there is no SDK or bridge process involved. In the connection editor, choose libSQL and fill in two things: the host and the Auth token field. Leave the token blank for an anonymous local server.

You can also paste a URL and let the editor fill itself in. SQLly understands all three shapes you are likely to have lying around:

libsql://shop-acme.turso.io?authToken=eyJhbGciOi...
https://shop-acme.turso.io
http://127.0.0.1:8080

libsql:// and https:// mean TLS on port 443; http:// means a plaintext local server. An authToken in the URL is moved into the token field rather than kept in the address. A few details worth knowing about how the token is handled:

  • It is stored in your operating system's keychain, like a password, not in the connection file.
  • Any *.turso.io host is always contacted over HTTPS, so a token cannot accidentally travel in the clear because of a mistyped scheme.
  • Exported connection definitions carry no token.

Self-hosted servers that are not reachable directly work through the same SSH tunnels, proxies and Kubernetes port-forwards as every other network connection in SQLly.

It is SQLite, over the network

Once connected, you are writing SQLite's dialect, so everything you know about SQLite applies: INTEGER PRIMARY KEY rowid aliases, flexible typing, strftime for dates, json_extract for JSON. Explore the schema the SQLite way:

SELECT name, type, sql
FROM sqlite_schema
WHERE type IN ('table', 'view', 'index')
ORDER BY type, name;

SELECT * FROM pragma_table_info('orders');

You rarely need to, though. SQLly runs its SQLite catalog queries against libSQL, so the explorer tree and IntelliSense work as they do for a local SQLite file. Query plans come from EXPLAIN QUERY PLAN and appear in SQLly's plan views.

Transactions: keep them in one run

Because nothing is held open between requests, SQLly sends a whole script as one request: it splits the statements locally, then runs them as a single batch on one server-side stream, where each step only runs if the previous one succeeded. Two consequences follow.

First, a transaction works when BEGIN and COMMIT run together:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

If the second UPDATE fails, the script stops there with the server's own error message, and the transaction is rolled back. Second, and this is the one that catches people: do not run BEGIN on its own, then the next statement a minute later, and expect them to share a transaction. Select the whole block and run it together.

The same mechanism is what makes SQLly's rollback wrap work on libSQL: with it on, your script runs inside BEGIN; ... ROLLBACK; in one request, so you can see what an UPDATE would do without keeping it. And the read-only connection setting refuses anything that is not a read before it is sent, which pairs nicely with a read-only Turso token.

Large results: page them

Hrana returns each result in one response rather than streaming it row by row. SQLly reads the response and feeds it to the grid in batches, so the grid still paints progressively, but a very large result costs its full size in memory before the first row appears. Page instead of pulling everything:

SELECT id, customer_id, total, created_at
FROM orders
WHERE id > 25000   -- the last id from the previous page
ORDER BY id
LIMIT 1000;

Keyset paging like this (remember the last id you saw) stays fast on large tables, where OFFSET gets slower the deeper you go. The related caveat is cancellation: pressing Cancel drops the in-flight request, but libSQL has no way to kill a running statement, so a long query finishes on the server anyway. Put a LIMIT on exploratory queries.

What about embedded replicas?

Embedded replicas are a feature of the libSQL client SDKs: your application keeps a local SQLite file that syncs from the remote primary, so reads are local and fast. They are designed for applications, not for a SQL client. SQLly connects to the remote database over HTTP and does not create or sync an embedded replica. For looking at the data, the remote database is the source of truth anyway, and it is what you want to be querying.

Which tools work on libSQL

SQLly reuses its SQLite tooling for libSQL wherever the server allows it: Disk Usage, Schema Compare and Data Compare, the test data generator, the structure editor, and dumps. Maintenance is a smaller set than local SQLite, REINDEX and an integrity check, because the server refuses VACUUM, ANALYZE and PRAGMA optimize. Editing rows directly in the grid, Data Transfer into libSQL, Activity Monitor, Index Analyzer, Security and Backup are not available for libSQL, and each says why rather than showing an empty pane.

If you have a Turso database and have been poking at it through the web shell, download SQLly, create a read-only token, paste the libsql:// URL into a new connection, and you should be browsing tables a minute later.